hecks 1.5.0 → 2.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.
@@ -0,0 +1,221 @@
1
+ Hecks.bluebook "Compliance" do
2
+ vision "Something elsewhere already acted to contain a risk; this domain tracks the human review that decides what happens next."
3
+ supporting
4
+
5
+ # LIVES HERE (lib/hecks/framework/bluebook/), NOT under some
6
+ # domain's own examples/ directory, but that's a statement about
7
+ # SOURCE SHARING, not about how it's consumed — unlike Governance/
8
+ # Identity (which every real attaching domain only ever wants IN-
9
+ # PROCESS), Compliance genuinely supports BOTH modes from the same
10
+ # source: a domain that wants Review handled locally can
11
+ # `uses_framework "Compliance"` exactly like those two; a domain that
12
+ # wants it as a real, separately-deployed service — Banking's own
13
+ # choice, examples/compliance/ — reaches it through `across
14
+ # "Compliance"` on a policy instead, genuine cross-Lambda delivery.
15
+ # Nothing about this bluebook's own declarations changes between the
16
+ # two; only whether the CONSUMER merges it in or deploys it apart.
17
+ #
18
+ # examples/compliance/bluebook/compliance.bluebook is a SYMLINK back
19
+ # to this file (not the other way around, as it briefly was in
20
+ # 1.5.0) — a symlink pointing outside lib/ never survives `gem
21
+ # build` (RubyGems drops it, warning "not supported on all
22
+ # platforms"), so the real content has to live in the tree the
23
+ # gemspec actually packages.
24
+ #
25
+ # CONTAIN FIRST, INVESTIGATE SECOND — the real pattern this domain
26
+ # exists for. Banking's own "Compliance officer" role already acts
27
+ # directly and synchronously in three places (Customer.Suspend/
28
+ # Reinstate, Account.Freeze/Unfreeze, OnboardingCase.Clear/Decline) —
29
+ # this domain is deliberately NOT a fourth. By the time either
30
+ # aggregate below is opened, the containing action (a freeze, a box
31
+ # surrender) has ALREADY happened, unilaterally, elsewhere; this
32
+ # domain never asks permission, it is TOLD a fact and gives the same
33
+ # officer a place to record what they decided about it afterward.
34
+ #
35
+ # TWO AGGREGATES, NOT ONE — found live, designing this: Banking's own
36
+ # `ReviewOnFreeze`/`ReviewOnBoxSurrender` policies forward their
37
+ # triggering event's payload VERBATIM (neither declares a `with:`
38
+ # projection, which policies have gained since), and the two events
39
+ # carry genuinely different fields (`AccountFrozen`: `number`;
40
+ # `BoxSurrendered`: `branch_code` + `box_number`). One shared
41
+ # aggregate would need an artificial union of both shapes — and while
42
+ # `with:` could now reshape either side into it, that would be
43
+ # inventing a sameness the domain does not have. Two small aggregates,
44
+ # each identified by its own subject's own natural key, need neither.
45
+
46
+ aggregate "AccountFreezeReview" do
47
+ description "The compliance review a frozen account gets, opened the moment Banking::Account.Freeze already happened."
48
+
49
+ attribute :number, AccountNumber
50
+
51
+ identified_by :number
52
+
53
+ value_object "AccountNumber" do
54
+ attribute :value, String
55
+ invariant("an account number is present") { !value.to_s.empty? }
56
+ end
57
+
58
+ lifecycle :status, default: "open" do
59
+ transition "Clear" => "cleared", from: "open"
60
+ transition "Escalate" => "escalated", from: "open"
61
+ end
62
+
63
+ # SYSTEM-TRIGGERED, matching `AccountFrozen`'s own payload shape
64
+ # field for field — `Banking::Account.AccountFrozen`'s `number` is
65
+ # the ONLY field this ever receives (ReviewOnFreeze's own `on
66
+ # "Account.AccountFrozen"`), so this declares exactly that and
67
+ # nothing more; an unrecognized field would refuse at the JSON
68
+ # boundary rather than silently drop it.
69
+ command "Open" do
70
+ role "System"
71
+ goal "Register that a frozen account needs compliance review"
72
+
73
+ sets :number
74
+
75
+ emits AccountFreezeReviewOpened
76
+ end
77
+
78
+ command "Clear" do
79
+ role "Compliance officer"
80
+ goal "Confirm the freeze was warranted, or resolve it, with nothing further to escalate"
81
+
82
+ reference_to AccountFreezeReview
83
+
84
+ emits AccountFreezeReviewCleared
85
+ end
86
+
87
+ command "Escalate" do
88
+ role "Compliance officer"
89
+ goal "Send a genuine finding on to whatever handles it next — a formal filing, a longer hold, a referral"
90
+
91
+ reference_to AccountFreezeReview
92
+
93
+ emits AccountFreezeReviewEscalated
94
+ end
95
+ end
96
+
97
+ aggregate "BoxSurrenderReview" do
98
+ description "The compliance review a surrendered safe deposit box gets — audit/escheatment implications, not a fraud contain the way a freeze is, but the same after-the-fact review shape."
99
+
100
+ attribute :branch_code, BranchCode
101
+ attribute :box_number, BoxNumber
102
+
103
+ identified_by :branch_code, :box_number
104
+
105
+ value_object "BranchCode" do
106
+ attribute :value, String
107
+ invariant("a branch is coded") { !value.to_s.empty? }
108
+ end
109
+
110
+ value_object "BoxNumber" do
111
+ attribute :value, Integer
112
+ invariant("a box is numbered from one") { value.positive? }
113
+ end
114
+
115
+ lifecycle :status, default: "open" do
116
+ transition "Clear" => "cleared", from: "open"
117
+ transition "Escalate" => "escalated", from: "open"
118
+ end
119
+
120
+ # Matches `BoxSurrendered`'s own payload field for field
121
+ # (`ReviewOnBoxSurrender`'s own `on SafeDepositBox::BoxSurrendered`) — the same
122
+ # discipline `AccountFreezeReview.Open` holds itself to, above.
123
+ command "Open" do
124
+ role "System"
125
+ goal "Register that a surrendered box needs compliance review"
126
+
127
+ sets :branch_code
128
+ sets :box_number
129
+
130
+ emits BoxSurrenderReviewOpened
131
+ end
132
+
133
+ command "Clear" do
134
+ role "Compliance officer"
135
+ goal "Confirm the surrender needs no further compliance action"
136
+
137
+ reference_to BoxSurrenderReview
138
+
139
+ emits BoxSurrenderReviewCleared
140
+ end
141
+
142
+ command "Escalate" do
143
+ role "Compliance officer"
144
+ goal "Send a genuine finding on to whatever handles it next"
145
+
146
+ reference_to BoxSurrenderReview
147
+
148
+ emits BoxSurrenderReviewEscalated
149
+ end
150
+ end
151
+
152
+ # A THIRD SHAPE, NOT A UNION OF THE OTHER TWO — the same restraint the
153
+ # header above holds Banking's own two events to. `Privacy::Marking`'s
154
+ # own `Marked` event carries `domain`/`attribute_path`, matching
155
+ # neither `AccountFrozen` (`number`) nor `BoxSurrendered`
156
+ # (`branch_code`/`box_number`), so this gets its own aggregate rather
157
+ # than an artificial union.
158
+ #
159
+ # REACHED VIA `translates`, NOT a `policy` this bluebook declares —
160
+ # which foreign domain's event a consumer conforms to is a wiring
161
+ # decision (see `HecksagonBuilder#translates`'s own header), so the
162
+ # reaction lives in whichever `.hecksagon` attaches both Privacy and
163
+ # Compliance, not here.
164
+ aggregate "PrivacyReview" do
165
+ description "The compliance review a newly marked sensitive field gets, opened the moment Privacy::Marking.Mark already happened."
166
+
167
+ attribute :domain, Domain
168
+ attribute :attribute_path, AttributePath
169
+
170
+ identified_by :domain, :attribute_path
171
+
172
+ value_object "Domain" do
173
+ attribute :value, String
174
+ invariant("a review names the domain whose attribute was marked") { !value.to_s.empty? }
175
+ end
176
+
177
+ value_object "AttributePath" do
178
+ attribute :value, String
179
+ invariant("a review names the attribute that was marked") { !value.to_s.empty? }
180
+ end
181
+
182
+ lifecycle :status, default: "open" do
183
+ transition "Clear" => "cleared", from: "open"
184
+ transition "Escalate" => "escalated", from: "open"
185
+ end
186
+
187
+ # SYSTEM-TRIGGERED, matching only `Privacy::Marking`'s own identity
188
+ # fields — the `translates` block that reacts to `Marked` forwards
189
+ # just `domain`/`attribute_path`, the same restraint
190
+ # `AccountFreezeReview.Open`/`BoxSurrenderReview.Open` already hold
191
+ # themselves to above; `category`/`readable_by` stay on the
192
+ # marking itself, not duplicated here.
193
+ command "Open" do
194
+ role "System"
195
+ goal "Register that a newly marked sensitive field needs compliance review"
196
+
197
+ sets :domain
198
+ sets :attribute_path
199
+
200
+ emits PrivacyReviewOpened
201
+ end
202
+
203
+ command "Clear" do
204
+ role "Compliance officer"
205
+ goal "Confirm the marking was warranted, or resolve it, with nothing further to escalate"
206
+
207
+ reference_to PrivacyReview
208
+
209
+ emits PrivacyReviewCleared
210
+ end
211
+
212
+ command "Escalate" do
213
+ role "Compliance officer"
214
+ goal "Send a genuine finding on to whatever handles it next"
215
+
216
+ reference_to PrivacyReview
217
+
218
+ emits PrivacyReviewEscalated
219
+ end
220
+ end
221
+ end
@@ -9,6 +9,17 @@ Hecks.bluebook "Identity" do
9
9
  # identity provider is.
