hecks 3.0.4 → 3.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.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +25 -7
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  5. data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
  6. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
  7. data/lib/hecks/bluebook/hexagon.rb +45 -25
  8. data/lib/hecks/bluebook/model_check.rb +7 -7
  9. data/lib/hecks/chapters.rb +31 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/console.rb +24 -8
  12. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  13. data/lib/hecks/cli/doc_coverage.rb +1 -1
  14. data/lib/hecks/cli/domain_stub.rb +128 -0
  15. data/lib/hecks/cli/interview_draft.rb +246 -0
  16. data/lib/hecks/cli/model_check.rb +1 -1
  17. data/lib/hecks/cli/project_cli.rb +69 -18
  18. data/lib/hecks/cli/reference.rb +2 -1
  19. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  20. data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
  21. data/lib/hecks/cli/spec_example.rb +1 -1
  22. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  23. data/lib/hecks/corpus.rb +11 -8
  24. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  25. data/lib/hecks/deploy/oidc.json +5 -0
  26. data/lib/hecks/doc/reference.rb +4 -3
  27. data/lib/hecks/doors/cli_door.rb +13 -13
  28. data/lib/hecks/doors/cli_runner.rb +52 -32
  29. data/lib/hecks/doors/launcher_options.rb +23 -5
  30. data/lib/hecks/doors/mcp_door.rb +40 -18
  31. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  32. data/lib/hecks/doors.rb +1 -1
  33. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  34. data/lib/hecks/forms/banking_presentation.rb +1 -1
  35. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  36. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  37. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  38. data/lib/hecks/framework.rb +1 -1
  39. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  40. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  41. data/lib/hecks/fuzzing/differential.rb +4 -2
  42. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  43. data/lib/hecks/fuzzing/form_census.rb +2 -2
  44. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  45. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  46. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  47. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  48. data/lib/hecks/gate/stages.yml +8 -4
  49. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  50. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  51. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  52. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  53. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  54. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  55. data/lib/hecks/hecks/codebase.bluebook +4 -3
  56. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  57. data/lib/hecks/hecks/custodian.bluebook +45 -3
  58. data/lib/hecks/hecks/hecks.hecksagon +10 -4
  59. data/lib/hecks/hecks/hecks.world +7 -4
  60. data/lib/hecks/hecks/site.behaviors +17 -0
  61. data/lib/hecks/hecks/site.world +10 -0
  62. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  63. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  64. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  65. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
  66. data/lib/hecks/projections/deploy/box/settings.rb +248 -0
  67. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
  68. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
  69. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  70. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
  71. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  72. data/lib/hecks/projections/deploy/box.rb +387 -0
  73. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  74. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  75. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  76. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  77. data/lib/hecks/projections/diagrams.rb +2 -2
  78. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  79. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  80. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  81. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  82. data/lib/hecks/projections/site/edge.rb +136 -0
  83. data/lib/hecks/projections/site/regions.rb +44 -0
  84. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  85. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  86. data/lib/hecks/projections/site/table/checks.rb +124 -0
  87. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  88. data/lib/hecks/projections/site/table.rb +98 -0
  89. data/lib/hecks/projections.rb +2 -0
  90. data/lib/hecks/projector/cli_projector.rb +172 -95
  91. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  92. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  93. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  94. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  95. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  96. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  97. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  98. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  99. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  100. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  101. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  102. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  103. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  104. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  105. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  106. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  107. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  108. data/lib/hecks/runtime/command_interpreter.rb +2 -0
  109. data/lib/hecks/runtime/interpreting.rb +21 -0
  110. data/lib/hecks/runtime/invocation.rb +5 -3
  111. data/lib/hecks/runtime/registry/verification.rb +15 -29
  112. data/lib/hecks/runtime/registry.rb +14 -20
  113. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  114. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  115. data/lib/hecks/rust_build/domain_name.rb +26 -0
  116. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  117. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  118. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  119. data/lib/hecks/rust_build/project_rust.rb +57 -24
  120. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  121. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  122. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  123. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  124. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  125. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  126. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  127. data/lib/hecks/storehouse.rb +37 -7
  128. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  129. data/lib/hecks/three_zero/forms.yml +88 -88
  130. data/lib/hecks/tools/ci_gates.rb +1 -1
  131. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  132. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  133. data/lib/hecks/tools/sequence_script.rb +2 -2
  134. data/lib/hecks/tools/site_routes.rb +152 -0
  135. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  136. data/lib/hecks/tools.rb +1 -0
  137. data/lib/hecks/version.rb +1 -1
  138. data/rust/build/src/cargo_sync.rs +1 -1
  139. data/rust/build/src/pipeline.rs +23 -16
  140. data/rust/build/src/resolve.rs +39 -6
  141. data/rust/host/HECKS_RELEASE +1 -1
  142. data/rust/host/src/api.rs +1 -1
  143. data/rust/host/src/dispatch.rs +87 -7
  144. data/rust/host/src/needs.rs +111 -0
  145. data/rust/host/src/web/newsletter/template.rs +69 -0
  146. data/rust/host/src/web/newsletter/tests.rs +43 -0
  147. data/rust/host/src/web/newsletter.rs +6 -1
  148. data/rust/host/src/web/newsletter_send.rs +1 -1
  149. data/rust/parser/src/keywords.rs +7 -6
  150. data/rust/parser/src/main.rs +4 -3
  151. data/rust/parser/src/parse/chapter.rs +1 -1
  152. data/rust/parser/src/parse/hecksagon.rs +25 -10
  153. data/rust/project/mutations.rb +4 -30
  154. data/rust/project/naming.rb +8 -28
  155. data/rust/project/write_if_changed.rb +4 -80
  156. data/rust/project_rust_pipeline.rb +16 -12
  157. data/rust/src/kernel/cli.rs +2 -0
  158. data/rust/src/kernel/needs.rs +74 -1
  159. metadata +39 -2
