hecks 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/memory.rb +1 -1
  4. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  5. data/lib/hecks/adapters/driven/prism.rb +1 -1
  6. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  7. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  8. data/lib/hecks/behaviors/expectations.rb +2 -2
  9. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  10. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  11. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  12. data/lib/hecks/bluebook/assembly.rb +1 -1
  13. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  14. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  15. data/lib/hecks/bluebook/capabilities.rb +2 -2
  16. data/lib/hecks/bluebook/chapter.rb +1 -1
  17. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  18. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  19. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  21. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  22. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  23. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
  26. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  27. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  28. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  29. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  30. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  31. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  32. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  33. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  34. data/lib/hecks/bluebook/model_check.rb +12 -13
  35. data/lib/hecks/cache_dir.rb +103 -0
  36. data/lib/hecks/cli/document.rb +104 -0
  37. data/lib/hecks/cli/ir.rb +47 -0
  38. data/lib/hecks/cli/mcp.rb +32 -0
  39. data/lib/hecks/cli/mcp_door.rb +461 -0
  40. data/lib/hecks/cli/model_check.rb +270 -0
  41. data/lib/hecks/cli/project_cli.rb +123 -0
  42. data/lib/hecks/cli/project_diagrams.rb +43 -0
  43. data/lib/hecks/cli/run.rb +219 -0
  44. data/lib/hecks/cli/smoke_test.rb +59 -0
  45. data/lib/hecks/cli/stores.rb +54 -0
  46. data/lib/hecks/cli.rb +166 -0
  47. data/lib/hecks/facade/handle.rb +2 -2
  48. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  50. data/lib/hecks/mcp_door_scope.rb +176 -0
  51. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  52. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
  53. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  54. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  55. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  56. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  57. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  58. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
  59. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  60. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  61. data/lib/hecks/projections/deploy/lambda.rb +3 -3
  62. data/lib/hecks/projections/reference.rb +7 -1
  63. data/lib/hecks/projector/exporter.rb +4 -4
  64. data/lib/hecks/projector/target.rb +1 -1
  65. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  66. data/lib/hecks/release/runner/commands.rb +68 -0
  67. data/lib/hecks/release/runner/console.rb +52 -0
  68. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  69. data/lib/hecks/release/runner/git.rb +48 -0
  70. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  71. data/lib/hecks/release/runner/preflight.rb +119 -0
  72. data/lib/hecks/release/runner/published.rb +55 -0
  73. data/lib/hecks/release/runner/tagger.rb +87 -0
  74. data/lib/hecks/release/runner.rb +244 -0
  75. data/lib/hecks/router/namespace_installer.rb +1 -1
  76. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  77. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  78. data/lib/hecks/runtime/entity_element.rb +1 -1
  79. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  80. data/lib/hecks/runtime/invocation.rb +1 -1
  81. data/lib/hecks/runtime/loader.rb +1 -1
  82. data/lib/hecks/runtime/outbox.rb +1 -1
  83. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  84. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  85. data/lib/hecks/runtime/reference_hop.rb +4 -4
  86. data/lib/hecks/runtime/registry/verification.rb +11 -11
  87. data/lib/hecks/runtime/registry.rb +8 -4
  88. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  89. data/lib/hecks/runtime/value/coercion.rb +3 -3
  90. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  91. data/lib/hecks/storehouse.rb +18 -12
  92. data/lib/hecks/version.rb +1 -1
  93. data/lib/hecks/vocabulary.rb +5 -0
  94. data/lib/hecks.rb +6 -1
  95. metadata +28 -58
  96. data/lib/hecks/bench/cli.rb +0 -74
  97. data/lib/hecks/bench/environment.rb +0 -100
  98. data/lib/hecks/bench/postgres_probe.rb +0 -41
  99. data/lib/hecks/bench/report.rb +0 -116
  100. data/lib/hecks/bench/ruby_runner.rb +0 -151
  101. data/lib/hecks/bench/run.rb +0 -55
  102. data/lib/hecks/bench/rust_runner.rb +0 -165
  103. data/lib/hecks/bench/stats.rb +0 -75
  104. data/lib/hecks/bench/suite.rb +0 -172
  105. data/lib/hecks/bench/workload.rb +0 -137
  106. data/lib/hecks/bench.rb +0 -28
  107. data/lib/hecks/codemod.rb +0 -443
  108. data/lib/hecks/corpus.rb +0 -479
  109. data/lib/hecks/doc/reference.rb +0 -585
  110. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  111. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  112. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  113. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  114. data/lib/hecks/fuzzing/differential.rb +0 -192
  115. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  116. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  117. data/lib/hecks/fuzzing/form_census.rb +0 -285
  118. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  119. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  120. data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
  121. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  122. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  123. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  124. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  125. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  126. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  127. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  128. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  129. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  130. data/lib/hecks/fuzzing/properties.rb +0 -267
  131. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  132. data/lib/hecks/fuzzing/replay.rb +0 -930
  133. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  134. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  135. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  136. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  137. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  138. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  139. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  140. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  141. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  142. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  143. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  144. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  145. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  146. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  147. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  148. data/lib/hecks/fuzzing.rb +0 -23
  149. data/lib/hecks/grammar/evolve.rb +0 -524
  150. data/lib/hecks/query_ir.rb +0 -482