10
10
  generic
11
11
 
12
+ # WHAT THIS CHAPTER ANSWERS FOR EVERY DOMAIN THAT ATTACHES IT. Same
13
+ # declared-not-named shape Governance's own `provides "authorization"`
14
+ # already is. rust/host does not build this chapter's payloads — any
15
+ # field mapping lives on the consuming hecksagon's `translates` ACL
16
+ # (any field; this chapter does not list which). Link's reference is
17
+ # `identity`, not `identity_id` and not `to:`.
18
+ provides "identity",
19
+ register: "Identity.Register",
20
+ link: "ExternalIdentifier.Link",
21
+ resolve: "ExternalIdentifier.ResolvedBy"
22
+
12
23
  aggregate "Identity" do
13
24
  description "A stable organizational identity, independent of how it was authenticated."
14
25
 
@@ -435,11 +435,18 @@ module Hecks
435
435
  # @param schema [String] the PostgresEra schema name to write into the `.world` file
436
436
  # @return [void]
437
437
  def write_postgres_era_world!(copy, database:, schema:)
438
+ # Same merge IsolatedBoot.rebind_to_postgres_era! now does — one
439
+ # world file per directory, every hecksagon name in that directory.
440
+ worlds_by_dir = Hash.new { |h, k| h[k] = [] }
438
441
  Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
