hecks 0.3.0 → 1.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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -81,9 +81,24 @@ module Hecks
81
81
  raise RouteNotFound, "#{domain.inspect} is not exposed by this app — declared chapters: #{@exposed.join(', ')}"
82
82
  end
83
83
 
84
+ # H12 (docs/audits/2026-08-10-main-bug-audit.md) — splitting on the
85
+ # FIRST "." truncated any identity value containing a dot (an email
86
+ # `identified_by { email.address }`, a decimal-ish reference — an
87
+ # aggregate's identity is free-form unless its value object declares
88
+ # a `pattern:`, see S3 in the same audit) at its own first dot, so
89
+ # `reference.value=c.1` 404'd everywhere: detail page, JSON view, and
90
+ # its own index-table link. Only a LITERAL trailing ".html"/".json"
91
+ # now counts as a format — every other dot in the segment is just
92
+ # part of the identity. An identity that itself happens to end in
93
+ # exactly ".html" or ".json" is still ambiguous with a real format
94
+ # suffix (the same tension any extension-based content-negotiation
95
+ # scheme has), but that was already true before this fix and is not
96
+ # this bug.
84
97
  def split_format(segment)
85
- name, format = segment.to_s.split(".", 2)
86
- [name, format || "json"]
98
+ segment = segment.to_s
99
+ return [Regexp.last_match(1), Regexp.last_match(2)] if segment =~ /\A(.*)\.(html|json)\z/
100
+
101
+ [segment, "json"]
87
102
  end
88
103
 
89
104
  # ---- home -------------------------------------------------------
@@ -122,6 +137,21 @@ module Hecks
122
137
  aggregate = find_aggregate(chapter, aggregate_name)
123
138
  domain = chapter.name
124
139
 
140
+ # L11 (docs/audits/2026-08-10-main-bug-audit.md) — a record's own
141
+ # id is free-form (S3) and can collide with one of its own
142
+ # aggregate's command/query names ("Close", "Overdrawn", ...).
143
+ # A GET for such an id must still be able to reach that RECORD's
144
+ # own detail page when a record with that literal id actually
145
+ # exists — checking the verb first (the previous order) meant a
146
+ # record unlucky enough to be named after a real verb could never
147
+ # be viewed again. POST never means "view a record" at all
148
+ # (`record_route` only ever answers GET), so command submission
149
+ # there is unambiguous and is left to match the verb first, same
150
+ # as before.
151
+ if request.get? && (instance = @registry.repository(domain, aggregate).find(verb_or_id))
152
+ return record_route(request, domain, aggregate, verb_or_id, format, instance: instance)
153
+ end
154
+
125
155
  if (command = aggregate.command(verb_or_id))
126
156
  return command_route(request, domain, aggregate, command, format)
127
157
  end
@@ -157,7 +187,9 @@ module Hecks
157
187
  def submit_command(request, domain, aggregate, command, action)
158
188
  raw, envelope = submitted_command(request, aggregate, command)
159
189
  result = @dispatcher.dispatch("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}", **envelope)
160
- redirect("/#{domain}/#{aggregate.hecks_name}/#{result.id}.html")
190
+ # L12 — the id is free-form (S3), so it must be percent-encoded as
191
+ # a path segment here, not just interpolated raw.
192
+ redirect("/#{domain}/#{aggregate.hecks_name}/#{Escape.path(result.id)}.html")
161
193
  rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
162
194
  status = e.is_a?(Runtime::NotFound) ? 404 : 422
163
195
  command_form(domain, aggregate, command, action, status: status, values: raw, error: e)
@@ -221,14 +253,23 @@ module Hecks
221
253
  args = Params.extract(fields, asked)
222
254
  rows = @dispatcher.query("#{domain}::#{aggregate.hecks_name}.#{query.hecks_name}", **args)
223
255
  [rows.map { |row| Record.new(row[:id], row.reject { |k, _| k == :id }) }, nil]