@@ -0,0 +1,59 @@
1
+ require_relative "../../hecks"
2
+ require_relative "../bluebook/smoke_test"
3
+
4
+ module Hecks
5
+ module CLI
6
+ # The command behind `bin/smoke_test` and `hecks smoke_test`: boots a real domain
7
+ # and dispatches one synthesized call per declared command and report
8
+ # (`Bluebook::SmokeTest`), printing every failure rather than the first.
9
+ #
10
+ # It needs a real `.hecksagon`: a bare `.bluebook` with nothing bound to an
11
+ # adapter has nothing to run commands against. A failure may be a domain bug or
12
+ # a value the synthesizer's naive choices (0, an empty list, "smoke-test") cannot
13
+ # satisfy; the message underneath says which. Smoke-testing proves nothing
14
+ # crashes, not that an answer is right.
15
+ module SmokeTest
16
+ module_function
17
+
18
+ # Smoke-tests the domain `argv` names, or every wired domain under
19
+ # `root/examples/`, and exits with the verdict.
20
+ #
21
+ # @param argv [Array<String>] an optional domain directory
22
+ # @param root [String] the directory whose `examples/*` are tested when `argv`
23
+ # names no domain
24
+ # @return [void]
25
+ # @raise [SystemExit] always: 0 when clean, 1 with failures or no domain found
26
+ def call(argv, root:)
27
+ dir_arg = argv.first
28
+ targets = dir_arg ? [dir_arg] : Dir.glob("#{root}/examples/*/").map { |d| d.chomp("/") }.select { |d| wired?(d) }
29
+ abort "no wired example domains found (none of examples/* has a .hecksagon)" if targets.empty?
30
+
31
+ ok = targets.reduce(true) { |all_ok, dir| clean?(dir) && all_ok }
32
+
33
+ puts
34
+ puts ok ? "Every declared command and report dispatched cleanly." : "SMOKE TEST FOUND PROBLEMS."
35
+ exit(ok ? 0 : 1)
36
+ end
37
+
38
+ # Says whether `dir`, or something nested under it, is a bindable domain.
39
+ #
40
+ # @param dir [String] a directory to check
41
+ # @return [Boolean] true if `dir` contains at least one `.hecksagon` file
42
+ def wired?(dir) = !Dir.glob(File.join(dir, "**/*.hecksagon")).empty?
43
+
44
+ # @api private
45
+ def clean?(dir)
46
+ puts "── #{File.basename(dir)}"
47
+ failures = Bluebook::SmokeTest.call(dir)
48
+ if failures.empty?
49
+ puts " clean — every declared command and report dispatched cleanly"
50
+ return true
51
+ end
52
+
53
+ puts " #{failures.size} failure(s)"
54
+ failures.each { |f| puts " #{f}" }
55
+ false
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,54 @@
1
+ require "json"
2
+ require_relative "../../hecks"
3
+
4
+ module Hecks
5
+ module CLI
6
+ # The command behind `bin/stores` and `hecks stores`: every aggregate's current
7
+ # records as JSON, the head rather than the journal (`bin/history` prints the
8
+ # full write history).
9
+ module Stores
10
+ module_function
11
+
12
+ # Boots the domain `argv` names and prints its stores as one JSON document.
13
+ #
14
+ # @param argv [Array<String>] the domain directory, first
15
+ # @param program [String] the name the error message calls this command by
16
+ # @return [void]
17
+ # @raise [IndexError] when `argv` is empty
18
+ # @raise [SystemExit] when the domain directory does not exist
19
+ def call(argv, program:)
20
+ domain = argv.fetch(0)
21
+ unless Dir.exist?(domain)
22
+ warn "#{program}: no such domain #{domain.inspect}"
23
+ exit 1
24
+ end
25
+
26
+ registry = Hecks.boot(domain).registry
27
+ stores = registry.bluebooks.each_with_object({}) do |(name, bluebook), all|
28
+ bluebook.aggregates.each do |aggregate|
29
+ all[aggregate.storage_name] = data_for(registry.repository(name, aggregate))
30
+ end
31
+ end
32
+
33
+ puts JSON.generate(stores)
34
+ end
35
+
36
+ # Reads one aggregate's current records as plain JSON-ready hashes, ordered by id.
37
+ #
38
+ # @param repository [Ports::Persistence::AppendOnly] the aggregate's repository
39
+ # @return [Array<Hash{String => Object}>] one hash per stored `Runtime::Instance`,
40
+ # sorted by id; `[]` when the aggregate has no records
41
+ def records(repository)
42
+ repository.all.sort_by(&:id).map { |instance| JSON.parse(JSON.generate(instance.to_h)) }
43
+ end
44
+
45
+ # Shapes one aggregate's dump for the top-level stores document.
46
+ #
47
+ # @param repository [Ports::Persistence::AppendOnly] the aggregate's repository
48
+ # @return [Hash{String => Object}] `"authoritative"` mapped to `records(repository)`
49
+ def data_for(repository)
50
+ { "authoritative" => records(repository) }
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/hecks/cli.rb ADDED
@@ -0,0 +1,166 @@
1
+ module Hecks
2
+ # The `hecks` command an installed gem puts on the path: one router over the
3
+ # domain-operator tools, each of which also runs from a checkout as `bin/<name>`.
4
+ #
5
+ # ## A router, not a second copy
6
+ #
7
+ # Every subcommand's logic lives in `lib/hecks/cli/`, and the matching `bin/`
8
+ # script is a thin wrapper over the same entry point, so `hecks run` and
9
+ # `bin/run` cannot drift apart. The domain-scoped verbs go through
10
+ # `Facade::CliRunner`, the same way `bin/run` does.
11
+ #
12
+ # ## What a checkout has and an install does not
13
+ #
14
+ # The `bin/` wrappers pass the repository root, which is where they look for
15
+ # `examples/`, `rust/` and `docs/generated/`. Here the root is the working
16
+ # directory, so a command that lists or writes files does it where the caller
17
+ # stands. `model_check` with no domain sweeps the repository's corpus, which an
18
+ # install does not carry, so here it needs at least one domain path.
19
+ #
20
+ # ## Loading
21
+ #
22
+ # Nothing here requires the framework. A subcommand loads it when it runs, and
23
+ # `mcp` checks its transport before anything else loads, as
24
+ # `McpStdioGuard.enforce_stdio!` requires.
25
+ #
26
+ # See `docs/decisions/0066-the-gem-ships-a-hecks-executable-and-dev-tooling-stays-in-the-repo.md`.
27
+ module CLI
28
+ # One subcommand's help: a one-line summary and its usage line.
29
+ Command = Struct.new(:summary, :usage)
30
+
31
+ # Every subcommand, in the order the help lists them.
32
+ COMMANDS = {
33
+ "run" => Command.new(
34
+ "Dispatch a verb, or run a JSON step list, against a domain.",
35
+ "hecks run [domain] <verb [name=value …] | script.json | - | '{\"steps\":[…]}'>"
36
+ ),
37
+ "docs" => Command.new(
38
+ "Print a domain's usage document, projected from its bluebook.",
39
+ "hecks docs [domain-path] [aggregate]"
40
+ ),
41
+ "narrate" => Command.new(
42
+ "Print a domain read back in English, projected from its bluebook.",
43
+ "hecks narrate [domain-path] [aggregate]"
44
+ ),
45
+ "ir" => Command.new(
46
+ "Print a booted domain's IR as JSON.",
47
+ "hecks ir <domain> [--translations] | hecks ir --meta"
48
+ ),
49
+ "stores" => Command.new(
50
+ "Print every aggregate's current records as JSON.",
51
+ "hecks stores <domain>"
52
+ ),
53
+ "model_check" => Command.new(
54
+ "Statically check a domain's IR for dead states and unreachable steps.",
55
+ "hecks model_check [--strict] [--profile client] <domain> [<domain> …]"
56
+ ),
57
+ "smoke_test" => Command.new(
58
+ "Boot a domain and dispatch every declared command and report once.",
59
+ "hecks smoke_test [domain]"
60
+ ),
61
+ "project_diagrams" => Command.new(
62
+ "Write a domain's Mermaid diagrams under ./docs/generated/diagrams/.",
63
+ "hecks project_diagrams <domain-path> <ChapterName>"
64
+ ),
65
+ "project_cli" => Command.new(
66
+ "Write a command-line launcher beside each domain, named after its bluebook.",
67
+ "hecks project_cli [domain-path …]"
68
+ ),
69
+ "mcp" => Command.new(
70
+ "Serve the MCP door over stdio (no authentication; stdio only).",
71
+ "hecks mcp [--stdio]"
72
+ )
73
+ }.freeze
74
+
75
+ HELP_FLAGS = ["--help", "-h"].freeze
76
+
77
+ # Exit status for a missing or unknown subcommand.
78
+ USAGE_STATUS = 2
79
+
80
+ module_function
81
+
82
+ # Routes `argv` to its subcommand and runs it.
83
+ #
84
+ # A subcommand followed by only `--help` or `-h` prints that subcommand's usage
85
+ # without running it; any other arguments pass through untouched, so
86
+ # `hecks run order.place --help` still reaches the verb's own help.
87
+ #
88
+ # @param argv [Array<String>] the command line, subcommand first
89
+ # @param out [#puts] where help goes
90
+ # @param err [#puts] where usage errors go
91
+ # @return [Integer] the process exit status for a subcommand that returns; a
92
+ # subcommand that fails exits the process itself
93
+ def start(argv, out: $stdout, err: $stderr)
94
+ name, *rest = argv
95
+ if name.nil? || !COMMANDS.key?(name)
96
+ asked = HELP_FLAGS.include?(name) || name == "help"
97
+ err.puts "hecks: unknown command #{name.inspect}" unless asked || name.nil?
98
+ overview(asked ? out : err)
99
+ return asked ? 0 : USAGE_STATUS
100
+ end
101
+
102
+ if rest.length == 1 && HELP_FLAGS.include?(rest.first)
103
+ help(COMMANDS.fetch(name), out)
104
+ else
105
+ dispatch(name, rest)
106
+ end
107
+ 0
108
+ end
109
+
110
+ # Runs one known subcommand with the working directory as its root.
111
+ #
112
+ # @param name [String] a key of `COMMANDS`
113
+ # @param argv [Array<String>] the arguments after the subcommand
114
+ # @return [void]
115
+ # @raise [SystemExit] when the subcommand exits the process itself
116
+ def dispatch(name, argv)
117
+ program = "hecks #{name}"
118
+ case name
119
+ when "run"
120
+ require_relative "cli/run"
121
+ Run.call(argv, program: program)
122
+ when "docs", "narrate"
123
+ require_relative "cli/document"
124
+ Document.call(argv, projection: name.to_sym, program: program, root: Dir.pwd)
125
+ when "ir"
126
+ require_relative "cli/ir"
127
+ Ir.call(argv, program: program)
128
+ when "stores"
129
+ require_relative "cli/stores"
130
+ Stores.call(argv, program: program)
131
+ when "model_check"
132
+ require_relative "cli/model_check"
133
+ ModelCheck.call(argv, program: program)
134
+ when "smoke_test"
135
+ require_relative "cli/smoke_test"
136
+ SmokeTest.call(argv, root: Dir.pwd)
137
+ when "project_diagrams"
138
+ require_relative "cli/project_diagrams"
139
+ ProjectDiagrams.call(argv, program: program, root: Dir.pwd)
140
+ when "project_cli"
141
+ require_relative "cli/project_cli"
142
+ ProjectCli.call(argv, program: program, root: Dir.pwd, remove_stale_bin: false)
143
+ when "mcp"
144
+ require_relative "cli/mcp"
145
+ Mcp.call(argv)
146
+ end
147
+ end
148
+
149
+ # @api private
150
+ def overview(io)
151
+ io.puts "usage: hecks <command> [arguments]"
152
+ io.puts ""
153
+ width = COMMANDS.keys.map(&:length).max
154
+ COMMANDS.each { |name, command| io.puts " #{name.ljust(width)} #{command.summary}" }
155
+ io.puts ""
156
+ io.puts "Run `hecks <command> --help` for one command's usage."
157
+ end
158
+
159
+ # @api private
160
+ def help(command, io)
161
+ io.puts "usage: #{command.usage}"
162
+ io.puts ""
163
+ io.puts command.summary
164
+ end
165
+ end
166
+ end
@@ -142,14 +142,14 @@ module Hecks
142
142
  # privacy.bluebook): a marking's presence is what makes a read
