hecks 2.7.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.
- checksums.yaml +4 -4
- data/exe/hecks +13 -0
- data/lib/hecks/adapters/driven/memory.rb +1 -1
- data/lib/hecks/adapters/driven/postgres.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
- data/lib/hecks/behaviors/expectations.rb +2 -2
- data/lib/hecks/bluebook/assembly/contract.rb +1 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
- data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
- data/lib/hecks/bluebook/assembly.rb +1 -1
- data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
- data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
- data/lib/hecks/bluebook/capabilities.rb +2 -2
- data/lib/hecks/bluebook/chapter.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
- data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
- data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
- data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
- data/lib/hecks/bluebook/meta_validator.rb +1 -1
- data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
- data/lib/hecks/bluebook/model_check.rb +12 -13
- data/lib/hecks/cache_dir.rb +103 -0
- data/lib/hecks/cli/document.rb +104 -0
- data/lib/hecks/cli/ir.rb +47 -0
- data/lib/hecks/cli/mcp.rb +32 -0
- data/lib/hecks/cli/mcp_door.rb +461 -0
- data/lib/hecks/cli/model_check.rb +270 -0
- data/lib/hecks/cli/project_cli.rb +123 -0
- data/lib/hecks/cli/project_diagrams.rb +43 -0
- data/lib/hecks/cli/run.rb +219 -0
- data/lib/hecks/cli/smoke_test.rb +59 -0
- data/lib/hecks/cli/stores.rb +54 -0
- data/lib/hecks/cli.rb +166 -0
- data/lib/hecks/facade/handle.rb +2 -2
- data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
- data/lib/hecks/mcp_door_scope.rb +176 -0
- data/lib/hecks/ports/persistence/append_only.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
- data/lib/hecks/ports/persistence/state_codec.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +1 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -3
- data/lib/hecks/projections/reference.rb +7 -1
- data/lib/hecks/projector/exporter.rb +4 -4
- data/lib/hecks/projector/target.rb +1 -1
- data/lib/hecks/release/runner/ci_publisher.rb +96 -0
- data/lib/hecks/release/runner/commands.rb +68 -0
- data/lib/hecks/release/runner/console.rb +52 -0
- data/lib/hecks/release/runner/gem_publisher.rb +44 -0
- data/lib/hecks/release/runner/git.rb +48 -0
- data/lib/hecks/release/runner/npm_publisher.rb +89 -0
- data/lib/hecks/release/runner/preflight.rb +119 -0
- data/lib/hecks/release/runner/published.rb +55 -0
- data/lib/hecks/release/runner/tagger.rb +87 -0
- data/lib/hecks/release/runner.rb +244 -0
- data/lib/hecks/router/namespace_installer.rb +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +3 -3
- data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
- data/lib/hecks/runtime/entity_element.rb +1 -1
- data/lib/hecks/runtime/entity_interpreter.rb +1 -1
- data/lib/hecks/runtime/invocation.rb +1 -1
- data/lib/hecks/runtime/loader.rb +1 -1
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/reaction_invocation.rb +3 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
- data/lib/hecks/runtime/reference_hop.rb +4 -4
- data/lib/hecks/runtime/registry/verification.rb +11 -11
- data/lib/hecks/runtime/registry.rb +8 -4
- data/lib/hecks/runtime/saga_interpreter.rb +1 -1
- data/lib/hecks/runtime/value/coercion.rb +3 -3
- data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
- data/lib/hecks/storehouse.rb +18 -12
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +5 -0
- data/lib/hecks.rb +6 -1
- metadata +28 -58
- data/lib/hecks/bench/cli.rb +0 -74
- data/lib/hecks/bench/environment.rb +0 -100
- data/lib/hecks/bench/postgres_probe.rb +0 -41
- data/lib/hecks/bench/report.rb +0 -116
- data/lib/hecks/bench/ruby_runner.rb +0 -151
- data/lib/hecks/bench/run.rb +0 -55
- data/lib/hecks/bench/rust_runner.rb +0 -165
- data/lib/hecks/bench/stats.rb +0 -75
- data/lib/hecks/bench/suite.rb +0 -172
- data/lib/hecks/bench/workload.rb +0 -137
- data/lib/hecks/bench.rb +0 -28
- data/lib/hecks/codemod.rb +0 -443
- data/lib/hecks/corpus.rb +0 -479
- data/lib/hecks/doc/reference.rb +0 -585
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
- data/lib/hecks/fuzzing/combination_miner.rb +0 -178
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
- data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
- data/lib/hecks/fuzzing/differential.rb +0 -192
- data/lib/hecks/fuzzing/domain_generator.rb +0 -871
- data/lib/hecks/fuzzing/era_boundary.rb +0 -154
- data/lib/hecks/fuzzing/form_census.rb +0 -285
- data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
- data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
- data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
- data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
- data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
- data/lib/hecks/fuzzing/properties/guards.rb +0 -398
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
- data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
- data/lib/hecks/fuzzing/properties/querying.rb +0 -260
- data/lib/hecks/fuzzing/properties.rb +0 -267
- data/lib/hecks/fuzzing/qa_settings.rb +0 -164
- data/lib/hecks/fuzzing/replay.rb +0 -930
- data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
- data/lib/hecks/fuzzing/self_consistency.rb +0 -825
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
- data/lib/hecks/fuzzing/shrinker.rb +0 -309
- data/lib/hecks/fuzzing/structural_skips.rb +0 -53
- data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
- data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
- data/lib/hecks/fuzzing/value_generator.rb +0 -340
- data/lib/hecks/fuzzing.rb +0 -23
- data/lib/hecks/grammar/evolve.rb +0 -524
- 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
|