hecks 1.5.1 → 2.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29c54ce86e36189a63578e970929e42c213d5812282c5cf5ca6e56820eb02938
4
- data.tar.gz: 1d564ee387fb38c619fbdfef209bcc757570a492e70dac16f1d5fb80889c37e8
3
+ metadata.gz: b7e80387e679bdc08c6a48b9c1eabefee28d7f9fd0e28a41f203e1fd9b207c34
4
+ data.tar.gz: 455038d69b310e2fc9ae72b30d811623c8c1a85a4a5da118b842b8af7fd082b0
5
5
  SHA512:
6
- metadata.gz: 24794f4aaa4f5d2268f27e063b79a23b61c7dffe7b4a87b4b58f83db73188d57716fd734d695082bdf11d78d3101fc41640a45f76510fe7cc45fd9c3cd9347a8
7
- data.tar.gz: 9ce51558bbe7c55e9dc451140a21d7c83b7073b8a692304579f15c76db3c56371cb90c7cac07b6c02e6eb64ddd73c27572e786ab941659d2a76e649de5d2a699
6
+ metadata.gz: 7738a1869d51907dce7b981d7f0d3d1977f73a8baae57d947d2667155830495f9e19163a4c1fe5785e79fa262a7733c6d1d31086b6bc0ed85191bd31d6ad829b
7
+ data.tar.gz: f3506ffdc5133c083030ac87f0056a1373de5802520116956b0e30c00ea3d473e1e55f9fa62e813048e98c063aa05446a44e61ff49540dcc266e74546d6da7fa
@@ -24,8 +24,14 @@ module Hecks
24
24
  # @param subject [String] the OIDC subject the issuer vouches for, compared as a String
25
25
  # @return [String, nil] the linked identity's id, or nil if nothing has linked this pair
26
26
  def resolve(registry, issuer:, subject:)
27
+ # The resolve verb this registry's declared identity provider names
28
+ # (`provides "identity", resolve:`), never a hard-coded chapter —
29
+ # same declared-not-named shape GovernanceAuthorization already holds.
30
+ provider = registry.bluebooks.values.find { |chapter| chapter.provides?(::Hecks::Bluebook::Capabilities::IDENTITY) }
31
+ verb = provider&.provided_verb(::Hecks::Bluebook::Capabilities::IDENTITY, :resolve) ||
32
+ "Identity::ExternalIdentifier.ResolvedBy"
27
33
  rows = Runtime::Dispatcher.new(registry).query(
28
- "Identity::ExternalIdentifier.ResolvedBy",
34
+ verb,
29
35
  issuer: { value: issuer.to_s }, subject: { value: subject.to_s }
30
36
  )
31
37
 
@@ -8,9 +8,30 @@ module Hecks
8
8
  # complete and point at real verbs. `BluebookBuilder::Validation
9
9
  # #validate_provisions!` holds every `provides` to this table.
10
10
  #
11
- # key => :command | :query the kind of verb that key must name
11
+ # key => :command | :query | :port_operation the kind of verb that key must name
12
+ #
13
+ # A `:port_operation` verb is spelled `"Aggregate.Port.Operation"` and names
14
+ # an operation the chapter's hecksagon declares on one of its aggregates.
15
+ # The hecksagon attaches after the chapter is built, so the chapter itself
16
+ # only checks the spelling; `Registry#verify!` checks the operation exists.
12
17
  module Capabilities
13
18
  AUTHORIZATION = "authorization".freeze
19
+ # Who may sign in, and with what role — rust/host's Google-OAuth
20
+ # provision/member_rows resolve this instead of an env var naming
21
+ # the aggregate (HECKS_MEMBERSHIP_AGGREGATE). Same declared-
22
+ # not-named shape AUTHORIZATION already is for Governance.
23
+ MEMBERSHIP = "membership".freeze
24
+ # A stable organizational identity, independent of how it was
25
+ # authenticated — recognised by declaration, not the literal name
26
+ # "Identity". Same declared-not-named shape AUTHORIZATION already
27
+ # is. rust/host does not build this chapter's payloads; any field
28
+ # mapping lives on the consuming hecksagon's `translates` ACL.
29
+ IDENTITY = "identity".freeze
30
+ # Guest-facing newsletter signup: who subscribed, confirmed, or left —
31
+ # recognised by declaration, not the literal chapter name
32
+ # "Newsletter". rust/host's newsletter routes dispatch these verbs
33
+ # and read the subscribing aggregate's instances off `subscribe`.
34
+ NEWSLETTER = "newsletter".freeze
14
35
 