143
143
  # redacted, not a separate flag this class carries itself.
144
144
  #
145
- # ALWAYS THE STRONG CHECK, never the weak string-only fallback
145
+ # Always the strong check, never the weak string-only fallback
146
146
  # `CommandRules::Authorization#refuse_role_mismatch` allows an
147
147
  # unidentified caller — a read gate gone wrong is a leak, not a
148
148
  # refused command, so an ambient caller with no `actor_id` (or no
149
149
  # caller at all) is masked here, full stop, rather than waved
150
150
  # through the way a self-asserted `role` string is for a command.
151
151
  #
152
- # ONE LEVEL OF NESTING ONLY — `attendee.medications` masks inside
152
+ # One level of nesting only — `attendee.medications` masks inside
153
153
  # the returned `Runtime::Value` via its own `#with`; a marking two
154
154
  # levels deep is not supported and is left unmasked rather than
155
155
  # silently mishandled, since nothing in this corpus needs it yet.
@@ -165,7 +165,7 @@ module Hecks
165
165
  end
166
166
 
167
167
  door.define_singleton_method(:method_missing) do |verb, *args, **kwargs, &block|
168
- # A BARE CALL starts a Privacy marking chain — see
168
+ # A bare call starts a Privacy marking chain — see
169
169
  # `Bluebook::DSL::BindingProxy#method_missing`'s own header;