224
- rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError => e
256
+ # L10 (docs/audits/2026-08-10-main-bug-audit.md) `Params.extract`
257
+ # (params.rb's `extract_list`) reads a list-of-value-object line as
258
+ # JSON (the honest fallback for a multi-attribute list element this
259
+ # prototype's textarea doesn't build a second widget for). A caller
260
+ # who types a non-JSON line into that field raises `JSON::ParserError`
261
+ # BEFORE dispatch ever sees it — both command submission paths
262
+ # already rescue it (`submit_command`, `command_json`); this one
263
+ # didn't, so a malformed list-of-VO query 500'd instead of showing
264
+ # the same 422 every other bad-input path shows.
265
+ rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
225
266
  [nil, e]
226
267
  end
227
268
 
228
- def record_route(request, domain, aggregate, id, format)
269
+ def record_route(request, domain, aggregate, id, format, instance: nil)
229
270
  return respond(405, "text/plain", "GET only") unless request.get?
230
271
 
231
- instance = @registry.repository(domain, aggregate).find(id)
272
+ instance ||= @registry.repository(domain, aggregate).find(id)
232
273
  return not_found(aggregate, id, format) unless instance
233
274
  # id LAST — same reasoning as the other JSON-serializing call
234
275
  # sites in this file (see aggregate_route's own comment).
@@ -57,7 +57,7 @@ module Hecks
57
57
  def self.header(domain, aggregate, command)
58
58
  <<~HTML
59
59
  <h1>#{Escape.html("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}")}</h1>
60
- #{command.role ? %(<span class="badge role">role: #{Escape.html(command.role)}</span>) : ''}
60
+ #{command.role ? %(<span class="badge role" title="Declared on the command; this prototype dispatches with no caller bound, so the check does not run.">role: #{Escape.html(command.role)} (not enforced here)</span>) : ''}
61
61
  #{command.creates? ? %(<span class="badge">creates a new #{Escape.html(aggregate.hecks_name)}</span>) : ''}
62
62
  #{command.goal ? %(<p class="goal">#{Escape.html(command.goal)}</p>) : ''}
63
63
  #{givens_callout(command)}
@@ -156,13 +156,20 @@ module Hecks
156
156
  # an existing record's own state hands back a NESTED hash instead
157
157
  # (`{amount: {cents: 1050}}`). Flat wins when both would answer,
158
158
  # since only the raw form is ever what the caller actually typed.
159
- flat = values[path.to_s] || values[path.to_sym]
160
- return flat unless flat.nil?
159
+ # `key?` decides which spelling answers, at every step below —
160
+ # never `||`, which would treat a genuinely-held `false` the
161
+ # same as an absent key and fall through to `nil`.
162
+ str = path.to_s
163
+ return values[str] if values.key?(str)
161
164
 
162
- path.to_s.split(".").reduce(values) do |acc, segment|
165
+ sym = path.to_sym
166
+ return values[sym] if values.key?(sym)
167
+
168
+ str.split(".").reduce(values) do |acc, segment|
163
169
  break nil unless acc.is_a?(Hash)
164
170
 
165
- acc[segment.to_sym] || acc[segment]
171
+ seg_sym = segment.to_sym
172
+ acc.key?(seg_sym) ? acc[seg_sym] : acc[segment]
166
173
  end
167
174
  end
168
175
  end
@@ -1,3 +1,5 @@
1
+ require "uri"
2
+
1
3
  module Hecks
2
4
  module Forms
3
5
  # Hand-rolled, on purpose — the repo has no ERB anywhere and no template
@@ -25,6 +27,35 @@ module Hecks
25
27
  # call site reads "this value fills an attribute" rather than repeating
26
28
  # the same escaping and leaving the reader to check they match.
27
29
  def self.attr(value) = html(value)
