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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/d1.rb +38 -14
- data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
- data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
- data/lib/hecks/adapters/driven/heki.rb +40 -9
- data/lib/hecks/adapters/driven/lambda.rb +16 -2
- data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
- data/lib/hecks/adapters/driven/postgres.rb +76 -29
- data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
- data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
- data/lib/hecks/adapters/driven/sqlite.rb +30 -9
- data/lib/hecks/adapters/driven.rb +19 -1
- data/lib/hecks/behaviors/dsl.rb +29 -0
- data/lib/hecks/behaviors/expectations.rb +62 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
- data/lib/hecks/bluebook/assembly/marks.rb +4 -3
- data/lib/hecks/bluebook/assembly.rb +14 -1
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
- data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
- data/lib/hecks/bluebook/chapter.rb +21 -11
- data/lib/hecks/bluebook/command.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
- data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
- data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
- data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
- data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
- data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
- data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
- data/lib/hecks/bluebook/expression/projection.json +48 -0
- data/lib/hecks/bluebook/expression/resolver.rb +161 -10
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
- data/lib/hecks/bluebook/meta_validator.rb +103 -13
- data/lib/hecks/bluebook/model_check.rb +132 -5
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/value_object.rb +9 -1
- data/lib/hecks/doc/reference.rb +22 -1
- data/lib/hecks/facade/cli_door.rb +6 -3
- data/lib/hecks/facade/json_door.rb +16 -4
- data/lib/hecks/forms/app.rb +47 -6
- data/lib/hecks/forms/command_form_renderer.rb +1 -1
- data/lib/hecks/forms/field_renderer.rb +11 -4
- data/lib/hecks/forms/html.rb +31 -0
- data/lib/hecks/forms/params.rb +30 -1
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/record_renderer.rb +6 -2
- data/lib/hecks/forms/record_table.rb +6 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
- data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
- data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
- data/lib/hecks/fuzzing/properties.rb +52 -6
- data/lib/hecks/fuzzing/replay.rb +51 -18
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
- data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
- data/lib/hecks/fuzzing/value_generator.rb +25 -2
- data/lib/hecks/grammar/evolve.rb +33 -0
- data/lib/hecks/grammar/expression.bluebook +8 -5
- data/lib/hecks/grammar/expression_operators.json +497 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
- data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
- data/lib/hecks/language/bluebook/command.bluebook +42 -8
- data/lib/hecks/language/bluebook/entity.bluebook +86 -10
- data/lib/hecks/language/bluebook/policy.bluebook +21 -4
- data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
- data/lib/hecks/language/bluebook/projection.bluebook +6 -6
- data/lib/hecks/language/bluebook/query.bluebook +4 -4
- data/lib/hecks/language/bluebook/shape.bluebook +6 -6
- data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
- data/lib/hecks/language/oidc.json +20 -0
- data/lib/hecks/language/port.bluebook +30 -2
- data/lib/hecks/naming.rb +54 -1
- data/lib/hecks/ports/access_control.port +7 -2
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.port +6 -2
- data/lib/hecks/ports/agent.rb +1 -1
- data/lib/hecks/ports/authentication.port +4 -2
- data/lib/hecks/ports/authentication.rb +1 -1
- data/lib/hecks/ports/authorization.port +5 -2
- data/lib/hecks/ports/authorization.rb +14 -11
- data/lib/hecks/ports/clock.port +3 -2
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.port +3 -2
- data/lib/hecks/ports/extraction.rb +1 -1
- data/lib/hecks/ports/identity_assignment.port +3 -2
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.port +3 -2
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.port +3 -2
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/execution.rb +6 -1
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
- data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence.rb +1 -1
- data/lib/hecks/ports/projection.rb +20 -0
- data/lib/hecks/projections/diagrams.rb +230 -1
- data/lib/hecks/projections/shape.rb +15 -2
- data/lib/hecks/projector/cli_projector.rb +10 -1
- data/lib/hecks/projector/exporter.rb +19 -2
- data/lib/hecks/query_ir.rb +19 -0
- data/lib/hecks/query_specification/common/null_policy.rb +13 -1
- data/lib/hecks/query_specification/field_path.rb +20 -2
- data/lib/hecks/query_specification/hop_path.rb +7 -5
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +19 -3
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
- data/lib/hecks/runtime/command_interpreter.rb +97 -13
- data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
- data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
- data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
- data/lib/hecks/runtime/command_rules/references.rb +27 -19
- data/lib/hecks/runtime/dependency_planning.rb +14 -0
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +85 -14
- data/lib/hecks/runtime/errors.rb +22 -0
- data/lib/hecks/runtime/identity.rb +30 -2
- data/lib/hecks/runtime/instance.rb +59 -4
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/loader.rb +59 -18
- data/lib/hecks/runtime/query_interpreter.rb +36 -4
- data/lib/hecks/runtime/reaction_invocation.rb +9 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
- data/lib/hecks/runtime/refusal_wording.rb +2 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
- data/lib/hecks/runtime/registry/verification.rb +88 -0
- data/lib/hecks/runtime/registry.rb +69 -8
- data/lib/hecks/runtime/saga_interpreter.rb +215 -13
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/value/admission.rb +19 -1
- data/lib/hecks/runtime/value/coercion.rb +75 -10
- data/lib/hecks/runtime.rb +17 -5
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +7 -2
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +48 -37
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
- /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
data/lib/hecks/forms/app.rb
CHANGED
|
@@ -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
|
-
|
|
86
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/hecks/forms/html.rb
CHANGED
|
@@ -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
|
data/lib/hecks/forms/params.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
126
|
+
emits RoleTransitionRevoked
|
|
115
127
|
end
|
|
116
128
|
|
|
117
|
-
#
|
|
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
|
|
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 "
|
|
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
|
|
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
|
|
74
|
+
emits ExternalIdentifierLinked
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# THE OIDC-SHAPED LOOKUP — a token hands over `(issuer, subject)`,
|