hecks 2.6.0 → 2.8.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.
Files changed (216) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/memory.rb +1 -1
  6. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  7. data/lib/hecks/adapters/driven/prism.rb +2 -2
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  9. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  10. data/lib/hecks/behaviors/expectations.rb +2 -2
  11. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  12. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  13. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  14. data/lib/hecks/bluebook/assembly.rb +1 -1
  15. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  16. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  17. data/lib/hecks/bluebook/capabilities.rb +2 -2
  18. data/lib/hecks/bluebook/chapter.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  21. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  22. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  23. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  26. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  27. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  28. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  29. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  30. data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
  31. data/lib/hecks/bluebook/hexagon.rb +15 -7
  32. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  33. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  34. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  35. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  36. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  37. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  38. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  39. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  40. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  41. data/lib/hecks/bluebook/model_check.rb +13 -14
  42. data/lib/hecks/cache_dir.rb +103 -0
  43. data/lib/hecks/cli/document.rb +104 -0
  44. data/lib/hecks/cli/ir.rb +47 -0
  45. data/lib/hecks/cli/mcp.rb +32 -0
  46. data/lib/hecks/cli/mcp_door.rb +461 -0
  47. data/lib/hecks/cli/model_check.rb +270 -0
  48. data/lib/hecks/cli/project_cli.rb +123 -0
  49. data/lib/hecks/cli/project_diagrams.rb +43 -0
  50. data/lib/hecks/cli/run.rb +219 -0
  51. data/lib/hecks/cli/smoke_test.rb +59 -0
  52. data/lib/hecks/cli/stores.rb +54 -0
  53. data/lib/hecks/cli.rb +166 -0
  54. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  55. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  56. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  57. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  58. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  59. data/lib/hecks/facade/handle.rb +2 -2
  60. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  61. data/lib/hecks/forms/value_object_shape.rb +1 -1
  62. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  63. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  64. data/lib/hecks/language/world/world.bluebook +32 -2
  65. data/lib/hecks/mcp_door_scope.rb +176 -0
  66. data/lib/hecks/ports/key_vault.rb +1 -1
  67. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  68. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  69. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
  70. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  71. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  72. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  73. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  74. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  75. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  76. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  77. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
  78. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  79. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  80. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  81. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  82. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  83. data/lib/hecks/ports/persistence.rb +4 -0
  84. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  85. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  86. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  87. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  88. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  89. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  90. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  91. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  92. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  93. data/lib/hecks/projections/deploy/fargate.rb +188 -139
  94. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  95. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  96. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  97. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  98. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  99. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  100. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  101. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  102. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  103. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  104. data/lib/hecks/projections/deploy/preview.rb +103 -0
  105. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  106. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  107. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  108. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  109. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  110. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  111. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  112. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  113. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  114. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  115. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  116. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  117. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  118. data/lib/hecks/projections/reference.rb +7 -1
  119. data/lib/hecks/projections.rb +2 -0
  120. data/lib/hecks/projector/exporter.rb +4 -4
  121. data/lib/hecks/projector/target.rb +1 -1
  122. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  123. data/lib/hecks/release/gem_pin.rb +112 -0
  124. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  125. data/lib/hecks/release/runner/commands.rb +68 -0
  126. data/lib/hecks/release/runner/console.rb +52 -0
  127. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  128. data/lib/hecks/release/runner/git.rb +48 -0
  129. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  130. data/lib/hecks/release/runner/preflight.rb +119 -0
  131. data/lib/hecks/release/runner/published.rb +55 -0
  132. data/lib/hecks/release/runner/tagger.rb +87 -0
  133. data/lib/hecks/release/runner.rb +244 -0
  134. data/lib/hecks/router/namespace_installer.rb +1 -1
  135. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  136. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  137. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  138. data/lib/hecks/runtime/entity_element.rb +1 -1
  139. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  140. data/lib/hecks/runtime/invocation.rb +1 -1
  141. data/lib/hecks/runtime/loader.rb +27 -3
  142. data/lib/hecks/runtime/outbox.rb +1 -1
  143. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  144. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  145. data/lib/hecks/runtime/reference_hop.rb +4 -4
  146. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  147. data/lib/hecks/runtime/registry/verification.rb +15 -13
  148. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  149. data/lib/hecks/runtime/registry.rb +22 -13
  150. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  151. data/lib/hecks/runtime/tenant_check.rb +1 -1
  152. data/lib/hecks/runtime/value/coercion.rb +3 -3
  153. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  154. data/lib/hecks/storehouse.rb +18 -12
  155. data/lib/hecks/vendoring/git_environment.rb +35 -0
  156. data/lib/hecks/vendoring/git_source.rb +102 -0
  157. data/lib/hecks/vendoring.rb +102 -0
  158. data/lib/hecks/version.rb +1 -1
  159. data/lib/hecks/vocabulary.rb +5 -0
  160. data/lib/hecks.rb +8 -2
  161. metadata +72 -58
  162. data/lib/hecks/bench/cli.rb +0 -74
  163. data/lib/hecks/bench/environment.rb +0 -100
  164. data/lib/hecks/bench/postgres_probe.rb +0 -41
  165. data/lib/hecks/bench/report.rb +0 -116
  166. data/lib/hecks/bench/ruby_runner.rb +0 -151
  167. data/lib/hecks/bench/run.rb +0 -55
  168. data/lib/hecks/bench/rust_runner.rb +0 -165
  169. data/lib/hecks/bench/stats.rb +0 -75
  170. data/lib/hecks/bench/suite.rb +0 -172
  171. data/lib/hecks/bench/workload.rb +0 -137
  172. data/lib/hecks/bench.rb +0 -28
  173. data/lib/hecks/codemod.rb +0 -443
  174. data/lib/hecks/corpus.rb +0 -479
  175. data/lib/hecks/doc/reference.rb +0 -585
  176. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  177. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  178. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  179. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  180. data/lib/hecks/fuzzing/differential.rb +0 -192
  181. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  182. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  183. data/lib/hecks/fuzzing/form_census.rb +0 -285
  184. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  185. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  186. data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
  187. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  188. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  189. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  190. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  191. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  192. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  193. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  194. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  195. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  196. data/lib/hecks/fuzzing/properties.rb +0 -267
  197. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  198. data/lib/hecks/fuzzing/replay.rb +0 -930
  199. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  200. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  201. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  202. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  203. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  204. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  205. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  206. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  207. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  208. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  209. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  210. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  211. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  212. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  213. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  214. data/lib/hecks/fuzzing.rb +0 -23
  215. data/lib/hecks/grammar/evolve.rb +0 -524
  216. data/lib/hecks/query_ir.rb +0 -482
