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.
@@ -17,15 +17,20 @@ module Hecks
17
17
  # @raise [Runtime::WiringError] if a bind names an undeclared aggregate, an
18
18
  # adapter cannot satisfy its port's verb or declared `answers`, a world
19
19
  # setting names a field its adapter does not declare, the default adapter
20
- # is unusable, or a command declares a role with no authorization provider
21
- # attached
20
+ # is unusable, a command declares a role with no authorization provider
21
+ # attached, or a membership chapter is loaded with no identity chapter
22
22
  def verify!
23
23
  verify_default_adapter!
24
24
  verify_singleton_port_answers!
25
25
  refuse_cross_package_bluebook_merge!
26
+ refuse_membership_without_identity!
27
+ refuse_unresolved_port_operations!
26
28
 
27
29
  @hecksagons.each_value do |hexagon|
28
30
  refuse_ungoverned_roles!(hexagon)
31
+ refuse_unwired_framework_members!(hexagon)
32
+ refuse_unwired_vendored_bluebooks!(hexagon)
33
+ refuse_bounded_without_acl!(hexagon)
29
34
 
30
35
  hexagon.binds.each do |bind|
31
36
  # A domain-level default (§0) — `persisted_by "Heki"` bare,
@@ -261,6 +266,115 @@ module Hecks
261
266
  "without that it is silent decoration, the exact defect this refusal exists to catch"
262
267
  end
263
268
 
