hecks 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/d1.rb +38 -14
- data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
- data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
- data/lib/hecks/adapters/driven/heki.rb +40 -9
- data/lib/hecks/adapters/driven/lambda.rb +16 -2
- data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
- data/lib/hecks/adapters/driven/postgres.rb +76 -29
- data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
- data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
- data/lib/hecks/adapters/driven/sqlite.rb +30 -9
- data/lib/hecks/adapters/driven.rb +19 -1
- data/lib/hecks/behaviors/dsl.rb +29 -0
- data/lib/hecks/behaviors/expectations.rb +62 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
- data/lib/hecks/bluebook/assembly/marks.rb +4 -3
- data/lib/hecks/bluebook/assembly.rb +14 -1
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
- data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
- data/lib/hecks/bluebook/chapter.rb +21 -11
- data/lib/hecks/bluebook/command.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
- data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
- data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
- data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
- data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
- data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
- data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
- data/lib/hecks/bluebook/expression/projection.json +48 -0
- data/lib/hecks/bluebook/expression/resolver.rb +161 -10
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
- data/lib/hecks/bluebook/meta_validator.rb +103 -13
- data/lib/hecks/bluebook/model_check.rb +132 -5
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/value_object.rb +9 -1
- data/lib/hecks/doc/reference.rb +22 -1
- data/lib/hecks/facade/cli_door.rb +6 -3
- data/lib/hecks/facade/json_door.rb +16 -4
- data/lib/hecks/forms/app.rb +47 -6
- data/lib/hecks/forms/command_form_renderer.rb +1 -1
- data/lib/hecks/forms/field_renderer.rb +11 -4
- data/lib/hecks/forms/html.rb +31 -0
- data/lib/hecks/forms/params.rb +30 -1
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/record_renderer.rb +6 -2
- data/lib/hecks/forms/record_table.rb +6 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
- data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
- data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
- data/lib/hecks/fuzzing/properties.rb +52 -6
- data/lib/hecks/fuzzing/replay.rb +51 -18
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
- data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
- data/lib/hecks/fuzzing/value_generator.rb +25 -2
- data/lib/hecks/grammar/evolve.rb +33 -0
- data/lib/hecks/grammar/expression.bluebook +8 -5
- data/lib/hecks/grammar/expression_operators.json +497 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
- data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
- data/lib/hecks/language/bluebook/command.bluebook +42 -8
- data/lib/hecks/language/bluebook/entity.bluebook +86 -10
- data/lib/hecks/language/bluebook/policy.bluebook +21 -4
- data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
- data/lib/hecks/language/bluebook/projection.bluebook +6 -6
- data/lib/hecks/language/bluebook/query.bluebook +4 -4
- data/lib/hecks/language/bluebook/shape.bluebook +6 -6
- data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
- data/lib/hecks/language/oidc.json +20 -0
- data/lib/hecks/language/port.bluebook +30 -2
- data/lib/hecks/naming.rb +54 -1
- data/lib/hecks/ports/access_control.port +7 -2
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.port +6 -2
- data/lib/hecks/ports/agent.rb +1 -1
- data/lib/hecks/ports/authentication.port +4 -2
- data/lib/hecks/ports/authentication.rb +1 -1
- data/lib/hecks/ports/authorization.port +5 -2
- data/lib/hecks/ports/authorization.rb +14 -11
- data/lib/hecks/ports/clock.port +3 -2
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.port +3 -2
- data/lib/hecks/ports/extraction.rb +1 -1
- data/lib/hecks/ports/identity_assignment.port +3 -2
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.port +3 -2
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.port +3 -2
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/execution.rb +6 -1
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
- data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence.rb +1 -1
- data/lib/hecks/ports/projection.rb +20 -0
- data/lib/hecks/projections/diagrams.rb +230 -1
- data/lib/hecks/projections/shape.rb +15 -2
- data/lib/hecks/projector/cli_projector.rb +10 -1
- data/lib/hecks/projector/exporter.rb +19 -2
- data/lib/hecks/query_ir.rb +19 -0
- data/lib/hecks/query_specification/common/null_policy.rb +13 -1
- data/lib/hecks/query_specification/field_path.rb +20 -2
- data/lib/hecks/query_specification/hop_path.rb +7 -5
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +19 -3
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
- data/lib/hecks/runtime/command_interpreter.rb +97 -13
- data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
- data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
- data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
- data/lib/hecks/runtime/command_rules/references.rb +27 -19
- data/lib/hecks/runtime/dependency_planning.rb +14 -0
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +85 -14
- data/lib/hecks/runtime/errors.rb +22 -0
- data/lib/hecks/runtime/identity.rb +30 -2
- data/lib/hecks/runtime/instance.rb +59 -4
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/loader.rb +59 -18
- data/lib/hecks/runtime/query_interpreter.rb +36 -4
- data/lib/hecks/runtime/reaction_invocation.rb +9 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
- data/lib/hecks/runtime/refusal_wording.rb +2 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
- data/lib/hecks/runtime/registry/verification.rb +88 -0
- data/lib/hecks/runtime/registry.rb +69 -8
- data/lib/hecks/runtime/saga_interpreter.rb +215 -13
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/value/admission.rb +19 -1
- data/lib/hecks/runtime/value/coercion.rb +75 -10
- data/lib/hecks/runtime.rb +17 -5
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +7 -2
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +48 -37
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
- /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
data/lib/hecks/naming.rb
CHANGED
|
@@ -61,7 +61,21 @@ module Hecks
|
|
|
61
61
|
def singularize(text)
|
|
62
62
|
word = text.to_s
|
|
63
63
|
return "#{word[0..-4]}y" if word.length > 3 && word.end_with?("ies")
|
|
64
|
-
|
|
64
|
+
|
|
65
|
+
# `plural`'s OWN second rule adds "es" (not bare "s") after
|
|
66
|
+
# s/x/z/ch/sh — undone here the same way, or a word `plural`
|
|
67
|
+
# itself would have suffixed with "es" comes back missing its
|
|
68
|
+
# own trailing letter ("Boxes" -> "Boxe", not "Box") once this
|
|
69
|
+
# only ever knew how to drop a bare "s". Checked BEFORE the
|
|
70
|
+
# bare-"s" rule below: stripping "es" first and confirming what
|
|
71
|
+
# is left actually ends in one of those five shapes is what
|
|
72
|
+
# keeps an ordinary "-es" word (e.g. "Invoices" -> "Invoice")
|
|
73
|
+
# from also losing a letter it never doubled.
|
|
74
|
+
if word.length > 3 && word.end_with?("es") && word[0..-3].match?(/(s|x|z|ch|sh)\z/)
|
|
75
|
+
return word[0..-3]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return word[0..-2] if word.length > 1 && word.end_with?("s")
|
|
65
79
|
|
|
66
80
|
word
|
|
67
81
|
end
|
|
@@ -117,5 +131,44 @@ module Hecks
|
|
|
117
131
|
path, _, command = text.rpartition("::")
|
|
118
132
|
path.empty? ? text : "#{path}.#{command}"
|
|
119
133
|
end
|
|
134
|
+
|
|
135
|
+
# `emits Account::AccountFrozen` / `on Account::AccountFrozen` — the
|
|
136
|
+
# event-side twin of `command_ref`, above (ADR 0025, S6 — "events
|
|
137
|
+
# first-class"). Identical transform (a bare `ScopedConstant`'s last
|
|
138
|
+
# `::` becomes `.`, a String passes through unchanged for legacy
|
|
139
|
+
# `shadow_parse` text and for corpus sites this pass didn't migrate —
|
|
140
|
+
# see that method's own comment for why both rules exist), given its
|
|
141
|
+
# own name because the two references mean different things even
|
|
142
|
+
# though the rewrite is byte-identical: an event name is not a
|
|
143
|
+
# command name that happens to share a format.
|
|
144
|
+
def event_ref(value) = command_ref(value)
|
|
145
|
+
|
|
146
|
+
# `transition Account::AccountDebited => "state"` / `starts_on
|
|
147
|
+
# Transfer::TransferRequested` / `ends_on Transfer::TransferSettled`
|
|
148
|
+
# — a process manager's OWN event references (ADR 0025, S6),
|
|
149
|
+
# DELIBERATELY NOT `event_ref` — found live, not assumed, wiring a
|
|
150
|
+
# real migrated corpus site into `bin/model_check` for the first
|
|
151
|
+
# time (2026-08-28): `SagaInterpreter#begin_saga`/`#advance_saga`
|
|
152
|
+
# match `pm.starts_on`/`pm.handler_for` against `event.name`, which
|
|
153
|
+
# `CommandRules::Emission#emit` stamps BARE — a command's own
|
|
154
|
+
# `emits AccountDebited` never carries its aggregate's name at all
|
|
155
|
+
# (unlike a policy's cross-aggregate `on`, matched instead by
|
|
156
|
+
# `Naming.demodulise(event.aggregate)` split apart from the bare
|
|
157
|
+
# name — `PolicyInterpreter#policies_for`). Handing a saga's own
|
|
158
|
+
# matcher the DOTTED `event_ref` form ("Account.AccountDebited")
|
|
159
|
+
# would silently name an event no command in the domain ever
|
|
160
|
+
# actually emits — caught by `bin/model_check`'s own `deaf_handler`/
|
|
161
|
+
# `deaf_trigger` findings the moment a real qualified corpus site
|
|
162
|
+
# existed to trip them, not by any unit test in isolation.
|
|
163
|
+
#
|
|
164
|
+
# A qualifier is still worth WRITING (`Account::`) — the same
|
|
165
|
+
# provenance a reader gets from `trigger Account::Debit` — it is
|
|
166
|
+
# only not worth KEEPING: `demodulise` drops everything but the
|
|
167
|
+
# final segment, so `Account::AccountDebited` and a bare
|
|
168
|
+
# `AccountDebited` resolve to the identical stored string. A String
|
|
169
|
+
# passes through unchanged either way, exactly like `command_ref`'s
|
|
170
|
+
# own legacy branch — this corpus never spelled one dotted to begin
|
|
171
|
+
# with, so there is nothing here to strip.
|
|
172
|
+
def event_name_ref(value) = demodulise(value)
|
|
120
173
|
end
|
|
121
174
|
end
|
|
@@ -47,7 +47,7 @@ module Hecks
|
|
|
47
47
|
implementations = registry.adapters.values.select { |a| a.port == NAME }
|
|
48
48
|
|
|
49
49
|
case implementations.size
|
|
50
|
-
when 1 then
|
|
50
|
+
when 1 then registry.adapter_class(implementations.first.name)
|
|
51
51
|
when 0
|
|
52
52
|
raise Runtime::WiringError,
|
|
53
53
|
"no adapter implements the #{NAME} port — nothing can answer who's allowed in"
|
data/lib/hecks/ports/agent.port
CHANGED
data/lib/hecks/ports/agent.rb
CHANGED
|
@@ -150,7 +150,7 @@ module Hecks
|
|
|
150
150
|
implementations = registry.adapters.values.select { |a| a.port == NAME }
|
|
151
151
|
|
|
152
152
|
case implementations.size
|
|
153
|
-
when 1 then
|
|
153
|
+
when 1 then registry.adapter_class(implementations.first.name)
|
|
154
154
|
when 0
|
|
155
155
|
raise Runtime::WiringError,
|
|
156
156
|
"no adapter implements the #{NAME} port — nothing can conduct an interview"
|
|
@@ -35,7 +35,7 @@ module Hecks
|
|
|
35
35
|
implementations = registry.adapters.values.select { |a| a.port == NAME }
|
|
36
36
|
|
|
37
37
|
case implementations.size
|
|
38
|
-
when 1 then
|
|
38
|
+
when 1 then registry.adapter_class(implementations.first.name)
|
|
39
39
|
when 0
|
|
40
40
|
raise Runtime::WiringError,
|
|
41
41
|
"no adapter implements the #{NAME} port — nothing can authenticate a sign-in"
|
|
@@ -15,21 +15,24 @@ module Hecks
|
|
|
15
15
|
# A caller decides what to DO with the answer — dispatch under that
|
|
16
16
|
# role, refuse, log, prefer it over some other fallback value — this
|
|
17
17
|
# only answers the question asked. Nothing here binds a
|
|
18
|
-
# `Runtime::Caller
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
18
|
+
# `Runtime::Caller`.
|
|
19
|
+
#
|
|
20
|
+
# STALE AS OF THE `as_of`/`scope` split below, this used to also say
|
|
21
|
+
# nothing here is consulted by `CommandRules::Authorization` — it now
|
|
22
|
+
# is: `refuse_role_mismatch` calls `holds_role?` directly, once a
|
|
23
|
+
# caller binds an `actor_id` and the command's domain has Governance
|
|
24
|
+
# attached (see that rule's own header). `spec/act_as_spec.rb`
|
|
25
|
+
# remains the precedent for the OTHER shape — two separate
|
|
26
|
+
# registries, queried directly by name — for whenever Governance is
|
|
27
|
+
# not in the same boot as the caller; this port is the same
|
|
28
|
+
# questions asked through one adapter when it is.
|
|
26
29
|
module Authorization
|
|
27
30
|
NAME = "authorization"
|
|
28
31
|
|
|
29
32
|
module_function
|
|
30
33
|
|
|
31
|
-
def holds_role?(registry, actor_id:, role:)
|
|
32
|
-
adapter(registry).holds_role?(registry, actor_id: actor_id, role: role)
|
|
34
|
+
def holds_role?(registry, actor_id:, role:, as_of: nil, scope: nil)
|
|
35
|
+
adapter(registry).holds_role?(registry, actor_id: actor_id, role: role, as_of: as_of, scope: scope)
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
def authorized_as?(registry, from_role:, to_role:)
|
|
@@ -44,7 +47,7 @@ module Hecks
|
|
|
44
47
|
implementations = registry.adapters.values.select { |a| a.port == NAME }
|
|
45
48
|
|
|
46
49
|
case implementations.size
|
|
47
|
-
when 1 then
|
|
50
|
+
when 1 then registry.adapter_class(implementations.first.name)
|
|
48
51
|
when 0
|
|
49
52
|
raise Runtime::WiringError,
|
|
50
53
|
"no adapter implements the #{NAME} port — nothing can answer a role check"
|
data/lib/hecks/ports/clock.port
CHANGED
data/lib/hecks/ports/clock.rb
CHANGED
|
@@ -47,7 +47,7 @@ module Hecks
|
|
|
47
47
|
implementations = registry.adapters.values.select { |a| a.port == NAME }
|
|
48
48
|
|
|
49
49
|
case implementations.size
|
|
50
|
-
when 1 then
|
|
50
|
+
when 1 then registry.adapter_class(implementations.first.name)
|
|
51
51
|
when 0
|
|
52
52
|
raise Runtime::WiringError,
|
|
53
53
|
"no adapter implements the #{NAME} port — nothing can say what time it is"
|
|
@@ -20,7 +20,7 @@ module Hecks
|
|
|
20
20
|
implementations = registry.adapters.values.select { |a| a.port == NAME }
|
|
21
21
|
|
|
22
22
|
case implementations.size
|
|
23
|
-
when 1 then
|
|
23
|
+
when 1 then registry.adapter_class(implementations.first.name)
|
|
24
24
|
when 0
|
|
25
25
|
raise Runtime::WiringError,
|
|
26
26
|
"no adapter implements the #{NAME} port — nothing can recover a " \
|
|
@@ -30,7 +30,7 @@ module Hecks
|
|
|
30
30
|
implementations = registry.adapters.values.select { |a| a.port == NAME }
|
|
31
31
|
|
|
32
32
|
case implementations.size
|
|
33
|
-
when 1 then
|
|
33
|
+
when 1 then registry.adapter_class(implementations.first.name)
|
|
34
34
|
when 0
|
|
35
35
|
raise Runtime::WiringError,
|
|
36
36
|
"no adapter implements the #{NAME} port — nothing can assign an identity"
|
|
@@ -34,7 +34,7 @@ module Hecks
|
|
|
34
34
|
implementations = registry.adapters.values.select { |a| a.port == NAME }
|
|
35
35
|
|
|
36
36
|
case implementations.size
|
|
37
|
-
when 1 then
|
|
37
|
+
when 1 then registry.adapter_class(implementations.first.name)
|
|
38
38
|
when 0
|
|
39
39
|
raise Runtime::WiringError,
|
|
40
40
|
"no adapter implements the #{NAME} port — nothing can mint an identity"
|
|
@@ -25,7 +25,7 @@ module Hecks
|
|
|
25
25
|
implementations = registry.adapters.values.select { |a| a.port == NAME }
|
|
26
26
|
|
|
27
27
|
case implementations.size
|
|
28
|
-
when 1 then
|
|
28
|
+
when 1 then registry.adapter_class(implementations.first.name)
|
|
29
29
|
when 0
|
|
30
30
|
raise Runtime::WiringError,
|
|
31
31
|
"no adapter implements the #{NAME} port — nothing can resolve an identity"
|
|
@@ -64,11 +64,32 @@ module Hecks
|
|
|
64
64
|
def append(entry) = @adapter.append(entry)
|
|
65
65
|
def project(entry) = @adapter.project(entry)
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
# Returns an `Outcome`, not a bare `Instance` — every call site
|
|
68
|
+
# (`CommandInterpreter`/`EntityInterpreter`'s own `step_save`,
|
|
69
|
+
# `RebuildSweep#refresh`) reads it that way.
|
|
70
|
+
#
|
|
71
|
+
# `expected_version:` requests optimistic-concurrency CAS — commit
|
|
72
|
+
# only if the stored record's version still matches what THIS
|
|
73
|
+
# instance was read at. It is `nil` both when a caller explicitly
|
|
74
|
+
# doesn't want CAS (`RebuildSweep#refresh`'s own projection-field
|
|
75
|
+
# touch-up, which has no `given` to protect) and when the instance
|
|
76
|
+
# is brand new (never read from storage, so `instance.version` is
|
|
77
|
+
# nil) — both cases fall through to the plain, unconditional
|
|
78
|
+
# `project(entry)` below, byte-for-byte today's behavior. Only an
|
|
79
|
+
# adapter that both receives a non-nil `expected_version` AND
|
|
80
|
+
# declares `:optimistic_concurrency` gets CAS treatment; every
|
|
81
|
+
# other adapter/call site is unaffected.
|
|
82
|
+
def save(instance, expected_version: nil)
|
|
68
83
|
entry = Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup)
|
|
69
84
|
append(entry)
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
if expected_version && capabilities.include?(:optimistic_concurrency)
|
|
86
|
+
saved = @adapter.project(entry, expected_version: expected_version)
|
|
87
|
+
return Outcome.new(status: :stale, instance: instance) if saved.nil?
|
|
88
|
+
|
|
89
|
+
return Outcome.new(status: :saved, instance: saved)
|
|
90
|
+
end
|
|
91
|
+
saved = @adapter.project(entry)
|
|
92
|
+
Outcome.new(status: :saved, instance: saved || instance)
|
|
72
93
|
end
|
|
73
94
|
|
|
74
95
|
def atomic_put(instance, insert_only: false)
|
|
@@ -90,7 +111,22 @@ module Hecks
|
|
|
90
111
|
true
|
|
91
112
|
end
|
|
92
113
|
|
|
93
|
-
def record_event
|
|
114
|
+
# NOT an endless `def record_event = ... if ...` — same gotcha as
|
|
115
|
+
# `events` above, and it bit for real here: this guard evaluated
|
|
116
|
+
# against `@adapter` at class-body time (nil, always false), so
|
|
117
|
+
# `record_event` was never defined at all. `emission.rb`'s own
|
|
118
|
+
# `repository.record_event(event) if repository.respond_to?(:record_event)`
|
|
119
|
+
# therefore never fired for any adapter, ever — every declared
|
|
120
|
+
# `emits` was computed and reported in `registry.event_log` (an
|
|
121
|
+
# in-process array, gone at exit) but never durably recorded.
|
|
122
|
+
# Caught because a live tail of a domain's own persisted events
|
|
123
|
+
# found nothing to tail. `sqlite_spec.rb`/`postgres_spec.rb`/
|
|
124
|
+
# `postgres_era_spec.rb` all call `adapter.record_event` directly,
|
|
125
|
+
# bypassing this wrapper — which is exactly why no spec noticed.
|
|
126
|
+
def record_event(event)
|
|
127
|
+
@adapter.record_event(event) if @adapter.respond_to?(:record_event)
|
|
128
|
+
end
|
|
129
|
+
|
|
94
130
|
def query_read_model(domain, model, args, bluebook = nil)
|
|
95
131
|
return unless @adapter.respond_to?(:query_read_model)
|
|
96
132
|
|
|
@@ -3,7 +3,12 @@ module Hecks
|
|
|
3
3
|
module Persistence
|
|
4
4
|
# Adapter outcomes are runtime facts, never command lifecycle.
|
|
5
5
|
Outcome = Struct.new(:status, :instance, keyword_init: true) do
|
|
6
|
-
|
|
6
|
+
# `:stale` is an optimistic-concurrency conflict on a plain `save`
|
|
7
|
+
# (someone else committed since this instance was read) — never a
|
|
8
|
+
# domain refusal, distinct from `:conflicted` (a `creates?`
|
|
9
|
+
# command's identity already exists, `AlreadyExists`). See
|
|
10
|
+
# `Runtime::StaleWrite` (runtime/errors.rb) and `AppendOnly#save`.
|
|
11
|
+
STATUSES = %i[inserted replaced updated conflicted missing saved stale].freeze
|
|
7
12
|
|
|
8
13
|
def initialize(status:, instance: nil)
|
|
9
14
|
normalized = status.to_sym
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Ports
|
|
3
|
+
module Persistence
|
|
4
|
+
# A tiny, real seam — ADR 0033. Core never names a persistence plugin;
|
|
5
|
+
# a plugin makes itself known as a side effect of being `require`d, the
|
|
6
|
+
# same way an adapter's own `.rb` file already registers itself
|
|
7
|
+
# (`Runtime::Registry#add_adapter`) rather than core listing adapter
|
|
8
|
+
# names anywhere. `register_plugin` is the ENTIRE contract a plugin
|
|
9
|
+
# must satisfy: anything responding to `contribute_boot_gates(registry,
|
|
10
|
+
# gates)` (a no-op is a valid implementation).
|
|
11
|
+
#
|
|
12
|
+
# This is deliberately NOT the same registry `Hecks::Projector` uses
|
|
13
|
+
# (ADR 0027) or `Runtime::BootGates` (ADR 0031) — each of those is
|
|
14
|
+
# scoped to its own seam (IR-in/artifact-out; per-boot phased gates).
|
|
15
|
+
# A shared base is deferred until a third registry actually wants one.
|
|
16
|
+
#
|
|
17
|
+
# NAME-KEYED, PROCESS-WIDE, NOT PER-BOOT — unlike `BootGates` (one
|
|
18
|
+
# instance per `Loader.boot` call, because gate REGISTRATION is
|
|
19
|
+
# capability-conditional per registry), a persistence plugin is either
|
|
20
|
+
# `require`d into this process or it isn't; there is no "this boot's
|
|
21
|
+
# registry doesn't need it" case to isolate against, so a plain
|
|
22
|
+
# module-level hash is the right shape here, not an instantiable class.
|
|
23
|
+
module Plugin
|
|
24
|
+
@plugins = {}
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def register(name, plugin)
|
|
28
|
+
@plugins[name.to_sym] = plugin
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def registered?(name)
|
|
32
|
+
@plugins.key?(name.to_sym)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def each(&)
|
|
36
|
+
@plugins.each_value(&)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def any? = !@plugins.empty?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# `Ports::Persistence.plugin?(:era)` / `.each_plugin` — the ordinary
|
|
44
|
+
# call surface; `Plugin.register`/`.registered?`/`.each` stay reachable
|
|
45
|
+
# directly for a plugin's own file to call at require-time.
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
def register_plugin(name, plugin) = Plugin.register(name, plugin)
|
|
49
|
+
def plugin?(name) = Plugin.registered?(name)
|
|
50
|
+
def each_plugin(&) = Plugin.each(&)
|
|
51
|
+
def plugins_loaded? = Plugin.any?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
require_relative "
|
|
2
|
-
require_relative "
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "registry"
|
|
1
|
+
require_relative "../../../../ports/persistence"
|
|
2
|
+
require_relative "../../../../ports/persistence/binding_policy"
|
|
3
|
+
require_relative "lineage"
|
|
4
|
+
require_relative "../../../../naming"
|
|
5
|
+
require_relative "../../../../framework"
|
|
6
|
+
require_relative "../../../../runtime/registry"
|
|
7
7
|
|
|
8
8
|
module Hecks
|
|
9
9
|
module Runtime
|
|
@@ -44,11 +44,46 @@ module Hecks
|
|
|
44
44
|
# the first refuses toward a scaffold that was never the real
|
|
45
45
|
# drift.
|
|
46
46
|
def check!(registry, directory)
|
|
47
|
+
check_compute_rules_for_registry!(registry)
|
|
48
|
+
check_lineage!(registry, directory)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The domain-agnostic half, split out for ADR 0031's boot-gate
|
|
52
|
+
# registry: a compute rule requires Postgres whatever adapter is
|
|
53
|
+
# actually bound, so this must run for EVERY registry, the same way
|
|
54
|
+
# `registry.verify!` does — it is not conditional on any adapter
|
|
55
|
+
# being lineage-capable, and must never be skipped by
|
|
56
|
+
# `check_lineage!`'s own capability gate below.
|
|
57
|
+
def check_compute_rules_for_registry!(registry)
|
|
58
|
+
registry.bluebooks.each_value { |bluebook| check_compute_rules!(registry, bluebook) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The capability-gated half — ADR 0031's registered `:era_check`
|
|
62
|
+
# gate. Registration is conditional on `lineage_capable_registry?`;
|
|
63
|
+
# `check_bluebook!` below still carries its own per-bluebook
|
|
64
|
+
# `lineage_capable?` return-early, unchanged, for a registry with a
|
|
65
|
+
# mix of lineage-capable and plain-adapter bluebooks.
|
|
66
|
+
def check_lineage!(registry, directory)
|
|
47
67
|
registry.bluebooks.each_value do |bluebook|
|
|
48
68
|
check_bluebook!(registry, bluebook, source_text_for(bluebook, directory), directory: directory)
|
|
49
69
|
end
|
|
50
70
|
end
|
|
51
71
|
|
|
72
|
+
# The `:era_check` gate's own registration predicate: true iff at
|
|
73
|
+
# least one bluebook's own anchor (first) aggregate resolves to a
|
|
74
|
+
# lineage-capable adapter — mirrors `check_bluebook!`'s existing
|
|
75
|
+
# per-bluebook anchor check, just asked once, up front, of the
|
|
76
|
+
# whole registry, so a registry with nothing lineage-capable bound
|
|
77
|
+
# anywhere never registers the gate at all.
|
|
78
|
+
def lineage_capable_registry?(registry)
|
|
79
|
+
registry.bluebooks.each_value.any? do |bluebook|
|
|
80
|
+
first = bluebook.aggregates.first
|
|
81
|
+
next false unless first
|
|
82
|
+
|
|
83
|
+
lineage_capable?(registry, adapter_for(registry, bluebook.name, first))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
52
87
|
# The domain's own directory first, matched by name — a real app's
|
|
53
88
|
# directory may hold more than one file once `uses_framework`
|
|
54
89
|
# exists, so ".first" alone can no longer be trusted, the exact
|
|
@@ -80,8 +115,6 @@ module Hecks
|
|
|
80
115
|
end
|
|
81
116
|
|
|
82
117
|
def check_bluebook!(registry, bluebook, current_text, directory: nil)
|
|
83
|
-
check_compute_rules!(registry, bluebook)
|
|
84
|
-
|
|
85
118
|
first = bluebook.aggregates.first
|
|
86
119
|
return unless first
|
|
87
120
|
|
|
@@ -1,67 +1,34 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
1
|
require_relative "era_guard/shape_diff"
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "
|
|
5
|
-
require_relative "
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "../ports/persistence/lineage"
|
|
8
|
-
require_relative "era_check"
|
|
9
|
-
require_relative "registry"
|
|
2
|
+
require_relative "../../../../bluebook/dsl/malformed"
|
|
3
|
+
require_relative "../../../../bluebook/meta_validator"
|
|
4
|
+
require_relative "../../../../ports/loading"
|
|
5
|
+
require_relative "../../../../runtime/registry"
|
|
10
6
|
|
|
11
7
|
module Hecks
|
|
12
8
|
module Runtime
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
9
|
+
# The shape-drift coverage primitives — ADR 0032. Not a driver: nothing
|
|
10
|
+
# here walks a registry or reads/writes a held snapshot on its own.
|
|
11
|
+
# `PostgresEra::LineageManager::CoverageCheck` calls `uncovered_
|
|
12
|
+
# attributes`/`unsafe_additions`/`refuse_uncovered!`/`refuse_unsafe_
|
|
13
|
+
# addition!`/`check_vanished_aggregates!` directly, per translation
|
|
14
|
+
# edge, over its own DB-held shapes; `Translation::Reattest` and
|
|
15
|
+
# `PostgresEra::LineageManager` call `shadow_parse` directly, to read
|
|
16
|
+
# historical bluebook text under old grammar defaults. Both are real,
|
|
17
|
+
# independent, currently-shipped consumers.
|
|
17
18
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
19
|
+
# A prior version of this module ALSO drove its own top-level check —
|
|
20
|
+
# `check!`/`check_bluebook!`, walking a registry and reading/writing a
|
|
21
|
+
# held snapshot under `data/eras/*.bluebook` — duplicating, on its own,
|
|
22
|
+
# the same per-aggregate walk `CoverageCheck` already performs against
|
|
23
|
+
# `PostgresEra`'s own DB-held shapes. Nothing in production ever called
|
|
24
|
+
# it (only a direct unit spec did); deleted rather than kept unwired,
|
|
25
|
+
# per ADR 0032. Wanted again, it's rebuilt informed by `CoverageCheck`'s
|
|
26
|
+
# real orchestration, not resurrected from here.
|
|
22
27
|
module EraGuard
|
|
23
28
|
extend ShapeDiff
|
|
24
29
|
|
|
25
30
|
module_function
|
|
26
31
|
|
|
27
|
-
def check!(registry, directory)
|
|
28
|
-
registry.bluebooks.each_value { |bluebook| check_bluebook!(registry, bluebook, directory) }
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def check_bluebook!(registry, bluebook, directory)
|
|
32
|
-
current_text = EraCheck.source_text_for(bluebook, directory)
|
|
33
|
-
return unless current_text
|
|
34
|
-
|
|
35
|
-
era_dir = File.join(registry.root, "data", "eras")
|
|
36
|
-
held_path = File.join(era_dir, "#{Naming.snake(bluebook.name)}.bluebook")
|
|
37
|
-
|
|
38
|
-
FileUtils.mkdir_p(era_dir)
|
|
39
|
-
unless File.exist?(held_path)
|
|
40
|
-
File.write(held_path, current_text)
|
|
41
|
-
return
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
held_bluebook = shadow_parse(File.read(held_path, encoding: "UTF-8"), held_path)
|
|
45
|
-
drifted = false
|
|
46
|
-
|
|
47
|
-
bluebook.aggregates.each do |aggregate|
|
|
48
|
-
lineage = Ports::Persistence::Lineage.for(registry, bluebook.name, aggregate)
|
|
49
|
-
held_aggregate = held_bluebook.aggregate(lineage&.ancestor_name || aggregate.name)
|
|
50
|
-
next unless held_aggregate
|
|
51
|
-
next if shape(aggregate) == shape(held_aggregate)
|
|
52
|
-
|
|
53
|
-
drifted = true
|
|
54
|
-
uncovered = uncovered_attributes(aggregate, held_aggregate, lineage)
|
|
55
|
-
refuse_uncovered!(bluebook, aggregate, uncovered) unless uncovered.empty?
|
|
56
|
-
|
|
57
|
-
unsafe = unsafe_additions(aggregate, held_aggregate, lineage)
|
|
58
|
-
refuse_unsafe_addition!(bluebook, aggregate, unsafe) unless unsafe.empty?
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
check_vanished_aggregates!(registry, bluebook, held_bluebook)
|
|
62
|
-
File.write(held_path, current_text) if drifted
|
|
63
|
-
end
|
|
64
|
-
|
|
65
32
|
# An aggregate that existed in the held text and answers to no
|
|
66
33
|
# current name — renamed silently, with nothing declaring `was:` to
|
|
67
34
|
# explain where its data went — is exactly the disease this guards
|
|
@@ -87,8 +54,9 @@ module Hecks
|
|
|
87
54
|
end
|
|
88
55
|
end
|
|
89
56
|
|
|
90
|
-
# The Layer-1 coverage refusal — one wording,
|
|
91
|
-
#
|
|
57
|
+
# The Layer-1 coverage refusal — one wording, shared with whoever
|
|
58
|
+
# calls it (today, `PostgresEra::LineageManager::CoverageCheck`'s
|
|
59
|
+
# own mint-time coverage check).
|
|
92
60
|
def refuse_uncovered!(bluebook, aggregate, uncovered)
|
|
93
61
|
raise WiringError,
|
|
94
62
|
"cannot boot #{bluebook.name}::#{aggregate.name}: its shape changed and " \
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
require_relative "
|
|
2
|
-
require_relative "append_only"
|
|
3
|
-
require_relative "
|
|
1
|
+
require_relative "../../../../naming"
|
|
2
|
+
require_relative "../../append_only"
|
|
3
|
+
require_relative "../../../../runtime/registry"
|
|
4
4
|
|
|
5
5
|
module Hecks
|
|
6
6
|
module Ports
|
|
@@ -91,7 +91,7 @@ module Hecks
|
|
|
91
91
|
# value-object hash, and a shallow dup would quietly mutate
|
|
92
92
|
# the caller's copy of the original entry.
|
|
93
93
|
state = deep_dup(entry.state)
|
|
94
|
-
@renames
|
|
94
|
+
apply_renames(state, @renames)
|
|
95
95
|
@moves.each { |move| apply_move(state, move) }
|
|
96
96
|
@converts.each { |convert| apply_convert(state, convert) }
|
|
97
97
|
@drops.each { |name| apply_drop(state, name) }
|
|
@@ -183,6 +183,33 @@ module Hecks
|
|
|
183
183
|
end
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
+
# M27 (docs/audits/2026-08-10-main-bug-audit.md,
|
|
187
|
+
# docs/audits/2026-08-11-bug-triage.md) — SIMULTANEOUS, not
|
|
188
|
+
# sequential: `state[new] = state.delete(old)` per rename, run
|
|
189
|
+
# one rule at a time against the SAME hash it was reading from,
|
|
190
|
+
# loses data the instant one rule's destination is another
|
|
191
|
+
# rule's source. A swap (`rename :a, to: :b` alongside
|
|
192
|
+
# `rename :b, to: :a`) on `{a: 1, b: 2}` used to produce
|
|
193
|
+
# `{a: 1}` — the first rule wrote `b: 1` over the real `b: 2`
|
|
194
|
+
# before the second rule ever got a chance to read it, and the
|
|
195
|
+
# value the whole edge was supposed to preserve (2, moved to
|
|
196
|
+
# `:a`) was gone. The standard fix: snapshot every rule's OLD
|
|
197
|
+
# key and value from `state` FIRST, then remove every old key
|
|
198
|
+
# and only THEN write every new key — a rename never reads a
|
|
199
|
+
# key this same pass has already written to, so a swap or a
|
|
200
|
+
# longer chain applies as one permutation, not a sequence of
|
|
201
|
+
# edits each stepping on the last.
|
|
202
|
+
def apply_renames(state, renames)
|
|
203
|
+
snapshot = renames.filter_map { |old_name, new_name| [old_name, new_name, state[old_name]] if state.key?(old_name) }
|
|
204
|
+
snapshot.each { |old_name, _new_name, _value| state.delete(old_name) }
|
|
205
|
+
# rubocop:disable Style/CombinableLoops -- NOT combinable: a swap
|
|
206
|
+
# (:a<->:b) needs every delete done before any write, or the first
|
|
207
|
+
# rename's write becomes the second rename's delete target — see
|
|
208
|
+
# this method's own comment above.
|
|
209
|
+
snapshot.each { |_old_name, new_name, value| state[new_name] = value }
|
|
210
|
+
# rubocop:enable Style/CombinableLoops
|
|
211
|
+
end
|
|
212
|
+
|
|
186
213
|
def apply_drop(state, name)
|
|
187
214
|
name = name.to_s
|
|
188
215
|
top, member = name.split(".", 2)
|