15
36
  CONTRACTS = {
16
37
  AUTHORIZATION => {
@@ -20,6 +41,32 @@ module Hecks
20
41
  grant: :command,
21
42
  # every grant of one role acting as another
22
43
  transitions: :query
44
+ }.freeze,
45
+ MEMBERSHIP => {
46
+ # recognize a person who may eventually sign in
47
+ admit: :command,
48
+ # grant an admitted person a role (the access-grant half)
49
+ grant: :command,
50
+ # every admitted person, for the admin listing
51
+ people: :query
52
+ }.freeze,
53
+ IDENTITY => {
54
+ # mint a stable identity, independent of how it authenticated
55
+ register: :command,
56
+ # associate an (issuer, subject) pair with that identity
57
+ link: :command,
58
+ # look up the identity an authenticated pair resolves to
59
+ resolve: :query
60
+ }.freeze,
61
+ NEWSLETTER => {
62
+ # a guest signs up; the aggregate it names is the subscriber
63
+ subscribe: :command,
64
+ # attach a display name to an existing subscriber
65
+ add_name: :command,
66
+ # a subscriber confirms their address from the emailed link
67
+ confirm: :command,
68
+ # a subscriber leaves from the emailed link
69
+ unsubscribe: :command
23
70
  }.freeze
24
71
  }.freeze
25
72
  end
@@ -113,12 +113,14 @@ module Hecks
113
113
  # @param capability [String] the capability the row belongs to, such as
114
114
  # `"authorization"`
115
115
  # @param row [Bluebook::Chapter::Provision] the one declared row to check
116
- # @param kind [Symbol] `:command` or `:query`, the kind of verb this row's key
117
- # must name (from `Capabilities::CONTRACTS`)
116
+ # @param kind [Symbol] `:command`, `:query` or `:port_operation`, the kind of
117
+ # verb this row's key must name (from `Capabilities::CONTRACTS`)
118
118
  # @return [void]
119
119
  # @raise [Bluebook::DSL::Malformed] if `row.verb` does not split into a real
120
120
  # aggregate and a member of the right kind that aggregate declares
121
121
  def validate_provided_verb!(bluebook, capability, row, kind)
122
+ return validate_provided_port_operation!(bluebook, capability, row) if kind == :port_operation
123
+
122
124
  aggregate_name, member = row.verb.split(".", 2)
123
125
  aggregate = bluebook.aggregate(aggregate_name)
124
126
  return if aggregate && member && provided_member_names(aggregate, kind).include?(member)
@@ -127,6 +129,26 @@ module Hecks
127
129
  "which names no #{kind} this chapter declares (spelled \"Aggregate.#{kind.capitalize}\")"
128
130
  end
129
131
 
132
+ # Checks the spelling of a `provides` verb that names a hecksagon port
133
+ # operation. The port itself is declared in the hecksagon, which attaches
134
+ # after this chapter is built, so all that can be checked here is that the
135
+ # verb has the `"Aggregate.Port.Operation"` shape and its aggregate is
136
+ # this chapter's; `Registry#verify!` checks the operation exists.
137
+ #
138
+ # @param bluebook [Bluebook::Chapter] the chapter the row was declared on
139
+ # @param capability [String] the capability the row belongs to
140
+ # @param row [Bluebook::Chapter::Provision] the one declared row to check
141
+ # @return [void]
142
+ # @raise [Bluebook::DSL::Malformed] if the verb is not `"Aggregate.Port.Operation"`
143
+ # or names an aggregate this chapter does not declare
144
+ def validate_provided_port_operation!(bluebook, capability, row)
145
+ aggregate_name, port, operation = row.verb.split(".", 3)
146
+ return if bluebook.aggregate(aggregate_name) && port && operation && !operation.include?(".")
147
+
148
+ raise Malformed, "#{bluebook.name} provides #{capability.inspect} #{row.key}: #{row.verb.inspect}, " \
149
+ "which is not spelled \"Aggregate.Port.Operation\" over an aggregate this chapter declares"
150
+ end
151
+
130
152
  # Names of the members a `provides` verb of the given kind may address.