269
+ # A vendored embryonaut bluebook is a bounded context, not a pile of
270
+ # types the consumer inlines. `uses_embryonaut_bluebook` only loads
271
+ # the package's `.bluebook` files — persistence, Governance, and the
272
+ # anti-corruption layer (`translates` field mapping) live on a named
273
+ # sibling hecksagon the CONSUMER must declare. Without that hexagon
274
+ # the package has no wiring of its own, and cross-context field
275
+ # mapping has nowhere to be written. Breaking in 2.0: attaching a
276
+ # vendored chapter without `Hecks.hecksagon "PackageName"` refuses
277
+ # at boot rather than silently sharing the consumer's hexagon.
278
+ def refuse_unwired_vendored_bluebooks!(hexagon)
279
+ Array(hexagon.vendored_bluebooks).each do |package|
280
+ chapter_name = Naming.pascal(package)
281
+ next if hecksagon(chapter_name)
282
+
283
+ raise WiringError,
284
+ "#{hexagon.domain} attaches vendored bluebook #{package.inspect} " \
285
+ "(bounded context #{chapter_name}) but never declared " \
286
+ "Hecks.hecksagon #{chapter_name.inspect} — put that sibling " \
287
+ "(and any `translates` ACL) in context_map.hecksagon; " \
288
+ "same-name blocks merge, order-independent."
289
+ end
290
+ end
291
+
292
+ # Same gate as vendored packages: `uses_framework` loads a bounded
293
+ # context, and the consumer must declare the sibling hecksagon that
294
+ # is its ACL. Governance/Identity/Privacy already follow this in
295
+ # every real consumer; boot now refuses a silent miss.
296
+ def refuse_unwired_framework_members!(hexagon)
297
+ Array(hexagon.framework_members).each do |member|
298
+ next if hecksagon(member)
299
+
300
+ raise WiringError,
301
+ "#{hexagon.domain} attaches framework member #{member.inspect} " \
302
+ "(bounded context) but never declared Hecks.hecksagon " \
303
+ "#{member.inspect} — put that sibling (and any `translates` " \
304
+ "ACL) in context_map.hecksagon; same-name blocks merge, " \
305
+ "order-independent."
306
+ end
307
+ end
308
+
309
+ # An explicit `bounded` mark on a consumer chapter always needs an
310
+ # ACL — at least one `translates` block. Any field can be mapped;
311
+ # the BC does not list which. `uses_framework` / `uses_embryonaut_bluebook`
312
+ # mark the ATTACHED chapter bounded (module wrap, no Object shortcut)
313
+ # and require the sibling hecksagon above; they do not require a
314
+ # `translates` on that sibling unless the consumer also wrote `bounded`.
315
+ # rust/host Google sign-in reads `ir.json`'s `membership` and
316
+ # `identity` keys (Exporter.membership / Exporter.identity) —
317
+ # never a deploy-time env var. Membership without Identity is
318
+ # the exact gap that produced a live `google_unlinked` after a
319
+ # successful Google handshake: provision cannot Register/Link.
320
+ # Refuse at Ruby boot (and project_rust, which exports the same
321
+ # pair) so a missing sibling cannot ship.
322
+ def refuse_membership_without_identity!
323
+ return unless @bluebooks.values.any? { |chapter| chapter.provides?(Bluebook::Capabilities::MEMBERSHIP) }
324
+ return if @bluebooks.values.any? { |chapter| chapter.provides?(Bluebook::Capabilities::IDENTITY) }
325
+
326
+ raise WiringError,
327
+ "a chapter that provides \"membership\" is loaded, but none provides " \
328
+ "\"identity\" — rust/host Google sign-in cannot register or link an " \
329
+ "identity from the hecksagon/world. Attach Identity (`uses_framework " \
330
+ "\"Identity\"` plus a sibling Hecks.hecksagon \"Identity\") so the " \
331
+ "identity verbs are exported onto ir.json, not guessed at deploy."
332
+ end
333
+
334
+ # A `provides` row whose contract kind is `:port_operation` names an
335
+ # operation the chapter's hecksagon declares, and the hecksagon
336
+ # attaches after the chapter is built, so this is the first point the
337
+ # operation can be checked. Without it, a host reading the declared
338
+ # verb would dispatch to an operation nothing declares.
339
+ def refuse_unresolved_port_operations!
340
+ @bluebooks.each_value do |chapter|
341
+ chapter.provides.each do |row|
342
+ next unless Bluebook::Capabilities::CONTRACTS.dig(row.capability, row.key.to_sym) == :port_operation
343
+ next if port_operation_declared?(chapter, row.verb)
344
+
345
+ raise WiringError,
346
+ "#{chapter.name} provides #{row.capability.inspect} #{row.key}: #{row.verb.inspect}, " \
347
+ "but its hecksagon declares no such port operation — declare it with " \
348
+ "`#{chapter.name}::Aggregate.port \"Port\" do operation \"Operation\" ... end`."
349
+ end
350
+ end
351
+ end
352
+
353
+ # Says whether a chapter declares the port operation an
354
+ # `"Aggregate.Port.Operation"` verb names.
355
+ #
356
+ # @param chapter [Bluebook::Chapter] the chapter whose aggregates' ports are searched
357
+ # @param verb [String] the verb, spelled `"Aggregate.Port.Operation"`
358
+ # @return [Boolean] whether that aggregate has that port with that operation
359
+ def port_operation_declared?(chapter, verb)
360
+ aggregate_name, port_name, operation_name = verb.split(".", 3)
361
+ ports = chapter.aggregate(aggregate_name)&.ports || []
362
+ ports.any? { |port| port.name == port_name && port.operations.any? { |op| op.hecks_name == operation_name } }
363
+ end
364
+
365
+ def refuse_bounded_without_acl!(hexagon)
366
+ return unless hexagon.bounded?
367
+ return if hexagon.translates.any?
368
+
369
+ raise WiringError,
370
+ "#{hexagon.domain} is marked bounded but never declared a " \
371
+ "translates ACL — a bounded chapter wraps in its own module and " \
372
+ "cross-context field mapping lives on the hecksagon, not in " \
373
+ "rust/host and not as a field list on the bluebook. " \
374
+ "Add `translates \"Name\" do on Foreign::Event; trigger Local::Command, " \
375
+ "with: { ... } end` (any field) or drop `bounded`."
376
+ end
377
+
264
378
  # The suggestion, derived from whichever framework members actually
265
379
  # declare `provides "authorization"` — never a hardcoded name.
266
380
  def authorization_attachment_hint
@@ -1,6 +1,7 @@
1
1
  require_relative "registry/verification"
2
2
  require_relative "registry/saga_persistence"
3
3
  require_relative "outbox"
4
+ require_relative "../naming"
4
5
 
5
6
  module Hecks
6
7
  module Runtime
@@ -27,6 +28,7 @@ module Hecks
27
28
  @bluebooks = {}
28
29
  @bluebook_sources = {}
29
30
  @hecksagons = {}
31
+ @bounded_chapters = {}
30
32
  @ports = {}
31
33
  @adapters = {}
32
34
  @worlds = {}
@@ -186,8 +188,29 @@ module Hecks
186
188
  def add_hecksagon(item)
187
189
  existing = @hecksagons[item.domain]
188
190
  @hecksagons[item.domain] = existing ? merge_hecksagons(existing, item) : item