@@ -266,30 +266,28 @@ module Hecks
266
266
  end
267
267
  end
268
268
 
269
- # `domain:` is confined to `Storehouse::BOOT_ROOT`, and further to a reader
270
- # door's `HECKS_DOOR_DOMAINS`, before anything boots.
269
+ # `domain:` is confined to `Storehouse::BOOT_ROOT`, and further to a restricted
270
+ # door's `HECKS_DOOR_DOMAINS`, before anything boots. An unrestricted door boots the
271
+ # domain afresh on every call; a restricted door lives for one spawner's session and
272
+ # keeps each named domain booted until the files of its directory change.
271
273
  def boot(domain)
272
- Hecks.boot(Storehouse.confine!(scope.admit_domain!(domain), "domain"), install_doors: false)
274
+ path = Storehouse.confine!(scope.admit_domain!(domain), "domain")
275
+ return Hecks.boot(path, install_doors: false) unless scope.restricted?
276
+
277
+ resident = (@resident ||= {})
278
+ fingerprint = Storehouse.fingerprint(path)
279
+ return resident[path][:runtime] if resident[path] && resident[path][:fingerprint] == fingerprint
280
+
281
+ resident[path] = { fingerprint: fingerprint, runtime: Hecks.boot(path, install_doors: false) }
282
+ resident[path][:runtime]
273
283
  end
274
284
 
275
285
  # One `when` per tool, so the shared `rescue` catches every branch's escape
276
286
  # once; a domain refusal is already `{ok: false, ...}` from `Storehouse`.
277
287
  def call_tool(name, args) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
278
288
  case name
279
- when "dispatch"
280
- runtime = boot(args["domain"])
281
- if args["steps"]
282
- Storehouse.dispatch_batch(runtime: runtime, steps: args["steps"], summary: args["summary"],
283
- source: args["source"], role: args["role"], actor_id: args["actor_id"])
284
- else
285
- Storehouse.dispatch(runtime: runtime, command: args["command"], summary: args["summary"],
286
- args: args["args"] || {}, source: args["source"],
287
- dry_run: args["dry_run"] == true, role: args["role"], actor_id: args["actor_id"])
288
- end
289
- when "query"
290
- Storehouse.query(runtime: boot(args["domain"]), question: args["question"],
291
- summary: args["summary"], args: args["args"] || {}, source: args["source"],
292
- role: args["role"], actor_id: args["actor_id"])
289
+ when "dispatch" then dispatch_tool(args)
290
+ when "query" then query_tool(args)
293
291
  when "events"
294
292
  Storehouse.events(runtime: boot(args["domain"]), aggregate: args["aggregate"], id: args["id"],
295
293
  limit: args["limit"])
@@ -319,6 +317,29 @@ module Hecks
319
317
  { ok: false, error: "#{e.class}: #{e.message}" }
320
318
  end
321
319
 
320
+ # A question, once the scope has admitted its arguments.
321
+ def query_tool(args)
322
+ scope.admit_arguments!(args["args"])
323
+ Storehouse.query(runtime: boot(args["domain"]), question: args["question"],
324
+ summary: args["summary"], args: args["args"] || {}, source: args["source"],
325
+ role: args["role"], actor_id: args["actor_id"])
326
+ end
327
+
328
+ # A single command or a batch, once the scope has admitted every command in it.
329
+ def dispatch_tool(args)
330
+ runtime = boot(args["domain"])
331
+ if args["steps"]
332
+ scope.admit_steps!(runtime, args["steps"])
333
+ Storehouse.dispatch_batch(runtime: runtime, steps: args["steps"], summary: args["summary"],
334
+ source: args["source"], role: args["role"], actor_id: args["actor_id"])
335
+ else
336
+ scope.admit_command!(runtime, args["command"], args["args"])
337
+ Storehouse.dispatch(runtime: runtime, command: args["command"], summary: args["summary"],
338
+ args: args["args"] || {}, source: args["source"],
339
+ dry_run: args["dry_run"] == true, role: args["role"], actor_id: args["actor_id"])
340
+ end
341
+ end
342
+
322
343
  def answer_tool(name, args)
323
344
  return scope.refusal(name) unless scope.permits_tool?(name)
324
345
 
@@ -352,7 +373,8 @@ module Hecks
352
373
  when "notifications/initialized"
353
374
  nil # a notification — no id, no response
354
375
  when "tools/list"