@@ -1,398 +0,0 @@
1
- module Hecks
2
- module Fuzzing
3
- module Properties
4
- # Guard/authorization properties: every given/ensures refusal a run
5
- # actually raised names a rule the refusing command actually declares,
6
- # a scope-authorized answer (or its refusal) is correctly worded, and
7
- # a lifecycle guard's own violation is refused rather than silently
8
- # admitted.
9
-
10
- # Holds authorize_scopes_or_refuses, guard_refusals_are_declared, and
11
- # lifecycle_guard_and_given_violations_are_refused, plus the shared
12
- # verb-to-declaration lookups (#command_for_verb,
13
- # #effective_guard_descriptions) each resolves a refusal against.
14
- module Guards
15
- # `Query#options`' other half — TenantScope.apply's own contract
16
- # (tenant_scope.rb), independently restated as a property rather
17
- # than exercised only through whatever the generator happens to
18
- # try. Not closed by the generator here on purpose: SafeDepositBox.
19
- # Rented — the only real corpus query declaring `authorize` at
20
- # all — declares zero attributes of its own, so StepBuilder#args_for
21
- # always hands it `{}` and TenantScope.apply refuses every
22
- # generated attempt, unconditionally (confirmed: no successful ask
23
- # against an authorize-bearing query reaches this property via the
24
- # standard battery today). Extending the generator to invent a
25
- # `tenant:` value ran into a separate, real finding along the way —
26
- # SafeDepositBox is composite-identified (`identified_by` is nil
27
- # for it — Runtime::Identified#derive_identity), so the generator's
28
- # existing `known_ids` pool (keyed by `aggregate.identified_by ||
29
- # "id"`) tracks a stray, never-real scalar for it rather than its
30
- # true `branch_code`+`box_number` pair — a second, narrower
31
- # generator gap this property does not attempt to fix, since fixing
32
- # it well enough to trust a generated `tenant:` value would be the
33
- # heavier, "benefits every future property" path the plan itself
34
- # names as the alternative. Hand-built fixtures close the real
35
- # claim directly instead: faster, narrower, and correct either way,
36
- # since TenantScope.apply's contract is identical regardless of
37
- # where a `tenant:` arg came from.
38
- #
39
- # Two claims, matching TenantScope.apply's own two branches: every
40
- # successful answer's own tenant field agrees with the tenant arg
41
- # given (the WhereClause TenantScope injects is a Symbol reference
42
- # into args, resolved dynamically — this checks the outcome, not
43
- # re-deriving that resolution) ; every ask missing a required
44
- # tenant: refuses with the declared wording, never succeeds. A
45
- # refusal for an unrelated reason with the tenant arg present is
46
- # not this property's claim either way — skipped, not graded.
47
- # The three mutually exclusive outcomes ("refused, was it for the
48
- # declared reason?" / "succeeded without a tenant that was
49
- # required?" / "succeeded with a tenant, does every row actually
50
- # agree with it?") map exactly onto TenantScope.apply's own two
51
- # branches, per the comment above — splitting them into separate
52
- # methods would mean threading `asked`, `tenant`, `args`, and
53
- # `declared` out to each one for no gain, since none of the three
54
- # branches shares logic with the others beyond that shared setup.
55
- # rubocop:disable-next Metrics/CyclomaticComplexity
56
- # rubocop:disable-next Metrics/PerceivedComplexity
57
- #
58
- # @param history [Hash] a replayed history as returned by `Replay.call`
59
- # @return [true, String] true if every tenant-scoped query answer, and every
60
- # refusal of one, agrees with `TenantScope.apply`'s own contract; otherwise a
61
- # message naming the offending query, its args, and what it should have done
62
- def authorize_scopes_or_refuses(history)
63
- bluebooks = history.fetch(:bluebooks)
64
-
65
- offenders = history.fetch(:queries).filter_map do |asked|
66
- next unless asked[:query].is_a?(String) && asked[:query].include?("::")
67
-
68
- declared = query_for_verb(bluebooks, asked[:query])
69
- authorization = declared&.authorization
70
- tenant = authorization&.tenant&.to_sym
71
- next unless tenant
72
-
73
- args = asked[:args] || {}
74
- tenant_given = args.key?(tenant)
75
-
76
- if asked[:error]
77
- next if tenant_given
78
- next if asked[:error].to_s.include?("declares authorize with tenant: #{tenant}")
79
-
80
- "#{asked[:query]} #{args.inspect} refused with no #{tenant}: given, but not with the declared " \
81
- "tenant_required wording (#{asked[:error]})"
82
- elsif !tenant_given
83
- "#{asked[:query]} #{args.inspect} answered successfully with no #{tenant}: given, but #{declared.name} " \
84
- "declares authorize with tenant: #{tenant}"
85
- else
86
- wanted = args[tenant].to_s
87
- mismatched = asked[:rows].find do |row|
88
- Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, tenant)).to_s != wanted
89
- end
90
- next unless mismatched
91
-
92
- "#{asked[:query]} #{args.inspect} answered a row whose #{tenant} disagrees with the given " \
93
- "#{wanted.inspect}: #{mismatched.inspect}"
94
- end
95
- end
96
-
97
- offenders.empty? || offenders.join("; ")
98
- end
99
-
100
- # Every given/ensures refusal a run actually raised names a rule
101
- # the command actually declares. `GivenNotMet`/`EnsuresNotMet` both
102
- # quote their guard's own `description` verbatim
103
- # (command_rules/admissibility.rb: `"#{command.hecks_name} refused
104
- # — #{given.description}"`) — the same text `behavior.bluebook`'s
105
- # own `Rule`/Command.Ensure hold as `Rule#description`, so a
106
- # refusal whose quoted text is not among the refusing command's
107
- # own `guard_descriptions` (Behaviour::Command, both givens and
108
- # ensures) is either a stale message surviving a renamed rule, a
109
- # rule firing against the wrong command's own guard set, or the
110
- # wording drifting out from under the declaration it is supposed
111
- # to quote — banking's own 128 status givens (customer/account
112
- # guards, some through a cross-aggregate dereference) are exactly
113
- # the surface this exists to hold to its word.
114
- #
115
- # `kind:` is what tells a guard refusal apart from the four other
116
- # `RefusalWording` templates sharing the identical "X refused — Y"
117
- # shape (LifecycleRefused/transition_blocked, both TypeMismatch
118
- # object-reference templates, Unauthorized/role_mismatch) — see
119
- # Replay's own comment at the refusal rescue site. Pattern-matching
120
- # the string alone would confuse a guard's own wording with any of
121
- # those; the raised class does not.
122
- GUARD_REFUSAL_KINDS = %w[Hecks::Runtime::GivenNotMet Hecks::Runtime::EnsuresNotMet].freeze
123
-
124
- # Checks that every given/ensures refusal a run actually raised names a rule
125
- # the refusing command actually declares.
126
- #
127
- # @param history [Hash] a replayed history as returned by `Replay.call`
128
- # @return [true, String] true if every guard refusal's quoted description matches
129
- # a `given`/`ensures` the refusing command (or its delegation target) declares;
130
- # otherwise a message naming the refusal and the declared descriptions it does
131
- # not match
132
- def guard_refusals_are_declared(history)
133
- bluebooks = history.fetch(:bluebooks)
134
-
135
- offenders = history.fetch(:refusals).filter_map do |refusal|
136
- next unless GUARD_REFUSAL_KINDS.include?(refusal[:kind])
137
-
138
- match = refusal[:error].to_s.match(/\A(.+) refused — (.+)\z/)
139
- next "#{refusal[:verb]} raised #{refusal[:kind]} with unparseable message #{refusal[:error].inspect}" unless match
140
-
141
- command = command_for_verb(bluebooks, refusal[:verb])
142
- next "#{refusal[:verb]} raised #{refusal[:kind]}, but no declared command resolves that verb" unless command
143
-
144
- declared = effective_guard_descriptions(bluebooks, refusal[:verb], command)
145
- next if declared.include?(match[2])
146
-
147
- "#{refusal[:verb]} refused — #{match[2].inspect} — but #{command.hecks_name} declares no given " \
148
- "or ensures with that description (it declares #{declared.inspect})"
149
- end
150
-
151
- offenders.empty? || offenders.join("; ")
152
- end
153
-
154
- # Angle-8's own write-side half. `authorize_scopes_or_refuses`
155
- # (above) enforces `TenantScope.apply`'s boundary, and that
156
- # boundary exists only for queries/read models — `authorize
157
- # policy, tenant: field` is a word `QuerySpecification::Common::
158
- # DSL#authorize_impl` grants to `QueryBuilder`/`ReadModelBuilder`
159
- # alone; `CommandBuilder` never includes that module, so no
160
- # bluebook can declare it on a command at all (confirmed by
161
- # reading the grammar directly, not inferred). A write that
162
- # carries a `reference_to` from one tenant-scoped record into
163
- # another's is checked by nothing at dispatch time: `TenantScope`
164
- # never runs for a command, and no runtime `given`/`ensures`
165
- # anywhere in this corpus reads a cross-aggregate tenant field
166
- # either. `qa/stress_domains/tenant_ledger` exists to give this
167
- # property a real place to fire.
168
- #
169
- # The rule: an aggregate's own declared tenant field is whichever
170
- # field one of its own queries names in `authorize policy, tenant:
171
- # :field` — the exact same declaration `authorize_scopes_or_
172
- # refuses` reads off a query above, reused here to name a field
173
- # on the aggregate itself that stores the tenant it belongs to.
174
- # For every stored record (`history[:instances]` — a refused
175
- # dispatch never writes one, so "a refusal is correct behaviour,
176
- # not a finding" holds by construction, the same way `history
177
- # [:instances]` already guarantees this for `stored_records_
178
- # satisfy_declared_invariants`) whose own aggregate declares a
179
- # tenant field, walk every `reference_to`-typed attribute it
180
- # carries (`Bluebook::Reference` — "a reference IS the id",
181
- # value/coercion.rb's own header, so the stored value is always a
182
- # plain id, never a nested payload) pointing at another aggregate
183
- # that also declares a tenant field: if the referenced record's
184
- # own tenant value disagrees with the referencing record's own
185
- # tenant value, the write crossed a tenant boundary and nothing
186
- # refused it — a finding.
187
- #
188
- # A dangling/unresolvable reference is skipped — a different,
189
- # existence-shaped property's claim, not this one's (the same
190
- # "inconclusive, not a claimed pass" restraint `lifecycle_guard_
191
- # and_given_violations_are_refused` already documents for a
192
- # differently-shaped case). Comparison goes through `Ports::
193
- # Query::InMemory.comparable` (the same normalization `authorize_
194
- # scopes_or_refuses` already applies to a query row's own tenant
195
- # field, just above) rather than `Runtime::Value#==` directly —
196
- # two single-attribute value objects with the same scalar but
197
- # different declared names (`LedgerRegion`/`TransferRegion`, this
198
- # domain's own pair — a value object is always declared inside
199
- # the aggregate that owns it, so two independently tenant-scoped
200
- # aggregates can never share one) compare unequal under `Value#==`
201
- # (`type_name` is part of that equality) despite meaning the
202
- # identical tenant, which would make every same-tenant write a
203
- # false positive.
204
- # rubocop:disable-next Metrics/CyclomaticComplexity
205
- # rubocop:disable-next Metrics/PerceivedComplexity
206
- #
207
- # @param history [Hash] a replayed history as returned by `Replay.call`
208
- # @return [true, String] true if every stored record's `reference_to` attribute
209
- # points at a record sharing the same tenant value (or has no resolvable
210
- # target, or neither side declares a tenant field); otherwise a message naming
211
- # the referencing record, the reference, and the tenant mismatch
212
- def commands_respect_tenant_scope(history)
213
- bluebooks = history.fetch(:bluebooks)
214
- instances = history.fetch(:instances)
215
-
216
- offenders = instances.flat_map do |key, state|
217
- domain_name = key.split("::").first
218
- aggregate_name = key.split("::").last.split("#").first
219
- aggregate = bluebooks[domain_name]&.aggregate(aggregate_name)
220
- next [] unless aggregate
221
-
222
- own_tenant_field = tenant_field_for(aggregate)
223
- next [] unless own_tenant_field && state.key?(own_tenant_field)
224
-
225
- own_tenant = Ports::Query::InMemory.comparable(state[own_tenant_field])
226
-
227
- aggregate.attributes.filter_map do |attribute|
228
- next unless attribute.type.is_a?(Bluebook::Reference)
229
-
230
- target = bluebooks[domain_name]&.aggregate(attribute.type.target_name)
231
- target_tenant_field = target && tenant_field_for(target)
232
- next unless target_tenant_field
233
-
234
- target_id = state[attribute.name]
235
- next unless target_id
236
-
237
- target_state = instances["#{domain_name}::#{target.name}##{target_id}"]
238
- next unless target_state&.key?(target_tenant_field)
239
-
240
- target_tenant = Ports::Query::InMemory.comparable(target_state[target_tenant_field])
241
- next if target_tenant == own_tenant
242
-
243
- "#{key} (#{own_tenant_field}: #{own_tenant.inspect}) references #{attribute.name}: #{target_id.inspect}, " \
244
- "but #{domain_name}::#{target.name}##{target_id} carries #{target_tenant_field}: " \
245
- "#{target_tenant.inspect} — a cross-tenant write nothing refused"
246
- end
247
- end
248
-
249
- offenders.empty? || offenders.join("; ")
250
- end
251
-
252
- # The field an aggregate's own query names as tenant-scoping —
253
- # shared by `commands_respect_tenant_scope` above for both sides
254
- # of a `reference_to`. `nil` for an aggregate with no `authorize
255
- # ..., tenant:` on any of its own queries — not every aggregate
256
- # is tenant-scoped, and one that isn't has nothing for this
257
- # property to check either side of.
258
- #
259
- # @param aggregate [Bluebook::Aggregate] the aggregate to look up
260
- # @return [Symbol, nil] the tenant field name, or nil if no query on the
261
- # aggregate declares `authorize ..., tenant:`
262
- def tenant_field_for(aggregate)
263
- authorization = aggregate.queries.filter_map(&:authorization).find(&:tenant)
264
- authorization&.tenant&.to_sym
265
- end
266
-
267
- # A declared process manager's own command — `command.hecks_name`,
268
- # or an entity's own if the verb's second component is itself
269
- # dotted (`Aggregate.Entity.Command`, the same two shapes
270
- # `Dispatcher#dispatch` itself branches on). Shared by the guard
271
- # property above and available for anything else that needs to go
272
- # from a replayed verb back to its declaration.
273
- #
274
- # Resolved against `bluebooks` (the full map, `history[:bluebooks]`
275
- # — every loaded domain, keyed by name), never a single assumed
276
- # bluebook: a verb names its own domain (`Naming.split_verb`'s
277
- # first element), and that domain is not always the one Replay
278
- # happens to expose as `history[:bluebook]`. A fuzz run against
279
- # `lib/hecks/grammar` (Expression + Translation, in load
280
- # order) found this the hard way — every `Translation::Map.Seal`
281
- # refusal read as "no declared command resolves that verb" purely
282
- # because `history[:bluebook]` was Expression, not Translation; the
283
- # refusal was real, this property's own domain resolution was not.
284
- # A delegating door refuses with its target's own words. `delegates_to`
285
- # (CommandBuilder#delegates_to_impl) hands the whole dispatch to one
286
- # entity command, and that command's given is what refuses — raised
287
- # back through the door, in the door's name (chess: `Game.MoveKnight
288
- # refused — "it is that color's turn"`, a given Knight.Move declares
289
- # and MoveKnight, a pure passthrough, never could). Read the door's
290
- # own guards first, then every delegation target's; an offence is
291
- # only a description neither declares. Found live mining chess's
292
- # history: every refused move through a door read as undeclared.
293
- #
294
- # @param bluebooks [Hash{String => Bluebook::Chapter}] every loaded domain,
295
- # keyed by domain name
296
- # @param verb [String] the dispatched verb, `"Domain::Aggregate.Command"` or
297
- # `"Domain::Aggregate.Entity.Command"`
298
- # @param command [Bluebook::Command] the command `verb` resolves to
299
- # @return [Array<String>] `command`'s own declared given/ensures descriptions,
300
- # plus every `delegate`d command's, in that order
301
- def effective_guard_descriptions(bluebooks, verb, command)
302
- own = command.guard_descriptions
303
- delegated = command.mutations.select { |m| m.op == :delegate }.flat_map do |delegation|
304
- domain, aggregate_name, = Naming.split_verb(verb)
305
- target = command_for_verb(bluebooks, "#{domain}::#{aggregate_name}.#{delegation.target}")
306
- target ? target.guard_descriptions : []
307
- end
308
- own + delegated
309
- end
310
-
311
- # Resolves a dispatched verb back to the declared command it names.
312
- #
313
- # @param bluebooks [Hash{String => Bluebook::Chapter}] every loaded domain,
314
- # keyed by domain name
315
- # @param verb [String] the dispatched verb, `"Domain::Aggregate.Command"` or
316
- # `"Domain::Aggregate.Entity.Command"`
317
- # @return [Bluebook::Command, nil] the declared command, or nil if `verb`'s
318
- # domain, aggregate, entity, or command is not found among `bluebooks`
319
- def command_for_verb(bluebooks, verb)
320
- domain, aggregate_name, command_path = Naming.split_verb(verb)
321
- return nil unless command_path
322
-
323
- bluebook = bluebooks[domain]
324
- return nil unless bluebook
325
-
326
- aggregate = bluebook.aggregate(aggregate_name)
327
- return nil unless aggregate
328
-
329
- if command_path.include?(".")
330
- entity_name, sub = command_path.split(".", 2)
331
- entity = aggregate.entities.find { |e| e.hecks_name == entity_name }
332
- entity&.command(sub)
333
- else
334
- aggregate.command(command_path)
335
- end
336
- end
337
-
338
- # `guard_refusals_are_declared`'s own opposite direction. That
339
- # property is passive and one-directional — for a refusal that
340
- # already happened, is the quoted text real declared text? It says
341
- # nothing about a guard that should have refused and silently did
342
- # not — a call site that stopped calling enforce_givens/enforce_
343
- # lifecycle_guard would never appear in history[:refusals] at all,
344
- # invisible to that property by construction.
345
- #
346
- # This one calls Admissibility#enforce_givens (which itself folds
347
- # in #enforce_lifecycle_guard whenever `declaring:` is passed)
348
- # directly, against Replay's own pre-dispatch snapshot
349
- # (history[:guard_checks], one bounded, additive extension — see
350
- # that file's own comment at the capture site) — an independent
351
- # recomputation, not grading production against itself, the same
352
- # "two engines, compared" shape query_answers_match_reference and
353
- # the fan-out oracle already establish. `recomputed_refused`
354
- # (Replay's own call, made live, before this step's real dispatch
355
- # could mutate anything a cross-aggregate given dereferences) is
356
- # compared against `actual_refused` (GivenNotMet/LifecycleRefused
357
- # specifically — Replay's own comment on GUARD_REFUSAL_CLASSES
358
- # explains why any other refusal class, or an outright success,
359
- # both count as "the guard did not fire," since enforce_givens
360
- # runs first in DISPATCH_ORDER).
361
- #
362
- # Aggregate#preconditions closes for free alongside this — a
363
- # no-block `given` reference (CommandBuilder#given) pushes the
364
- # same Given struct object `enforce_givens` already iterates
365
- # command.givens for, so there is no separate runtime path a
366
- # property could exercise beyond what this already reaches.
367
- # Entity#preconditions closes the identical way, one level down
368
- # (ADR 0028) — a piece's own bare `given` reference pushes the
369
- # same Given struct onto its own referencing command's givens,
370
- # so LedgerEntry's own Amend/Reverse (banking) already exercise
371
- # this through the exact mechanism above, no separate path.
372
- #
373
- # Real targets: Account.Debit/CloseAccount (`from:` guards),
374
- # Credit/Debit (the named-once `given("customer is active")`
375
- # precondition) — FreezeAccount deliberately references the
376
- # different named precondition `"customer is not closed"` instead
377
- # (a suspended customer must still be freezable), so it is not a
378
- # `"customer is active"` example, just the same mechanism.
379
- #
380
- # @param history [Hash] a replayed history as returned by `Replay.call`
381
- # @return [true, String] true if every recomputed `enforce_givens`/
382
- # `enforce_lifecycle_guard` check agrees with what the real dispatch did;
383
- # otherwise a message naming the verb and the disagreement
384
- def lifecycle_guard_and_given_violations_are_refused(history)
385
- offenders = history.fetch(:guard_checks).filter_map do |check|
386
- next if check[:recomputed_refused] == check[:actual_refused]
387
-
388
- "#{check[:verb]} — independently recomputing enforce_givens/enforce_lifecycle_guard against the " \
389
- "pre-dispatch state says #{check[:recomputed_refused] ? "refused (#{check[:recomputed_kind]})" : 'admitted'}, " \
390
- "but the real dispatch #{check[:actual_refused] ? "refused (#{check[:actual_kind]})" : 'admitted it'}"
391
- end
392
-
393
- offenders.empty? || offenders.join("; ")
394
- end
395
- end
396
- end
397
- end
398
- end