170
170
  # this is the same mechanism, reached when the constant is
171
171
  # already a real, installed door (a second boot in-process)
@@ -339,6 +339,12 @@ Hecks.bluebook "Bluebook" do
339
339
  member refusal: "InvariantViolation", site: "undeclared_set",
340
340
  template: "{name} admits {admits}, which this chapter does not declare — a closed set is named " \
341
341
  "Aggregate::SetName, and it must be one the bluebook actually holds"
342
+ # ADR 0061 decision D1: a group_by leaf holds one row, so two rows
343
+ # reaching the same full key path refuse at dispatch rather than one
344
+ # of them being dropped.
345
+ member refusal: "InvariantViolation", site: "group_by_collision",
346
+ template: "{read_model} groups by {fields}, but rows {ids} share {key} — a group_by " \
347
+ "leaf holds one row; add a field that tells them apart"
342
348
  member refusal: "Unauthorized", site: "tenant_required",
343
349
  template: "{query} declares authorize with tenant: {field} — pass {field}: to name which " \
344
350
  "{field} this ask is scoped to"
@@ -620,6 +626,16 @@ Hecks.bluebook "Bluebook" do
620
626
  shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
621
627
  member refusal: "InvariantViolation", site: "undeclared_set", argument: "admits",
622
628
  shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