355
- send_response(id, { tools: TOOLS.select { |tool| scope.permits_tool?(tool[:name]) } })
376
+ served = TOOLS.select { |tool| scope.permits_tool?(tool[:name]) }
377
+ send_response(id, { tools: served.map { |tool| scope.present(tool) } })
356
378
  when "tools/call"
357
379
  send_response(id, tool_result(answer_tool(params["name"], params["arguments"] || {})))
358
380
  when "ping"
@@ -2,27 +2,55 @@ require_relative "../runtime/errors"
2
2
 
3
3
  module Hecks
4
4
  module Doors
5
- # What a spawned hecks mcp may do: every tool by default, or only
6
- # reader tools against named domains in reader mode — reach, not identity (ADR 0072).
5
+ # What a spawned hecks mcp may do: every tool by default; only reader tools against named
6
+ # domains in reader mode; and in commands mode those plus `dispatch` of a closed list of
7
+ # commands — reach, not identity (ADR 0072, ADR 0089).
7
8
  class McpDoorScope
8
- ENV_PREFIX = "HECKS_DOOR_".freeze
9
- TOOLS_VARIABLE = "HECKS_DOOR_TOOLS".freeze
10
- DOMAINS_VARIABLE = "HECKS_DOOR_DOMAINS".freeze
11
- READERS = "readers".freeze
12
- EXIT_STATUS = 2
9
+ ENV_PREFIX = "HECKS_DOOR_".freeze
10
+ TOOLS_VARIABLE = "HECKS_DOOR_TOOLS".freeze
11
+ DOMAINS_VARIABLE = "HECKS_DOOR_DOMAINS".freeze
12
+ COMMANDS_VARIABLE = "HECKS_DOOR_COMMANDS".freeze
13
+ READERS = "readers".freeze
14
+ COMMANDS = "commands".freeze
15
+ MODES = [READERS, COMMANDS].freeze
16
+ EXIT_STATUS = 2
13
17
 
14
18
  # The tools a reader door serves — each reads a domain or the door's own
15
19
  # audit log and writes no domain state. `validate` still boots its
16
20
  # domain, so it stays bound to the allowed set like every other tool.
17
21
  READER_TOOLS = %w[query events state catalog describe validate domains history follow].freeze
18
22
 
23
+ # The tools a commands door serves: the reader tools, and `dispatch` for the allowed commands.
24
+ COMMAND_TOOLS = (READER_TOOLS + %w[dispatch]).freeze
25
+
26
+ # Argument names a restricted door never passes on, whatever command carries them: each one
27
+ # names a host or a URL, a binary to run, a place to write, a port to open, a store to
28
+ # switch to, or flips a command from a preview to a change of state.
29
+ DENIED_ARGUMENTS = %w[
30
+ adapter artifact confirm expected from gem_only header health_path host no_wait npm_local
31
+ npm_only output path payload payload_file port rust_binary scheme state_path stdio url write
32
+ ].freeze
33
+
34
+ # Argument names whose values are paths (a comma-separated list for some). A restricted door
35
+ # passes them on only when each resolves, symlinks followed, inside `Storehouse::BOOT_ROOT`,
36
+ # and holds no colon: `host:repo` and `https://host/x` would otherwise pass as relative paths.
37
+ PATH_ARGUMENTS = %w[dir domain domains file fixture paths root script].freeze
38
+
39
+ # Argument names whose values are git refs: plain names only, so a value cannot read as an
40
+ # option to git.
41
+ REF_ARGUMENTS = %w[ref].freeze
42
+
43
+ # What a plain git ref looks like: no leading dash, no `..`, no spaces.
44
+ PLAIN_REF = %r{\A[A-Za-z0-9][A-Za-z0-9._/~^@-]*\z}
45
+
19
46
  # Unrestricted when no HECKS_DOOR_* variable is set.
20
47
  def self.from_env(env = ENV)
21
48
  problems = setting_problems(env)
22
49
  raise ArgumentError, problems.join("\n") unless problems.empty?
23
50
  return new(allowed_domains: nil) unless env[TOOLS_VARIABLE]
24
51
 
25
- new(allowed_domains: env[DOMAINS_VARIABLE].split(File::PATH_SEPARATOR).reject(&:empty?))
52
+ commands = env[TOOLS_VARIABLE] == COMMANDS ? split_names(env[COMMANDS_VARIABLE]) : nil
53
+ new(allowed_domains: split_paths(env[DOMAINS_VARIABLE]), allowed_commands: commands)
26
54
  end
27
55
 
28
56
  # Refuses to stderr, not stdout, which carries the MCP protocol.
@@ -34,44 +62,80 @@ module Hecks
34
62
  end
35
63
 
36
64
  def self.setting_problems(env)
37
- unknown = env.keys.select { |name| name.start_with?(ENV_PREFIX) } - [TOOLS_VARIABLE, DOMAINS_VARIABLE]
38
- problems = unknown.map do |name|
39
- "environment #{name} is not accepted; the door takes only #{TOOLS_VARIABLE} and #{DOMAINS_VARIABLE}"
65
+ known = [TOOLS_VARIABLE, DOMAINS_VARIABLE, COMMANDS_VARIABLE]
66
+ problems = (env.keys.select { |name| name.start_with?(ENV_PREFIX) } - known).map do |name|
67
+ "environment #{name} is not accepted; the door takes only #{known.join(', ')}"
40
68
  end
