hecks 2.9.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (600) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +51 -6
  3. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  6. data/lib/hecks/adapters/driven/memory.rb +16 -3
  7. data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
  8. data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
  9. data/lib/hecks/adapters/driven/postgres.rb +4 -2
  10. data/lib/hecks/adapters/driven/prism.rb +41 -8
  11. data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
  12. data/lib/hecks/behaviors/expectations.rb +1 -1
  13. data/lib/hecks/behaviors.rb +1 -1
  14. data/lib/hecks/bench/cli.rb +62 -0
  15. data/lib/hecks/bench/environment.rb +71 -0
  16. data/lib/hecks/bench/postgres_probe.rb +30 -0
  17. data/lib/hecks/bench/report.rb +76 -0
  18. data/lib/hecks/bench/ruby_runner.rb +93 -0
  19. data/lib/hecks/bench/run.rb +55 -0
  20. data/lib/hecks/bench/rust_runner.rb +101 -0
  21. data/lib/hecks/bench/stats.rb +46 -0
  22. data/lib/hecks/bench/suite.rb +98 -0
  23. data/lib/hecks/bench/workload.rb +89 -0
  24. data/lib/hecks/bench.rb +17 -0
  25. data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
  26. data/lib/hecks/bluebook/attribute.rb +1 -1
  27. data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
  28. data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
  29. data/lib/hecks/bluebook/chapter.rb +6 -2
  30. data/lib/hecks/bluebook/domain_port.rb +36 -5
  31. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
  32. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
  33. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
  34. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
  35. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
  36. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
  37. data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
  39. data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
  40. data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
  41. data/lib/hecks/bluebook/expression/resolver.rb +3 -2
  42. data/lib/hecks/bluebook/hexagon.rb +13 -5
  43. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
  44. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
  45. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
  46. data/lib/hecks/bluebook/meta_validator.rb +9 -4
  47. data/lib/hecks/bluebook/model_check.rb +23 -0
  48. data/lib/hecks/bluebook/pattern_subset.rb +38 -0
  49. data/lib/hecks/bluebook/query.rb +20 -5
  50. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  51. data/lib/hecks/canonical_json.rb +30 -0
  52. data/lib/hecks/chapters.rb +102 -0
  53. data/lib/hecks/cli/behaviors.rb +61 -0
  54. data/lib/hecks/cli/canonicalise.rb +25 -0
  55. data/lib/hecks/cli/check_engine_agreement.rb +32 -0
  56. data/lib/hecks/cli/check_era.rb +88 -0
  57. data/lib/hecks/cli/console.rb +66 -0
  58. data/lib/hecks/cli/deploy_template_diff.rb +40 -0
  59. data/lib/hecks/cli/doc_coverage.rb +32 -0
  60. data/lib/hecks/cli/document.rb +2 -2
  61. data/lib/hecks/cli/expression_projection.rb +41 -0
  62. data/lib/hecks/cli/follow.rb +88 -0
  63. data/lib/hecks/cli/history.rb +44 -0
  64. data/lib/hecks/cli/ir.rb +1 -1
  65. data/lib/hecks/cli/mcp.rb +3 -3
  66. data/lib/hecks/cli/model_check.rb +7 -4
  67. data/lib/hecks/cli/pattern_cases.rb +47 -0
  68. data/lib/hecks/cli/present.rb +39 -0
  69. data/lib/hecks/cli/project_cli.rb +224 -32
  70. data/lib/hecks/cli/project_diagrams.rb +4 -3
  71. data/lib/hecks/cli/project_glossary.rb +44 -0
  72. data/lib/hecks/cli/reference.rb +46 -0
  73. data/lib/hecks/cli/refresh_projections.rb +44 -0
  74. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
  75. data/lib/hecks/cli/release.rb +70 -0
  76. data/lib/hecks/cli/release_gem.rb +59 -0
  77. data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
  78. data/lib/hecks/cli/rspec_shard_files.rb +66 -0
  79. data/lib/hecks/cli/run.rb +3 -3
  80. data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
  81. data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
  82. data/lib/hecks/cli/shape.rb +70 -0
  83. data/lib/hecks/cli/smoke_http.rb +230 -0
  84. data/lib/hecks/cli/smoke_test.rb +5 -3
  85. data/lib/hecks/cli/spec_example.rb +27 -0
  86. data/lib/hecks/cli/statements.rb +32 -0
  87. data/lib/hecks/cli/stores.rb +4 -5
  88. data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
  89. data/lib/hecks/cli.rb +201 -52
  90. data/lib/hecks/codemod.rb +324 -0
  91. data/lib/hecks/corpus.rb +423 -0
  92. data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
  93. data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
  94. data/lib/hecks/deploy/oidc.json +82 -2
  95. data/lib/hecks/doc/reference.rb +351 -0
  96. data/lib/hecks/doors/cli_door.rb +196 -0
  97. data/lib/hecks/doors/cli_runner.rb +297 -0
  98. data/lib/hecks/{facade → doors}/command_request.rb +1 -1
  99. data/lib/hecks/{facade → doors}/handle.rb +2 -2
  100. data/lib/hecks/{facade → doors}/json_door.rb +4 -4
  101. data/lib/hecks/doors/launcher_options.rb +129 -0
  102. data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
  103. data/lib/hecks/doors/mcp_door_scope.rb +97 -0
  104. data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
  105. data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
  106. data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
  107. data/lib/hecks/doors.rb +61 -0
  108. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
  109. data/lib/hecks/embryonaut_bluebook.rb +1 -1
  110. data/lib/hecks/engine_agreement.rb +156 -0
  111. data/lib/hecks/facade.rb +24 -37
  112. data/lib/hecks/forms/app.rb +6 -6
  113. data/lib/hecks/forms/banking_presentation.rb +50 -0
  114. data/lib/hecks/forms/field_shape.rb +2 -1
  115. data/lib/hecks/forms/port_argument.rb +1 -1
  116. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
  117. data/lib/hecks/fuzzing/combination_miner.rb +86 -0
  118. data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
  119. data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
  120. data/lib/hecks/fuzzing/differential.rb +156 -0
  121. data/lib/hecks/fuzzing/domain_generator.rb +615 -0
  122. data/lib/hecks/fuzzing/era_boundary.rb +71 -0
  123. data/lib/hecks/fuzzing/form_census.rb +136 -0
  124. data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
  125. data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
  126. data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
  127. data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
  128. data/lib/hecks/fuzzing/outside_world.rb +45 -0
  129. data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
  130. data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
  131. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
  132. data/lib/hecks/fuzzing/properties/guards.rb +190 -0
  133. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
  134. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
  135. data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
  136. data/lib/hecks/fuzzing/properties/querying.rb +175 -0
  137. data/lib/hecks/fuzzing/properties.rb +162 -0
  138. data/lib/hecks/fuzzing/qa_settings.rb +123 -0
  139. data/lib/hecks/fuzzing/replay.rb +505 -0
  140. data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
  141. data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
  142. data/lib/hecks/fuzzing/self_consistency.rb +339 -0
  143. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
  144. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
  145. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
  146. data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
  147. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
  148. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
  149. data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
  150. data/lib/hecks/fuzzing/shrinker.rb +166 -0
  151. data/lib/hecks/fuzzing/structural_skips.rb +33 -0
  152. data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
  153. data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
  154. data/lib/hecks/fuzzing/value_generator.rb +216 -0
  155. data/lib/hecks/fuzzing.rb +23 -0
  156. data/lib/hecks/grammar/evolve.rb +336 -0
  157. data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
  158. data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
  159. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
  160. data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
  161. data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
  162. data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
  163. data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
  164. data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
  165. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
  166. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
  167. data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
  168. data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
  169. data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
  170. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
  171. data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
  172. data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
  173. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
  174. data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
  175. data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
  176. data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
  177. data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
  178. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
  179. data/lib/hecks/hecks/adapters/git.adapter +7 -0
  180. data/lib/hecks/hecks/adapters/git.rb +102 -0
  181. data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
  182. data/lib/hecks/hecks/adapters/host_http.rb +65 -0
  183. data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
  184. data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
  185. data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
  186. data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
  187. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
  188. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
  189. data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
  190. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
  191. data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
  192. data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
  193. data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
  194. data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
  195. data/lib/hecks/hecks/adapters/local_files.rb +46 -0
  196. data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
  197. data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
  198. data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
  199. data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
  200. data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
  201. data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
  202. data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
  203. data/lib/hecks/hecks/adapters/shell.adapter +7 -0
  204. data/lib/hecks/hecks/adapters/shell.rb +49 -0
  205. data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
  206. data/lib/hecks/hecks/adapters/terminal.rb +74 -0
  207. data/lib/hecks/hecks/codebase.behaviors +172 -0
  208. data/lib/hecks/hecks/codebase.bluebook +2453 -0
  209. data/lib/hecks/hecks/context_map.hecksagon +13 -0
  210. data/lib/hecks/hecks/custodian.behaviors +179 -0
  211. data/lib/hecks/hecks/custodian.bluebook +1847 -0
  212. data/lib/hecks/hecks/deploy.behaviors +74 -0
  213. data/lib/hecks/hecks/deploy.world +12 -0
  214. data/lib/hecks/hecks/environments/memory.world +10 -0
  215. data/lib/hecks/hecks/governance.world +7 -0
  216. data/lib/hecks/hecks/hecks.behaviors +62 -0
  217. data/lib/hecks/hecks/hecks.bluebook +124 -0
  218. data/lib/hecks/hecks/hecks.hecksagon +390 -0
  219. data/lib/hecks/hecks/hecks.world +42 -0
  220. data/lib/hecks/kernel_capabilities.rb +194 -0
  221. data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
  222. data/lib/hecks/language/bluebook/query.bluebook +7 -0
  223. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
  224. data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
  225. data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
  226. data/lib/hecks/mcp_stdio_guard.rb +2 -2
  227. data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
  228. data/lib/hecks/persistence_legacy_fixture.rb +113 -0
  229. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
  230. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
  231. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +6 -3
  237. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
  238. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
  239. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
  240. data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
  241. data/lib/hecks/ports/persistence/state_codec.rb +24 -5
  242. data/lib/hecks/projection_files.rb +285 -0
  243. data/lib/hecks/projections/bootstrap_table.rb +1 -1
  244. data/lib/hecks/projections/deploy/fargate.rb +5 -5
  245. data/lib/hecks/projections/deploy/lambda.rb +43 -32
  246. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  247. data/lib/hecks/projections/deploy/preview/template.rb +1 -1
  248. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
  249. data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
  250. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
  251. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
  252. data/lib/hecks/projections/deploy/shared.rb +1 -1
  253. data/lib/hecks/projections/diagrams.rb +3 -3
  254. data/lib/hecks/projections/model/deviations.rb +1 -1
  255. data/lib/hecks/projections/parser_table.rb +4 -4
  256. data/lib/hecks/projections/reference.rb +2 -3
  257. data/lib/hecks/projections/rust_vocabulary.rb +1 -1
  258. data/lib/hecks/projector/cli_projector.rb +106 -37
  259. data/lib/hecks/projector.rb +1 -1
  260. data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
  261. data/lib/hecks/quality_control/adapters/agent.rb +58 -0
  262. data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
  263. data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
  264. data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
  265. data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
  266. data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
  267. data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
  268. data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
  269. data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
  270. data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
  271. data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
  272. data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
  273. data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
  274. data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
  275. data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
  276. data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
  277. data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
  278. data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
  279. data/lib/hecks/quality_control/cli/child.rb +45 -0
  280. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
  281. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
  282. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
  283. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
  284. data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
  285. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
  286. data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
  287. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
  288. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
  289. data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
  290. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
  291. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
  292. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
  293. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
  294. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
  295. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
  296. data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
  297. data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
  298. data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
  299. data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
  300. data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
  301. data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
  302. data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
  303. data/lib/hecks/query_ir.rb +346 -0
  304. data/lib/hecks/query_ir_mcp.rb +198 -0
  305. data/lib/hecks/release/runner/ci_publisher.rb +2 -2
  306. data/lib/hecks/release/runner/clean_tree.rb +42 -0
  307. data/lib/hecks/release/runner/console.rb +6 -0
  308. data/lib/hecks/release/runner/gem_publisher.rb +14 -7
  309. data/lib/hecks/release/runner/npm_publisher.rb +6 -36
  310. data/lib/hecks/release/runner/preflight.rb +26 -2
  311. data/lib/hecks/release/runner/published.rb +8 -26
  312. data/lib/hecks/release/runner/tagger.rb +30 -15
  313. data/lib/hecks/release/runner.rb +57 -6
  314. data/lib/hecks/router/namespace_installer.rb +3 -3
  315. data/lib/hecks/runtime/adapter_lookup.rb +124 -0
  316. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
  317. data/lib/hecks/runtime/dependency_planning.rb +20 -2
  318. data/lib/hecks/runtime/dispatcher.rb +53 -3
  319. data/lib/hecks/runtime/interpreting.rb +3 -0
  320. data/lib/hecks/runtime/loader.rb +65 -12
  321. data/lib/hecks/runtime/outbox.rb +1 -1
  322. data/lib/hecks/runtime/policy_interpreter.rb +5 -2
  323. data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
  324. data/lib/hecks/runtime/query_interpreter.rb +77 -0
  325. data/lib/hecks/runtime/reaction_outcome.rb +89 -0
  326. data/lib/hecks/runtime/refusal_wording.rb +23 -1
  327. data/lib/hecks/runtime/registry/verification.rb +163 -4
  328. data/lib/hecks/runtime/registry.rb +57 -3
  329. data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
  330. data/lib/hecks/runtime/value/coercion.rb +23 -135
  331. data/lib/hecks/runtime/value/field_checks.rb +172 -0
  332. data/lib/hecks/runtime/value.rb +4 -1
  333. data/lib/hecks/runtime.rb +20 -6
  334. data/lib/hecks/rust_build/conformance.rb +158 -0
  335. data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
  336. data/lib/hecks/rust_build/coverage.rb +218 -0
  337. data/lib/hecks/rust_build/native_build.rb +124 -0
  338. data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
  339. data/lib/hecks/rust_build/project_rust.rb +332 -0
  340. data/lib/hecks/rust_build/wasm.rb +105 -0
  341. data/lib/hecks/rust_build/wasm_browser.rb +113 -0
  342. data/lib/hecks/rust_build.rb +144 -0
  343. data/lib/hecks/storehouse.rb +16 -16
  344. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
  345. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
  346. data/lib/hecks/three_zero/forms.yml +127 -0
  347. data/lib/hecks/three_zero.rb +12 -0
  348. data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
  349. data/lib/hecks/tools/comment_style.rb +1070 -0
  350. data/lib/hecks/tools/corpus_report.rb +87 -0
  351. data/lib/hecks/tools/deploy_recipe.rb +255 -0
  352. data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
  353. data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
  354. data/lib/hecks/tools/era_projection_backfill.rb +94 -0
  355. data/lib/hecks/tools/era_reattest.rb +146 -0
  356. data/lib/hecks/tools/evolve_run.rb +189 -0
  357. data/lib/hecks/tools/fuzz_sweep.rb +373 -0
  358. data/lib/hecks/tools/heki_compaction.rb +100 -0
  359. data/lib/hecks/tools/hoist_local_givens.rb +246 -0
  360. data/lib/hecks/tools/journal_compaction.rb +89 -0
  361. data/lib/hecks/tools/oidc_manifests.rb +70 -0
  362. data/lib/hecks/tools/query_ir_run.rb +81 -0
  363. data/lib/hecks/tools/regeneration_run.rb +166 -0
  364. data/lib/hecks/tools/rust_comment_style.rb +642 -0
  365. data/lib/hecks/tools/sequence_script.rb +62 -0
  366. data/lib/hecks/tools/tail_merge.rb +117 -0
  367. data/lib/hecks/tools/tenant_provisioning.rb +142 -0
  368. data/lib/hecks/tools/translation_audit.rb +208 -0
  369. data/lib/hecks/tools/translation_scaffold.rb +131 -0
  370. data/lib/hecks/tools.rb +86 -0
  371. data/lib/hecks/version.rb +1 -1
  372. data/lib/hecks.rb +32 -15
  373. data/qa/settings.yml +66 -0
  374. data/rust/.cargo/config.toml +6 -0
  375. data/rust/.gitignore +2 -0
  376. data/rust/Cargo.lock +54 -0
  377. data/rust/Cargo.toml +61 -0
  378. data/rust/build/.cargo/config.toml +3 -0
  379. data/rust/build/.gitignore +1 -0
  380. data/rust/build/Cargo.lock +7 -0
  381. data/rust/build/Cargo.toml +19 -0
  382. data/rust/build/src/build_artifact.rs +47 -0
  383. data/rust/build/src/cargo_sync.rs +248 -0
  384. data/rust/build/src/json.rs +421 -0
  385. data/rust/build/src/lineage_pass.rs +285 -0
  386. data/rust/build/src/main.rs +51 -0
  387. data/rust/build/src/optional_pass.rs +150 -0
  388. data/rust/build/src/pipeline.rs +264 -0
  389. data/rust/build/src/reserved_names.rs +76 -0
  390. data/rust/build/src/resolve.rs +124 -0
  391. data/rust/build/src/root.rs +25 -0
  392. data/rust/build/src/sidecars.rs +73 -0
  393. data/rust/build/src/subprocess.rs +51 -0
  394. data/rust/build/src/tmp.rs +31 -0
  395. data/rust/codegen/.cargo/config.toml +3 -0
  396. data/rust/codegen/.gitignore +1 -0
  397. data/rust/codegen/Cargo.lock +7 -0
  398. data/rust/codegen/Cargo.toml +20 -0
  399. data/rust/codegen/src/attr.rs +32 -0
  400. data/rust/codegen/src/bridging.rs +481 -0
  401. data/rust/codegen/src/commands.rs +1226 -0
  402. data/rust/codegen/src/constraints.rs +109 -0
  403. data/rust/codegen/src/dependency_planning.rs +209 -0
  404. data/rust/codegen/src/domain_generator.rs +1288 -0
  405. data/rust/codegen/src/exemplar.rs +230 -0
  406. data/rust/codegen/src/expr_emitter.rs +87 -0
  407. data/rust/codegen/src/fielded.rs +192 -0
  408. data/rust/codegen/src/hecks_naming.rs +33 -0
  409. data/rust/codegen/src/json.rs +335 -0
  410. data/rust/codegen/src/json_codec.rs +697 -0
  411. data/rust/codegen/src/literal.rs +207 -0
  412. data/rust/codegen/src/main.rs +290 -0
  413. data/rust/codegen/src/manifest.rs +227 -0
  414. data/rust/codegen/src/mutations.rs +784 -0
  415. data/rust/codegen/src/naming.rs +286 -0
  416. data/rust/codegen/src/ports.rs +208 -0
  417. data/rust/codegen/src/prelude.rs +116 -0
  418. data/rust/codegen/src/queries.rs +786 -0
  419. data/rust/codegen/src/reactions.rs +532 -0
  420. data/rust/codegen/src/read_models.rs +697 -0
  421. data/rust/codegen/src/reference_specs.rs +40 -0
  422. data/rust/codegen/src/registry.rs +966 -0
  423. data/rust/codegen/src/reserved_names.rs +76 -0
  424. data/rust/codegen/src/shared.rs +93 -0
  425. data/rust/codegen/src/skip_reason.rs +25 -0
  426. data/rust/codegen/src/types.rs +362 -0
  427. data/rust/host/.cargo/config.toml +3 -0
  428. data/rust/host/.gitignore +1 -0
  429. data/rust/host/Cargo.lock +4297 -0
  430. data/rust/host/Cargo.toml +61 -0
  431. data/rust/host/HECKS_RELEASE +1 -0
  432. data/rust/host/rds-ca-bundle.pem +2736 -0
  433. data/rust/host/src/api.rs +1596 -0
  434. data/rust/host/src/approval.rs +652 -0
  435. data/rust/host/src/auth.rs +1287 -0
  436. data/rust/host/src/bin/expr_harness.rs +46 -0
  437. data/rust/host/src/bin/lineage_harness.rs +119 -0
  438. data/rust/host/src/bin/mint_harness.rs +135 -0
  439. data/rust/host/src/checkout.rs +265 -0
  440. data/rust/host/src/dispatch.rs +1443 -0
  441. data/rust/host/src/expr_json.rs +1117 -0
  442. data/rust/host/src/field_hints.rs +52 -0
  443. data/rust/host/src/ir.rs +733 -0
  444. data/rust/host/src/journal.rs +874 -0
  445. data/rust/host/src/lambda_client.rs +378 -0
  446. data/rust/host/src/log.rs +147 -0
  447. data/rust/host/src/main.rs +474 -0
  448. data/rust/host/src/mint.rs +1865 -0
  449. data/rust/host/src/payments/keystore.rs +330 -0
  450. data/rust/host/src/payments/tests.rs +1400 -0
  451. data/rust/host/src/payments.rs +767 -0
  452. data/rust/host/src/presentation.rs +698 -0
  453. data/rust/host/src/presentation_write.rs +1431 -0
  454. data/rust/host/src/query_step.rs +149 -0
  455. data/rust/host/src/rate_limit.rs +904 -0
  456. data/rust/host/src/reference_transform.rs +241 -0
  457. data/rust/host/src/reference_validate.rs +338 -0
  458. data/rust/host/src/resend.rs +296 -0
  459. data/rust/host/src/secrets.rs +106 -0
  460. data/rust/host/src/server.rs +663 -0
  461. data/rust/host/src/storage_shape.rs +175 -0
  462. data/rust/host/src/test_pg.rs +40 -0
  463. data/rust/host/src/ui_schema.rs +1351 -0
  464. data/rust/host/src/wasm_runner.rs +191 -0
  465. data/rust/host/src/web/newsletter/cooldown.rs +126 -0
  466. data/rust/host/src/web/newsletter/tests.rs +104 -0
  467. data/rust/host/src/web/newsletter.rs +310 -0
  468. data/rust/host/src/web/newsletter_send.rs +357 -0
  469. data/rust/host/src/web/registrations.rs +1505 -0
  470. data/rust/host/src/web.rs +2937 -0
  471. data/rust/lsp/.gitignore +1 -0
  472. data/rust/lsp/Cargo.lock +7 -0
  473. data/rust/lsp/Cargo.toml +16 -0
  474. data/rust/lsp/README.md +133 -0
  475. data/rust/lsp/src/diagnostics.rs +165 -0
  476. data/rust/lsp/src/json.rs +385 -0
  477. data/rust/lsp/src/main.rs +399 -0
  478. data/rust/lsp/src/outline.rs +177 -0
  479. data/rust/lsp/src/rpc.rs +67 -0
  480. data/rust/parser/.cargo/config.toml +5 -0
  481. data/rust/parser/.gitignore +1 -0
  482. data/rust/parser/Cargo.lock +7 -0
  483. data/rust/parser/Cargo.toml +21 -0
  484. data/rust/parser/src/build/closed_sets.rs +27 -0
  485. data/rust/parser/src/build/identity.rs +276 -0
  486. data/rust/parser/src/build/mod.rs +12 -0
  487. data/rust/parser/src/build/naming.rs +141 -0
  488. data/rust/parser/src/build/pattern_subset.rs +161 -0
  489. data/rust/parser/src/build/query_derive.rs +72 -0
  490. data/rust/parser/src/build/query_inference.rs +269 -0
  491. data/rust/parser/src/build/query_options.rs +100 -0
  492. data/rust/parser/src/build/read_model.rs +94 -0
  493. data/rust/parser/src/build/references.rs +32 -0
  494. data/rust/parser/src/canonical.rs +114 -0
  495. data/rust/parser/src/diag.rs +55 -0
  496. data/rust/parser/src/emit.rs +953 -0
  497. data/rust/parser/src/expr/ast_json.rs +147 -0
  498. data/rust/parser/src/expr/evaluator.rs +81 -0
  499. data/rust/parser/src/expr/mod.rs +84 -0
  500. data/rust/parser/src/expr/resolver.rs +497 -0
  501. data/rust/parser/src/ir.rs +387 -0
  502. data/rust/parser/src/keywords.rs +585 -0
  503. data/rust/parser/src/lex.rs +662 -0
  504. data/rust/parser/src/main.rs +252 -0
  505. data/rust/parser/src/parse/aggregate.rs +612 -0
  506. data/rust/parser/src/parse/chapter.rs +517 -0
  507. data/rust/parser/src/parse/command.rs +637 -0
  508. data/rust/parser/src/parse/domain_port.rs +127 -0
  509. data/rust/parser/src/parse/entity.rs +527 -0
  510. data/rust/parser/src/parse/file.rs +67 -0
  511. data/rust/parser/src/parse/hecksagon.rs +231 -0
  512. data/rust/parser/src/parse/lifecycle.rs +162 -0
  513. data/rust/parser/src/parse/mod.rs +1262 -0
  514. data/rust/parser/src/parse/policy.rs +87 -0
  515. data/rust/parser/src/parse/process_manager.rs +305 -0
  516. data/rust/parser/src/parse/query.rs +100 -0
  517. data/rust/parser/src/parse/read_model.rs +214 -0
  518. data/rust/parser/src/parse/value_object.rs +250 -0
  519. data/rust/parser/src/ruby_value.rs +422 -0
  520. data/rust/project/bridging.rb +257 -0
  521. data/rust/project/commands.rb +877 -0
  522. data/rust/project/constraints.rb +86 -0
  523. data/rust/project/dependency_planning.rb +178 -0
  524. data/rust/project/domain_generator.rb +980 -0
  525. data/rust/project/exemplar.rb +148 -0
  526. data/rust/project/expr_emitter.rb +72 -0
  527. data/rust/project/fielded.rb +141 -0
  528. data/rust/project/json_codec.rb +641 -0
  529. data/rust/project/mutations.rb +721 -0
  530. data/rust/project/naming.rb +205 -0
  531. data/rust/project/ports.rb +65 -0
  532. data/rust/project/queries.rb +493 -0
  533. data/rust/project/reactions.rb +317 -0
  534. data/rust/project/read_models.rb +690 -0
  535. data/rust/project/reference_specs.rb +27 -0
  536. data/rust/project/registry.rb +396 -0
  537. data/rust/project/skip_reason.rb +29 -0
  538. data/rust/project/types.rb +225 -0
  539. data/rust/project/write_if_changed.rb +83 -0
  540. data/rust/project.rb +30 -0
  541. data/rust/project_rust_pipeline.rb +383 -0
  542. data/rust/src/exemplar/commands.rs +279 -0
  543. data/rust/src/exemplar/constraints.rs +18 -0
  544. data/rust/src/exemplar/fielded.rs +280 -0
  545. data/rust/src/exemplar/json.rs +277 -0
  546. data/rust/src/exemplar/mod.rs +17 -0
  547. data/rust/src/exemplar/mutations.rs +80 -0
  548. data/rust/src/exemplar/queries.rs +33 -0
  549. data/rust/src/exemplar/reactions.rs +88 -0
  550. data/rust/src/exemplar/read_models.rs +48 -0
  551. data/rust/src/exemplar/registry.rs +166 -0
  552. data/rust/src/exemplar/types.rs +44 -0
  553. data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
  554. data/rust/src/kernel/attribute_shapes/list.rs +18 -0
  555. data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
  556. data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
  557. data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
  558. data/rust/src/kernel/cli.rs +585 -0
  559. data/rust/src/kernel/dispatch.rs +1090 -0
  560. data/rust/src/kernel/expr.rs +458 -0
  561. data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
  562. data/rust/src/kernel/expression_operators/comparison.rs +48 -0
  563. data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
  564. data/rust/src/kernel/expression_operators/logical.rs +13 -0
  565. data/rust/src/kernel/expression_operators/membership.rs +18 -0
  566. data/rust/src/kernel/expression_operators/mod.rs +68 -0
  567. data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
  568. data/rust/src/kernel/expression_operators/positional.rs +126 -0
  569. data/rust/src/kernel/expression_operators/presence.rs +101 -0
  570. data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
  571. data/rust/src/kernel/expression_operators/sized.rs +35 -0
  572. data/rust/src/kernel/expression_operators/text.rs +79 -0
  573. data/rust/src/kernel/expression_operators/to_string.rs +18 -0
  574. data/rust/src/kernel/json.rs +779 -0
  575. data/rust/src/kernel/mod.rs +133 -0
  576. data/rust/src/kernel/named_query.rs +219 -0
  577. data/rust/src/kernel/orchestrate.rs +1810 -0
  578. data/rust/src/kernel/pattern.rs +307 -0
  579. data/rust/src/kernel/query_comparators.rs +206 -0
  580. data/rust/src/kernel/query_ordering.rs +103 -0
  581. data/rust/src/kernel/read_model.rs +914 -0
  582. data/rust/src/kernel/reference_lookup.rs +149 -0
  583. data/rust/src/kernel/repository.rs +468 -0
  584. data/rust/src/kernel/routing.rs +612 -0
  585. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
  586. data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
  587. data/rust/src/kernel/vocab/field_hint.rs +70 -0
  588. data/rust/src/kernel/vocab/mod.rs +20 -0
  589. data/rust/src/kernel/vocab/query_comparator.rs +65 -0
  590. data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
  591. data/rust/src/lib.rs +8 -0
  592. data/rust/src/main.rs +16 -0
  593. data/rust/web/.gitignore +1 -0
  594. data/rust/web/Cargo.lock +119 -0
  595. data/rust/web/Cargo.toml +18 -0
  596. data/rust/web/src/lib.rs +8 -0
  597. metadata +477 -12
  598. data/lib/hecks/facade/cli_door.rb +0 -96
  599. data/lib/hecks/facade/cli_runner.rb +0 -127
  600. data/lib/hecks/mcp_door_scope.rb +0 -95