629
+ member refusal: "InvariantViolation", site: "group_by_collision", argument: "read_model",
630
+ shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
631
+ member refusal: "InvariantViolation", site: "group_by_collision", argument: "fields",
632
+ shape: "list", quoting: "none", separator: ", ", sorted: "false", when_empty: ""
633
+ member refusal: "InvariantViolation", site: "group_by_collision", argument: "ids",
634
+ shape: "list", quoting: "inspect", separator: ", ", sorted: "true", when_empty: ""
635
+ # "field = value" per key-path level, joined with ", " by the caller:
636
+ # each value is its own `to_s`, the same text the nested Hash keys it by.
637
+ member refusal: "InvariantViolation", site: "group_by_collision", argument: "key",
638
+ shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
623
639
  member refusal: "Unauthorized", site: "tenant_required", argument: "query",
624
640
  shape: "scalar", quoting: "none", separator: "", sorted: "false", when_empty: ""
625
641
  member refusal: "Unauthorized", site: "tenant_required", argument: "field",
@@ -0,0 +1,176 @@
1
+ require_relative "runtime/errors"
2
+
3
+ module Hecks
4
+ # What one spawned `bin/hecks_mcp_door` may do: every tool against any domain under
5
+ # `Storehouse::BOOT_ROOT` (the default), or, in reader mode, only the reader tools
6
+ # against the domains its spawner named. ADR 0072, decision 2.
7
+ #
8
+ # ## Reach, not identity
9
+ #
10
+ # This is a capability allowlist, not authentication. It identifies no one and adds no
11
+ # secret: whoever can spawn the door chooses its mode, and whoever can write to its stdin
12
+ # gets whatever that mode allows. What it buys is a smaller surface per agent. A reader
13
+ # door cannot dispatch, cannot run `.behaviors` files, and cannot `Kernel.load` Ruby from
14
+ # a domain its spawner did not name.
15
+ #
16
+ # ## Why `HECKS_DOOR_*` and not `HECKS_MCP_*`
17
+ #
18
+ # `McpStdioGuard` refuses every `HECKS_MCP_*` variable except
19
+ # `HECKS_MCP_TRANSPORT=stdio`, and every argument except `--stdio`, so that no option can
20
+ # ask a server for another transport. Those refusals belong to ADR 0062. The settings
21
+ # here live under their own prefix so the guard stays exactly as that ADR describes it,
22
+ # and an unknown `HECKS_DOOR_*` name is refused here, the same fail-closed way.
23
+ #
24
+ # ## The settings
25
+ #
26
+ # - `HECKS_DOOR_TOOLS=readers` turns reader mode on. Unset means every tool, as before.
27
+ # Any other value refuses to start.
28
+ # - `HECKS_DOOR_DOMAINS` lists the domains a reader door may name, separated by
29
+ # `File::PATH_SEPARATOR` (`:` on Unix), each resolved against `Storehouse::BOOT_ROOT`.
30
+ # Required in reader mode and refused without it.
31
+ #
32
+ # ## The allowed set is named, not loaded
33
+ #
34
+ # The door boots a domain afresh on every call and keeps nothing loaded between calls,
35
+ # so there is no set of already-loaded domains to check against. The allowed set is the
36
+ # list the spawner named, resolved to absolute paths at startup. A call's `domain:` is
37
+ # resolved the same way and compared before anything boots, so a path outside the set
38
+ # never reaches `Kernel.load`. The comparison is on resolved path text: a symlink under a
39
+ # named path is followed when that domain boots, as it is for every domain today.
40
+ class McpDoorScope
41
+ ENV_PREFIX = "HECKS_DOOR_".freeze
42
+ TOOLS_VARIABLE = "HECKS_DOOR_TOOLS".freeze
43
+ DOMAINS_VARIABLE = "HECKS_DOOR_DOMAINS".freeze
44
+ READERS = "readers".freeze
45
+ EXIT_STATUS = 2
46
+
47
+ # The tools a reader door serves. Each reads a domain or the door's own audit log and
48
+ # writes no domain state. `query` and `state` still append their call to that audit
49
+ # log. `validate` boots the domain it is given, so it is held to the allowed set like
50
+ # every other domain-scoped tool. `domains` lists directories and loads nothing.
51
+ READER_TOOLS = %w[query events state catalog describe validate domains history follow].freeze
52
+
53
+ # Reads the door's mode from its environment.
54
+ #
55
+ # @param env [Hash{String => String}] the process environment
56
+ # @return [McpDoorScope] an unrestricted scope when no `HECKS_DOOR_*` variable is set;
57
+ # otherwise a reader scope over the named domains
58
+ # @raise [ArgumentError] with every problem found, one per line, when the settings are
59
+ # not a mode this door knows
60
+ # @raise [Runtime::TypeMismatch] if a named domain resolves outside
61
+ # `Storehouse::BOOT_ROOT`
62
+ def self.from_env(env = ENV)
63
+ problems = setting_problems(env)
64
+ raise ArgumentError, problems.join("\n") unless problems.empty?
65
+ return new(allowed_domains: nil) unless env[TOOLS_VARIABLE]
66
+
67
+ new(allowed_domains: env[DOMAINS_VARIABLE].split(File::PATH_SEPARATOR).reject(&:empty?))
68
+ end
69
+
70
+ # Reads the door's mode, or refuses to start on settings it does not know.
71
+ #
72
+ # Everything goes to `stderr`, because stdout carries the MCP protocol.
73
+ #
74
+ # @param server [String] the server's name, used as the line prefix
75
+ # @param env [Hash{String => String}] the process environment
76
+ # @param stderr [IO] where a refusal is written
77
+ # @return [McpDoorScope] the door's scope
78
+ # @raise [SystemExit] with status `EXIT_STATUS` when `from_env` refuses the settings
79
+ def self.start!(server:, env: ENV, stderr: $stderr)
80
+ from_env(env)
81
+ rescue ArgumentError, Runtime::TypeMismatch => e
82
+ e.message.each_line { |line| stderr.puts("#{server}: refusing to start: #{line.chomp}") }
83
+ exit(EXIT_STATUS)
84
+ end
85
+
86
+ # @api private
87
+ def self.setting_problems(env)
88
+ unknown = env.keys.select { |name| name.start_with?(ENV_PREFIX) } - [TOOLS_VARIABLE, DOMAINS_VARIABLE]
89
+ problems = unknown.map do |name|
90
+ "environment #{name} is not accepted; the door takes only #{TOOLS_VARIABLE} and #{DOMAINS_VARIABLE}"
91
+ end
92
+ problems + mode_problems(env[TOOLS_VARIABLE], env[DOMAINS_VARIABLE])
93
+ end
94
+
95
+ # @api private
96
+ def self.mode_problems(tools, domains)
97
+ if tools.nil?
98
+ domains.nil? ? [] : ["#{DOMAINS_VARIABLE} is set without #{TOOLS_VARIABLE}=#{READERS}; it only narrows a reader door"]
99
+ elsif tools != READERS
100
+ ["#{TOOLS_VARIABLE}=#{tools.inspect} is not accepted; the only value is #{READERS.inspect}"]
101
+ elsif domains.to_s.split(File::PATH_SEPARATOR).none? { |path| !path.empty? }
102
+ ["#{TOOLS_VARIABLE}=#{READERS} needs #{DOMAINS_VARIABLE}, the domains this door may read"]
103
+ else
104
+ []
105
+ end
106
+ end
107
+
108
+ # The named domains, resolved against `Storehouse::BOOT_ROOT`; nil when unrestricted.
109
+ #
110
+ # @return [Array<String>, nil]
111
+ attr_reader :allowed_domains
112
+
113
+ # @param allowed_domains [Array<String>, nil] the domains a reader door may name, as
114
+ # given; nil for an unrestricted door
115
+ # @raise [Runtime::TypeMismatch] if a named domain resolves outside `Storehouse::BOOT_ROOT`
116
+ def initialize(allowed_domains:)
117
+ @allowed_domains = allowed_domains&.map { |path| Storehouse.confine!(path, DOMAINS_VARIABLE) }.freeze
118
+ end
119
+
120
+ # Tells whether this door runs in reader mode.
121
+ #
122
+ # @return [Boolean] true when the spawner set `HECKS_DOOR_TOOLS=readers`
123
+ def restricted?
124
+ !@allowed_domains.nil?
125
+ end
126
+
127
+ # Tells whether this door serves a tool.
128
+ #
129
+ # @param name [String] the tool name from a `tools/call` request
130
+ # @return [Boolean] true for every name on an unrestricted door; on a reader door, true
131
+ # only for `READER_TOOLS`
132
+ def permits_tool?(name)
133
+ !restricted? || READER_TOOLS.include?(name)
134
+ end
135
+
136
+ # Builds the answer to a call for a tool this door does not serve.
137
+ #
138
+ # @param name [String] the tool name from a `tools/call` request
139
+ # @return [Hash{Symbol => Object}] `{ok: false, error:}`, the error naming the mode and
140
+ # the tools it serves
141
+ def refusal(name)
142
+ { ok: false,
143
+ error: "#{name.inspect} is refused: this door runs in reader mode (#{TOOLS_VARIABLE}=#{READERS}), " \
144
+ "which serves only #{READER_TOOLS.join(', ')}" }
145
+ end
146
+
147
+ # Checks a call's `domain:` against the allowed set, before anything boots.
148
+ #
149
+ # @param domain [String, nil] the caller's `domain:` argument
150
+ # @return [String, nil] `domain` unchanged, so an unrestricted door boots exactly what
151
+ # it did before
152
+ # @raise [Runtime::TypeMismatch] on a reader door, if `domain` resolves outside
153
+ # `Storehouse::BOOT_ROOT` or to a path its spawner did not name
154
+ def admit_domain!(domain)
155
+ return domain unless restricted?
156
+ return domain if @allowed_domains.include?(Storehouse.confine!(domain, "domain"))
157
+
158
+ raise Runtime::TypeMismatch,
159
+ "domain: #{domain.inspect} is refused: this door runs in reader mode (#{TOOLS_VARIABLE}=#{READERS}) " \
160
+ "and reads only #{DOMAINS_VARIABLE}: #{@allowed_domains.join(', ')}"
161
+ end
162
+
163
+ # The startup warning lines that describe this door's mode.
164
+ #
165
+ # @return [Array<String>] empty when unrestricted; on a reader door, what it serves,
166
+ # what it refuses, the domains it reads, and that it identifies no one
167
+ def notes
168
+ return [] unless restricted?
169
+
170
+ ["Reader mode (#{TOOLS_VARIABLE}=#{READERS}): serves #{READER_TOOLS.join(', ')}; " \
171
+ "refuses dispatch (with dry_run and steps), behaviors and every other tool.",
172
+ "domain: boots only #{DOMAINS_VARIABLE}: #{@allowed_domains.join(', ')}.",
173
+ "Reader mode limits reach and identifies no one; it is not authentication."]
174
+ end
175
+ end
176
+ end
@@ -308,7 +308,7 @@ module Hecks
308
308
  #