41
- problems + mode_problems(env[TOOLS_VARIABLE], env[DOMAINS_VARIABLE])
69
+ problems + mode_problems(env[TOOLS_VARIABLE], env[DOMAINS_VARIABLE], env[COMMANDS_VARIABLE])
42
70
  end
43
71
 
44
- def self.mode_problems(tools, domains)
45
- if tools.nil?
46
- domains.nil? ? [] : ["#{DOMAINS_VARIABLE} is set without #{TOOLS_VARIABLE}=#{READERS}; it only narrows a reader door"]
47
- elsif tools != READERS
48
- ["#{TOOLS_VARIABLE}=#{tools.inspect} is not accepted; the only value is #{READERS.inspect}"]
49
- elsif domains.to_s.split(File::PATH_SEPARATOR).none? { |path| !path.empty? }
50
- ["#{TOOLS_VARIABLE}=#{READERS} needs #{DOMAINS_VARIABLE}, the domains this door may read"]
72
+ def self.mode_problems(tools, domains, commands)
73
+ return stray_problems(domains, commands) if tools.nil?
74
+ unless MODES.include?(tools)
75
+ return ["#{TOOLS_VARIABLE}=#{tools.inspect} is not accepted; the values are #{MODES.map(&:inspect).join(' and ')}"]
76
+ end
77
+
78
+ problems = []
79
+ if split_paths(domains.to_s).empty?
80
+ problems << "#{TOOLS_VARIABLE}=#{tools} needs #{DOMAINS_VARIABLE}, the domains this door may read"
81
+ end
82
+ problems + command_problems(tools, commands)
83
+ end
84
+
85
+ def self.stray_problems(domains, commands)
86
+ stray = { DOMAINS_VARIABLE => domains, COMMANDS_VARIABLE => commands }.compact.keys
87
+ return [] if stray.empty?
88
+
89
+ ["#{stray.join(' and ')} set without #{TOOLS_VARIABLE}=#{READERS} or #{TOOLS_VARIABLE}=#{COMMANDS}; " \
90
+ "they only narrow a restricted door"]
91
+ end
92
+
93
+ def self.command_problems(tools, commands)
94
+ if tools == COMMANDS && split_names(commands).empty?
95
+ ["#{TOOLS_VARIABLE}=#{COMMANDS} needs #{COMMANDS_VARIABLE}, the commands this door may dispatch"]
96
+ elsif tools == READERS && !commands.nil?
97
+ ["#{COMMANDS_VARIABLE} is set in reader mode; it only narrows a commands door"]
51
98
  else
52
99
  []
53
100
  end
54
101
  end
55
102
 
103
+ def self.split_paths(value) = value.to_s.split(File::PATH_SEPARATOR).reject(&:empty?)
104
+
105
+ def self.split_names(value) = value.to_s.split(",").map(&:strip).reject(&:empty?).uniq
106
+
56
107
  # The named domains, resolved against Storehouse::BOOT_ROOT; nil when unrestricted.
57
108
  attr_reader :allowed_domains
58
109
 
59
- def initialize(allowed_domains:)
110
+ # The command names a commands door dispatches, as the spawner wrote them; nil unless
111
+ # the door runs in commands mode.
112
+ attr_reader :allowed_commands
113
+
114
+ def initialize(allowed_domains:, allowed_commands: nil)
60
115
  @allowed_domains = allowed_domains&.map { |path| Storehouse.confine!(path, DOMAINS_VARIABLE) }.freeze
116
+ @allowed_commands = allowed_commands&.dup&.freeze
61
117
  end
62
118
 
63
119
  def restricted?
64
120
  !@allowed_domains.nil?
65
121
  end
66
122
 
123
+ def commands_mode?
124
+ !@allowed_commands.nil?
125
+ end
126
+
127
+ def served_tools
128
+ commands_mode? ? COMMAND_TOOLS : READER_TOOLS
129
+ end
130
+
67
131
  def permits_tool?(name)
68
- !restricted? || READER_TOOLS.include?(name)
132
+ !restricted? || served_tools.include?(name)
69
133
  end
70
134
 
71
135
  def refusal(name)
72
136
  { ok: false,
73
- error: "#{name.inspect} is refused: this door runs in reader mode (#{TOOLS_VARIABLE}=#{READERS}), " \
74
- "which serves only #{READER_TOOLS.join(', ')}" }
137
+ error: "#{name.inspect} is refused: this door runs in #{mode_label}, " \
138
+ "which serves only #{served_tools.join(', ')}" }
75
139
  end
76
140
 
77
141
  # Checked before anything boots, so a path outside the allowed set never reaches Kernel.load.
@@ -80,18 +144,174 @@ module Hecks
80
144
  return domain if @allowed_domains.include?(Storehouse.confine!(domain, "domain"))
81
145
 
82
146
  raise Runtime::TypeMismatch,
83
- "domain: #{domain.inspect} is refused: this door runs in reader mode (#{TOOLS_VARIABLE}=#{READERS}) " \
147
+ "domain: #{domain.inspect} is refused: this door runs in #{mode_label} " \
84
148
  "and reads only #{DOMAINS_VARIABLE}: #{@allowed_domains.join(', ')}"