@@ -0,0 +1,1596 @@
1
+ //! The console's `/api/*` surface: answers `/api/me`, `/api/presentation`,
2
+ //! `/api/ui-schema`, `/api/schema` and `/api/:coll` in the Ruby engine's own shapes.
3
+
4
+ use crate::auth::Session;
5
+ use crate::dispatch;
6
+ use crate::journal::LineageConfig;
7
+ use crate::lambda_client::LambdaInvoker;
8
+ use crate::presentation;
9
+ use crate::presentation_write;
10
+ use crate::ui_schema;
11
+ use crate::web::{percent_decode, respond};
12
+ use serde_json::{json, Map, Value};
13
+ use std::cmp::Ordering;
14
+ use std::collections::HashMap;
15
+ use std::path::Path;
16
+ use tokio::sync::Mutex;
17
+ use tokio_postgres::Client;
18
+
19
+ /// Every `/api/...` request, answered. `session` is `None` only in tests —
20
+ /// `web::route` runs `auth_gate` first in production — but it stays an
21
+ /// `Option` because `/api/me` itself has a real empty-session answer.
22
+ #[allow(clippy::too_many_arguments)]
23
+ pub async fn route(
24
+ domain_ir: &Value,
25
+ method: &str,
26
+ path: &str,
27
+ query: &HashMap<String, String>,
28
+ raw_body: &str,
29
+ session: Option<&Session>,
30
+ client: &Mutex<Client>,
31
+ wasm_path: &Path,
32
+ config: &LineageConfig,
33
+ invoker: &dyn LambdaInvoker,
34
+ ) -> Value {
35
+ match (method, path) {
36
+ ("GET", "/api/me") => ok(&me(session)),
37
+
38
+ // The whole UI, derived: nav, columns, field shapes, transitions,
39
+ // create forms. Read fresh every call so a Settings-screen save is
40
+ // visible on the app's very next request.
41
+ ("GET", "/api/ui-schema") => match presentation::load(client, wasm_path, config).await {
42
+ Ok(presentation) => ok(&ui_schema::build(domain_ir, &presentation)),
43
+ Err(e) => internal_error(&e.to_string()),
44
+ },
45
+
46
+ // Structural facts only, no presentation opinion: aggregates,
47
+ // lifecycle states, queries.
48
+ ("GET", "/api/schema") => match presentation::load(client, wasm_path, config).await {
49
+ Ok(presentation) => ok(&ui_schema::schema(domain_ir, &presentation)),
50
+ Err(e) => internal_error(&e.to_string()),
51
+ },
52
+
53
+ // Read fresh every call, never memoized, for the same reason.
54
+ ("GET", "/api/presentation") => match presentation::load(client, wasm_path, config).await {
55
+ Ok(presentation) => ok(&presentation),
56
+ Err(e) => internal_error(&e.to_string()),
57
+ },
58
+
59
+ ("PUT", "/api/presentation") => {
60
+ presentation_save(domain_ir, raw_body, client, wasm_path, config, invoker).await
61
+ }
62
+
63
+ // Collection routes (`/api/:coll[/:id]`), matched last so `:coll`
64
+ // doesn't swallow `me`/`schema`. Segments are percent-decoded: a
65
+ // record id is often an email or a slug from `encodeURIComponent`.
66
+ _ => {
67
+ let segments: Vec<String> = path
68
+ .strip_prefix("/api/")
69
+ .unwrap_or_default()
70
+ .split('/')
71
+ .filter(|s| !s.is_empty())
72
+ .map(percent_decode)
73
+ .collect();
74
+
75
+ match (method, segments.as_slice()) {
76
+ ("GET", [collection]) => {
77
+ collection_index(domain_ir, collection, query, client, wasm_path, config).await
78
+ }
79
+ ("GET", [collection, id]) => record_show(domain_ir, collection, id, client, wasm_path, config).await,
80
+ ("POST", [collection]) => {
81
+ collection_create(domain_ir, collection, raw_body, client, wasm_path, config, invoker).await
82
+ }
83
+ ("POST", [collection, id, command]) => {
84
+ command_route(domain_ir, collection, id, command, raw_body, client, wasm_path, config, invoker).await
85
+ }
86
+ _ => not_found(&format!("no API route for {method} {path}")),
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ /// `GET /api/:coll` — every record (`.all()`), or, with `?query=<name>`,
93
+ /// one of the aggregate's declared queries; falls back to `.all()` on an
94
+ /// unknown name or a missing required argument.
95
+ async fn collection_index(
96
+ domain_ir: &Value,
97
+ collection: &str,
98
+ params: &HashMap<String, String>,
99
+ client: &Mutex<Client>,
100
+ wasm_path: &Path,
101
+ lineage: &LineageConfig,
102
+ ) -> Value {
103
+ let config = match presentation::load(client, wasm_path, lineage).await {
104
+ Ok(config) => config,
105
+ Err(e) => return internal_error(&e.to_string()),
106
+ };
107
+ let aggregate = match resolve_collection(domain_ir, &config, collection) {
108
+ Ok(aggregate) => aggregate,
109
+ Err(refusal) => return refusal,
110
+ };
111
+
112
+ let wanted = params.get("query").map(String::as_str).filter(|name| !name.is_empty());
113
+ let named = wanted.and_then(|name| find_query(aggregate, name));
114
+ if let Some(named) = named {
115
+ if let Some(args) = query_args_from_params(aggregate, named, params) {
116
+ return named_query_rows(domain_ir, aggregate, named, args, client, wasm_path).await;
117
+ }
118
+ }
119
+
120
+ let mut records = match read_records(domain_ir, aggregate, client, wasm_path).await {
121
+ Ok(records) => records,
122
+ Err(refusal) => return refusal,
123
+ };
124
+ sort_records(&mut records, &resolve_sort(domain_ir, &config, collection, aggregate, params));
125
+ ok(&Value::Array(records.into_iter().map(|(id, state)| with_id(&id, &state)).collect()))
126
+ }
127
+
128
+ /// `GET /api/:coll/:id` — one record's full state, or the Ruby
129
+ /// engine's own `Runtime::NotFound` message, verbatim.
130
+ async fn record_show(
131
+ domain_ir: &Value,
132
+ collection: &str,
133
+ id: &str,
134
+ client: &Mutex<Client>,
135
+ wasm_path: &Path,
136
+ lineage: &LineageConfig,
137
+ ) -> Value {
138
+ let config = match presentation::load(client, wasm_path, lineage).await {
139
+ Ok(config) => config,
140
+ Err(e) => return internal_error(&e.to_string()),
141
+ };
142
+ let aggregate = match resolve_collection(domain_ir, &config, collection) {
143
+ Ok(aggregate) => aggregate,
144
+ Err(refusal) => return refusal,
145
+ };
146
+ let records = match read_records(domain_ir, aggregate, client, wasm_path).await {
147
+ Ok(records) => records,
148
+ Err(refusal) => return refusal,
149
+ };
150
+ match records.into_iter().find(|(record_id, _)| record_id == id) {
151
+ Some((record_id, state)) => ok(&with_id(&record_id, &state)),
152
+ None => not_found(&format!("no {} found for id {:?}", ui_schema::agg_name(aggregate), id)),
153
+ }
154
+ }
155
+
156
+ /// A collection key is presentation, not domain (`collections.<Name>.key`
157
+ /// can rename it), so this resolves through the same `collection_key`
158
+ /// `/api/ui-schema` hands the client, regardless of lifecycle.
159
+ fn resolve_collection<'a>(domain_ir: &'a Value, config: &Value, collection: &str) -> Result<&'a Value, Value> {
160
+ ui_schema::aggregates(domain_ir)
161
+ .into_iter()
162
+ .find(|aggregate| ui_schema::collection_key(aggregate, config) == collection)
163
+ .ok_or_else(|| not_found(&format!("no such collection: {collection}")))
164
+ }
165
+
166
+ fn find_query<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
167
+ aggregate
168
+ .get("queries")
169
+ .and_then(|v| v.as_array())?
170
+ .iter()
171
+ .find(|query| query.get("name").and_then(|v| v.as_str()) == Some(name))
172
+ }
173
+
174
+ /// Every live record of one aggregate, keyed by id — this host's own
175
+ /// `instances` read, filtered to one `"Domain::Aggregate#"` prefix.
176
+ async fn read_records(
177
+ domain_ir: &Value,
178
+ aggregate: &Value,
179
+ client: &Mutex<Client>,
180
+ wasm_path: &Path,
181
+ ) -> Result<Vec<(String, Value)>, Value> {
182
+ let result = dispatch::read(client, wasm_path).await.map_err(|e| internal_error(&format!("{e:#}")))?;
183
+ let prefix = format!(
184
+ "{}::{}#",
185
+ domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or(""),
186
+ ui_schema::agg_name(aggregate)
187
+ );
188
+ Ok(result
189
+ .get("instances")
190
+ .and_then(|v| v.as_object())
191
+ .map(|instances| {
192
+ instances
193
+ .iter()
194
+ .filter_map(|(key, state)| key.strip_prefix(&prefix).map(|id| (id.to_string(), state.clone())))
195
+ .collect()
196
+ })
197
+ .unwrap_or_default())
198
+ }
199
+
200
+ /// `Handle#to_h` ends `state.merge(id: @id)` — the record's own
201
+ /// already-reduced scalar identity, added beside its real fields.
202
+ fn with_id(id: &str, state: &Value) -> Value {
203
+ let mut record = state.clone();
204
+ if let Some(object) = record.as_object_mut() {
205
+ object.insert("id".to_string(), json!(id));
206
+ }
207
+ record
208
+ }
209
+
210
+ async fn named_query_rows(
211
+ domain_ir: &Value,
212
+ aggregate: &Value,
213
+ named: &Value,
214
+ args: Value,
215
+ client: &Mutex<Client>,
216
+ wasm_path: &Path,
217
+ ) -> Value {
218
+ let question = format!(
219
+ "{}::{}.{}",
220
+ domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or(""),
221
+ ui_schema::agg_name(aggregate),
222
+ named.get("name").and_then(|v| v.as_str()).unwrap_or("")
223
+ );
224
+ let result = match dispatch::query(client, wasm_path, &question, args).await {
225
+ Ok(result) => result,
226
+ Err(e) => return internal_error(&format!("{e:#}")),
227
+ };
228
+ let answered = result.get("queries").and_then(|v| v.as_array()).and_then(|queries| queries.first()).cloned();
229
+ let Some(answered) = answered else {
230
+ return internal_error(&format!("the kernel answered no query step for {question}"));
231
+ };
232
+ match answered.get("rows").filter(|rows| !rows.is_null()) {
233
+ Some(rows) => ok(rows),
234
+ // A refused query, not a missing one — the refusal lands in the
235
+ // kernel's own top-level `refusals` array, read the same way a
236
+ // refused command is.
237
+ None => domain_refusal(&result),
238
+ }
239
+ }
240
+
241
+ /// Same-named params, shaped per argument (bare for a reference/primitive,
242
+ /// wrapped for a value object). `None` the moment a required one is missing.
243
+ fn query_args_from_params(aggregate: &Value, named: &Value, params: &HashMap<String, String>) -> Option<Value> {
244
+ let mut args = Map::new();
245
+ for attribute in named.get("attributes").and_then(|v| v.as_array())? {
246
+ let name = attribute.get("name").and_then(|v| v.as_str()).unwrap_or("");
247
+ let raw = params.get(name).filter(|value| !value.is_empty());
248
+ let Some(raw) = raw else {
249
+ if attribute.get("optional").and_then(|v| v.as_bool()).unwrap_or(false) {
250
+ continue;
251
+ }
252
+ return None;
253
+ };
254
+
255
+ let ty = attribute.get("type").and_then(|v| v.as_str()).unwrap_or("");
256
+ let bare = ui_schema::reference_target(ty).is_some() || PRIMITIVES.contains(&ty);
257
+ if bare {
258
+ args.insert(name.to_string(), json!(raw));
259
+ continue;
260
+ }
261
+ let wire_key = value_object_field(aggregate, ty)?;
262
+ args.insert(name.to_string(), json!({ wire_key: raw }));
263
+ }
264
+ Some(Value::Object(args))
265
+ }
266
+
267
+ const PRIMITIVES: &[&str] = &["String", "Integer", "Float", "TrueClass", "FalseClass"];
268
+
269
+ fn value_object_field(aggregate: &Value, type_name: &str) -> Option<String> {
270
+ let value_object = aggregate
271
+ .get("value_objects")
272
+ .and_then(|v| v.as_array())?
273
+ .iter()
274
+ .find(|v| v.get("name").and_then(|n| n.as_str()) == Some(type_name))?;
275
+ let first = value_object.get("attributes").and_then(|v| v.as_array())?.first()?;
276
+ first.get("name").and_then(|v| v.as_str()).map(String::from)
277
+ }
278
+
279
+ // ---- POST /api/:coll, POST /api/:coll/:id/:command -------------------
280
+
281
+ /// `POST /api/:coll` — the aggregate's creating command, plus what the
282
+ /// console does around it: minting an identity (`apply_identity`) and
283
+ /// checking a cross-aggregate precondition a `given` can't express
284
+ /// (`check_preconditions`), both driven by `collections.<Name>` config.
285
+ #[allow(clippy::too_many_arguments)]
286
+ async fn collection_create(
287
+ domain_ir: &Value,
288
+ collection: &str,
289
+ raw_body: &str,
290
+ client: &Mutex<Client>,
291
+ wasm_path: &Path,
292
+ config: &LineageConfig,
293
+ invoker: &dyn LambdaInvoker,
294
+ ) -> Value {
295
+ let presentation = match presentation::load(client, wasm_path, config).await {
296
+ Ok(presentation) => presentation,
297
+ Err(e) => return internal_error(&e.to_string()),
298
+ };
299
+ let aggregate = match resolve_collection(domain_ir, &presentation, collection) {
300
+ Ok(aggregate) => aggregate,
301
+ Err(refusal) => return refusal,
302
+ };
303
+ let mut args = match parsed_body(raw_body) {
304
+ Ok(args) => args,
305
+ Err(refusal) => return refusal,
306
+ };
307
+ let name = ui_schema::agg_name(aggregate);
308
+ let Some(creating) = ui_schema::commands(aggregate).into_iter().find(|c| ui_schema::creates(c)) else {
309
+ return not_found(&format!("{name} declares no creating command"));
310
+ };
311
+
312
+ let records = match read_records(domain_ir, aggregate, client, wasm_path).await {
313
+ Ok(records) => records,
314
+ Err(refusal) => return refusal,
315
+ };
316
+ if let Err(refusal) = apply_identity(&presentation, aggregate, &mut args, records.len()) {
317
+ return refusal;
318
+ }
319
+ if let Err(refusal) = check_preconditions(domain_ir, &presentation, aggregate, &args, client, wasm_path).await {
320
+ return refusal;
321
+ }
322
+
323
+ let verb = format!("{}::{name}.{}", domain_name(domain_ir), command_name(creating));
324
+ let outcome = match dispatch::handle_facts(client, wasm_path, &verb, args, None, config, invoker).await {
325
+ Ok(outcome) => outcome,
326
+ Err(e) => return internal_error(&format!("{e:#}")),
327
+ };
328
+ if !outcome.accepted {
329
+ return domain_refusal(&outcome.result);
330
+ }
331
+ // The id this call's own command targeted: the first mutation of the
332
+ // last step, not whichever mutation a side-effect policy pushed on
333
+ // after it.
334
+ let id = created_id(&outcome.result).unwrap_or_default();
335
+ created_record(domain_ir, aggregate, &outcome.result, &id)
336
+ }
337
+
338
+ /// `POST /api/:coll/:id/:command` — a command against an existing record.
339
+ /// Order matters: find the record first (404), then the command (404),
340
+ /// then dispatch — the Ruby engine's own order.
341
+ #[allow(clippy::too_many_arguments)]
342
+ async fn command_route(
343
+ domain_ir: &Value,
344
+ collection: &str,
345
+ id: &str,
346
+ command: &str,
347
+ raw_body: &str,
348
+ client: &Mutex<Client>,
349
+ wasm_path: &Path,
350
+ config: &LineageConfig,
351
+ invoker: &dyn LambdaInvoker,
352
+ ) -> Value {
353
+ let presentation = match presentation::load(client, wasm_path, config).await {
354
+ Ok(presentation) => presentation,
355
+ Err(e) => return internal_error(&e.to_string()),
356
+ };
357
+ let aggregate = match resolve_collection(domain_ir, &presentation, collection) {
358
+ Ok(aggregate) => aggregate,
359
+ Err(refusal) => return refusal,
360
+ };
361
+ let name = ui_schema::agg_name(aggregate);
362
+ let records = match read_records(domain_ir, aggregate, client, wasm_path).await {
363
+ Ok(records) => records,
364
+ Err(refusal) => return refusal,
365
+ };
366
+ if !records.iter().any(|(record_id, _)| record_id == id) {
367
+ return not_found(&format!("no {name} found for id {id:?}"));
368
+ }
369
+ let Some(declared) = dispatchable_command(aggregate, command) else {
370
+ return not_found(&format!("{name} declares no command named {command:?}"));
371
+ };
372
+ let args = match parsed_body(raw_body) {
373
+ Ok(args) => args,
374
+ Err(refusal) => return refusal,
375
+ };
376
+
377
+ let verb = format!("{}::{name}.{}", domain_name(domain_ir), command_name(declared));
378
+ let outcome = match dispatch::handle_routed(client, wasm_path, &verb, json!(id), args, None, config, invoker).await {
379
+ Ok(outcome) => outcome,
380
+ Err(e) => return internal_error(&format!("{e:#}")),
381
+ };
382
+ if !outcome.accepted {
383
+ return domain_refusal(&outcome.result);
384
+ }
385
+ created_record(domain_ir, aggregate, &outcome.result, id)
386
+ }
387
+
388
+ /// Every command except the creating one, which is reached only through
389
+ /// `POST /api/:coll`. Matched against the snake_cased name
390
+ /// `/api/ui-schema` hands the client, not the declared name itself.
391
+ fn dispatchable_command<'a>(aggregate: &'a Value, wanted: &str) -> Option<&'a Value> {
392
+ ui_schema::commands(aggregate)
393
+ .into_iter()
394
+ .filter(|command| !ui_schema::creates(command))
395
+ .find(|command| ui_schema::snake(command_name(command)) == wanted)
396
+ }
397
+
398
+ fn command_name(command: &Value) -> &str {
399
+ command.get("name").and_then(|v| v.as_str()).unwrap_or("")
400
+ }
401
+
402
+ fn domain_name(domain_ir: &Value) -> &str {
403
+ domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("")
404
+ }
405
+
406
+ /// An empty body is an empty argument hash; anything that isn't a JSON
407
+ /// object refuses (400) rather than reaching the kernel with the wrong
408
+ /// shape.
409
+ fn parsed_body(raw: &str) -> Result<Value, Value> {
410
+ if raw.trim().is_empty() {
411
+ return Ok(json!({}));
412
+ }
413
+ match serde_json::from_str::<Value>(raw) {
414
+ Ok(Value::Object(body)) => Ok(Value::Object(body)),
415
+ _ => Err(refusal(400, "MalformedBody", "request body is not valid JSON")),
416
+ }
417
+ }
418
+
419
+ /// The command's own target id: the first mutation of the last step.
420
+ fn created_id(result: &Value) -> Option<String> {
421
+ result
422
+ .get("mutations")?
423
+ .as_array()?
424
+ .last()?
425
+ .as_array()?
426
+ .first()?
427
+ .get("id")?
428
+ .as_str()
429
+ .map(String::from)
430
+ }
431
+
432
+ /// The record as it stands after the command, read straight out of the
433
+ /// kernel's post-dispatch `instances` rather than re-reading the journal.
434
+ fn created_record(domain_ir: &Value, aggregate: &Value, result: &Value, id: &str) -> Value {
435
+ let key = format!("{}::{}#{id}", domain_name(domain_ir), ui_schema::agg_name(aggregate));
436
+ match result.get("instances").and_then(|instances| instances.get(&key)) {
437
+ Some(state) => ok(&with_id(id, state)),
438
+ // Accepted, but this host cannot see the record it just wrote
439
+ // — a real inconsistency, never a 200 with an empty body.
440
+ None => internal_error(&format!("{key} was accepted but is not in the resulting state")),
441
+ }
442
+ }
443
+
444
+ /// A refused command, in the Ruby engine's own envelope (422, refusal
445
+ /// class as `error`). The last refusal, not the first: `dispatch::handle`
446
+ /// replays the whole rehydrated history, and every earlier step already
447
+ /// succeeded once.
448
+ fn domain_refusal(result: &Value) -> Value {
449
+ let last = result.get("refusals").and_then(|r| r.as_array()).and_then(|refusals| refusals.last());
450
+ let Some(last) = last else { return refusal(422, "Refused", "the command was refused") };
451
+ refusal(
452
+ 422,
453
+ last.get("kind").and_then(|v| v.as_str()).unwrap_or("Refused"),
454
+ last.get("error").and_then(|v| v.as_str()).unwrap_or("the command was refused"),
455
+ )
456
+ }
457
+
458
+ // ---- identity minting ------------------------------------------------
459
+
460
+ /// Mints an identity field per `collections.<Name>.identity`: `slug`
461
+ /// derives it from another submitted field, `sequence` pads the next
462
+ /// count. Only fires when the field is genuinely missing; a caller that
463
+ /// supplies one is left alone.
464
+ fn apply_identity(presentation: &Value, aggregate: &Value, args: &mut Value, existing: usize) -> Result<(), Value> {
465
+ let name = ui_schema::agg_name(aggregate);
466
+ let Some(rule) = presentation.get("collections").and_then(|c| c.get(name)).and_then(|c| c.get("identity")) else {
467
+ return Ok(());
468
+ };
469
+ let Some(field) = rule.get("field").and_then(|v| v.as_str()) else { return Ok(()) };
470
+ if args.get(field).is_some() {
471
+ return Ok(());
472
+ }
473
+ let Some(wire_key) = identity_wire_key(aggregate, field) else { return Ok(()) };
474
+
475
+ let minted = match rule.get("strategy").and_then(|v| v.as_str()) {
476
+ Some("slug") => {
477
+ let source = rule.get("source").and_then(|v| v.as_str()).unwrap_or("");
478
+ Some(slugify(&dig_source(args, source)))
479
+ }
480
+ Some("sequence") => {
481
+ let prefix = rule.get("prefix").and_then(|v| v.as_str()).unwrap_or("");
482
+ let pad = rule.get("pad").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
483
+ Some(format!("{prefix}{:0>pad$}", existing + 1))
484
+ }
485
+ // `port` delegates to a Ruby adapter this host has no runtime for
486
+ // (ADR 0007); refusing names the real reason rather than
487
+ // dispatching without the identity field.
488
+ Some("port") => {
489
+ return Err(not_implemented(
490
+ "this collection mints its identity through the domain's own identity_assignment port, which this host has no adapter runtime to call — create through the Ruby console engine, or configure a slug or sequence strategy instead",
491
+ ))
492
+ }
493
+ _ => None,
494
+ };
495
+ let Some(minted) = minted.filter(|value| !value.is_empty()) else { return Ok(()) };
496
+ if let Some(object) = args.as_object_mut() {
497
+ object.insert(field.to_string(), json!({ wire_key: minted }));
498
+ }
499
+ Ok(())
500
+ }
501
+
502
+ /// The wire key an identity value has to be wrapped in — whatever the
503
+ /// target value object's own single attribute is actually called. This
504
+ /// domain always spells it "value"; nothing here assumes that.
505
+ fn identity_wire_key(aggregate: &Value, field: &str) -> Option<String> {
506
+ let attribute = ui_schema::find_attribute(aggregate, field)?;
507
+ let ty = attribute.get("type").and_then(|v| v.as_str())?;
508
+ if ui_schema::reference_target(ty).is_some() {
509
+ return None;
510
+ }
511
+ value_object_field(aggregate, ty)
512
+ }
513
+
514
+ fn dig_source(args: &Value, source: &str) -> String {
515
+ match args.get(source) {
516
+ Some(Value::Object(fields)) => fields.values().next().map(scalar_to_string).unwrap_or_default(),
517
+ Some(value) => scalar_to_string(value),
518
+ None => String::new(),
519
+ }
520
+ }
521
+
522
+ fn scalar_to_string(value: &Value) -> String {
523
+ match value {
524
+ Value::String(text) => text.clone(),
525
+ Value::Null => String::new(),
526
+ other => other.to_string(),
527
+ }
528
+ }
529
+
530
+ /// `slugify` — lowercase, every run of non-alphanumerics to a single
531
+ /// hyphen, trimmed, and never empty (an unsluggable source becomes
532
+ /// "record" rather than an identity of no characters at all).
533
+ fn slugify(text: &str) -> String {
534
+ let mut slug = String::new();
535
+ for character in text.trim().to_lowercase().chars() {
536
+ if character.is_ascii_alphanumeric() {
537
+ slug.push(character);
538
+ } else if !slug.ends_with('-') {
539
+ slug.push('-');
540
+ }
541
+ }
542
+ let slug = slug.trim_matches('-').to_string();
543
+ if slug.is_empty() {
544
+ "record".to_string()
545
+ } else {
546
+ slug
547
+ }
548
+ }
549
+
550
+ // ---- preconditions ---------------------------------------------------
551
+
552
+ /// Cross-aggregate preconditions from `collections.<Name>.preconditions`
553
+ /// config, which a creating command's own `given` can't express because
554
+ /// it can only read its own aggregate.
555
+ async fn check_preconditions(
556
+ domain_ir: &Value,
557
+ presentation: &Value,
558
+ aggregate: &Value,
559
+ args: &Value,
560
+ client: &Mutex<Client>,
561
+ wasm_path: &Path,
562
+ ) -> Result<(), Value> {
563
+ let name = ui_schema::agg_name(aggregate);
564
+ let rules = presentation
565
+ .get("collections")
566
+ .and_then(|c| c.get(name))
567
+ .and_then(|c| c.get("preconditions"))
568
+ .and_then(|v| v.as_array())
569
+ .cloned()
570
+ .unwrap_or_default();
571
+ if rules.is_empty() {
572
+ return Ok(());
573
+ }
574
+
575
+ let instances = dispatch::read(client, wasm_path).await.map_err(|e| internal_error(&format!("{e:#}")))?;
576
+ let instances = instances.get("instances").cloned().unwrap_or_else(|| json!({}));
577
+
578
+ for rule in &rules {
579
+ check_precondition(domain_ir, aggregate, rule, args, &instances)?;
580
+ }
581
+ Ok(())
582
+ }
583
+
584
+ fn check_precondition(domain_ir: &Value, aggregate: &Value, rule: &Value, args: &Value, instances: &Value) -> Result<(), Value> {
585
+ let Some(field) = rule.get("field").and_then(|v| v.as_str()) else { return Ok(()) };
586
+ // A missing required reference is the command's own validation to
587
+ // refuse, not this one's.
588
+ let Some(target_id) = args.get(field).and_then(|v| v.as_str()) else { return Ok(()) };
589
+ // Config named a field that isn't a reference at all — caught at
590
+ // save time by the console's own validation, not at dispatch time.
591
+ let Some(target_name) = ui_schema::find_attribute(aggregate, field)
592
+ .and_then(|attribute| attribute.get("type"))
593
+ .and_then(|v| v.as_str())
594
+ .and_then(ui_schema::reference_target)
595
+ else {
596
+ return Ok(());
597
+ };
598
+
599
+ let key = format!("{}::{target_name}#{target_id}", domain_name(domain_ir));
600
+ let Some(target) = instances.get(&key) else {
601
+ return Err(precondition_failed(
602
+ rule,
603
+ &format!("no {} with that id", target_name.to_lowercase()),
604
+ ));
605
+ };
606
+
607
+ let Some(expected) = rule.get("state").and_then(|v| v.as_str()) else { return Ok(()) };
608
+ let target_aggregate = ui_schema::aggregates(domain_ir).into_iter().find(|a| ui_schema::agg_name(a) == target_name);
609
+ // Config named a state precondition against an aggregate with no
610
+ // lifecycle — again a save-time concern, not a dispatch-time one.
611
+ let Some(state_field) = target_aggregate
612
+ .and_then(|a| a.get("lifecycle").cloned())
613
+ .filter(|l| !l.is_null())
614
+ .and_then(|l| l.get("field").and_then(|v| v.as_str()).map(String::from))
615
+ else {
616
+ return Ok(());
617
+ };
618
+
619
+ let actual = target.get(&state_field).and_then(|v| v.as_str()).unwrap_or("");
620
+ if actual == expected {
621
+ return Ok(());
622
+ }
623
+ Err(precondition_failed(rule, &format!("{target_name} is at {actual}, not {expected}")))
624
+ }
625
+
626
+ fn precondition_failed(rule: &Value, derived: &str) -> Value {
627
+ refusal(422, "PreconditionFailed", rule.get("message").and_then(|v| v.as_str()).unwrap_or(derived))
628
+ }
629
+
630
+ // ---- sorting ---------------------------------------------------------
631
+
632
+ /// Column shapes a JSONB order path resolves correctly. A fold over a
633
+ /// list or a cross-aggregate join is excluded and stays client-side.
634
+ const SQL_SORTABLE_SHAPES: &[&str] = &["text_value", "text", "address", "number", "money", "enum", "pill"];
635
+
636
+ struct SortSpec {
637
+ field: String,
638
+ descending: bool,
639
+ }
640
+
641
+ /// `?sort=<column key>&direction=asc|desc`, checked against this
642
+ /// collection's sortable config. Falls back to default order (`None`)
643
+ /// when the column isn't sortable or its shape can't push to SQL.
644
+ fn resolve_sort(
645
+ domain_ir: &Value,
646
+ config: &Value,
647
+ collection: &str,
648
+ aggregate: &Value,
649
+ params: &HashMap<String, String>,
650
+ ) -> Option<SortSpec> {
651
+ let key = params.get("sort").filter(|key| !key.is_empty())?;
652
+ let schema = ui_schema::build(domain_ir, config);
653
+ let collection_schema = schema.get("collections")?.get(collection)?;
654
+ let column = collection_schema
655
+ .get("columns")?
656
+ .as_array()?
657
+ .iter()
658
+ .find(|column| column.get("key").and_then(|v| v.as_str()) == Some(key.as_str()))?;
659
+
660
+ if column.get("sortable") == Some(&json!(false)) {
661
+ return None;
662
+ }
663
+ let shape = column.get("shape").and_then(|v| v.as_str()).unwrap_or("");
664
+ if !SQL_SORTABLE_SHAPES.contains(&shape) {
665
+ return None;
666
+ }
667
+
668
+ let field = if key == "__state__" {
669
+ collection_schema.get("stateField")?.as_str()?.to_string()
670
+ } else {
671
+ key.to_string()
672
+ };
673
+ // Config could still name a field the aggregate doesn't have.
674
+ if ui_schema::find_attribute(aggregate, &field).is_none() && lifecycle_field(aggregate) != Some(field.as_str()) {
675
+ return None;
676
+ }
677
+
678
+ Some(SortSpec { field, descending: params.get("direction").map(String::as_str) == Some("desc") })
679
+ }
680
+
681
+ fn lifecycle_field(aggregate: &Value) -> Option<&str> {
682
+ aggregate.get("lifecycle")?.get("field")?.as_str()
683
+ }
684
+
685
+ /// In memory, not SQL: there's no ORDER BY to push into, so this follows
686
+ /// Postgres's own null ordering, with a stable id fallback for ties.
687
+ fn sort_records(records: &mut [(String, Value)], spec: &Option<SortSpec>) {
688
+ match spec {
689
+ None => records.sort_by(|(a, _), (b, _)| a.cmp(b)),
690
+ Some(spec) => records.sort_by(|(a_id, a), (b_id, b)| {
691
+ let ordering = compare_sort_keys(sort_key(a, &spec.field), sort_key(b, &spec.field), spec.descending);
692
+ ordering.then_with(|| a_id.cmp(b_id))
693
+ }),
694
+ }
695
+ }
696
+
697
+ /// The scalar a JSONB order path lands on: the field itself, `cents` for
698
+ /// a money value object, or a single-attribute value object's member.
699
+ fn sort_key<'a>(state: &'a Value, field: &str) -> Option<&'a Value> {
700
+ let value = state.get(field).filter(|v| !v.is_null())?;
701
+ let Some(object) = value.as_object() else { return Some(value) };
702
+ if let Some(cents) = object.get("cents") {
703
+ return Some(cents);
704
+ }
705
+ if object.len() == 1 {
706
+ return object.values().next().filter(|v| !v.is_null());
707
+ }
708
+ Some(value)
709
+ }
710
+
711
+ fn compare_sort_keys(left: Option<&Value>, right: Option<&Value>, descending: bool) -> Ordering {
712
+ let ordering = match (left, right) {
713
+ (None, None) => Ordering::Equal,
714
+ // NULLS LAST ascending; reversing below puts them first for
715
+ // descending, which is Postgres's own default pairing.
716
+ (None, Some(_)) => Ordering::Greater,
717
+ (Some(_), None) => Ordering::Less,
718
+ (Some(left), Some(right)) => compare_values(left, right),
719
+ };
720
+ // A null's placement is not reversed with the direction — Postgres
721
+ // moves nulls to the front for DESC by keeping the same rule and
722
+ // reversing everything, which is exactly what this does.
723
+ if descending {
724
+ ordering.reverse()
725
+ } else {
726
+ ordering
727
+ }
728
+ }
729
+
730
+ fn compare_values(left: &Value, right: &Value) -> Ordering {
731
+ match (left.as_f64(), right.as_f64()) {
732
+ (Some(left), Some(right)) => left.partial_cmp(&right).unwrap_or(Ordering::Equal),
733
+ _ => left.as_str().unwrap_or("").cmp(right.as_str().unwrap_or("")),
734
+ }
735
+ }
736
+
737
+ /// `GET /api/me` — `{"email", "name", "identity_id", "role"}` from
738
+ /// `session`, or `{}` with no session, which index.html treats as
739
+ /// "don't render the signed-in banner".
740
+ fn me(session: Option<&Session>) -> Value {
741
+ let Some(session) = session else { return json!({}) };
742
+ json!({
743
+ "email": session.email,
744
+ "name": session.name,
745
+ "identity_id": session.identity_id,
746
+ "role": session.role,
747
+ })
748
+ }
749
+
750
+ /// `PUT /api/presentation` — the whole config replaced at once, validated
751
+ /// against this domain's live shape, answered with the reloaded config.
752
+ /// 200 on save; 400 `MalformedBody`; 422 `Malformed` or a domain refusal;
753
+ /// 501 when this host's kernel carries no ConsoleSettings chapter.
754
+ #[allow(clippy::too_many_arguments)]
755
+ async fn presentation_save(
756
+ domain_ir: &Value,
757
+ raw_body: &str,
758
+ client: &Mutex<Client>,
759
+ wasm_path: &Path,
760
+ config: &LineageConfig,
761
+ invoker: &dyn LambdaInvoker,
762
+ ) -> Value {
763
+ let submitted = match parsed_body(raw_body) {
764
+ Ok(submitted) => submitted,
765
+ Err(refusal) => return refusal,
766
+ };
767
+
768
+ match presentation_write::save(domain_ir, &submitted, client, wasm_path, config, invoker).await {
769
+ Ok(saved) => ok(&saved),
770
+ Err(presentation_write::SaveRefusal::NoKernel) => not_implemented(PRESENTATION_WRITE_REFUSAL),
771
+ Err(presentation_write::SaveRefusal::Malformed(message)) => refusal(422, "Malformed", &message),
772
+ Err(presentation_write::SaveRefusal::Domain(result)) => domain_refusal(&result),
773
+ Err(presentation_write::SaveRefusal::Internal(message)) => internal_error(&message),
774
+ }
775
+ }
776
+
777
+ // Fires only for a domain whose kernel genuinely has no ConsoleSettings
778
+ // chapter; names the decision outstanding rather than 404-ing as if the
779
+ // route never existed.
780
+ const PRESENTATION_WRITE_REFUSAL: &str =
781
+ "this host's kernel carries no ConsoleSettings chapter, so it has no StateStyle/Collection/Overview \
782
+ commands to dispatch, and writing the rows behind its back would skip the invariants those commands \
783
+ enforce. Attach it with `uses_framework \"ConsoleSettings\"` in this domain's own .hecksagon and \
784
+ rebuild the kernel, or save from the Ruby console engine instead.";
785
+
786
+ // ---- response envelopes ---------------------------------------------
787
+
788
+ fn ok(body: &Value) -> Value {
789
+ respond(200, "application/json", &body.to_string())
790
+ }
791
+
792
+ pub(crate) fn not_found(message: &str) -> Value {
793
+ refusal(404, "NotFound", message)
794
+ }
795
+
796
+ pub(crate) fn internal_error(message: &str) -> Value {
797
+ refusal(500, "InternalError", message)
798
+ }
799
+
800
+ fn not_implemented(message: &str) -> Value {
801
+ refusal(501, "NotImplemented", message)
802
+ }
803
+
804
+ pub(crate) fn refusal(status: u16, error: &str, message: &str) -> Value {
805
+ respond(status, "application/json", &json!({"error": error, "message": message}).to_string())
806
+ }
807
+
808
+ #[cfg(test)]
809
+ mod tests {
810
+ use super::*;
811
+
812
+ fn session() -> Session {
813
+ Session {
814
+ identity_id: "11111111-2222-3333-4444-555555555555".to_string(),
815
+ email: "chris@example.com".to_string(),
816
+ name: "Chris".to_string(),
817
+ role: Some("Admin".to_string()),
818
+ }
819
+ }
820
+
821
+ fn body(response: &Value) -> Value {
822
+ serde_json::from_str(response["body"].as_str().expect("a JSON body")).expect("valid JSON")
823
+ }
824
+
825
+ // app.rb: `get "/api/me" { json(session[:member] || {}) }`, where
826
+ // session[:member] is embryonaut_access_control.rb's own
827
+ // `{ "email" =>, "name" =>, "identity_id" =>, "role" => }`.
828
+ #[test]
829
+ fn me_is_the_ruby_engines_own_member_hash() {
830
+ assert_eq!(
831
+ me(Some(&session())),
832
+ json!({
833
+ "email": "chris@example.com",
834
+ "name": "Chris",
835
+ "identity_id": "11111111-2222-3333-4444-555555555555",
836
+ "role": "Admin"
837
+ })
838
+ );
839
+ }
840
+
841
+ // A Member with a session but no granted role reads `role: null`,
842
+ // not a missing key — Ruby's own hash always carries all four keys
843
+ // (`role` comes back nil when Governance holds no grant and the
844
+ // aggregate's own field is unset).
845
+ #[test]
846
+ fn me_keeps_a_null_role_rather_than_dropping_the_key() {
847
+ let mut session = session();
848
+ session.role = None;
849
+ assert_eq!(me(Some(&session))["role"], Value::Null);
850
+ }
851
+
852
+ #[test]
853
+ fn me_with_no_session_is_the_empty_object_ruby_answers() {
854
+ assert_eq!(me(None), json!({}));
855
+ }
856
+
857
+ #[test]
858
+ fn an_unknown_api_route_is_a_json_404_not_an_html_one() {
859
+ let response = not_found("no API route for GET /api/nope/nope/nope");
860
+ assert_eq!(response["statusCode"], 404);
861
+ assert_eq!(response["headers"]["content-type"], "application/json");
862
+ assert_eq!(body(&response)["error"], "NotFound");
863
+ }
864
+
865
+ // The refusal envelope every non-2xx answer in this module shares,
866
+ // pinned against app.rb's own `json({ error:, message: })`.
867
+ #[test]
868
+ fn a_refusal_carries_exactly_error_and_message() {
869
+ let response = refusal(422, "PreconditionFailed", "the proposal hasn't been accepted yet");
870
+ assert_eq!(response["statusCode"], 422);
871
+ assert_eq!(
872
+ response["body"],
873
+ r#"{"error":"PreconditionFailed","message":"the proposal hasn't been accepted yet"}"#
874
+ );
875
+ }
876
+
877
+ // ---- collection routes -----------------------------------------
878
+
879
+ fn domain() -> Value {
880
+ json!({
881
+ "name": "SampleApp",
882
+ "aggregates": [
883
+ {
884
+ "name": "Client",
885
+ "identified_by": ["reference.value"],
886
+ "attributes": [
887
+ {"name": "reference", "type": "ClientReference", "list": false, "optional": false},
888
+ {"name": "name", "type": "ClientName", "list": false, "optional": false},
889
+ {"name": "owner", "type": "Reference<Member>", "list": false, "optional": true},
890
+ {"name": "fee", "type": "Money", "list": false, "optional": true}
891
+ ],
892
+ "value_objects": [
893
+ {"name": "ClientReference", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
894
+ {"name": "ClientName", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
895
+ {"name": "Money", "attributes": [{"name": "cents", "type": "Integer"}, {"name": "currency", "type": "String"}],
896
+ "closed_set": false, "members": []}
897
+ ],
898
+ "entities": [], "commands": [],
899
+ "queries": [
900
+ {"name": "Active", "attributes": []},
901
+ {"name": "ByOwner", "attributes": [{"name": "owner", "type": "Reference<Member>", "list": false, "optional": false}]},
902
+ {"name": "ByName", "attributes": [{"name": "name", "type": "ClientName", "list": false, "optional": false}]},
903
+ {"name": "Since", "attributes": [{"name": "year", "type": "Integer", "list": false, "optional": true}]}
904
+ ],
905
+ "lifecycle": {"field": "status", "default": "prospect",
906
+ "transitions": [{"command": "Activate", "to_state": "active", "from_state": "prospect"}]}
907
+ }
908
+ ]
909
+ })
910
+ }
911
+
912
+ fn client_aggregate() -> Value {
913
+ domain()["aggregates"][0].clone()
914
+ }
915
+
916
+ fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
917
+ pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
918
+ }
919
+
920
+ #[test]
921
+ fn a_collection_resolves_through_the_key_the_ui_schema_advertises() {
922
+ let domain = domain();
923
+ assert!(resolve_collection(&domain, &json!({}), "clients").is_ok());
924
+
925
+ let renamed = json!({"collections": {"Client": {"key": "accounts"}}});
926
+ assert!(resolve_collection(&domain, &renamed, "accounts").is_ok());
927
+ // The derived key stops working once config renames it — the
928
+ // same single answer `/api/ui-schema` gives the client.
929
+ assert!(resolve_collection(&domain, &renamed, "clients").is_err());
930
+ }
931
+
932
+ #[test]
933
+ fn an_unknown_collection_is_the_ruby_engines_own_not_found_message() {
934
+ let refusal = resolve_collection(&domain(), &json!({}), "nope").expect_err("unknown collections refuse");
935
+
936
+ assert_eq!(refusal["statusCode"], 404);
937
+ assert_eq!(body(&refusal), json!({"error": "NotFound", "message": "no such collection: nope"}));
938
+ }
939
+
940
+ #[test]
941
+ fn a_record_carries_its_id_beside_its_own_fields() {
942
+ assert_eq!(
943
+ with_id("acme", &json!({"name": {"value": "Acme"}})),
944
+ json!({"name": {"value": "Acme"}, "id": "acme"})
945
+ );
946
+ }
947
+
948
+ // ---- ?query= ----------------------------------------------------
949
+
950
+ #[test]
951
+ fn a_reference_or_primitive_query_argument_rides_bare_and_a_value_object_wraps() {
952
+ let aggregate = client_aggregate();
953
+ let by_owner = find_query(&aggregate, "ByOwner").expect("declared");
954
+ let by_name = find_query(&aggregate, "ByName").expect("declared");
955
+
956
+ assert_eq!(
957
+ query_args_from_params(&aggregate, by_owner, &params(&[("owner", "chris@example.com")])),
958
+ Some(json!({"owner": "chris@example.com"}))
959
+ );
960
+ assert_eq!(
961
+ query_args_from_params(&aggregate, by_name, &params(&[("name", "Acme")])),
962
+ Some(json!({"name": {"value": "Acme"}}))
963
+ );
964
+ }
965
+
966
+ #[test]
967
+ fn a_missing_required_query_argument_falls_all_the_way_back_to_all_rather_than_dispatching() {
968
+ let aggregate = client_aggregate();
969
+ let by_owner = find_query(&aggregate, "ByOwner").expect("declared");
970
+
971
+ assert_eq!(query_args_from_params(&aggregate, by_owner, &params(&[])), None);
972
+ // An empty param is the same as an absent one, matching Ruby's
973
+ // own `raw.nil? || raw.empty?`.
974
+ assert_eq!(query_args_from_params(&aggregate, by_owner, &params(&[("owner", "")])), None);
975
+ }
976
+
977
+ #[test]
978
+ fn an_optional_query_argument_is_simply_left_out_when_nothing_supplies_it() {
979
+ let aggregate = client_aggregate();
980
+ let since = find_query(&aggregate, "Since").expect("declared");
981
+
982
+ assert_eq!(query_args_from_params(&aggregate, since, &params(&[])), Some(json!({})));
983
+ assert_eq!(query_args_from_params(&aggregate, since, &params(&[("year", "2026")])), Some(json!({"year": "2026"})));
984
+ }
985
+
986
+ #[test]
987
+ fn a_no_arg_query_dispatches_with_an_empty_argument_hash() {
988
+ let aggregate = client_aggregate();
989
+ let active = find_query(&aggregate, "Active").expect("declared");
990
+
991
+ assert_eq!(query_args_from_params(&aggregate, active, &params(&[])), Some(json!({})));
992
+ }
993
+
994
+ #[test]
995
+ fn an_unknown_query_name_is_simply_not_found_so_the_caller_falls_back_to_all() {
996
+ assert!(find_query(&client_aggregate(), "NoSuchQuery").is_none());
997
+ }
998
+
999
+ // ---- ?sort= -----------------------------------------------------
1000
+
1001
+ #[test]
1002
+ fn a_sortable_column_of_a_pushable_shape_sorts_and_anything_else_falls_back() {
1003
+ let domain = domain();
1004
+ let aggregate = client_aggregate();
1005
+ let spec = resolve_sort(&domain, &json!({}), "clients", &aggregate, &params(&[("sort", "name")]));
1006
+ let spec = spec.expect("a text_value column is sortable");
1007
+ assert_eq!(spec.field, "name");
1008
+ assert!(!spec.descending);
1009
+
1010
+ // A reference column is deliberately excluded — it would need a
1011
+ // cross-aggregate join no order clause wires.
1012
+ assert!(resolve_sort(&domain, &json!({}), "clients", &aggregate, &params(&[("sort", "owner")])).is_none());
1013
+ // …as is a column config explicitly opts out of.
1014
+ let opted_out = json!({"collections": {"Client": {"columns": [{"field": "name", "sortable": false}]}}});
1015
+ assert!(resolve_sort(&domain, &opted_out, "clients", &aggregate, &params(&[("sort", "name")])).is_none());
1016
+ // …and a column key that isn't in this collection at all.
1017
+ assert!(resolve_sort(&domain, &json!({}), "clients", &aggregate, &params(&[("sort", "nope")])).is_none());
1018
+ }
1019
+
1020
+ #[test]
1021
+ fn sorting_by_the_state_column_sorts_by_whatever_the_lifecycle_field_is_really_called() {
1022
+ let domain = domain();
1023
+ let aggregate = client_aggregate();
1024
+ let spec = resolve_sort(&domain, &json!({}), "clients", &aggregate, &params(&[("sort", "__state__"), ("direction", "desc")]))
1025
+ .expect("the derived state pill is sortable");
1026
+
1027
+ assert_eq!(spec.field, "status");
1028
+ assert!(spec.descending);
1029
+ }
1030
+
1031
+ #[test]
1032
+ fn records_sort_by_id_when_nothing_asks_for_an_order() {
1033
+ let mut records = vec![
1034
+ ("b".to_string(), json!({"name": {"value": "Zed"}})),
1035
+ ("a".to_string(), json!({"name": {"value": "Ann"}})),
1036
+ ];
1037
+ sort_records(&mut records, &None);
1038
+
1039
+ assert_eq!(records.iter().map(|(id, _)| id.as_str()).collect::<Vec<&str>>(), vec!["a", "b"]);
1040
+ }
1041
+
1042
+ #[test]
1043
+ fn sorting_drills_through_a_single_attribute_value_object_and_through_money_to_its_cents() {
1044
+ let mut records = vec![
1045
+ ("c".to_string(), json!({"name": {"value": "Cara"}, "fee": {"cents": 300, "currency": "USD"}})),
1046
+ ("a".to_string(), json!({"name": {"value": "Ann"}, "fee": {"cents": 1000, "currency": "USD"}})),
1047
+ ];
1048
+
1049
+ sort_records(&mut records, &Some(SortSpec { field: "name".to_string(), descending: false }));
1050
+ assert_eq!(records[0].0, "a");
1051
+
1052
+ // Money sorts numerically by cents — 300 before 1000, which a
1053
+ // string comparison would get backwards.
1054
+ sort_records(&mut records, &Some(SortSpec { field: "fee".to_string(), descending: false }));
1055
+ assert_eq!(records.iter().map(|(id, _)| id.as_str()).collect::<Vec<&str>>(), vec!["c", "a"]);
1056
+ }
1057
+
1058
+ #[test]
1059
+ fn an_unset_field_sorts_last_ascending_and_first_descending_the_way_postgres_orders_nulls() {
1060
+ let mut records = vec![
1061
+ ("missing".to_string(), json!({"name": null})),
1062
+ ("ann".to_string(), json!({"name": {"value": "Ann"}})),
1063
+ ];
1064
+
1065
+ sort_records(&mut records, &Some(SortSpec { field: "name".to_string(), descending: false }));
1066
+ assert_eq!(records.iter().map(|(id, _)| id.as_str()).collect::<Vec<&str>>(), vec!["ann", "missing"]);
1067
+
1068
+ sort_records(&mut records, &Some(SortSpec { field: "name".to_string(), descending: true }));
1069
+ assert_eq!(records.iter().map(|(id, _)| id.as_str()).collect::<Vec<&str>>(), vec!["missing", "ann"]);
1070
+ }
1071
+
1072
+ #[test]
1073
+ fn a_record_that_does_not_exist_refuses_with_json_doors_own_wording() {
1074
+ // `JSON_DOOR.find!`'s own message, `id` inspected the way Ruby
1075
+ // inspects it.
1076
+ let refusal = not_found(&format!("no {} found for id {:?}", "Client", "acme"));
1077
+ assert_eq!(body(&refusal), json!({"error": "NotFound", "message": "no Client found for id \"acme\""}));
1078
+ }
1079
+
1080
+ // ---- write routes ------------------------------------------------
1081
+
1082
+ #[test]
1083
+ fn an_empty_body_is_an_empty_argument_hash_and_anything_unparseable_refuses() {
1084
+ assert_eq!(parsed_body(""), Ok(json!({})));
1085
+ assert_eq!(parsed_body(" "), Ok(json!({})));
1086
+ assert_eq!(parsed_body(r#"{"name":{"value":"Acme"}}"#), Ok(json!({"name": {"value": "Acme"}})));
1087
+
1088
+ let refusal = parsed_body("not json").expect_err("a malformed body refuses");
1089
+ assert_eq!(refusal["statusCode"], 400);
1090
+ assert_eq!(body(&refusal), json!({"error": "MalformedBody", "message": "request body is not valid JSON"}));
1091
+ // A body that parses but isn't an object has no arguments to
1092
+ // splat — refused the same way rather than dispatched.
1093
+ assert!(parsed_body("[1,2,3]").is_err());
1094
+ }
1095
+
1096
+ #[test]
1097
+ fn a_command_name_matches_the_snake_case_spelling_the_ui_schema_handed_the_client() {
1098
+ let aggregate = json!({
1099
+ "name": "RecurringPayment",
1100
+ "commands": [
1101
+ {"name": "Schedule", "references": null, "attributes": []},
1102
+ {"name": "AdvanceCycle", "references": "RecurringPayment", "attributes": []}
1103
+ ]
1104
+ });
1105
+
1106
+ assert_eq!(command_name(dispatchable_command(&aggregate, "advance_cycle").expect("declared")), "AdvanceCycle");
1107
+ // The creating command is reachable through POST /api/:coll,
1108
+ // never here — accepting it would pass this gate and then fail
1109
+ // as something far less legible.
1110
+ assert!(dispatchable_command(&aggregate, "schedule").is_none());
1111
+ assert!(dispatchable_command(&aggregate, "nope").is_none());
1112
+ }
1113
+
1114
+ #[test]
1115
+ fn a_refused_command_carries_the_kernels_own_refusal_class_and_message() {
1116
+ let result = json!({"refusals": [
1117
+ {"verb": "X.Y", "error": "an earlier one", "kind": "AlreadyExists"},
1118
+ {"verb": "X.Y", "error": "a proposal is only accepted once", "kind": "LifecycleRefused"}
1119
+ ]});
1120
+
1121
+ let refusal = domain_refusal(&result);
1122
+ assert_eq!(refusal["statusCode"], 422);
1123
+ // The last refusal — `handle` replays the whole rehydrated
1124
+ // history, so everything before this call's own step already
1125
+ // succeeded once.
1126
+ assert_eq!(
1127
+ body(&refusal),
1128
+ json!({"error": "LifecycleRefused", "message": "a proposal is only accepted once"})
1129
+ );
1130
+ }
1131
+
1132
+ #[test]
1133
+ fn the_created_id_is_this_commands_own_target_never_a_reactions() {
1134
+ let result = json!({"mutations": [
1135
+ [{"aggregate": "Client", "id": "earlier"}],
1136
+ [{"aggregate": "Proposal", "id": "P-004"}, {"aggregate": "Engagement", "id": "a-policy-fired"}]
1137
+ ]});
1138
+
1139
+ assert_eq!(created_id(&result), Some("P-004".to_string()));
1140
+ }
1141
+
1142
+ // ---- identity minting --------------------------------------------
1143
+
1144
+ fn client_with_identity(identity: Value) -> (Value, Value) {
1145
+ let aggregate = json!({
1146
+ "name": "Client",
1147
+ "identified_by": ["reference.value"],
1148
+ "attributes": [
1149
+ {"name": "reference", "type": "ClientReference", "list": false, "optional": false},
1150
+ {"name": "name", "type": "ClientName", "list": false, "optional": false},
1151
+ {"name": "owner", "type": "Reference<Member>", "list": false, "optional": true}
1152
+ ],
1153
+ "value_objects": [
1154
+ {"name": "ClientReference", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
1155
+ {"name": "ClientName", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []}
1156
+ ],
1157
+ "entities": [], "commands": [], "queries": [], "lifecycle": null
1158
+ });
1159
+ let presentation = json!({"collections": {"Client": {"identity": identity}}});
1160
+ (aggregate, presentation)
1161
+ }
1162
+
1163
+ #[test]
1164
+ fn a_slug_identity_is_minted_from_another_submitted_field() {
1165
+ let (aggregate, presentation) = client_with_identity(json!({"field": "reference", "strategy": "slug", "source": "name"}));
1166
+ let mut args = json!({"name": {"value": "Acme Corp."}});
1167
+
1168
+ apply_identity(&presentation, &aggregate, &mut args, 0).expect("slugging never refuses");
1169
+
1170
+ assert_eq!(args["reference"], json!({"value": "acme-corp"}));
1171
+ }
1172
+
1173
+ #[test]
1174
+ fn a_sequence_identity_counts_the_records_already_there_and_pads_to_the_configured_width() {
1175
+ let (aggregate, presentation) =
1176
+ client_with_identity(json!({"field": "reference", "strategy": "sequence", "prefix": "C-", "pad": 4}));
1177
+ let mut args = json!({"name": {"value": "Acme"}});
1178
+
1179
+ apply_identity(&presentation, &aggregate, &mut args, 41).expect("sequencing never refuses");
1180
+
1181
+ assert_eq!(args["reference"], json!({"value": "C-0042"}));
1182
+ }
1183
+
1184
+ #[test]
1185
+ fn an_identity_the_caller_supplied_is_left_exactly_as_it_came() {
1186
+ let (aggregate, presentation) = client_with_identity(json!({"field": "reference", "strategy": "slug", "source": "name"}));
1187
+ let mut args = json!({"reference": {"value": "chosen-by-hand"}, "name": {"value": "Acme"}});
1188
+
1189
+ apply_identity(&presentation, &aggregate, &mut args, 0).expect("supplied identities never refuse");
1190
+
1191
+ assert_eq!(args["reference"], json!({"value": "chosen-by-hand"}));
1192
+ }
1193
+
1194
+ #[test]
1195
+ fn an_identity_rule_naming_a_reference_field_mints_nothing_rather_than_guessing_a_wire_shape() {
1196
+ let (aggregate, presentation) = client_with_identity(json!({"field": "owner", "strategy": "slug", "source": "name"}));
1197
+ let mut args = json!({"name": {"value": "Acme"}});
1198
+
1199
+ apply_identity(&presentation, &aggregate, &mut args, 0).expect("no refusal");
1200
+
1201
+ assert!(args.get("owner").is_none(), "{args}");
1202
+ }
1203
+
1204
+ // Refuses by name rather than dispatching without the field and
1205
+ // surfacing a confusing "absent argument" from the kernel.
1206
+ #[test]
1207
+ fn a_port_identity_strategy_refuses_in_its_own_words_rather_than_dispatching_without_one() {
1208
+ let (aggregate, presentation) = client_with_identity(json!({"field": "reference", "strategy": "port"}));
1209
+ let mut args = json!({"name": {"value": "Acme"}});
1210
+
1211
+ let refusal = apply_identity(&presentation, &aggregate, &mut args, 0).expect_err("the port strategy refuses");
1212
+
1213
+ assert_eq!(refusal["statusCode"], 501);
1214
+ assert!(
1215
+ body(&refusal)["message"].as_str().expect("a message").contains("identity_assignment"),
1216
+ "{refusal}"
1217
+ );
1218
+ }
1219
+
1220
+ #[test]
1221
+ fn slugify_matches_the_console_engines_own_rules() {
1222
+ assert_eq!(slugify("Acme Corp."), "acme-corp");
1223
+ assert_eq!(slugify(" Hello, World! "), "hello-world");
1224
+ assert_eq!(slugify("...."), "record");
1225
+ assert_eq!(slugify(""), "record");
1226
+ }
1227
+
1228
+ // ---- preconditions ------------------------------------------------
1229
+
1230
+ fn precondition_domain() -> Value {
1231
+ json!({
1232
+ "name": "SampleApp",
1233
+ "aggregates": [
1234
+ {
1235
+ "name": "Contract",
1236
+ "identified_by": ["number.value"],
1237
+ "attributes": [
1238
+ {"name": "number", "type": "ContractNumber", "list": false, "optional": false},
1239
+ {"name": "proposal_id", "type": "Reference<Proposal>", "list": false, "optional": false}
1240
+ ],
1241
+ "value_objects": [{"name": "ContractNumber", "attributes": [{"name": "value", "type": "String"}],
1242
+ "closed_set": false, "members": []}],
1243
+ "entities": [], "commands": [], "queries": [], "lifecycle": null
1244
+ },
1245
+ {
1246
+ "name": "Proposal",
1247
+ "identified_by": ["number.value"],
1248
+ "attributes": [], "value_objects": [], "entities": [], "commands": [], "queries": [],
1249
+ "lifecycle": {"field": "status", "default": "drafted", "transitions": []}
1250
+ }
1251
+ ]
1252
+ })
1253
+ }
1254
+
1255
+ fn contract() -> Value {
1256
+ precondition_domain()["aggregates"][0].clone()
1257
+ }
1258
+
1259
+ #[test]
1260
+ fn a_precondition_passes_when_the_target_is_already_in_the_named_state() {
1261
+ let instances = json!({"SampleApp::Proposal#P-001": {"status": "accepted"}});
1262
+ let rule = json!({"field": "proposal_id", "state": "accepted"});
1263
+
1264
+ assert!(check_precondition(&precondition_domain(), &contract(), &rule, &json!({"proposal_id": "P-001"}), &instances).is_ok());
1265
+ }
1266
+
1267
+ #[test]
1268
+ fn a_precondition_refuses_when_the_target_is_in_some_other_state() {
1269
+ let instances = json!({"SampleApp::Proposal#P-001": {"status": "sent"}});
1270
+ let rule = json!({"field": "proposal_id", "state": "accepted"});
1271
+
1272
+ let refusal = check_precondition(&precondition_domain(), &contract(), &rule, &json!({"proposal_id": "P-001"}), &instances)
1273
+ .expect_err("a proposal that isn't accepted refuses");
1274
+
1275
+ assert_eq!(refusal["statusCode"], 422);
1276
+ assert_eq!(
1277
+ body(&refusal),
1278
+ json!({"error": "PreconditionFailed", "message": "Proposal is at sent, not accepted"})
1279
+ );
1280
+ }
1281
+
1282
+ #[test]
1283
+ fn a_precondition_uses_its_own_configured_message_when_it_has_one() {
1284
+ let rule = json!({"field": "proposal_id", "state": "accepted", "message": "the proposal hasn't been accepted yet"});
1285
+ let instances = json!({"SampleApp::Proposal#P-001": {"status": "sent"}});
1286
+
1287
+ let refusal = check_precondition(&precondition_domain(), &contract(), &rule, &json!({"proposal_id": "P-001"}), &instances)
1288
+ .expect_err("refuses");
1289
+
1290
+ assert_eq!(body(&refusal)["message"], "the proposal hasn't been accepted yet");
1291
+ }
1292
+
1293
+ #[test]
1294
+ fn a_precondition_refuses_a_reference_to_a_record_that_does_not_exist_at_all() {
1295
+ let rule = json!({"field": "proposal_id", "state": "accepted"});
1296
+
1297
+ let refusal = check_precondition(&precondition_domain(), &contract(), &rule, &json!({"proposal_id": "ghost"}), &json!({}))
1298
+ .expect_err("refuses");
1299
+
1300
+ assert_eq!(body(&refusal), json!({"error": "PreconditionFailed", "message": "no proposal with that id"}));
1301
+ }
1302
+
1303
+ #[test]
1304
+ fn a_precondition_says_nothing_about_a_reference_the_caller_left_out() {
1305
+ // A missing required reference is the command's own validation
1306
+ // to refuse, in the domain's own words — not this check's.
1307
+ let rule = json!({"field": "proposal_id", "state": "accepted"});
1308
+
1309
+ assert!(check_precondition(&precondition_domain(), &contract(), &rule, &json!({}), &json!({})).is_ok());
1310
+ }
1311
+
1312
+ #[test]
1313
+ fn a_precondition_naming_a_field_that_is_not_a_reference_simply_does_not_fire() {
1314
+ let rule = json!({"field": "number", "state": "accepted"});
1315
+
1316
+ assert!(check_precondition(&precondition_domain(), &contract(), &rule, &json!({"number": "C-001"}), &json!({})).is_ok());
1317
+ }
1318
+
1319
+ // ---- both write routes, end to end --------------------------------
1320
+ //
1321
+ // Runs the two POST routes for real against a throwaway Postgres and
1322
+ // the compiled banking kernel, reached through `dispatch.rs`'s own
1323
+ // test helpers. No ConsoleSettings relations, so the collection key
1324
+ // is the derived one ("customers").
1325
+
1326
+ fn banking_ir() -> Value {
1327
+ let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/banking.ir.json");
1328
+ serde_json::from_str(&std::fs::read_to_string(path).expect("hecks build_wasm writes banking.ir.json beside the wasm"))
1329
+ .expect("valid IR")
1330
+ }
1331
+
1332
+ /// A fixture whose `.wasm` carries a ConsoleSettings chapter, the
1333
+ /// premise `PUT /api/presentation` needs.
1334
+ fn console_fixture() -> (std::path::PathBuf, Value) {
1335
+ let dist = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist");
1336
+ let ir = serde_json::from_str(
1337
+ &std::fs::read_to_string(dist.join("checkout_fixture.ir.json"))
1338
+ .expect("hecks build_wasm writes checkout_fixture.ir.json beside the wasm"),
1339
+ )
1340
+ .expect("valid IR");
1341
+ (dist.join("checkout_fixture.wasm"), ir)
1342
+ }
1343
+
1344
+ /// Every real state styled, since `missing_state_entries` refuses a
1345
+ /// save that leaves one out.
1346
+ fn every_state_styled() -> Value {
1347
+ json!({"states": {
1348
+ "Event": {"open": {"tone": "good"}, "closed": {"tone": "muted"}},
1349
+ "Registration": {"active": {}, "archived": {"tone": "muted"}},
1350
+ "PaymentConnection": {"connected": {}, "enabled": {}, "disconnected": {}, "paused": {}}
1351
+ }})
1352
+ }
1353
+
1354
+ #[tokio::test]
1355
+ async fn the_presentation_config_is_saved_and_read_back_through_the_kernels_own_commands() {
1356
+ let client = crate::dispatch::tests::scratch_db("rust_host_api_presentation_save").await;
1357
+ crate::dispatch::tests::provision_lineage(
1358
+ &*client.lock().await,
1359
+ "CheckoutFixture",
1360
+ 1,
1361
+ &["Event", "Registration", "StateStyle", "Collection", "Overview"],
1362
+ )
1363
+ .await;
1364
+ let (wasm, ir) = console_fixture();
1365
+ let lineage = LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(1), mirrored: None };
1366
+ let invoker = crate::lambda_client::NeverInvoker;
1367
+
1368
+ // Nothing saved yet — every section empty, the same answer the
1369
+ // Ruby engine gives a console that has never saved.
1370
+ let before = presentation::load(&client, &wasm, &lineage).await.expect("a config");
1371
+ assert_eq!(before, json!({"states": {}, "collections": {}, "overview": {}}));
1372
+
1373
+ let mut submitted = every_state_styled();
1374
+ submitted["states"]["Event"]["open"] = json!({"tone": "good", "attention": true, "label": "Open for signups"});
1375
+ submitted["collections"] = json!({
1376
+ "Event": {
1377
+ "label": "Sessions",
1378
+ "nav_order": 1,
1379
+ "columns": ["slug", {"field": "name", "sortable": true, "head": "Title"}, "__state__"],
1380
+ "detail_fields": [{"field": "price", "display": "mono"}],
1381
+ "field_formats": {"capacity": "percent"},
1382
+ "identity": {"field": "slug", "strategy": "slug", "source": "name", "pad": 3},
1383
+ "noun_sing": "session"
1384
+ }
1385
+ });
1386
+ submitted["overview"] = json!({"stats": [{"label": "Open", "collection": "events",
1387
+ "where": {"state": "open"}}]});
1388
+
1389
+ let saved = presentation_save(&ir, &submitted.to_string(), &client, &wasm, &lineage, &invoker).await;
1390
+ assert_eq!(saved["statusCode"], 200, "{saved}");
1391
+ let stored = body(&saved);
1392
+
1393
+ // The response is the reloaded config, not the submitted one —
1394
+ // app.rb returns `json(PresentationConfig.load)` after a save.
1395
+ assert_eq!(stored["states"]["Event"]["open"]["tone"], "good");
1396
+ assert_eq!(stored["states"]["Event"]["open"]["attention"], json!(true));
1397
+ // A field this chapter does not model individually still
1398
+ // round-trips, through `extra_json`.
1399
+ assert_eq!(stored["states"]["Event"]["open"]["label"], "Open for signups");
1400
+ assert_eq!(stored["states"]["Event"]["closed"]["tone"], "muted");
1401
+ assert_eq!(stored["states"]["Registration"]["archived"]["tone"], "muted");
1402
+ assert_eq!(stored["collections"]["Event"]["label"], "Sessions");
1403
+ assert_eq!(stored["collections"]["Event"]["nav_order"], json!(1));
1404
+ assert_eq!(stored["collections"]["Event"]["noun_sing"], "session");
1405
+ assert_eq!(stored["collections"]["Event"]["columns"][1]["sortable"], json!(true));
1406
+ assert_eq!(stored["collections"]["Event"]["columns"][1]["head"], "Title");
1407
+ assert_eq!(stored["collections"]["Event"]["identity"]["pad"], json!(3));
1408
+ assert_eq!(stored["collections"]["Event"]["field_formats"], json!({"capacity": "percent"}));
1409
+ assert_eq!(stored["overview"]["stats"][0]["where"], json!({"state": "open"}));
1410
+
1411
+ // A fresh read agrees, proving the rows are in the kernel's store.
1412
+ assert_eq!(presentation::load(&client, &wasm, &lineage).await.expect("a config"), stored);
1413
+
1414
+ // Saving again is safe: an existing row is a `Set*`, never a
1415
+ // second `Declare` (which would refuse `AlreadyExists`).
1416
+ let again = presentation_save(&ir, &submitted.to_string(), &client, &wasm, &lineage, &invoker).await;
1417
+ assert_eq!(again["statusCode"], 200, "{again}");
1418
+ assert_eq!(body(&again), stored);
1419
+ }
1420
+
1421
+ #[tokio::test]
1422
+ async fn a_config_that_breaks_a_rule_refuses_422_malformed_and_writes_nothing() {
1423
+ let client = crate::dispatch::tests::scratch_db("rust_host_api_presentation_malformed").await;
1424
+ crate::dispatch::tests::provision_lineage(
1425
+ &*client.lock().await,
1426
+ "CheckoutFixture",
1427
+ 1,
1428
+ &["Event", "Registration", "StateStyle", "Collection", "Overview"],
1429
+ )
1430
+ .await;
1431
+ let (wasm, ir) = console_fixture();
1432
+ let lineage = LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(1), mirrored: None };
1433
+ let invoker = crate::lambda_client::NeverInvoker;
1434
+
1435
+ let mut bad = every_state_styled();
1436
+ bad["states"]["Event"]["open"] = json!({"tone": "chartreuse"});
1437
+
1438
+ let refused = presentation_save(&ir, &bad.to_string(), &client, &wasm, &lineage, &invoker).await;
1439
+
1440
+ assert_eq!(refused["statusCode"], 422, "{refused}");
1441
+ assert_eq!(body(&refused)["error"], "Malformed");
1442
+ assert_eq!(
1443
+ body(&refused)["message"],
1444
+ "Event.open's tone \"chartreuse\" isn't one of good, warn, danger, muted, accent"
1445
+ );
1446
+
1447
+ // Nothing written: `validate` runs before the first dispatch.
1448
+ assert_eq!(
1449
+ presentation::load(&client, &wasm, &lineage).await.expect("a config"),
1450
+ json!({"states": {}, "collections": {}, "overview": {}})
1451
+ );
1452
+ }
1453
+
1454
+ #[tokio::test]
1455
+ async fn a_body_that_is_not_json_at_all_is_a_400_before_anything_is_validated() {
1456
+ let client = crate::dispatch::tests::scratch_db("rust_host_api_presentation_bad_body").await;
1457
+ crate::dispatch::tests::provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event"]).await;
1458
+ let (wasm, ir) = console_fixture();
1459
+ let lineage = LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(1), mirrored: None };
1460
+
1461
+ let refused = presentation_save(
1462
+ &ir,
1463
+ "not json",
1464
+ &client,
1465
+ &wasm,
1466
+ &lineage,
1467
+ &crate::lambda_client::NeverInvoker,
1468
+ )
1469
+ .await;
1470
+
1471
+ assert_eq!(refused["statusCode"], 400, "{refused}");
1472
+ assert_eq!(body(&refused)["error"], "MalformedBody");
1473
+ }
1474
+
1475
+ /// Banking's kernel carries no ConsoleSettings chapter, and this
1476
+ /// asks the kernel rather than inferring it from anything else.
1477
+ #[tokio::test]
1478
+ async fn a_host_whose_kernel_has_no_console_settings_chapter_still_refuses_501() {
1479
+ let client = crate::dispatch::tests::scratch_db("rust_host_api_presentation_no_chapter").await;
1480
+ crate::dispatch::tests::provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
1481
+ let wasm = crate::dispatch::tests::wasm_path();
1482
+ let ir = banking_ir();
1483
+ let lineage = LineageConfig { domain: "Banking".to_string(), era: Some(1), mirrored: None };
1484
+
1485
+ let refused = presentation_save(
1486
+ &ir,
1487
+ r#"{"states":{}}"#,
1488
+ &client,
1489
+ &wasm,
1490
+ &lineage,
1491
+ &crate::lambda_client::NeverInvoker,
1492
+ )
1493
+ .await;
1494
+
1495
+ assert_eq!(refused["statusCode"], 501, "{refused}");
1496
+ assert_eq!(body(&refused)["error"], "NotImplemented");
1497
+ assert!(
1498
+ body(&refused)["message"].as_str().expect("a message").contains("ConsoleSettings"),
1499
+ "the refusal has to name what is missing: {refused}"
1500
+ );
1501
+ }
1502
+
1503
+ #[tokio::test]
1504
+ async fn a_record_is_created_then_commanded_then_read_back_through_the_api_routes() {
1505
+ let client = crate::dispatch::tests::scratch_db("rust_host_api_write_routes").await;
1506
+ crate::dispatch::tests::provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
1507
+ let wasm = crate::dispatch::tests::wasm_path();
1508
+ let ir = banking_ir();
1509
+ let lineage = LineageConfig { domain: "Banking".to_string(), era: Some(1), mirrored: None };
1510
+ let invoker = crate::lambda_client::NeverInvoker;
1511
+
1512
+ let created = collection_create(
1513
+ &ir,
1514
+ "customers",
1515
+ r#"{"reference":{"value":"CUST-9001"},"name":{"given":"Ada","family":"Lovelace"},"email":{"address":"ada@example.com"}}"#,
1516
+ &client,
1517
+ &wasm,
1518
+ &lineage,
1519
+ &invoker,
1520
+ )
1521
+ .await;
1522
+
1523
+ assert_eq!(created["statusCode"], 200, "{created}");
1524
+ let record = body(&created);
1525
+ assert_eq!(record["id"], "CUST-9001");
1526
+ assert_eq!(record["reference"]["value"], "CUST-9001");
1527
+
1528
+ // …and it is there to read, both ways.
1529
+ let index = collection_index(&ir, "customers", &params(&[]), &client, &wasm, &lineage).await;
1530
+ assert_eq!(body(&index).as_array().expect("an array").len(), 1);
1531
+ let shown = record_show(&ir, "customers", "CUST-9001", &client, &wasm, &lineage).await;
1532
+ assert_eq!(body(&shown)["id"], "CUST-9001");
1533
+
1534
+ // A command against it, named the snake_case way
1535
+ // /api/ui-schema hands it to the client.
1536
+ let suspended = command_route(
1537
+ &ir,
1538
+ "customers",
1539
+ "CUST-9001",
1540
+ "suspend",
1541
+ r#"{"standing":{"value":"watch"}}"#,
1542
+ &client,
1543
+ &wasm,
1544
+ &lineage,
1545
+ &invoker,
1546
+ )
1547
+ .await;
1548
+
1549
+ assert_eq!(suspended["statusCode"], 200, "{suspended}");
1550
+ assert_eq!(body(&suspended)["status"], "suspended");
1551
+
1552
+ // The same command again is a real domain refusal — 422 in the
1553
+ // Ruby engine's envelope, carrying the kernel's own refusal
1554
+ // class, not a generic error.
1555
+ let again = command_route(
1556
+ &ir,
1557
+ "customers",
1558
+ "CUST-9001",
1559
+ "suspend",
1560
+ r#"{"standing":{"value":"watch"}}"#,
1561
+ &client,
1562
+ &wasm,
1563
+ &lineage,
1564
+ &invoker,
1565
+ )
1566
+ .await;
1567
+
1568
+ assert_eq!(again["statusCode"], 422, "{again}");
1569
+ let refused = body(&again);
1570
+ assert!(!refused["error"].as_str().expect("a refusal class").is_empty(), "{refused}");
1571
+ assert!(!refused["message"].as_str().expect("a message").is_empty(), "{refused}");
1572
+
1573
+ // An unknown command, and an unknown record, both refuse the
1574
+ // way JsonDoor refuses them.
1575
+ let unknown_command =
1576
+ command_route(&ir, "customers", "CUST-9001", "nope", "", &client, &wasm, &lineage, &invoker).await;
1577
+ assert_eq!(unknown_command["statusCode"], 404);
1578
+ assert_eq!(body(&unknown_command)["message"], "Customer declares no command named \"nope\"");
1579
+
1580
+ let unknown_record =
1581
+ command_route(&ir, "customers", "ghost", "suspend", "", &client, &wasm, &lineage, &invoker).await;
1582
+ assert_eq!(unknown_record["statusCode"], 404);
1583
+ assert_eq!(body(&unknown_record)["message"], "no Customer found for id \"ghost\"");
1584
+ }
1585
+
1586
+ #[test]
1587
+ fn the_presentation_write_route_refuses_in_its_own_words() {
1588
+ let response = not_implemented(PRESENTATION_WRITE_REFUSAL);
1589
+ assert_eq!(response["statusCode"], 501);
1590
+ assert_eq!(body(&response)["error"], "NotImplemented");
1591
+ assert!(
1592
+ body(&response)["message"].as_str().expect("a message").contains("ConsoleSettings"),
1593
+ "the refusal has to name what is missing: {response}"
1594
+ );
1595
+ }
1596
+ }