439
- names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten.uniq
442
+ names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten
443
+ worlds_by_dir[File.dirname(hecksagon_path)].concat(names)
444
+ end
445
+ worlds_by_dir.each do |dir, names|
446
+ names = names.uniq
440
447
  next if names.empty?
441
448
 
442
- world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres_era.world")
449
+ world_path = File.join(dir, "hecks_fuzz_postgres_era.world")
443
450
  File.write(world_path, names.map do |name|
444
451
  <<~WORLD
445
452
  Hecks.world "#{name}" do
@@ -404,11 +404,21 @@ module Hecks
404
404
  # database it is about to throw away; the one-line warning
405
405
  # PostgresEra prints per boot under the opt-in is the honest
406
406
  # price. Inert on a machine whose ambient user is ordinary.
407
+ # One world file per directory, every hecksagon name in that
408
+ # directory — not one write per *.hecksagon file. context_map.hecksagon
409
+ # sits beside the domain file; a second File.write to the same
410
+ # hecks_fuzz_postgres_era.world would drop the first file's names
411
+ # (found live: ConcurrentDispatchUnboundFixture + Governance).
412
+ worlds_by_dir = Hash.new { |h, k| h[k] = [] }
407
413
  Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
408
- names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten.uniq
414
+ names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten
415
+ worlds_by_dir[File.dirname(hecksagon_path)].concat(names)
416
+ end
417
+ worlds_by_dir.each do |dir, names|
418
+ names = names.uniq
409
419
  next if names.empty?
410
420
 
411
- world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres_era.world")
421
+ world_path = File.join(dir, "hecks_fuzz_postgres_era.world")
412
422
  File.write(world_path, names.map do |name|
413
423
  <<~WORLD
414
424
  Hecks.world "#{name}" do
@@ -181,7 +181,11 @@ Hecks.bluebook "Bluebook", version: "1" do
181
181
 
182
182
  # ONE ROW OF ONE CAPABILITY — `provides "authorization", assignments:
183
183
  # ..., grant: ..., transitions: ...` offers three, one per key, in the
184
- # order they were written.
184
+ # order they were written. Membership (`admit`/`grant`/`people`) and
185
+ # identity (`register`/`link`/`resolve`) use the same word with their
186
+ # own named keys; the argument table admits every key any capability
187
+ # declares, and `Capabilities::CONTRACTS` holds which keys a given
188
+ # capability must name.
185
189
  command "Provide" do
186
190
  role "Language"
187
191
  goal "Declare one verb this chapter answers a capability with"
@@ -287,6 +291,11 @@ Hecks.bluebook "Bluebook", version: "1" do
287
291
  member keyword: "provides", context: "Bluebook", at: "", named: "assignments", kind: "text", required: "false", fills: "provides"
288
292
  member keyword: "provides", context: "Bluebook", at: "", named: "grant", kind: "text", required: "false", fills: "provides"
289
293
  member keyword: "provides", context: "Bluebook", at: "", named: "transitions", kind: "text", required: "false", fills: "provides"
294
+ member keyword: "provides", context: "Bluebook", at: "", named: "admit", kind: "text", required: "false", fills: "provides"
295
+ member keyword: "provides", context: "Bluebook", at: "", named: "people", kind: "text", required: "false", fills: "provides"
296
+ member keyword: "provides", context: "Bluebook", at: "", named: "register", kind: "text", required: "false", fills: "provides"
297
+ member keyword: "provides", context: "Bluebook", at: "", named: "link", kind: "text", required: "false", fills: "provides"
298
+ member keyword: "provides", context: "Bluebook", at: "", named: "resolve", kind: "text", required: "false", fills: "provides"
290
299
  member keyword: "aggregate", context: "Bluebook", at: "1", named: "", kind: "text", required: "true", fills: "name"
291
300
  member keyword: "read_model", context: "Bluebook", at: "1", named: "", kind: "text", required: "true", fills: "name"
292
301
  member keyword: "policy", context: "Bluebook", at: "1", named: "", kind: "text", required: "true", fills: "name"
@@ -110,6 +110,12 @@ Hecks.bluebook "Hecksagon" do
110
110
  # the same shape `subscribe`/`uses_framework` already are, reached
111
111
  # by Ruby's own method lookup before `word_gate` ever runs.
112
112
  member word: "translates", context: "Hecksagon", body: "keywords", inner: "Policy", opens: "Policy", fills: ""
113
+ # A CONSUMER-OWNED bounded context — framework/vendored packages
114
+ # get this mark automatically from uses_framework /
115
+ # uses_embryonaut_bluebook and never write it in their own files.
116
+ # No `calls:` — `HecksagonBuilder#bounded` is an ordinary method,
117
+ # same shape `subscribe` already is.
118
+ member word: "bounded", context: "Hecksagon", body: "none", inner: "", opens: "", fills: ""
113
119
 
114
120
  end
115
121
 
@@ -34,6 +34,33 @@ module Hecks
34
34
  # already destroyed, or was never issued
35
35
  def destroy(registry, key_reference:) = adapter(registry).destroy(key_reference: key_reference)
36
36
 
37
+ # Erases one subject: destroys their key in the vault, then durably records that it
38
+ # happened — in that fixed order, so a caller cannot dispatch `Shred` before the key
39
+ # is actually gone the way calling {#destroy} and `dispatch_flat` separately would
40
+ # allow. `Privacy::SubjectKey.Shred`'s own lifecycle guard (`from: "active"`) still
41
+ # refuses a raw double-dispatch; this is what closes the gap one level up, where the
42
+ # danger isn't a duplicate `Shred` but `Shred` running *before* {#destroy} does.
43
+ #
44
+ # Idempotent: a subject with no live `SubjectKey` record, or one already shredded,
45
+ # is left alone and answered `false` — a retried erasure request never re-destroys
46
+ # an already-gone key or double-dispatches `Shred`.
47
+ #
48
+ # @param dispatcher [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted
49
+ # dispatcher both the lookup query and the `Shred` dispatch run through
50
+ # @param domain [String] the subject's own domain FQN, e.g. `"Lifeadelics::Registration"`
51
+ # @param subject_id [String] the data subject to erase
52
+ # @return [Boolean] true when a live key was found and shredded by this call; false
53
+ # when no `SubjectKey` record exists for this subject, or it was already shredded
54
+ def shred!(dispatcher, domain:, subject_id:) # rubocop:disable Naming/PredicateMethod
55
+ record = dispatcher.query("Privacy::SubjectKey.ForSubject", domain: domain, subject_id: subject_id).first
56
+ return false unless record
57
+ return false if record[:status].to_s == "shredded"
58
+
59
+ destroy(dispatcher.registry, key_reference: record[:key_reference].value)
60
+ dispatcher.dispatch_flat("Privacy::SubjectKey.Shred", domain: { value: domain }, subject_id: { value: subject_id })
61
+ true
62
+ end
63
+
37
64
  # Finds the single adapter bound to this port, refusing an ambiguous wiring.
38
65
  #
39
66
  # @param registry [Runtime::Registry] the booted registry to search
@@ -155,8 +155,13 @@ module Hecks
155
155
  @drops.each { |name| apply_drop(state, name) }
156
156
  # Last, and only where nothing already answered — a backfill
157
157
  # fills the gap a rename/move/convert left untouched, never
158
- # overwrites a value that already made it across.
159
- @backfills.each { |backfill| state[backfill.name] = backfill.default unless state.key?(backfill.name) }
158
+ # overwrites a value that already made it across. Dotted-path
159
+ # aware, same as `apply_drop` above (`apply_backfill`'s own
160
+ # header) — a bare name was the only shape this ever needed
161
+ # until a value object gained new required members with zero
162
+ # source data of their own (found live: lifeadelics' Attendee
163
+ # redesign, commit 4326dcd).
164
+ @backfills.each { |backfill| apply_backfill(state, backfill) }
160
165
  Entry.new(operation: entry.operation, id: entry.id, state: state, mirrors: entry.mirrors)
161
166
  end
162
167
 
@@ -312,6 +317,31 @@ module Hecks
312
317
  end
313
318
  end
314
319
 
320
+ # Applies one backfill, dotted-path aware — `apply_drop`'s own
321
+ # mirror on the addition side. A bare name sets a plain top-level
322
+ # key, unchanged from before; a dotted name (`"attendee.
323
+ # first_name"`) reaches into an existing value-object member the
324
+ # same way `apply_drop`/`hecks_tr_insert` (rule_compiler.rb's own
325
+ # SQL-compiled twin, kept in step with this) already do — creating
326
+ # the container Hash if an old record somehow lacks it entirely,
327
+ # never overwriting a value already there at either level.
328
+ #
329
+ # @param state [Hash] the entry's own state Hash, mutated in place
330
+ # @param backfill [Bluebook::TranslationBackfill] the backfill rule to apply
331
+ # @return [void]
332
+ def apply_backfill(state, backfill)
333
+ name = backfill.name.to_s
334
+ top, member = name.split(".", 2)
335
+ top = top.to_sym
336
+
337
+ if member
338
+ nested = (state[top] ||= {})
339
+ nested[member] = backfill.default unless nested.key?(member)
340
+ else
341
+ state[top] = backfill.default unless state.key?(top)
342
+ end
343
+ end
344
+
315
345
  # A dotted path's first segment is a top-level (symbol) key; a
316
346
  # second segment reaches into a value-object member by its string
317
347
  # key — the spelling a raw stored row carries. Translation runs on
@@ -54,6 +54,21 @@ module Hecks
54
54
  declared.computes.each do |compute|
55
55
  expression = compile_compute(expression, compute)
56
56
  end
57
+ # Backfills last, same order `Lineage#translate` already applies
58
+ # in-process (that method's own comment: "only where nothing
59
+ # already answered") — now compiled here too, closing the gap
60
+ # this rule kind otherwise leaves: `rust/host`'s boot-time mint
61
+ # audit reads this exact compiled expression, never
62
+ # `Lineage#translate`, so it cannot see a backfilled value unless
63
+ # backfills compile to SQL like every other rule kind here. A
64
+ # real, live gap for any new required value-object member with no
65
+ # source data at all — `compute` cannot fill it either, since its
66
+ # own guard requires a real, already-present field to consume
67
+ # (found live: lifeadelics' Attendee redesign, commit 4326dcd,
68
+ # needed exactly this and had nothing that worked).
69
+ declared.backfills.each do |backfill|
70
+ expression = compile_backfill(expression, backfill)
71
+ end
57
72
  expression
58
73
  end
59
74
 
@@ -124,6 +139,32 @@ module Hecks
124
139
  "LATERAL (SELECT (__s ->> #{text_literal(from)}) AS #{quote(from)}) __fields)"
125
140
  end
126
141
 
142
+ # A newly added, required attribute with no source at all — the
143
+ # addition-side sibling of `compile_compute`'s own header, but with
144
+ # no field to consume: `hecks_tr_extract`'s own `.present` flag
145
+ # (already installed for `hecks_tr_drop`/`hecks_tr_move`/
146
+ # `hecks_tr_convert` — no new database function needed) answers
147
+ # "is this path — bare or a dotted value-object member alike —
148
+ # already there," and `hecks_tr_insert` (already merge-safe: it
149
+ # creates a missing intermediate container without disturbing any
150
+ # sibling member already in it) fills it only when it is not, the
151
+ # exact "never overwrites a value already there" rule `Lineage#
152
+ # translate`'s own in-process backfill already holds itself to.
153
+ #
154
+ # @param expression [String] the SQL expression built so far by `compile_rules`, read as
155
+ # `__s` inside this backfill's own check
156
+ # @param backfill [Bluebook::TranslationBackfill] the declared backfill rule
157
+ # @return [String] `expression` wrapped so the backfill's default lands at its declared
158
+ # path when nothing is there yet, unchanged otherwise
159
+ def compile_backfill(expression, backfill)
160
+ name = backfill.name.to_s
161
+ default_json = JSON.generate(backfill.default)
162
+ "(SELECT CASE WHEN (hecks_tr_extract(__s, #{path_literal(name)})).present THEN __s " \
163
+ "ELSE hecks_tr_insert(__s, #{path_literal(name)}, #{text_literal(default_json)}::jsonb, " \
164
+ "#{text_literal("backfill #{name}")}) END " \
165
+ "FROM (SELECT (#{expression}) AS __s) __outer)"
166
+ end
167
+
127
168
  # `PG::Connection.quote_ident` needs the `pg` gem loaded, not
128
169
  # connected — required here, lazily, the same "a domain that
129
170
  # never wires PostgresEra should never need the gem" reasoning