85
149
  end
86
150
 
151
+ # A commands door dispatches only the commands it was given. A requested name and each
152
+ # allowed name resolve through the alias map `dispatch` uses, and the resolved verbs are
153
+ # compared, so `complete` cannot reach a command of another aggregate that shares its short
154
+ # name, and an allowed name that resolves to nothing admits nothing.
155
+ #
156
+ # @param runtime [Runtime::Dispatcher] the booted domain
157
+ # @param command [String, nil] the command name the caller asked for
158
+ # @param args [Hash, nil] the arguments the caller gave it; see `admit_arguments!`
159
+ # @raise [Runtime::TypeMismatch] when the command is not on the list or an argument is refused
160
+ def admit_command!(runtime, command, args = nil)
161
+ return unless commands_mode?
162
+
163
+ verbs = Storehouse.verbs_for(runtime, [command, *allowed_commands])
164
+ unless verbs.first && verbs.drop(1).include?(verbs.first)
165
+ raise Runtime::TypeMismatch,
166
+ "command: #{command.to_s.inspect} is refused: this door runs in #{mode_label} " \
167
+ "and dispatches only #{COMMANDS_VARIABLE}: #{allowed_commands.join(', ')}"
168
+ end
169
+
170
+ admit_arguments!(args)
171
+ end
172
+
173
+ # Every step of a batch is admitted before any step runs.
174
+ #
175
+ # @param runtime [Runtime::Dispatcher] the booted domain
176
+ # @param steps [Array<Hash>] the batch, each `{"command" => ..., "args" => ...}`
177
+ # @raise [Runtime::TypeMismatch] when a step names a command off the list or a bad argument
178
+ def admit_steps!(runtime, steps)
179
+ Array(steps).each do |step|
180
+ step = {} unless step.is_a?(Hash)
181
+ admit_command!(runtime, step["command"], step["args"])
182
+ end
183
+ end
184
+
185
+ # The arguments a restricted door is about to pass to a command or a question. A command
186
+ # list admits commands, not values, so each argument is checked by its name: a denied name
187
+ # is refused, a path must resolve inside the root with symlinks followed, and a git ref must
188
+ # be a plain name. Other names pass; a domain with an argument that reaches outside its own
189
+ # files should name it in `DENIED_ARGUMENTS` or `PATH_ARGUMENTS` (ADR 0089).
190
+ #
191
+ # @param args [Hash, Array, nil] the arguments as the caller gave them, nested or not
192
+ # @raise [Runtime::TypeMismatch] on the first argument refused
193
+ def admit_arguments!(args)
194
+ return unless restricted?
195
+
196
+ each_argument(args) do |name, value|
197
+ refuse_argument!(name, "never passes it on: it names a host, a binary, an output or a change of state") if
198
+ DENIED_ARGUMENTS.include?(name)
199
+ admit_path!(name, value) if PATH_ARGUMENTS.include?(name)
200
+ admit_ref!(name, value) if REF_ARGUMENTS.include?(name)
201
+ end
202
+ end
203
+
204
+ # The tool as `tools/list` shows it: on a commands door, `dispatch` names the commands it
205
+ # serves, so a caller sees them as an enum instead of finding out by being refused.
206
+ #
207
+ # @param tool [Hash] a tool definition from `McpDoor::TOOLS`
208
+ # @return [Hash] the definition, narrowed for this door
209
+ def present(tool)
210
+ return tool unless commands_mode? && tool[:name] == "dispatch"
211
+
212
+ properties = tool[:inputSchema][:properties]
213
+ narrowed = properties.merge(command: with_enum(properties[:command]),
214
+ steps: properties[:steps].merge(items: step_items(properties[:steps][:items])))
215
+ tool.merge(description: "#{tool[:description]} This door dispatches only: #{allowed_commands.join(', ')}.",
216
+ inputSchema: tool[:inputSchema].merge(properties: narrowed))
217
+ end
218
+
87
219
  def notes
88
220
  return [] unless restricted?
89
221
 