131
153
  #
132
154
  # @param aggregate [Bluebook::Aggregate] the aggregate the verb is scoped to
@@ -27,6 +27,20 @@ module Hecks
27
27
  @subscriptions = []
28
28
  @framework_members = []
29
29
  @vendored_bluebooks = []
30
+ @bounded = false
31
+ @translates = []
32
+ end
33
+
34
+ # Marks THIS chapter as a bounded context — a consumer-owned chapter
35
+ # that `uses_framework` / `uses_embryonaut_bluebook` will not load.
36
+ # Framework and vendored packages get the mark automatically from
37
+ # those words; they never write `bounded` in their own bluebooks.
38
+ # A bounded chapter wraps in its own module (no Object shortcut)
39
+ # and must declare at least one `translates` ACL or boot refuses.
40
+ #
41
+ # @return [Boolean] true
42
+ def bounded
43
+ @bounded = true
30
44
  end
31
45
 
32
46
  # Subscribes this hecksagon to an event it takes from outside the domain's own bluebook.
@@ -54,6 +68,11 @@ module Hecks
54
68
  def uses_framework(name)
55
69
  @framework_members << name.to_s
56
70
  Hecks::Framework.load!(name)
71
+ # Automatic bounded mark — the framework member is a bounded
72
+ # context; the consumer's sibling `Hecks.hecksagon "Name"` is
73
+ # the anti-corruption layer. Not written in the framework
74
+ # bluebook itself.
75
+ Hecks.current_registry&.mark_bounded(name.to_s)
57
76
  end
58
77
 
59
78
  # Attaches a vendored embryonaut bluebook to this domain and loads its files into the
@@ -83,6 +102,10 @@ module Hecks
83
102
  def uses_embryonaut_bluebook(name)
84
103
  @vendored_bluebooks << name.to_s
85
104
  Hecks::EmbryonautBluebook.load!(name)
105
+ # Automatic bounded mark — same as `uses_framework`. The
106
+ # package's directory name Pascal-cases to the chapter
107
+ # (`"membership"` → `Membership`).
108
+ Hecks.current_registry&.mark_bounded(Hecks::Naming.pascal(name.to_s))
86
109
  end
87
110
 
88
111
  # Declares a port at the hecksagon's root and attaches it to the registered bluebook.
@@ -173,6 +196,7 @@ module Hecks
173
196
  resolver = ->(const) { ConstShim::ScopedConstant.for(const) }
174
197
  built = ConstShim.with(resolver) { PolicyBuilder.build(name, &block) }
175
198
 
199
+ @translates << name.to_s
176
200
  bluebook_ir.add_policy(built)
177
201
  end
178
202
 
@@ -197,7 +221,8 @@ module Hecks
197
221
  # with any other block declared for the same domain
198
222
  def build
199
223
  Hecksagon.new(domain: @domain, binds: @binds, subscriptions: @subscriptions,
200
- framework_members: @framework_members, vendored_bluebooks: @vendored_bluebooks)
224
+ framework_members: @framework_members, vendored_bluebooks: @vendored_bluebooks,
225
+ bounded: @bounded, translates: @translates)
201
226
  end
202
227
 
203
228
  # Records any verb the grammar does not own as a domain-wide bind to the named adapter.
@@ -51,10 +51,13 @@ module Hecks
51
51
  binds: many(:binds),
52
52
  subscriptions: -> { subscriptions.map(&:to_s) },
53
53
  framework_members: -> { framework_members.map(&:to_s) },