309
309
  # @param id [Integer] the row id assigned by `outbox_enqueue`
310
310
  # @return [Boolean] true when this call claimed the row; false when it is missing or
311
- # no longer pending
311
+ # not pending
312
312
  def outbox_claim(id) = @adapter.outbox_claim(id)
313
313
 
314
314
  # Records the final status of a claimed outbox row.
@@ -123,7 +123,7 @@ module Hecks
123
123
  #
124
124
  # The domain's own directory first, matched by name — a real app's
125
125
  # directory may hold more than one file once `uses_framework`
126
- # exists, so ".first" alone can no longer be trusted, the exact
126
+ # exists, so ".first" alone cannot be trusted, the exact
127
127
  # way it silently wasn't the day this was found: three domains
128
128
  # booted together, one real source text read once (the domain's
129
129
  # own, ".first"'d), and every other domain's era-1 held that text
@@ -131,7 +131,7 @@ module Hecks
131
131
  # Normal parse first, shadow only as a fallback — never shadow-parsing
132
132
  # unconditionally. A handful of DSL
133
133
  # defaults fork on `MetaValidator.shadow_parsing?` for a reason
134
- # that has nothing to do with syntax the live grammar can no longer
134
+ # that has nothing to do with syntax the live grammar cannot
135
135
  # read at all (`identified_by { }`, `belongs_to`, `has_one`,