222
+ commands_mode? ? commands_notes : reader_notes
223
+ end
224
+
225
+ private
226
+
227
+ # Yields every argument name with its value, descending into nested objects and lists, so
228
+ # `{"file" => {"value" => "x"}}` is checked the way `{"file" => "x"}` is.
229
+ def each_argument(args, &visit)
230
+ case args
231
+ when Hash
232
+ args.each do |name, value|
233
+ visit.call(name.to_s, value)
234
+ each_argument(value, &visit)
235
+ end
236
+ when Array
237
+ args.each { |item| each_argument(item, &visit) }
238
+ end
239
+ end
240
+
241
+ # A value written as an object of one field (`{"value" => "x"}`) is that field's value.
242
+ def scalar(value)
243
+ return value unless value.is_a?(Hash)
244
+
245
+ value.fetch("value") { value.fetch(:value, value) }
246
+ end
247
+
248
+ def refuse_argument!(name, why)
249
+ raise Runtime::TypeMismatch, "argument: #{name.inspect} is refused: this door runs in #{mode_label} and #{why}"
250
+ end
251
+
252
+ def admit_path!(name, value)
253
+ Array(scalar(value)).flat_map { |item| item.to_s.split(",") }.reject(&:empty?).each do |path|
254
+ next if !path.include?(":") && inside_root?(path)
255
+
256
+ refuse_argument!(name, "passes a path only when it resolves inside #{Storehouse::BOOT_ROOT} " \
257
+ "and holds no colon: #{path.inspect}")
258
+ end
259
+ end
260
+
261
+ def admit_ref!(name, value)
262
+ ref = scalar(value).to_s
263
+ return if ref.match?(PLAIN_REF) && !ref.include?("..")
264
+
265
+ refuse_argument!(name, "passes only a plain git ref: #{ref.inspect}")
266
+ end
267
+
268
+ def inside_root?(path)
269
+ root = resolve_real(Storehouse::BOOT_ROOT)
270
+ resolved = resolve_real(File.expand_path(path, Storehouse::BOOT_ROOT))
271
+ resolved == root || resolved.start_with?("#{root}#{File::SEPARATOR}")
272
+ end
273
+
274
+ # The real path of `path`, symlinks followed: of the whole path when it exists, else of its
275
+ # deepest existing parent with the missing names put back, so a link above a file that does
276
+ # not exist yet still shows where the file would land.
277
+ def resolve_real(path)
278
+ missing = []
279
+ current = File.expand_path(path)
280
+ until File.exist?(current)
281
+ parent = File.dirname(current)
282
+ break if parent == current
283
+
284
+ missing.unshift(File.basename(current))
285
+ current = parent
286
+ end
287
+ File.join(File.realpath(current), *missing)
288
+ end
289
+
290
+ def with_enum(property)
291
+ property.merge(enum: allowed_commands)
292
+ end
293
+
294
+ def step_items(items)
295
+ items.merge(properties: items[:properties].merge(command: with_enum(items[:properties][:command])))
296
+ end
297
+
298
+ def mode_label
299
+ commands_mode? ? "commands mode (#{TOOLS_VARIABLE}=#{COMMANDS})" : "reader mode (#{TOOLS_VARIABLE}=#{READERS})"
300
+ end
301
+
302
+ def reader_notes
90
303
  ["Reader mode (#{TOOLS_VARIABLE}=#{READERS}): serves #{READER_TOOLS.join(', ')}; " \
91
304
  "refuses dispatch (with dry_run and steps), behaviors and every other tool.",
92
305
  "domain: boots only #{DOMAINS_VARIABLE}: #{@allowed_domains.join(', ')}.",
93
306
  "Reader mode limits reach and identifies no one; it is not authentication."]
94
307
  end
308
+
309
+ def commands_notes
310
+ ["Commands mode (#{TOOLS_VARIABLE}=#{COMMANDS}): serves #{READER_TOOLS.join(', ')} and dispatch of only " \
311
+ "#{COMMANDS_VARIABLE}: #{@allowed_commands.join(', ')}; refuses behaviors and every other tool.",
312
+ "domain: boots only #{DOMAINS_VARIABLE}: #{@allowed_domains.join(', ')}.",
313
+ "Commands mode limits reach and identifies no one; role: stays self-asserted and it is not authentication."]
314
+ end
95
315
  end
96
316
  end
97
317
  end
data/lib/hecks/doors.rb CHANGED
@@ -2,7 +2,7 @@ module Hecks
2
2
  # The class-free public surface, installed per boot by `Runtime::Loader.bind_runtime`.
3
3
  module Doors
4
4
  # The removal release of the `Facade` and `install_facade:` spellings.
5
- REMOVAL = "3.1.0".freeze
5
+ REMOVAL = "3.2.0".freeze
6
6
 
7
7
  module_function
8
8
 
@@ -24,12 +24,12 @@ module Hecks
24
24
  def self.load!(name, registry: Hecks.current_registry)
25
25
  unless registry&.root
26
26
  raise Runtime::WiringError,
27
- "uses_embryonaut_bluebook(#{name.inspect}) needs a registry with a root to vendor from"
27
+ "attaches(#{name.inspect}, from: :vendor) needs a registry with a root to vendor from"
28
28
  end
29
29
 
30
30
  unless name.to_s.match?(PACKAGE_NAME)
31
31
  raise Runtime::WiringError,
32
- "uses_embryonaut_bluebook(#{name.inspect}) is not a package name — it must match " \
32
+ "attaches(#{name.inspect}, from: :vendor) is not a package name — it must match " \
33
33
  "[a-z][a-z0-9_]* (the name `hecks vendor` accepts)"
34
34
  end
35
35
 
@@ -37,7 +37,7 @@ module Hecks
37
37
  Hecks::Adapters::Folder.new.load_bluebooks(File.join(root, "examples/banking/bluebook"))
38
38
  Kernel.load(File.join(root, "lib/hecks/forms/examples/banking_console.bluebook"))
39
39
  Hecks.hecksagon("Banking") do
40
- uses_framework "Governance"
40
+ attaches "Governance"
41
41
  AGGREGATES.each { |name| ::Banking.const_get(name).persisted_by("Memory") }
42
42
  end
43
43
  Hecks.hecksagon("Governance") do
@@ -2,7 +2,7 @@ Hecks.bluebook "Compliance" do
2
2
  vision "Something elsewhere already acted to contain a risk; this domain tracks the human review that decides what happens next."