54
- vendored_bluebooks: -> { vendored_bluebooks.map(&:to_s) }
54
+ vendored_bluebooks: -> { vendored_bluebooks.map(&:to_s) },
55
+ bounded: :bounded,
56
+ translates: -> { translates.map(&:to_s) }
55
57
  )
56
58
 
57
- attr_reader :domain, :binds, :subscriptions, :framework_members, :vendored_bluebooks
59
+ attr_reader :domain, :binds, :subscriptions, :framework_members, :vendored_bluebooks,
60
+ :translates
58
61
 
59
62
  # @param domain [String, Symbol] the domain this hecksagon wires
60
63
  # @param binds [Array<Bluebook::Bind>] the declared adapter binds
@@ -64,13 +67,25 @@ module Hecks
64
67
  # (`Governance`, `Identity`, ...) this domain attaches
65
68
  # @param vendored_bluebooks [Array<String, Symbol>] the vendored embryonaut
66
69
  # bluebook package names this domain attaches
67
- def initialize(domain:, binds: [], subscriptions: [], framework_members: [], vendored_bluebooks: [])
70
+ # @param bounded [Boolean] whether this chapter is an explicit bounded context
71
+ # (consumer-owned; `uses_framework` / `uses_embryonaut_bluebook` mark
72
+ # attached chapters bounded on the registry instead)
73
+ # @param translates [Array<String>] names of `translates` ACL blocks declared here
74
+ def initialize(domain:, binds: [], subscriptions: [], framework_members: [],
75
+ vendored_bluebooks: [], bounded: false, translates: [])
68
76
  @domain = domain.to_s
69
77
  @binds = binds
70
78
  @subscriptions = subscriptions
71
79
  @framework_members = framework_members
72
80
  @vendored_bluebooks = vendored_bluebooks
81
+ @bounded = bounded ? true : false
82
+ @translates = Array(translates).map(&:to_s)
73
83
  end
84
+
85
+ # Says whether this hecksagon marked its own chapter `bounded`.
86
+ #
87
+ # @return [Boolean] whether `bounded` was declared on this block
88
+ def bounded? = @bounded
74
89
  end
75
90
 
76
91
  # The built form of a `.world` file, produced by `DSL::WorldBuilder` —
data/lib/hecks/corpus.rb CHANGED
@@ -265,20 +265,112 @@ module Hecks
265
265
  Elsewhere = Struct.new(:check, :destination, :names, :why)
266
266
 
267
267
  RUST_ELSEWHERE = {
268
- "meta" => Elsewhere.new(:named_in, "spec/codegen_parity_spec.rb", "bluebook_language",
269
- "the self-hosted grammar (lib/hecks/language), not a domain directory — every " \
270
- "bin/project_rust run rewrites it (so the drift check diffs it), codegen parity " \
271
- "checks it as bluebook_language, and there is no directory to fuzz"),
272
- "embryonaut" => Elsewhere.new(:external, "~/Projects/embryonautfoundersapp", "embryonaut",
273
- "an external product's domain — its bluebook, regeneration and parity are owed " \
274
- "by its own repo; here bin/rust_coverage checks only the committed snapshot")
268
+ "meta" => Elsewhere.new(:named_in, "spec/codegen_parity_spec.rb", "bluebook_language",
269
+ "the self-hosted grammar (lib/hecks/language), not a domain directory — every " \
270
+ "bin/project_rust run rewrites it (so the drift check diffs it), codegen parity " \
271
+ "checks it as bluebook_language, and there is no directory to fuzz"),
272
+ "embryonaut" => Elsewhere.new(:external, "~/Projects/embryonautfoundersapp", "embryonaut",
273
+ "an external product's domain — its bluebook, regeneration and parity are owed " \
274
+ "by its own repo; here bin/rust_coverage checks only the committed snapshot"),
275
+ # Lifeadelics — same external-product shape as "embryonaut" above,
276
+ # first generated 2026-09-19 fixing a live era-shape-drift outage.
277
+ # Its directory used to be named "domain" (a generic, collision-
278
+ # prone Cargo feature/module name — the chapter name is, and
279
+ # always was, "Lifeadelics"), so the generated module and Cargo
280
+ # feature were "domain" too, read off `metadata.rs`'s own stamp
281
+ # the same way `generated_source` reads any other module's. Fixed
282
+ # 2026-09-20 by renaming the directory itself
283
+ # (~/Projects/lifeadelics/domain -> ~/Projects/lifeadelics/
284
+ # lifeadelics) — the generated module and Cargo feature are
285
+ # "lifeadelics" now, matching every other domain's own convention.
286
+ #
287
+ # Lifeadelics also attaches `newsletter`/`payments`/`membership`
288
+ # (its own vendored embryonaut_bluebooks packages, see
289
+ # RUST_EXTERNAL_VENDORED_CHAPTERS below) and `Privacy` (an in-repo
290
+ # framework chapter, lib/hecks/framework/bluebook/privacy.bluebook,
291
+ # generated into Rust for the first time by any domain here).
292
+ # `rust_side_chapters` only ever looks for the attaching domain
293
+ # among `rust_domains` — an in-repo directory — so it can attribute
294
+ # neither module to lifeadelics on its own; `rust_attachment_
295
+ # hecksagon_text`, below, reads every `:external` domain's own
296
+ # hecksagon files too, the same way it reads an in-repo domain's,
297
+ # so `Privacy`'s own bucket membership (already correct — it is a
298
+ # real in-repo framework member with no merged.rs) can still be
299
+ # proven attached.
300
+ "lifeadelics" => Elsewhere.new(:external, "~/Projects/lifeadelics", "lifeadelics",
301
+ "an external product's domain — its bluebook, regeneration and parity are owed " \
302
+ "by its own repo; here bin/rust_coverage checks only the committed snapshot")
275
303
  }.freeze
276
304
 
305
+ # Vendored embryonaut_bluebooks packages attached only by an external
306
+ # RUST_ELSEWHERE domain — `members(:vendored)` can't find them
307
+ # itself; that glob only reaches `examples/*/vendor/
308
+ # embryonaut_bluebooks/*`, never an external checkout. Declared once,
309
+ # by hand, the same manual-commit contract "embryonaut" itself
310
+ # already carries: stem => the RUST_ELSEWHERE feature that attaches
311
+ # it. spec/corpus_rust_spec.rb checks each is really attached, the
312
+ # same as an in-repo vendored chapter (see
313
+ # `rust_external_vendored_domain_dir`, below).
314
+ RUST_EXTERNAL_VENDORED_CHAPTERS = {
315
+ "newsletter" => "lifeadelics",
316
+ "payments" => "lifeadelics",
317
+ "membership" => "lifeadelics"
318
+ }.freeze
319
+
320
+ # Every external vendored chapter's own stem.
321
+ #
322
+ # @return [Array<String>] stems declared in RUST_EXTERNAL_VENDORED_CHAPTERS
323
+ def rust_external_vendored_chapters
324
+ RUST_EXTERNAL_VENDORED_CHAPTERS.keys
325
+ end
326
+
327
+ # Where an external vendored chapter's own bluebook lives — somewhere
328
+ # under `<destination>/**/vendor/embryonaut_bluebooks/<stem>`, the
329
+ # same layout `EmbryonautBluebook.load!` resolves for an in-repo
330
+ # vendored member, one level further out. Globbed rather than joined
331
+ # directly: `RUST_ELSEWHERE`'s own `destination` names the external
332
+ # product's checkout root, not necessarily the exact directory its
333
+ # own bluebook lives under (confirmed live against lifeadelics's own
334
+ # checkout: `~/Projects/lifeadelics/lifeadelics/vendor/
335
+ # embryonaut_bluebooks/accounts/bluebook`, one level below
336
+ # `~/Projects/lifeadelics` itself).
337
+ #
338
+ # @param stem [String] an external vendored chapter's stem
339
+ # @return [String, nil] the vendored member's own directory, or nil when none is found
340
+ def rust_external_vendored_domain_dir(stem)
341
+ feature = RUST_EXTERNAL_VENDORED_CHAPTERS.fetch(stem)
342
+ destination = File.expand_path(RUST_ELSEWHERE.fetch(feature).destination)
343
+ Dir.glob(File.join(destination, "**", "vendor", "embryonaut_bluebooks", stem)).first
344
+ end
345
+
346
+ # Every place a Rust-facing domain's own `uses_framework`/
347
+ # `uses_embryonaut_bluebook` attachment could be declared — every
348
+ # in-repo Rust domain's own hecksagon files, plus each `:external`
349
+ # RUST_ELSEWHERE domain's own (its `destination`, expanded, is a
350
+ # real checkout on this machine, read the same way an in-repo
351
+ # domain's own hecksagon files already are).
352
+ #
353
+ # @param root [String] repository root to search under
354
+ # @return [String] every reachable hecksagon file's own text, joined by newlines
355
+ def rust_attachment_hecksagon_text(root: ROOT)
356
+ in_repo = rust_domains(root: root).flat_map { |domain| Dir.glob(File.join(domain.dir, "**", "*.hecksagon")) }
357
+ external = RUST_ELSEWHERE.values.select { |route| route.check == :external }
358
+ .flat_map { |route| Dir.glob(File.join(File.expand_path(route.destination), "**", "*.hecksagon")) }
359
+ (in_repo + external).map { |path| File.read(path) }.join("\n")
360
+ end
361
+
277
362
  # **Shrink-only**. A generated module `bin/rust_coverage` still reports a
278
363
  # gap for. `bin/corpus --rust-coverage` requires each of these to
279
364
  # still fail, so an entry that starts passing breaks the build until
280
365
  # it is deleted here.
281
- RUST_COVERAGE_PENDING = {}.freeze
366
+ RUST_COVERAGE_PENDING = {
367
+ "newsletter" => "query Listing declares no where clause at all, on all 3 of its own Listing queries " \
368
+ "(Delivery/Issue/Subscriber) — rust/project/queries.rb's own no_wheres skip refuses " \
369
+ "to generate an unfiltered per_instance Listing (\"nothing for filter_entries to " \
370
+ "bake in\"); a structural Rust codegen limitation, not a bug in this vendored package",
371
+ "lifeadelics" => "same no_wheres gap as newsletter, on Registration.Listing — see newsletter's entry above",
372
+ "membership" => "same no_wheres gap as newsletter, on Person.All — see newsletter's entry above"
373
+ }.freeze
282
374
 
283
375
  # The Cargo `[features]` table's raw text.
284
376
  #
@@ -396,7 +488,26 @@ module Hecks
396
488
  def rust_side_chapters(kind, root: ROOT)
397
489
  modules = generated_modules(root: root)
398
490
  members(kind, root: root).map(&:stem)
399
- .select { |stem| modules.include?(stem) && !generated?(stem, root: root) }
491
+ .select do |stem|
492
+ module_name = Naming.pascal(stem).downcase
493
+ modules.include?(module_name) && !generated?(module_name, root: root)
494
+ end
495
+ end
496
+
497
+ # The generated module name a `:framework`/`:vendored` stem writes
498
+ # under — `Naming.pascal(stem).downcase` (`bin/project_rust`'s own
499
+ # convention, see `rust_side_chapters`'s header); a chapter whose
500
+ # stem holds an underscore ("console_settings") writes to a
501
+ # different, underscore-free module name ("consolesettings"), so a
502
+ # bucket-membership check against `generated_modules` (a directory
503
+ # name) needs this mapping rather than the raw stem
504
+ # `rust_framework_chapters`/`rust_vendored_chapters` still return
505
+ # (every other caller wants the stem, to build a bluebook file path).
506
+ #
507
+ # @param stem [String] a `:framework`/`:vendored` corpus member's stem
508
+ # @return [String] the generated module's own directory name
509
+ def rust_side_module_name(stem)
510
+ Naming.pascal(stem).downcase
400
511
  end
401
512
 
402
513
  def rust_framework_chapters(root: ROOT)
@@ -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
 
@@ -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