30
+
31
+ # L12 (docs/audits/2026-08-10-main-bug-audit.md) — safe as a
32
+ # query-string VALUE. `html`/`attr` guard against the value becoming
33
+ # markup, but say nothing about it staying inside the URL syntax
34
+ # position it was placed in: an aggregate's identity is free-form
35
+ # unless its value object declares a `pattern:` (see S3 in the same
36
+ # audit), so `&`, `+`, `?`, `#`, and `/` are all otherwise legal id
37
+ # characters, and each would corrupt an href/Location built by naive
38
+ # interpolation (a stray `&` smuggles a second query parameter, `#`
39
+ # truncates the path at a fragment, `/` splits the path into an
40
+ # extra segment, ...). Percent-encodes via
41
+ # `application/x-www-form-urlencoded` (`+` for space) — correct ONLY
42
+ # for a query-string value (query_form_renderer.rb's `quick_links`,
43
+ # record_renderer.rb's `?to=`). For a URL PATH segment use `path`
44
+ # below instead — `+` is a literal plus there, not an escaped space,
45
+ # so this method would corrupt any id containing a space. Callers
46
+ # still wrap the ASSEMBLED href/Location in `attr` (or `html`) as
47
+ # usual — this only covers the id's own component, not the
48
+ # surrounding markup.
49
+ def self.url(value) = URI.encode_www_form_component(value.to_s)
50
+
51
+ # Same guard as `url`, for a URL PATH segment instead of a
52
+ # query-string value. `encode_www_form_component` renders space as
53
+ # `+`, which is only meaningful inside a query string — in a path
54
+ # segment `+` is a literal plus, so an id like "John Smith" would
55
+ # round-trip to "John+Smith" and 404 against the real id "John
56
+ # Smith". Reuse the same percent-encoding and just correct that one
57
+ # character back to `%20`.
58
+ def self.path(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
28
59
  end
29
60
 
30
61
  # A tiny attribute-hash -> string helper, shared by every renderer in
@@ -41,15 +41,44 @@ module Hecks
41
41
  end
42
42
  end
43
43
 
44
+ # A path-prefix collision: one field named (say) "price" alongside
45
+ # another named "price.cents" implies "price" is BOTH a scalar leaf
46
+ # and the parent of a nested group — the two can never coexist in
47
+ # the same result hash. Depending on which pair `each_with_object`
48
+ # reaches first, the naive walk below used to fail in one of two
49
+ # ways: a scalar planted first left `acc[segment] ||= {}` seeing a
50
+ # truthy non-Hash and reusing IT as `node`, so the next `node[leaf] =
51
+ # value` blew up with a raw `TypeError` from calling `String#[]=`
52
+ # with a Symbol key; a scalar planted AFTER the nested group instead
53
+ # sailed through `node[leaf] = value` and silently clobbered the
54
+ # entire nested hash with the scalar, losing every sibling under it
55
+ # with no error at all. Both directions are checked explicitly here
56
+ # so either order raises the SAME clear `ArgumentError` instead of a
57
+ # confusing crash or silent data loss — this is the family of error
58
+ # every command/query submission path in app.rb already rescues into
59
+ # a 422 (`ArgumentError` sits right alongside the domain refusals in
60
+ # every one of those rescue clauses).
44
61
  def self.nest(pairs)
45
62
  pairs.each_with_object({}) do |(path, value), result|
46
63
  segments = path.to_s.split(".").map(&:to_sym)
47
64
  leaf = segments.pop
48
- node = segments.reduce(result) { |acc, segment| acc[segment] ||= {} }
65
+ node = segments.reduce(result) do |acc, segment|
66
+ existing = acc[segment]
67
+ raise nesting_collision(path) if existing && !existing.is_a?(Hash)
68
+
69
+ acc[segment] ||= {}
70
+ end
71
+ raise nesting_collision(path) if node[leaf].is_a?(Hash)
72
+
49
73
  node[leaf] = value
50
74
  end
51
75
  end
52
76
 
77
+ def self.nesting_collision(path)
78
+ ArgumentError.new("#{path.inspect} conflicts with another field at the same path — " \
79
+ "one names it as a plain value and another as a nested group")
80
+ end
81
+
53
82
  SKIP = Object.new.freeze
54
83
  private_constant :SKIP
55
84
 
@@ -0,0 +1,46 @@
1
+ module Hecks
2
+ module Forms
3
+ # bin/present's own `-p`/`--port` reader, pulled out of the script so
4
+ # it can be driven directly instead of only through a real server
5
+ # boot. Two spellings the previous inline version got wrong:
6
+ #
7
+ # --port=8080 the equals form — the old `ARGV.each_cons(2)` scan
8
+ # only ever recognized "--port", "8080" as TWO
9
+ # separate argv entries, so this spelling matched
10
+ # nothing and silently fell through to the default.
11
+ # -p abc a non-numeric value — the old code did `.to_i` on
12
+ # whatever followed unconditionally, so a typo
13
+ # quietly became port 0 (Rackup/WEBrick's actual
14
+ # behavior for `Port: 0` is to bind an EPHEMERAL
15
+ # port — arguably useful on purpose elsewhere, but
16
+ # never what a mistyped `-p abc` meant to ask for).
17
+ #
18
+ # Returns `[port, nil]` on a clean parse (falling back to `default`
19
+ # when neither spelling appears at all) or `[nil, message]` when an
20
+ # explicit port was given but isn't a real port number — the caller
21
+ # decides what to do with a refusal (bin/present aborts on it).
22
+ module PortArgument
23
+ module_function
24
+
25
+ def parse(argv, default: 4567)
26
+ equals = argv.find { |arg| arg.start_with?("--port=") }
27
+ return resolve(equals.split("=", 2).last) if equals
28
+
29
+ index = argv.each_index.find { |i| %w[-p --port].include?(argv[i]) }
30
+ return [default, nil] unless index
31
+
32
+ resolve(argv[index + 1])
33
+ end
34
+
35
+ def resolve(value)
36
+ return [nil, "-p/--port requires a value"] if value.nil? || value.empty?
37
+ return [nil, "-p/--port must be a whole number, got #{value.inspect}"] unless value.match?(/\A\d+\z/)
38
+
39
+ port = value.to_i
40
+ return [nil, "-p/--port must be between 1 and 65535, got #{port}"] unless (1..65_535).cover?(port)
41
+
42
+ [port, nil]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -76,8 +76,12 @@ module Hecks
76
76
  return "<p><em>No commands act on an existing #{Escape.html(aggregate.hecks_name)}.</em></p>" if commands.empty?
77
77
 
78
78
  items = commands.map do |cmd|
79
- href = "/#{domain}/#{aggregate.hecks_name}/#{cmd.hecks_name}.html?to=#{Escape.attr(id)}"
80
- %(<li><a href="#{href}"><span>#{Escape.html(cmd.hecks_name)}</span><span class="kind">#{Escape.html(cmd.goal.to_s)}</span></a></li>)
79
+ # L12 — the id is free-form (S3): percent-encoded as the query
80
+ # VALUE (a raw `&` here would smuggle a second bogus query
81
+ # parameter), then the assembled href is attribute-escaped as
82
+ # usual.
83
+ href = "/#{domain}/#{aggregate.hecks_name}/#{cmd.hecks_name}.html?to=#{Escape.url(id)}"
84
+ %(<li><a href="#{Escape.attr(href)}"><span>#{Escape.html(cmd.hecks_name)}</span><span class="kind">#{Escape.html(cmd.goal.to_s)}</span></a></li>)
81
85
  end
82
86
  %(<ul class="verb-list">#{items.join}</ul>)
83
87
  end
@@ -41,7 +41,12 @@ module Hecks
41
41
 
42
42
  def self.row(instance, aggregate, cols, domain)
43
43
  cells = cols.map { |name| "<td>#{Escape.html(cell(instance, name))}</td>" }.join
44
- href = "/#{domain}/#{aggregate.hecks_name}/#{instance.id}.html"
44
+ # L12 — the id is free-form (S3): percent-encoded as the path
45
+ # segment, HTML-escaped as the link text, and the assembled href
46
+ # is itself attribute-escaped (belt-and-suspenders — nothing else
47
+ # in `href` is untrusted, but this matches the convention used
48
+ # everywhere else an href is built from parts).
49
+ href = "/#{domain}/#{aggregate.hecks_name}/#{Escape.path(instance.id)}.html"
45
50
  "<tr><td><a href=\"#{Escape.attr(href)}\">#{Escape.html(instance.id)}</a></td>#{cells}</tr>"
46
51
  end
47
52
 
@@ -105,7 +105,7 @@ Hecks.bluebook "ConsoleSettings" do
105
105
  given("a style names its own aggregate") { !agg.value.to_s.empty? }
106
106
  given("a style names its own state") { !state.value.to_s.empty? }
107
107
 
108
- emits "StateStyleDeclared"
108
+ emits StateStyleDeclared
109
109
  end
110
110
 
111
111
  command "SetTone" do
@@ -116,7 +116,7 @@ Hecks.bluebook "ConsoleSettings" do
116
116
  attribute :tone, one_of("good", "warn", "danger", "muted", "accent")
117
117
 
118
118
  sets :tone
119
- emits "ToneSet"
119
+ emits ToneSet
120
120
  end
121
121
 
122
122
  command "SetAttention" do
@@ -127,7 +127,7 @@ Hecks.bluebook "ConsoleSettings" do
127
127
  attribute :attention, Flag
128
128
 
129
129
  sets :attention
130
- emits "AttentionSet"
130
+ emits AttentionSet
131
131
  end
132
132
 
133
133
  command "SetExtra" do
@@ -138,7 +138,7 @@ Hecks.bluebook "ConsoleSettings" do
138
138
  attribute :extra_json, StateStyleExtra
139
139
 
140
140
  sets :extra_json
141
- emits "StateStyleExtraSet"
141
+ emits StateStyleExtraSet
142
142
  end
143
143
  end
144
144
 
@@ -261,7 +261,7 @@ Hecks.bluebook "ConsoleSettings" do
261
261
 
262
262
  given("a collection names its own aggregate") { !agg.value.to_s.empty? }
263
263
 
264
- emits "CollectionDeclared"
264
+ emits CollectionDeclared
265
265
  end
266
266
 
267
267
  command "SetLabel" do
@@ -272,7 +272,7 @@ Hecks.bluebook "ConsoleSettings" do
272
272
  attribute :label, SettingsText
273
273
 
274
274
  sets :label
275
- emits "LabelSet"
275
+ emits LabelSet
276
276
  end
277
277
 
278
278
  command "SetKey" do
@@ -283,7 +283,7 @@ Hecks.bluebook "ConsoleSettings" do
283
283
  attribute :key, SettingsText
284
284
 
285
285
  sets :key
286
- emits "KeySet"
286
+ emits KeySet
287
287
  end
288
288
 
289
289
  command "SetNavOrder" do
@@ -294,7 +294,7 @@ Hecks.bluebook "ConsoleSettings" do
294
294
  attribute :nav_order, SettingsNumber
295
295
 
296
296
  sets :nav_order
297
- emits "NavOrderSet"
297
+ emits NavOrderSet
298
298
  end
299
299
 
300
300
  command "SetPrimaryField" do
@@ -305,7 +305,7 @@ Hecks.bluebook "ConsoleSettings" do
305
305
  attribute :primary_field, SettingsText
306
306
 
307
307
  sets :primary_field
308
- emits "PrimaryFieldSet"
308
+ emits PrimaryFieldSet
309
309
  end
310
310
 
311
311
  command "SetListQuery" do
@@ -316,7 +316,7 @@ Hecks.bluebook "ConsoleSettings" do
316
316
  attribute :list_query, SettingsText
317
317
 
318
318
  sets :list_query
319
- emits "ListQuerySet"
319
+ emits ListQuerySet
320
320
  end
321
321
 
322
322
  command "SetIdentity" do
@@ -332,7 +332,7 @@ Hecks.bluebook "ConsoleSettings" do
332
332
  sets :identity_source
333
333
  sets :identity_prefix
334
334
  sets :identity_extra_json
335
- emits "IdentitySet"
335
+ emits IdentitySet
336
336
  end
337
337
 
338
338
  command "SetExtra" do
@@ -343,7 +343,7 @@ Hecks.bluebook "ConsoleSettings" do
343
343
  attribute :extra_json, CollectionExtra
344
344
 
345
345
  sets :extra_json
346
- emits "CollectionExtraSet"
346
+ emits CollectionExtraSet
347
347
  end
348
348
 
349
349
  command "ReplaceColumns" do
@@ -353,7 +353,7 @@ Hecks.bluebook "ConsoleSettings" do
353
353
  reference_to Collection
354
354
 
355
355
  sets :columns
356
- emits "ColumnsReplaced"
356
+ emits ColumnsReplaced
357
357
  end
358
358
 
359
359
  command "ReplaceDetailFields" do
@@ -363,7 +363,7 @@ Hecks.bluebook "ConsoleSettings" do
363
363
  reference_to Collection
364
364
 
365
365
  sets :detail_fields
366
- emits "DetailFieldsReplaced"
366
+ emits DetailFieldsReplaced
367
367
  end
368
368
 
369
369
  command "ReplaceDetailFieldColumns" do
@@ -373,7 +373,7 @@ Hecks.bluebook "ConsoleSettings" do
373
373
  reference_to Collection
374
374
 
375
375
  sets :detail_field_columns
376
- emits "DetailFieldColumnsReplaced"
376
+ emits DetailFieldColumnsReplaced
377
377
  end
378
378
 
379
379
  command "ReplacePreconditions" do
@@ -383,7 +383,7 @@ Hecks.bluebook "ConsoleSettings" do
383
383
  reference_to Collection
384
384
 
385
385
  sets :preconditions
386
- emits "PreconditionsReplaced"
386
+ emits PreconditionsReplaced
387
387
  end
388
388
 
389
389
  command "ReplaceFieldFormats" do
@@ -393,7 +393,7 @@ Hecks.bluebook "ConsoleSettings" do
393
393
  reference_to Collection
394
394
 
395
395
  sets :field_formats
396
- emits "FieldFormatsReplaced"
396
+ emits FieldFormatsReplaced
397
397
  end
398
398
  end
399
399
 
@@ -450,7 +450,7 @@ Hecks.bluebook "ConsoleSettings" do
450
450
 
451
451
  given("only the one true singleton key is ever declared") { key.value == "overview" }
452
452
 
453
- emits "OverviewDeclared"
453
+ emits OverviewDeclared
454
454
  end
455
455
 
456
456
  command "ReplaceStats" do
@@ -460,7 +460,7 @@ Hecks.bluebook "ConsoleSettings" do
460
460
  reference_to Overview
461
461
 
462
462
  sets :stats
463
- emits "StatsReplaced"
463
+ emits StatsReplaced
464
464
  end
465
465
  end
466
466
 
@@ -48,7 +48,7 @@ Hecks.bluebook "Governance" do
48
48
  attribute :scope, Scope
49
49
  attribute :starts_at, Timestamp
50
50
 
51
- emits "RoleAssigned"
51
+ emits RoleAssigned
52
52
  end
53
53
 
54
54
  command "Revoke" do
@@ -59,7 +59,7 @@ Hecks.bluebook "Governance" do
59
59
  attribute :ends_at, Timestamp
60
60
 
61
61
  sets :ends_at
62
- emits "RoleRevoked"
62
+ emits RoleRevoked
63
63
  end
64
64
 
65
65
  query "AssignmentsForActor" do
@@ -73,15 +73,26 @@ Hecks.bluebook "Governance" do
73
73
  # may role X ACT AS role Y — the fact `act_as` (the plan's §2) needs before
74
74
  # a caller is allowed to dispatch under a role it does not itself hold.
75
75
  # Same event-log shape as RoleAssignment : a natural key, `ends_at` for
76
- # revocation, no delete.
76
+ # revocation, no delete — and, like RoleAssignment, THE IDENTITY IS THE
77
+ # GRANT, not the (from_role, to_role) pair. `starts_at` used to be
78
+ # missing from here — the one field RoleAssignment's own identity adds
79
+ # beyond its own (actor, role) pair specifically so a second, later
80
+ # assignment is a SECOND real record rather than a collision. Without
81
+ # it, `identified_by :from_role, :to_role` alone made a revoked pair's
82
+ # own identity permanently occupied: `Grant` is a creating command, the
83
+ # same (from_role, to_role) pair is the SAME id forever, and a repeat
84
+ # `Grant` after a `Revoke` was refused as `AlreadyExists` no matter how
85
+ # long ago the prior grant ended — an absorbing "revoked" state this
86
+ # very comment already claimed didn't exist.
77
87
  aggregate "RoleTransition" do
78
- description "Role X is authorized to act as role Y — until revoked."
88
+ description "Role X is authorized to act as role Y, from one point in time — until revoked, and grantable again afterward, exactly like RoleAssignment."
79
89
 
80
90
  attribute :from_role, RoleName
81
91
  attribute :to_role, RoleName
92
+ attribute :starts_at, Timestamp
82
93
  attribute :ends_at, Timestamp, optional: true
83
94
 
84
- identified_by :from_role, :to_role
95
+ identified_by :from_role, :to_role, :starts_at
85
96
 
86
97
  value_object "RoleName" do
87
98
  attribute :value, String
@@ -99,8 +110,9 @@ Hecks.bluebook "Governance" do
99
110
 
100
111
  attribute :from_role, RoleName
101
112
  attribute :to_role, RoleName
113
+ attribute :starts_at, Timestamp
102
114
 
103
- emits "RoleTransitionGranted"
115
+ emits RoleTransitionGranted
104
116
  end
105
117
 
106
118
  command "Revoke" do
@@ -111,17 +123,20 @@ Hecks.bluebook "Governance" do
111
123
  attribute :ends_at, Timestamp
112
124
 
113
125
  sets :ends_at
114
- emits "RoleTransitionRevoked"
126
+ emits RoleTransitionRevoked
115
127
  end
116
128
 
117
- # THE RECORD FOR THIS PAIR, granted or revoked — same deferral
129
+ # EVERY GRANT OF THIS PAIR, current or historical — same deferral
118
130
  # `AssignmentsForActor` already makes for RoleAssignment: the where
119
131
  # clause has no way to ask "and ends_at is absent", so the query
120
- # answers with the row and the CALLER reads `ends_at` to tell a
132
+ # answers with every row and the CALLER reads `ends_at` to tell a
121
133
  # live grant from a revoked one, exactly as it already must for
122
- # every assignment.
134
+ # every assignment. Can answer MORE than one row now that `starts_at`
135
+ # is part of the identity — a pair granted again after a revocation is
136
+ # a SECOND row, not an overwrite of the first, the same way a
137
+ # re-assigned actor/role is for RoleAssignment.
123
138
  query "Allowed" do
124
- description "The role transition granted for this exact pair, if any — revoked or not."
139
+ description "Every role transition granted for this exact pair, current or historical."
125
140
  attribute :from_role, RoleName
126
141
  attribute :to_role, RoleName
127
142
  where(from_role: :from_role, to_role: :to_role)
@@ -30,7 +30,7 @@ Hecks.bluebook "Identity" do
30
30
  goal "Recognize an identity"
31
31
 
32
32
  attribute :identity_id, IdentityId
33
- emits "IdentityRegistered"
33
+ emits IdentityRegistered
34
34
  end
35
35
  end
36
36
 
@@ -71,7 +71,7 @@ Hecks.bluebook "Identity" do
71
71
  attribute :issuer, Issuer
72
72
  attribute :subject, Subject
73
73
 
74
- emits "ExternalIdentifierLinked"
74
+ emits ExternalIdentifierLinked
75
75
  end
76
76
 
77
77
  # THE OIDC-SHAPED LOOKUP — a token hands over `(issuer, subject)`,