136
136
  # `has_many` — genuinely removed spellings, exactly what shadow-
137
137
  # parsing exists to keep readable): `reference_to`'s own default
@@ -158,7 +158,7 @@ module Hecks
158
158
  # interpretation, the same one `label_of`/`mint_hash` on the same
159
159
  # source text always computes, whoever's asking. Only on a
160
160
  # `Malformed` refusal — the one signal that actually means "this
161
- # spelling doesn't exist anymore" — fall back to the legacy
161
+ # spelling is not in the live grammar" — fall back to the legacy
162
162
  # grammar. Any other exception (a
163
163
  # genuine syntax error, an unrelated validation refusal) propagates
164
164
  # unchanged; swallowing it here to retry under shadow mode would
@@ -5,7 +5,7 @@ require_relative "storage_shape"
5
5
 
6
6
  module Hecks
7
7
  module Runtime
8
- # The refusal wording for a held era whose digest no longer matches its
8
+ # The refusal wording for a held era whose digest does not match its
9
9
  # frozen text — the digest mismatch alone is what detects tampering (a
10
10
  # plain SHA256 comparison over raw bytes, unrelated to any of this);
11
11
  # this only supplies the wording once that's already fired.
@@ -28,7 +28,7 @@ module Hecks
28
28
  module EraTamper
29
29
  module_function
30
30
 
31
- # Words the boot refusal for a held era text whose digest no longer matches.
31
+ # Words the boot refusal for a held era text whose digest does not match.
32
32
  #
33
33
  # @param domain [String] name of the domain whose era was edited
34
34
  # @param ordinal [Integer] the edited era's ordinal in `hecks_eras`
@@ -24,7 +24,7 @@ module Hecks
24
24
  # the domain holds none; keys are `:ordinal` (Integer), `:hash` and `:label` (String,
25
25
  # nil until `mint_name!` names the era), `:held_text` (String) and `:watermark`
26
26
  # (Integer journal ordinal the era was cut at, nil for era 1)
27
- # @raise [Runtime::WiringError] if a held text no longer matches its stored digest
27
+ # @raise [Runtime::WiringError] if a held text does not match its stored digest
28
28
  def eras
29
29
  @db.exec_params(
30
30
  "SELECT ordinal, hash, label, held_text, watermark, held_digest, held_projection::text " \
@@ -153,7 +153,7 @@ module Hecks
153
153
  # Reports the ordinal of the newest era the domain holds.
154
154
  #
155
155
  # @return [Integer] the highest held ordinal, or 1 when no era is held yet
156
- # @raise [Runtime::WiringError] if a held text no longer matches its stored digest
156
+ # @raise [Runtime::WiringError] if a held text does not match its stored digest
157
157
  def current_era
158
158
  held = eras
159
159
  held.empty? ? 1 : held.last[:ordinal]
@@ -553,7 +553,7 @@ module Hecks
553
553
  # `full:` forces a rebuild from the raw journal. The tail-merge
554
554
  # needs it: it moves every watermark to the new tip, so every
555
555
  # ancestor matview's cut goes stale in the same statement, and
556
- # layering on one would carry a cut that no longer exists.
556
+ # layering on one would carry a cut that does not exist.
557
557
  #
558
558
  # The live half reads the snapshot table, not `WHERE era = era AND
559
559
  # aggregate = name` over the raw journal — that would be a
@@ -107,7 +107,7 @@ module Hecks
107
107
  # --approve` and bound to what was actually reviewed: this
108
108
  # edge's parsed content, and the journal as it stood when the
109
109
  # samples were read. A journal that has advanced past the
110
- # review invalidates it — the approved samples no longer cover
110
+ # review invalidates it — the approved samples do not cover
111
111
  # the data.
112
112
  #
113
113
  # @param bluebook [Bluebook::Chapter] the domain, named in the refusal
@@ -120,23 +120,29 @@ module Hecks
120
120
 
121
121
  # A compute is the one rule whose SQL is its only implementation
122
122
  # — evaluated exclusively inside the compiled head, never
123
- # in-process. The old field is exposed under its own name (as
124
- # text, exactly as the author's expression expects to cast it).
123
+ # in-process. The source is read as a path through
124
+ # `hecks_tr_extract`, bare or a dotted value-object member alike,
125
+ # the same way the destination goes through `path_literal`. The
126
+ # source's value is exposed to the author's SQL as text under a
127
+ # column named by the source exactly as declared (`price_cents`,
128
+ # or `"price.cents"` for a dotted member), and the whole record
129
+ # stays readable as `__s`.
125
130
  #
126
131
  # @param expression [String] the SQL expression built so far by `compile_rules`, read as
127
132
  # `__s` inside the compute's own SQL
128
133
  # @param compute [Bluebook::TranslationCompute] the declared compute rule
129
134
  # @return [String] `expression` wrapped so the compute's field lands at its declared
130
- # destination when the source field is present, unchanged otherwise
135
+ # destination when the source path is present, unchanged otherwise
131
136
  def compile_compute(expression, compute)
132
137
  from = compute.from.to_s
133
138
  to = compute.to.to_s
134
- "(SELECT CASE WHEN __s ? #{text_literal(from)} THEN " \
135
- "hecks_tr_insert(__s - #{text_literal(from)}, #{path_literal(to)}, to_jsonb((#{compute.sql})), " \
139
+ "(SELECT CASE WHEN (__x).present THEN " \
140
+ "hecks_tr_insert((__x).remaining, #{path_literal(to)}, to_jsonb((#{compute.sql})), " \
136
141
  "#{text_literal("compute #{from} to: #{to}")}) " \
137
142
  "ELSE __s END " \
138
143
  "FROM (SELECT (#{expression}) AS __s) __outer, " \
139
- "LATERAL (SELECT (__s ->> #{text_literal(from)}) AS #{quote(from)}) __fields)"
144
+ "LATERAL (SELECT hecks_tr_extract(__s, #{path_literal(from)}) AS __x) __extract, " \
145
+ "LATERAL (SELECT (__s #>> #{path_literal(from)}) AS #{quote(from)}) __fields)"
140
146
  end
141
147
 
142
148
  # A newly added, required attribute with no source at all — the
@@ -44,7 +44,7 @@ module Hecks
44
44
  # drops a key, nil or not: a stored nil stays a stored nil.
45
45
  #
46
46
  # It never touches an undeclared key's value — a retired field, or a
47
- # member a value object no longer declares, is exactly what an Era
47
+ # member a value object does not declare, is exactly what an Era
48
48
  # translation (rename/move/drop) still has to read. Its key keeps its
49
49
  # spelling below the top level; at the top level every key is a
50
50
  # symbol, declared or not, because every adapter has always