3
3
  supporting
4
4
 
5
- # Supports both modes from one source: `uses_framework "Compliance"` merges it in-process,
5
+ # Supports both modes from one source: `attaches "Compliance"` merges it in-process,
6
6
  # or a separately deployed service is reached with `across "Compliance"` on a policy.
7
7
  # examples/compliance/bluebook/compliance.bluebook symlinks here, since a symlink pointing
8
8
  # outside lib/ does not survive `gem build`.
@@ -3,7 +3,7 @@ Hecks.bluebook "ConsoleSettings" do
3
3
  supporting
4
4
 
5
5
  # A framework member like Identity/Governance: attach with
6
- # `uses_framework "ConsoleSettings"` in the consuming app's own `.hecksagon`,
6
+ # `attaches "ConsoleSettings"` in the consuming app's own `.hecksagon`,
7
7
  # plus a `Hecks.hecksagon "ConsoleSettings" do ... end` persistence binding.
8
8
 
9
9
  # Named ConsoleSettings, not "Forms" — Hecks::Forms (lib/hecks/forms/) names a
@@ -1,14 +1,14 @@
1
1
  # The framework members bound to Memory so they can boot standing alone with
2
- # `Hecks.boot("lib/hecks/framework")`. `uses_framework` does not read this file.
2
+ # `Hecks.boot("lib/hecks/framework")`. `attaches` does not read this file.
3
3
  Hecks.hecksagon "Compliance" do
4
4
  # Every command declares `role "Compliance officer"`; the role check needs Governance.
5
- uses_framework "Governance"
5
+ attaches "Governance"
6
6
  persisted_by "Memory"
7
7
  end
8
8
 
9
9
  Hecks.hecksagon "ConsoleSettings" do
10
10
  # Same reason as Compliance.
11
- uses_framework "Governance"
11
+ attaches "Governance"
12
12
  persisted_by "Memory"
13
13
  end
14
14
 
@@ -18,6 +18,6 @@ end
18
18
 
19
19
  Hecks.hecksagon "Identity" do
20
20
  # `Identity.Register` declares `role "Identity registrar"`; same reason as Compliance.
21
- uses_framework "Governance"
21
+ attaches "Governance"
22
22
  persisted_by "Memory"
23
23
  end
@@ -55,7 +55,7 @@ module Hecks
55
55
  # Loads a member's bluebook unless this registry already holds it.
56
56
  #
57
57
  # `Kernel.load` re-executes the file, and a second `Declare` of the same aggregate is
58
- # `AlreadyExists`, so repeated `uses_framework` calls in one boot must skip.
58
+ # `AlreadyExists`, so repeated `attaches` calls in one boot must skip.
59
59
  #
60
60
  # @param name [String, Symbol] the member's name, such as `"Governance"`
61
61
  # @return [Boolean, nil] true when this call loaded the member's bluebook, nil when
@@ -5,7 +5,7 @@ require_relative "form_census"
5
5
  module Hecks
6
6
  module Fuzzing
7
7
  # Asks an agent to write candidate bluebooks from the corpus's form-pair census.
8
- # Opt-in: `hecks quality_control tick` never runs it; an agent call is costly and
8
+ # Opt-in: `hecks quality_control sweep.tick` never runs it; an agent call is costly and
9
9
  # nondeterministic.
10
10
  module CombinationMiner
11
11
  PROMPT_TEMPLATE = "qa/combination_miner/prompt.md".freeze
@@ -55,7 +55,7 @@ module Hecks
55
55
  end
56
56
 
57
57
  # Filters to verbs whose aggregate resolves to a `:cross_process_lock`-declaring
58
- # adapter — a Memory-backed aggregate (e.g. one only `uses_framework`s) can never
58
+ # adapter — a Memory-backed aggregate (e.g. one only `attaches`s) can never
59
59
  # agree with the sequential oracle across two processes, a guaranteed false positive.
60
60
  def lockable_verbs(domain_path, steps, database:, schema:)
61
61
  verbs = steps.select { |step| COMMAND_STEP.call(step) }.map { |step| step["verb"] }.uniq
@@ -4,12 +4,14 @@ require_relative "replay"
4
4
  require_relative "properties"
5
5
  require_relative "self_consistency"
6
6
  require_relative "rust_gap_manifest"
7
+ require_relative "../rust_build/kernel_input"
7
8
  require_relative "nondeterministic"
8
9
 
9
10
  module Hecks
10
11
  module Fuzzing
11
12
  # The Ruby-vs-Rust comparison of one generated sequence, shared by the QualityControl sweep
12
- # (`hecks quality_control ask run`) and `hecks quality_control check_generated_domains --rust`.
13
+ # (`hecks quality_control query sweep.run`) and `hecks quality_control
14
+ # target.check_generated_domains --rust`.
13
15
  #
14
16
  # `differ` is duck-typed (the `RustConformanceHelpers` comparison helpers plus a
15
17
  # `structural_skips` set) so lib never requires spec/.
@@ -91,7 +93,7 @@ module Hecks
91
93
  outcomes[:adapter_parity_sqlite] = adapter_parity_sqlite.call
92
94
  end
93
95
 
94
- stdout, status = Open3.capture2(binary, stdin_data: JSON.generate({ "steps" => steps }))
96
+ stdout, status = Open3.capture2(binary, stdin_data: RustBuild::KernelInput.json(domain_path, steps))
95
97
  unless status.success?
96
98
  return outcomes.merge(differential: [{ field: "process",
97
99
  detail: "rust binary exited #{status.exitstatus}: #{stdout}" }])
@@ -9,7 +9,7 @@ module Hecks
9
9
 
10
10
  # `kind:` on a `checked: false` result distinguishes a target with no
11
11
  # lineage to audit (`:not_applicable`) from an audit that failed to
12
- # run (`:error`) — `hecks quality_control ask run` holds the first and surfaces the
12
+ # run (`:error`) — `hecks quality_control query sweep.run` holds the first and surfaces the
13
13
  # second as a finding, instead of treating both as a clean audit.
14
14
  def diverged_ancestor_writes(domain_path)
15
15
  registry, directory = load_registry(domain_path)
@@ -5,8 +5,8 @@ module Hecks
5
5
  module Fuzzing
6
6
  # The declared forms an aggregate can exhibit, and the pairs it puts together.
7
7
  #
8
- # Shared by the combination-coverage spec and `hecks quality_control judge_novelty` so they
9
- # cannot drift. The unit is one aggregate: forms on one head meet at dispatch, forms in one
8
+ # Shared by the combination-coverage spec and `hecks quality_control target.judge_novelty` so
9
+ # they cannot drift. The unit is one aggregate: forms on one head meet at dispatch, forms in one
10
10
  # chapter do not.
11
11
  module FormCensus
12
12
  # A given path crossing two references has at least this many segments.
@@ -7,7 +7,7 @@ require_relative "differential"
7
7
 
8
8
  module Hecks
9
9
  module Fuzzing
10
- # The child-process half of `hecks quality_control check_generated_domains`: checks one
10
+ # The child-process half of `hecks quality_control target.check_generated_domains`: checks one
11
11
  # generated domain.
12
12
  #
13
13
  # One domain per process, since every generated domain is named `QaGenerated`.
@@ -9,6 +9,12 @@ module Hecks
9
9
  # Rewrites a copied domain's persistence bindings before an ephemeral
10
10
  # fuzz/replay boot; a directory copy alone does not isolate Postgres state.
11
11
  module IsolatedBoot
12
+ # A vendored package named by `attaches "name", from: :vendor`.
13
+ VENDORED_ATTACHED = /attaches\s*\(?\s*"([\w-]+)"\s*\)?\s*,\s*from:\s*:vendor/
14
+
15
+ # A vendored package named by the deprecated `uses_embryonaut_bluebook "name"`.
16
+ VENDORED_DEPRECATED = /uses_embryonaut_bluebook\s*\(?\s*"([\w-]+)"/
17
+
12
18
  module_function
13
19
 
14
20
  # Copies `domain_path` to a tmpdir, rebinds its persistence to `adapter`, and
@@ -67,7 +73,8 @@ module Hecks
67
73
  # `Hecks.boot` only sees the domain directory itself, not `vendor/`.
68
74
  def carry_vendored_bluebooks!(source, destination)
69
75
  names = Dir.glob(File.join(destination, "**", "*.hecksagon")).flat_map do |path|
70
- File.read(path).scan(/uses_embryonaut_bluebook\s*\(?\s*"([\w-]+)"/).flatten
76
+ text = File.read(path)
77
+ (text.scan(VENDORED_DEPRECATED) + text.scan(VENDORED_ATTACHED)).flatten
71
78
  end
72
79
  names.uniq.each do |name|
73
80
  package = File.join(File.dirname(source), "vendor", "embryonaut_bluebooks", name)
@@ -161,8 +168,8 @@ module Hecks
161
168
  # `:postgres_era` is the only mode that exercises era/lineage-bound SQL; plain
162
169
  # `:postgres` never touches that machinery. Unlike `:postgres`, there's no shared
163
170
  # scratch constant here — `database:`/`schema:` are required keyword args because
164
- # the caller (`hecks quality_control ask run --persistence-parity`) owns that database's
165
- # lifecycle.
171
+ # the caller (`hecks quality_control query sweep.run --persistence-parity`) owns that
172
+ # database's lifecycle.
166
173
  def rebind_to_postgres_era!(copy, database:, schema:)
167
174
  require "pg"
168
175
  if database.to_s.empty? || schema.to_s.empty?
@@ -11,8 +11,8 @@ module Hecks
11
11
 
12
12
  # Replays `steps` on both adapters and lists where the results differ.
13
13
  #
14
- # Compares the same six fields as `hecks quality_control ask run`'s Ruby-vs-Rust mode. Both
15
- # results are JSON-round-tripped so plain data compares equal on both sides.
14
+ # Compares the same six fields as `hecks quality_control query sweep.run`'s Ruby-vs-Rust mode.
15
+ # Both results are JSON-round-tripped so plain data compares equal on both sides.
16
16
  #
17
17
  # @param left [Symbol] an `IsolatedBoot` adapter: `:memory`, `:sqlite`, `:postgres`,
18
18
  # `:postgres_era`