191
+ mark_bounded(item.domain) if item.bounded?
189
192
  end
190
193
 
194
+ # Marks `name` as a bounded context — called automatically by
195
+ # `uses_framework` / `uses_embryonaut_bluebook`, and by
196
+ # `add_hecksagon` when the block itself declared `bounded`.
197
+ # A bounded chapter wraps in its own module (no Object shortcut)
198
+ # and must have a `translates` ACL or boot refuses.
199
+ #
200
+ # @param name [String, Symbol] the chapter name to mark bounded
201
+ # @return [void]
202
+ def mark_bounded(name)
203
+ @bounded_chapters[name.to_s] = true
204
+ end
205
+
206
+ # Says whether `name` is a bounded context — attached via
207
+ # `uses_framework` / `uses_embryonaut_bluebook`, or a consumer
208
+ # chapter that declared `bounded` on its own hecksagon.
209
+ #
210
+ # @param name [String, Symbol] the chapter name to check
211
+ # @return [Boolean] whether that chapter is bounded
212
+ def bounded?(name) = @bounded_chapters[name.to_s] ? true : false
213
+
191
214
  # Registers a loaded port, keyed by its own declared name.
192
215
  #
193
216
  # @param item [Bluebook::Port, Bluebook::DomainPort] the loaded port
@@ -308,6 +331,91 @@ module Hecks
308
331
  @bluebooks.values.select { |chapter| chapter.provides?(Bluebook::Capabilities::AUTHORIZATION) }
309
332
  end
310
333
 
334
+ # The chapter that answers "who is this authenticated pair" for
335
+ # `domain` — the domain's own chapter, or any framework member its
336
+ # hecksagon attaches, that declares `provides "identity"`. Nil when
337
+ # none does. Replaces every check for the literal name "Identity":
338
+ # Identity is recognised by what it declares (Register/Link/ResolvedBy),
339
+ # and a chapter that declares the same thing is recognised the same way.
340
+ #
341
+ # @param domain [String, Symbol] the domain whose identity chapter is being resolved
342
+ # @return [Bluebook::Chapter, nil] the chapter that answers `domain`'s identity
343
+ # questions, or nil if none does
344
+ def identity_provider_for(domain)
345
+ names = [domain.to_s, *Array(hecksagon(domain)&.framework_members)]
346
+ attached = names.filter_map { |name| bluebook(name) }
347
+ .find { |chapter| chapter.provides?(Bluebook::Capabilities::IDENTITY) }
348
+ return attached if attached
349
+
350
+ # Sibling hecksagons — a consuming domain often wires Identity as
351
+ # `Hecks.hecksagon "Identity"` (so Register can attach Governance
352
+ # on that named hexagon), not only via uses_framework on the
353
+ # consuming domain. The chapter is loaded; it just isn't listed
354
+ # on the consumer's own hexagon.
355
+ @bluebooks.values.find { |chapter| chapter.provides?(Bluebook::Capabilities::IDENTITY) }
356
+ end
357
+
358
+ # The chapter that answers "who may sign in" for `domain` — the
359
+ # domain's own chapter, any framework member its hecksagon attaches,
360
+ # or any vendored embryonaut bluebook it attaches, that declares
361
+ # `provides "membership"`. Nil when none does. Replaces rust/host's
362
+ # own HECKS_MEMBERSHIP_AGGREGATE env var: Membership is recognised
363
+ # by what it declares (Person.Admit/GrantAccess/All), and a chapter
364
+ # that declares the same thing (Embryonaut::Member, say) is
365
+ # recognised the same way.
366
+ #
367
+ # Vendored packages are included here and not in
368
+ # `authorization_provider_for` because membership is a vendored
369
+ # embryonaut_bluebooks chapter (`uses_embryonaut_bluebook
370
+ # "membership"`), not a framework member shipped inside hecks.
371
+ # `Naming.pascal` is the same directory-to-chapter convention
372
+ # `EmbryonautBluebook.load!` already uses.
373
+ #
374
+ # @param domain [String, Symbol] the domain whose sign-in aggregate is being resolved
375
+ # @return [Bluebook::Chapter, nil] the chapter that answers `domain`'s membership
376
+ # questions, or nil if none does
377
+ def membership_provider_for(domain)
378
+ vendored_provider_for(domain, Bluebook::Capabilities::MEMBERSHIP)
379
+ end
380
+
381
+ # The chapter that answers the guest newsletter signup for `domain` —
382
+ # the domain's own chapter, a framework member, or a vendored
383
+ # embryonaut bluebook it attaches, that declares `provides
384
+ # "newsletter"`. Nil when none does. Newsletter is recognised by
385
+ # what it declares (Subscribe/AddName/Confirm/Unsubscribe), so a
386
+ # chapter that declares the same thing is recognised the same way.
387
+ #
388
+ # @param domain [String, Symbol] the domain whose newsletter chapter is being resolved
389
+ # @return [Bluebook::Chapter, nil] the chapter that answers `domain`'s newsletter
390
+ # signup, or nil if none does
391
+ def newsletter_provider_for(domain)
392
+ vendored_provider_for(domain, Bluebook::Capabilities::NEWSLETTER)
393
+ end
394
+
395
+ # The chapter that provides `capability` for `domain`: the domain's
396
+ # own chapter, any framework member its hecksagon attaches, or any
397
+ # vendored embryonaut bluebook it attaches, that declares it. Falls
398
+ # back to any loaded chapter that does, because a consuming domain
399
+ # often wires a vendored chapter as its own `Hecks.hecksagon "Name"`
400
+ # (so that chapter can attach Governance on that named hexagon)
401
+ # rather than via `uses_embryonaut_bluebook` on itself — the chapter
402
+ # is loaded, it just isn't listed on the consumer's own hexagon.
403
+ #
404
+ # @param domain [String, Symbol] the domain the provider is resolved for
405
+ # @param capability [String] the capability's name, such as
406
+ # `Bluebook::Capabilities::MEMBERSHIP`
407
+ # @return [Bluebook::Chapter, nil] the providing chapter, or nil if none loaded does
408
+ def vendored_provider_for(domain, capability)
409
+ hexagon = hecksagon(domain)
410
+ vendored = Array(hexagon&.vendored_bluebooks).map { |name| Naming.pascal(name) }
411
+ names = [domain.to_s, *Array(hexagon&.framework_members), *vendored]
412
+ attached = names.filter_map { |name| bluebook(name) }
413
+ .find { |chapter| chapter.provides?(capability) }
414
+ return attached if attached
415
+
416
+ @bluebooks.values.find { |chapter| chapter.provides?(capability) }
417
+ end
418
+
311
419
  # Resolves and memoizes `aggregate`'s authoritative repository.
312
420
  #
313
421
  # @param domain [String, Symbol] name of the domain `aggregate` belongs to
@@ -437,12 +545,20 @@ module Hecks
437
545
  # @return [Bluebook::Hecksagon] a new wiring with every list-shaped fact
438
546
  # concatenated, `base` then `overlay`
439
547
  def merge_hecksagons(base, overlay)
548
+ # Same-name blocks concatenate regardless of which file they came
549
+ # from (`lifeadelics.hecksagon`, `context_map.hecksagon`, an
550
+ # environment overlay). Order-independent: list facts uniq, so
551
+ # loading context_map before or after the domain file is the same
552
+ # merged hecksagon. Binds stay concatenated (BindingPolicy still
553
+ # refuses a genuine double-bind).
440
554
  Bluebook::Hecksagon.new(
441
555
  domain: base.domain,
442
556
  binds: base.binds + overlay.binds,
443
- subscriptions: base.subscriptions + overlay.subscriptions,
444
- framework_members: base.framework_members + overlay.framework_members,
445
- vendored_bluebooks: base.vendored_bluebooks + overlay.vendored_bluebooks
557
+ subscriptions: (base.subscriptions + overlay.subscriptions).uniq,
558
+ framework_members: (base.framework_members + overlay.framework_members).uniq,
559
+ vendored_bluebooks: (base.vendored_bluebooks + overlay.vendored_bluebooks).uniq,
560
+ bounded: base.bounded? || overlay.bounded?,
561
+ translates: (base.translates + overlay.translates).uniq
446
562
  )
447
563
  end
448
564
 
data/lib/hecks/version.rb CHANGED
@@ -12,5 +12,5 @@ module Hecks
12
12
  # it there, or even in the consuming Gemfile, never closed the gap,
13
13
  # because gemspec evaluation happens before anything Bundler
14
14
  # resolves is actually loadable yet.
15
- VERSION = "1.5.1".freeze
15
+ VERSION = "2.1.0".freeze
16
16
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-20 00:00:00.000000000 Z
11
+ date: 2026-09-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism