Module: Hecks::Storehouse
- Defined in:
- lib/hecks/storehouse.rb
Overview
THE BUS, NOT A DOOR — docs/hecks-survey-what-we-wish-we-had.md and
docs/future-features.md both name the sibling project's own
"Storehouse" the single highest-priority gap this repo had: "no
per-command tool... the bluebook IS the contract, the [door] just
projects it." This module IS that bus, borrowing its name too — the
dispatch/query/state/... surface below is a pure function of a
Runtime::Dispatcher plus plain Ruby arguments, no IO, no protocol
awareness. bin/hecks_mcp_door is ONE projection of it — MCP over
stdio — not the whole thing; a plain CLI door, an HTTP door, a second
transport of any shape, would sit beside it on the exact same bus,
sharing the same audit log and caller-identity handling, without
ever needing to speak MCP. (This module used to BE named McpDoor
and live under Facade — that conflated the bus with its one built
transport; this file is the rename, not a rewrite.)
catalog — what aggregates a domain declares, and what each can do
describe — one aggregate's full command/query/refusal contract
validate — is this domain's wiring sound (deep: also model-checks it)
domains — every domain directory under a root, discovered not typed
dispatch — issue a command (dry_run: preview, steps: batch)
query — ask a question
state — read what is actually stored, no verb involved
events — what HAPPENED, with payloads, to one record — not just its
current state; events THIS BUS witnessed, sourced from
its own audit log, not a full event-sourcing replay
history — the full append-only journal, not just current state
behaviors — run a domain's hand-curated `.behaviors` examples
follow — tail this bus's own dispatch/query/state audit log
dispatch/query/state EACH TAKE A summary — the survey's "every
audit row carries human intent for free" — and dispatch/query
additionally take an optional source: (SOURCE_TAGS, the survey's
SourceTag: who is calling) AND an optional role:/actor_id: —
a real caller identity, bound for the call's duration via Hecks. as_caller, checked against a role-gated command's own declared
role (CommandRules::Authorization) rather than merely documented by
describe. dispatch REQUIRES it for any command that declares a
role — require_caller_for_role_gated! refuses an unbound dispatch
against one rather than silently running it unchecked; query's own
authorization runs on a separate mechanism (Runtime::TenantScope)
that role: does not gate. This is self-asserted identity, not
authentication: the caller states its own role/actor_id, and this
bus checks it consistently once stated — see bin/hecks_mcp_door's
header for what that does and does not guarantee. Every call through
those three, plus a dry run, is appended to a per-domain JSONL audit
log (record!) follow tails back — a record of what the caller
SAID it was, not independently verified identity. catalog/
describe/validate/domains/history/behaviors/events need
neither — they change nothing and commit nothing to any log.
A CALLER HANDS IN AN ALREADY-BOOTED runtime, the same division of
labor Facade::CliRunner already keeps against bin/run: booting a
domain from a path is IO the calling bin/ script owns, this stays a
pure function of a Runtime::Dispatcher plus plain Ruby arguments —
no different from CliRunner.call(runtime:, argv:) itself.
validate and domains are the two exceptions: validate's whole
job is to attempt the boot and report whether it survived, so it
takes the domain PATH instead and boots it; domains has no
domain to be handed one of yet, that's what it's answering.
NO NEW VOCABULARY otherwise. Every method here composes doors that
already exist — Projector.call(:cli, ...) for verb/question alias
resolution (the identical table CliRunner itself resolves against),
Projector.call(:docs, ...) for describe, Facade::JsonDoor for
the JSON↔Runtime::Value boundary, Registry#repository for state/
history, Registry#verify! (run by every boot) for validate,
Bluebook::ModelCheck for validate(deep: true), Hecks::Behaviors
for behaviors, Adapters::Folder#domain? for domains,
Dispatcher#dry_run? for dispatch(dry_run: true). A second copy of
any of these here would be the exact duplication Facade::JsonDoor's
own header already warns against.
Constant Summary collapse
- SOURCE_TAGS =
THE SAME CLOSED SET
docs/hecks-survey-what-we-wish-we-had.md'sSourceTagnames — WHO dispatched, not WHAT. Optional: a caller that omits it getssource: nilrecorded, honestly, rather than a guessed default. %w[process-manager operator hook sidequest-agent cascade daemon].freeze
- LOG_ROOT =
THE BUS'S OWN AUDIT TRAIL — a JSONL file per domain, one line per
dispatch/query/state/dry-run call,followtails it back.tmp/, not the domain's own directory: this is the BUS's record of what was asked of it, not part of the domain's own persisted state, andtmp/is already gitignored for exactly this kind of local, disposable-but-useful-while-it-lasts file. File.("../../tmp/storehouse", __dir__)
- BOOT_ROOT =
THE ROOT EVERY
domain:/under:MUST RESOLVE UNDER — the project directory by default,HECKS_STOREHOUSE_ROOTto widen or move it.Hecks.bootKernel.loads the.hecksagon/.bluebook/.worldfiles a domain path resolves to, and those are Ruby, not a data format — a caller-supplied path with no confinement at all is an unmarked door out of "a narrower, checked surface" (the README's own pitch for this bus) and into arbitrary code execution from any path on disk. This is a boundary a local convention was standing in for, not new behavior for a caller already confined to the project tree. File.(ENV["HECKS_STOREHOUSE_ROOT"] || File.("../..", __dir__))
Class Method Summary collapse
- .aggregate_ir!(bluebook, name) ⇒ Object
-
.behaviors(target:) ⇒ Object
.behaviorsFILES, RUN AND REPORTED — hand-curated examples of how to use a domain, in domain vocabulary (docs/guides/behaviors.md), the survey's own "honest-refusal", generated-example-suite items. - .behaviors_file(result) ⇒ Object
-
.bluebook_for(runtime) ⇒ Object
THE ONE BLUEBOOK A DOMAIN DIRECTORY BOOTS.
-
.catalog(runtime:) ⇒ Object
ZOOM LEVEL ONE — every aggregate this domain declares, and every command/query name each answers to, snake_cased exactly as
dispatch/querywant it. -
.confine!(path, label) ⇒ Object
REFUSED, NOT SILENTLY CLAMPED — a path outside
BOOT_ROOTis either an honest mistake (a relative path typed against the wrong cwd) or the exact thing this check exists to catch, and both deserve the same clear refusal rather than a silent rewrite to something the caller didn't ask for. -
.describe(runtime:, aggregate: nil) ⇒ Object
ZOOM LEVEL TWO — the exact same usage document a human gets from
bin/docs <domain> [aggregate](Projector::DocsProjector, the identical projectionSurface::AggregateDoor#docscalls one door over): every command's arguments, the states it may be issued from, and every way it can refuse. -
.dispatch(runtime:, command:, summary:, args: {}, source: nil, dry_run: false, role: nil, actor_id: nil) ⇒ Object
dry_run: trueANSWERS A DIFFERENT QUESTION than a real dispatch does — "would this succeed", not "here is what happened" — so a domain refusal is the legitimate, complete ANSWER (ok: true, would_succeed: false), not a failed call. -
.dispatch_batch(runtime:, steps:, summary:, source: nil, role: nil, actor_id: nil) ⇒ Object
ONE CALL, MANY STEPS — the survey's own
bin/run <domain> scriptshape, so an agent issuing a known SEQUENCE of commands (open an account, then fund it) pays one round trip instead of N. -
.domains(under: "examples") ⇒ Object
ZOOM LEVEL ZERO — every domain directory a root actually holds, discovered rather than typed from memory.
- .dry_run_outcome(runtime, spec, envelope, summary:) ⇒ Object
-
.events(runtime:, aggregate: nil, id: nil, limit: nil) ⇒ Object
WHAT ACTUALLY HAPPENED, with payloads — distinct from
state(what's stored NOW) andhistory(append-only operation SNAPSHOTS, no payload). -
.flatten_legacy(envelope, receiver, legacy_receiver) ⇒ Object
dry_run?(Runtime::Dispatcher) understands only the OLD flat legacy args shape — no to:/with: envelope,route:never passed (its own header explains why: built directly against CommandInterpreter/EntityInterpreter's pre-envelope contract, never updated because nothing else needed it to be — a real record of history, not a defect this bus should paper over silently). -
.follow(runtime:, limit: 20) ⇒ Object
A LIVE TAIL WITHOUT A LIVE PROCESS —
bin/hecks_mcp_door(its transport of MCP-over-stdio) answers one request at a time, no push channel to a client that only ever asks. -
.history(runtime:) ⇒ Object
THE FULL WRITE HISTORY, not just the current head —
bin/history's own logic, unchanged: an append-only-backed aggregate'sentries, every operation that ever touched it. - .journal_entries(repository) ⇒ Object
- .log_lines(domain_name) ⇒ Object
-
.log_path(domain_name) ⇒ Object
── the audit log
followreads back ───────────────────────────────. - .ok(**fields) ⇒ Object
- .perform_dispatch(runtime, bluebook, command, summary, args, source, dry_run, role, actor_id) ⇒ Object
- .perform_query(bluebook, runtime, question, summary, args, source, role, actor_id) ⇒ Object
- .perform_state(runtime, bluebook, aggregate, summary, id) ⇒ Object
- .query(runtime:, question:, summary:, args: {}, source: nil, role: nil, actor_id: nil) ⇒ Object
- .real_dispatch(runtime, spec, envelope, summary) ⇒ Object
-
.record!(domain_name, tool:, summary:, source:, outcome:, verb: nil, role: nil, actor_id: nil) ⇒ Object
NEVER FAILS A REAL CALL BECAUSE ITS OWN AUDIT LOG COULDN'T BE WRITTEN — a full disk or a permissions problem is a
followfeature going dark, not a reason to refuse the dispatch/query/ state call that was actually asked for. -
.refusal_classes ⇒ Object
Runtime::WiringErrorBELONGS HERE TOO, alongside the true domain refusals — not because it IS one (it's a structural defect, not a rule the caller broke), but because "this domain isn't wired to answer what you're asking" (arole:+actor_id:caller reaching an authorization port nothing implements, a dry run against a port verb) is exactly the shape this bus promises never crashes through it. -
.refused(error, summary: nil) ⇒ Object
AN HONEST REFUSAL, NOT A CRASH — the survey's own item #9: "an explicit, structured refusal a caller can act on" rather than a stack trace an agent has to parse to find the one line that mattered.
-
.require_caller_for_role_gated!(spec, role) ⇒ Object
THE FAIL-OPEN HALF
with_callerITSELF CANNOT CLOSE — ADR 0025's Governance RBAC work fixed WHAT a bound role is checked against (a liveGovernance::RoleAssignmentlookup instead of a bare string match), but changed nothing about a caller who binds no role at all:refuse_role_mismatchreturns immediately whenCaller.currentis nil, so a bus caller who simply omitsrole:sails past a role-gated command unchecked, not denied. - .require_summary!(summary) ⇒ Object
-
.resolve!(cli, name, asking:) ⇒ Object
THE SAME ALIAS TABLE
CliRunnerRESOLVES A TYPED WORD AGAINST — a short name when it's unambiguous, the qualifiedAggregate.Verbform always. -
.state(runtime:, aggregate:, summary:, id: nil, source: nil) ⇒ Object
WHAT IS ACTUALLY STORED — no verb, no interpretation, the repository itself.
-
.valid_caller!(role, actor_id) ⇒ Object
actor_idNAMES WHO,roleNAMES WHAT THEY HOLD — `Hecks. - .valid_source!(source) ⇒ Object
-
.validate(domain:, deep: false) ⇒ Object
ZOOM LEVEL THREE — is the wiring sound at all: every bind names a declared aggregate, every adapter satisfies the port it claims, the default adapter is usable.
-
.with_caller(role, actor_id, &block) ⇒ Object
BOUND FOR THE DURATION OF ONE CALL, THEN GONE —
Hecks.as_calleris itself aThread.current-scopedensure-guarded block, so nothing here needs its own cleanup.
Class Method Details
.aggregate_ir!(bluebook, name) ⇒ Object
136 137 138 139 140 |
# File 'lib/hecks/storehouse.rb', line 136 def aggregate_ir!(bluebook, name) bluebook.aggregate(name) or raise Runtime::NotFound, "#{bluebook.name} declares no aggregate named #{name.inspect} — " \ "known: #{bluebook.aggregates.map(&:hecks_name).sort.join(', ')}" end |
.behaviors(target:) ⇒ Object
.behaviors FILES, RUN AND REPORTED — hand-curated examples of how
to use a domain, in domain vocabulary (docs/guides/behaviors.md),
the survey's own "honest-refusal", generated-example-suite items.
Hecks::Behaviors boots each test fresh through Hecks.boot_files
itself — target: names a .behaviors file OR a directory to
sweep, never a runtime:, the one other method here besides
validate that takes a path instead.
529 530 531 532 533 534 535 536 537 538 539 540 541 542 |
# File 'lib/hecks/storehouse.rb', line 529 def behaviors(target:) require_relative "behaviors" raise Runtime::NotFound, "no such file or directory: #{target.inspect}" unless target && File.exist?(target) if File.directory?(target) sweep = Hecks::Behaviors.run_all(target) ok(target: target, files: sweep.files.map { |file| behaviors_file(file) }, counts: sweep.summary) else result = Hecks::Behaviors.run(target) ok(target: target, files: [behaviors_file(result)], counts: Hecks::Behaviors.summarize([result])) end rescue *refusal_classes => e refused(e) end |
.behaviors_file(result) ⇒ Object
544 545 546 547 548 |
# File 'lib/hecks/storehouse.rb', line 544 def behaviors_file(result) { path: result.path, parse_error: result.parse_error, runs: Array(result.runs).map { |run| { description: run.description, status: run.status, message: run. } } } end |
.bluebook_for(runtime) ⇒ Object
THE ONE BLUEBOOK A DOMAIN DIRECTORY BOOTS. Every bin/* script that
projects a whole-domain CLI or doc set makes this same assumption
(Facade::CliRunner#call's own bluebook = runtime.registry. bluebooks.values.first) — one .hecksagon names one chapter.
131 132 133 134 |
# File 'lib/hecks/storehouse.rb', line 131 def bluebook_for(runtime) runtime.registry.bluebooks.values.first or raise Runtime::NotFound, "this boot loaded no bluebook" end |
.catalog(runtime:) ⇒ Object
ZOOM LEVEL ONE — every aggregate this domain declares, and every
command/query name each answers to, snake_cased exactly as
dispatch/query want it. Enough to pick a target; describe is
the next level down for what one of them actually takes.
429 430 431 432 433 434 435 436 437 438 439 440 |
# File 'lib/hecks/storehouse.rb', line 429 def catalog(runtime:) bluebook = bluebook_for(runtime) ok(domain: bluebook.name, aggregates: bluebook.aggregates.map do |aggregate| { name: aggregate.hecks_name, commands: aggregate.commands.map { |c| "#{Naming.snake(c.hecks_name)}!" }.sort, queries: aggregate.queries.map { |q| Naming.snake(q.hecks_name) }.sort } end) rescue *refusal_classes => e refused(e) end |
.confine!(path, label) ⇒ Object
REFUSED, NOT SILENTLY CLAMPED — a path outside BOOT_ROOT is either
an honest mistake (a relative path typed against the wrong cwd) or
the exact thing this check exists to catch, and both deserve the
same clear refusal rather than a silent rewrite to something the
caller didn't ask for.
116 117 118 119 120 121 122 123 |
# File 'lib/hecks/storehouse.rb', line 116 def confine!(path, label) resolved = File.(path.to_s, BOOT_ROOT) return resolved if resolved == BOOT_ROOT || resolved.start_with?("#{BOOT_ROOT}#{File::SEPARATOR}") raise Runtime::TypeMismatch, "#{label}: #{path.inspect} resolves outside #{BOOT_ROOT} — this bus only boots domains under its " \ "own root (HECKS_STOREHOUSE_ROOT to widen it)" end |
.describe(runtime:, aggregate: nil) ⇒ Object
ZOOM LEVEL TWO — the exact same usage document a human gets from
bin/docs <domain> [aggregate] (Projector::DocsProjector, the
identical projection Surface::AggregateDoor#docs calls one door
over): every command's arguments, the states it may be issued
from, and every way it can refuse. aggregate: omitted answers the
whole chapter.
448 449 450 451 452 453 454 455 |
# File 'lib/hecks/storehouse.rb', line 448 def describe(runtime:, aggregate: nil) bluebook = bluebook_for(runtime) = aggregate ? { aggregate: aggregate_ir!(bluebook, aggregate).hecks_name } : {} ok(domain: bluebook.name, docs: Projector.call(:docs, bluebook: bluebook, options: )) rescue *refusal_classes => e refused(e) end |
.dispatch(runtime:, command:, summary:, args: {}, source: nil, dry_run: false, role: nil, actor_id: nil) ⇒ Object
dry_run: true ANSWERS A DIFFERENT QUESTION than a real dispatch
does — "would this succeed", not "here is what happened" — so a
domain refusal is the legitimate, complete ANSWER (ok: true, would_succeed: false), not a failed call. A malformed request
(unknown command, a bad args shape) is still a failed call
(ok: false) either way — it never reached the domain to be asked.
278 279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'lib/hecks/storehouse.rb', line 278 def dispatch(runtime:, command:, summary:, args: {}, source: nil, dry_run: false, role: nil, actor_id: nil) bluebook = bluebook_for(runtime) tool = dry_run ? "dry_run" : "dispatch" outcome = perform_dispatch(runtime, bluebook, command, summary, args, source, dry_run, role, actor_id) record!(bluebook.name, tool: tool, verb: outcome[:verb], summary: summary, source: source, outcome: outcome, role: role, actor_id: actor_id) outcome.except(:verb) rescue *refusal_classes => e outcome = refused(e, summary: summary) record!(bluebook&.name, tool: tool, summary: summary, source: source, outcome: outcome, role: role, actor_id: actor_id) outcome end |
.dispatch_batch(runtime:, steps:, summary:, source: nil, role: nil, actor_id: nil) ⇒ Object
ONE CALL, MANY STEPS — the survey's own bin/run <domain> script
shape, so an agent issuing a known SEQUENCE of commands (open an
account, then fund it) pays one round trip instead of N. Every step
goes through dispatch itself — same resolution, same audit log
line per step, same summary/source stamped on all of them since
the batch is what the caller is declaring intent about, not each
individual step. Runs every step regardless of an earlier one
refusing — a later step naming a record an earlier step never
created will refuse honestly on its own account, which is more
informative than silently dropping the rest of the batch.
336 337 338 339 340 341 342 343 344 345 346 |
# File 'lib/hecks/storehouse.rb', line 336 def dispatch_batch(runtime:, steps:, summary:, source: nil, role: nil, actor_id: nil) require_summary!(summary) results = Array(steps).map do |raw| step = Facade::JsonDoor.deep_symbolize(raw) dispatch(runtime: runtime, command: step[:command], args: step[:args] || {}, summary: summary, source: source, role: role, actor_id: actor_id) end { ok: results.all? { |r| r[:ok] }, summary: summary, results: results } rescue *refusal_classes => e refused(e, summary: summary) end |
.domains(under: "examples") ⇒ Object
ZOOM LEVEL ZERO — every domain directory a root actually holds,
discovered rather than typed from memory. Every other tool takes
domain: as a directory it assumes the caller already knows; this
is how a caller who doesn't finds out. Adapters::Folder#domain?
is the same predicate domain_root/nearest_domain already walk
up directories checking — a bare .hecksagon or one under
bluebook/, the two real shapes this corpus uses.
415 416 417 418 419 420 421 422 423 |
# File 'lib/hecks/storehouse.rb', line 415 def domains(under: "examples") root = confine!(under, "under") return ok(under: under, domains: []) unless Dir.exist?(root) folder = Adapters::Folder.new found = Dir.children(root).sort.select { |name| folder.domain?(File.join(root, name)) } ok(under: under, domains: found.map { |name| File.join(under, name) }) end |
.dry_run_outcome(runtime, spec, envelope, summary:) ⇒ Object
318 319 320 321 322 323 324 |
# File 'lib/hecks/storehouse.rb', line 318 def dry_run_outcome(runtime, spec, envelope, summary:) flat = flatten_legacy(envelope, spec[:receiver], spec[:legacy_receiver]) runtime.dry_run?(spec[:verb], **flat) ok(summary: summary, would_succeed: true) rescue Runtime::WiringError, *Runtime::DOMAIN_REFUSALS => e ok(summary: summary, would_succeed: false, error: e.) end |
.events(runtime:, aggregate: nil, id: nil, limit: nil) ⇒ Object
WHAT ACTUALLY HAPPENED, with payloads — distinct from state
(what's stored NOW) and history (append-only operation
SNAPSHOTS, no payload). NOT a domain-wide event-sourcing replay:
"one boot per call" means runtime.events is always empty except
during the very call that populated it, discarded the moment that
call returns — there is no cross-call in-memory log to read here.
So this reads the SAME durable audit log follow already tails
(record! now stamps a successful dispatch's own announced
events onto its log line), reshaped: follow answers "what was
CALLED, in order, across every tool"; this answers "what HAPPENED
to one aggregate/record" — events THIS BUS witnessed, which is
every real dispatch ever routed through it, but no more than that.
aggregate: narrows to one aggregate; id: (requires
aggregate: — an id alone is not unique across aggregates)
narrows to one record's own events.
589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 |
# File 'lib/hecks/storehouse.rb', line 589 def events(runtime:, aggregate: nil, id: nil, limit: nil) raise Runtime::TypeMismatch, "id: requires aggregate: too — an id alone is not unique across aggregates" if id && !aggregate bluebook = bluebook_for(runtime) fqn = aggregate ? "#{bluebook.name}::#{aggregate_ir!(bluebook, aggregate).hecks_name}" : nil found = log_lines(bluebook.name).filter_map do |entry| next unless entry[:tool] == "dispatch" && entry[:ok] && entry[:events] next if fqn && !entry[:verb].to_s.start_with?("#{fqn}.") next if id && entry[:id] != id entry[:events].map { |event| event.merge(time: entry[:time], verb: entry[:verb], id: entry[:id]) } end.flatten(1) found = found.last(limit.to_i) if limit ok(domain: bluebook.name, events: found) rescue *refusal_classes => e refused(e) end |
.flatten_legacy(envelope, receiver, legacy_receiver) ⇒ Object
dry_run? (Runtime::Dispatcher) understands only the OLD flat
legacy args shape — no to:/with: envelope, route: never passed
(its own header explains why: built directly against
CommandInterpreter/EntityInterpreter's pre-envelope contract,
never updated because nothing else needed it to be — a real
record of history, not a defect this bus should paper over
silently). Facade::CommandRequest/spec[:legacy_receiver]
already know how to NAME that same flat shape for every receiver
kind (a bare id under one string key for :aggregate, a
entity: pair of keys for :entity) — this is the one
door back INTO it.
234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/hecks/storehouse.rb', line 234 def flatten_legacy(envelope, receiver, legacy_receiver) facts = envelope[:with] || {} return facts unless envelope.key?(:to) route = envelope[:to] case receiver when :aggregate then facts.merge(legacy_receiver.to_sym => route) when :entity facts.merge(legacy_receiver.fetch(:aggregate).to_sym => route[:aggregate], legacy_receiver.fetch(:entity).to_sym => route[:entity]) else facts end end |
.follow(runtime:, limit: 20) ⇒ Object
A LIVE TAIL WITHOUT A LIVE PROCESS — bin/hecks_mcp_door (its
transport of MCP-over-stdio) answers one request at a time, no push
channel to a client that only ever asks. This is the honest version
of the survey's storehouse follow for that shape: not a
subscription, a durable JSONL log every dispatch/query/state/
dry-run call appends to (record!), tailed back here. Still real,
still cross-process — the log outlives any one door's own process —
just pull instead of push.
558 559 560 561 562 563 564 565 |
# File 'lib/hecks/storehouse.rb', line 558 def follow(runtime:, limit: 20) bluebook = bluebook_for(runtime) entries = log_lines(bluebook.name).last([limit.to_i, 1].max) ok(domain: bluebook.name, entries: entries) rescue *refusal_classes => e refused(e) end |
.history(runtime:) ⇒ Object
THE FULL WRITE HISTORY, not just the current head — bin/history's
own logic, unchanged: an append-only-backed aggregate's entries,
every operation that ever touched it. An aggregate bound to a
non-append-only adapter (Memory, Postgres proper) answers an empty
list honestly rather than pretending to a history it never kept.
504 505 506 507 508 509 510 511 512 513 514 |
# File 'lib/hecks/storehouse.rb', line 504 def history(runtime:) bluebook = bluebook_for(runtime) entries = bluebook.aggregates.each_with_object({}) do |aggregate, all| repository = runtime.registry.repository(bluebook.name, aggregate) all[aggregate.storage_name] = journal_entries(repository) end ok(domain: bluebook.name, history: entries) rescue *refusal_classes => e refused(e) end |
.journal_entries(repository) ⇒ Object
516 517 518 519 520 |
# File 'lib/hecks/storehouse.rb', line 516 def journal_entries(repository) return [] unless repository.is_a?(Ports::Persistence::AppendOnly) repository.entries.map { |entry| { operation: entry.operation, id: entry.id, state: Facade::JsonDoor.materialize(entry.state) } } end |
.log_lines(domain_name) ⇒ Object
567 568 569 570 571 572 |
# File 'lib/hecks/storehouse.rb', line 567 def log_lines(domain_name) path = log_path(domain_name) return [] unless File.exist?(path) File.readlines(path).map { |line| JSON.parse(line, symbolize_names: true) } end |
.log_path(domain_name) ⇒ Object
── the audit log follow reads back ───────────────────────────────
250 251 252 |
# File 'lib/hecks/storehouse.rb', line 250 def log_path(domain_name) File.join(LOG_ROOT, "#{domain_name.to_s.gsub(/[^A-Za-z0-9_-]/, '_')}.jsonl") end |
.ok(**fields) ⇒ Object
622 |
# File 'lib/hecks/storehouse.rb', line 622 def ok(**fields) = { ok: true }.merge(fields) |
.perform_dispatch(runtime, bluebook, command, summary, args, source, dry_run, role, actor_id) ⇒ Object
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 |
# File 'lib/hecks/storehouse.rb', line 293 def perform_dispatch(runtime, bluebook, command, summary, args, source, dry_run, role, actor_id) require_summary!(summary) valid_source!(source) valid_caller!(role, actor_id) cli = Projector.call(:cli, bluebook: bluebook, options: { program: "mcp" }) spec = resolve!(cli, command, asking: false) require_caller_for_role_gated!(spec, role) envelope = Facade::CommandRequest.normalize(Facade::JsonDoor.deep_symbolize(args), receiver: spec[:receiver], legacy_receiver: spec[:legacy_receiver]) result = with_caller(role, actor_id) do dry_run ? dry_run_outcome(runtime, spec, envelope, summary: summary) : real_dispatch(runtime, spec, envelope, summary) end result.merge(verb: spec[:verb]) end |
.perform_query(bluebook, runtime, question, summary, args, source, role, actor_id) ⇒ Object
362 363 364 365 366 367 368 369 370 371 |
# File 'lib/hecks/storehouse.rb', line 362 def perform_query(bluebook, runtime, question, summary, args, source, role, actor_id) require_summary!(summary) valid_source!(source) valid_caller!(role, actor_id) cli = Projector.call(:cli, bluebook: bluebook, options: { program: "mcp" }) spec = resolve!(cli, question, asking: true) rows = with_caller(role, actor_id) { runtime.query(spec[:verb], **Facade::JsonDoor.deep_symbolize(args)) } ok(summary: summary, rows: rows.map { |row| Facade::JsonDoor.materialize(row) }).merge(verb: spec[:verb]) end |
.perform_state(runtime, bluebook, aggregate, summary, id) ⇒ Object
391 392 393 394 395 396 397 398 399 400 401 402 403 404 |
# File 'lib/hecks/storehouse.rb', line 391 def perform_state(runtime, bluebook, aggregate, summary, id) require_summary!(summary) ir = aggregate_ir!(bluebook, aggregate) repository = runtime.registry.repository(bluebook.name, ir) if id instance = repository.find(id) or raise Runtime::NotFound, "no #{ir.hecks_name} found for id #{id.inspect}" ok(summary: summary, record: Facade::JsonDoor.materialize(instance.to_h)) else records = repository.all.map { |instance| Facade::JsonDoor.materialize(instance.to_h) } ok(summary: summary, count: records.length, records: records) end end |
.query(runtime:, question:, summary:, args: {}, source: nil, role: nil, actor_id: nil) ⇒ Object
348 349 350 351 352 353 354 355 356 357 358 359 360 |
# File 'lib/hecks/storehouse.rb', line 348 def query(runtime:, question:, summary:, args: {}, source: nil, role: nil, actor_id: nil) bluebook = bluebook_for(runtime) outcome = perform_query(bluebook, runtime, question, summary, args, source, role, actor_id) record!(bluebook.name, tool: "query", verb: outcome[:verb], summary: summary, source: source, outcome: outcome, role: role, actor_id: actor_id) outcome.except(:verb) rescue *refusal_classes => e outcome = refused(e, summary: summary) record!(bluebook&.name, tool: "query", summary: summary, source: source, outcome: outcome, role: role, actor_id: actor_id) outcome end |
.real_dispatch(runtime, spec, envelope, summary) ⇒ Object
310 311 312 313 314 315 316 |
# File 'lib/hecks/storehouse.rb', line 310 def real_dispatch(runtime, spec, envelope, summary) result = runtime.dispatch(spec[:verb], **envelope) ok(summary: summary, id: result.id, state: result.state.nil? ? nil : Facade::JsonDoor.materialize(result.state), events: result.events.map { |event| { name: event.name, payload: Facade::JsonDoor.materialize(event.payload) } }) end |
.record!(domain_name, tool:, summary:, source:, outcome:, verb: nil, role: nil, actor_id: nil) ⇒ Object
NEVER FAILS A REAL CALL BECAUSE ITS OWN AUDIT LOG COULDN'T BE
WRITTEN — a full disk or a permissions problem is a follow
feature going dark, not a reason to refuse the dispatch/query/
state call that was actually asked for.
258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/hecks/storehouse.rb', line 258 def record!(domain_name, tool:, summary:, source:, outcome:, verb: nil, role: nil, actor_id: nil) return unless domain_name entry = { time: Time.now.utc.iso8601, tool: tool, verb: verb, summary: summary, source: source, role: role, actor_id: actor_id, ok: outcome[:ok], id: outcome[:id], error: outcome[:error], events: outcome[:events] }.compact FileUtils.mkdir_p(LOG_ROOT) File.open(log_path(domain_name), "a") { |f| f.puts(JSON.generate(entry)) } rescue StandardError nil end |
.refusal_classes ⇒ Object
Runtime::WiringError BELONGS HERE TOO, alongside the true domain
refusals — not because it IS one (it's a structural defect, not a
rule the caller broke), but because "this domain isn't wired to
answer what you're asking" (a role:+actor_id: caller reaching
an authorization port nothing implements, a dry run against a
port verb) is exactly the shape this bus promises never crashes
through it. dry_run_outcome already treats it this way locally;
this makes every OTHER caller of refusal_classes do the same.
620 |
# File 'lib/hecks/storehouse.rb', line 620 def refusal_classes = [Runtime::NotFound, Runtime::TypeMismatch, Runtime::WiringError, *Runtime::DOMAIN_REFUSALS] |
.refused(error, summary: nil) ⇒ Object
AN HONEST REFUSAL, NOT A CRASH — the survey's own item #9: "an
explicit, structured refusal a caller can act on" rather than a
stack trace an agent has to parse to find the one line that
mattered. The domain's own refusal text travels verbatim
(RefusalWording already renders every one of these to be read),
this only wraps it consistently.
630 |
# File 'lib/hecks/storehouse.rb', line 630 def refused(error, summary: nil) = { ok: false, summary: summary, error: error. } |
.require_caller_for_role_gated!(spec, role) ⇒ Object
THE FAIL-OPEN HALF with_caller ITSELF CANNOT CLOSE — ADR 0025's
Governance RBAC work fixed WHAT a bound role is checked against
(a live Governance::RoleAssignment lookup instead of a bare
string match), but changed nothing about a caller who binds no
role at all: refuse_role_mismatch returns immediately when
Caller.current is nil, so a bus caller who simply omits role:
sails past a role-gated command unchecked, not denied. That is a
property of THIS BUS choosing to dispatch unbound, not of the
domain rule — bin/run, the human CLI, has no such gap because a
human always dispatches through a real Hecks.as_caller binding
upstream of it. Refusing here, before with_caller/dispatch are
ever reached, makes the bus keep the same promise: a command whose
bluebook declares a role is not run through this bus without one.
215 216 217 218 219 220 221 |
# File 'lib/hecks/storehouse.rb', line 215 def require_caller_for_role_gated!(spec, role) return unless spec[:role_gated] && role.nil? raise Runtime::Unauthorized, "#{spec[:verb]} requires role: #{spec[:role].inspect} — this command is role-gated and no caller " \ "(role:/actor_id:) is bound; dispatching it unbound is refused, not silently unchecked" end |
.require_summary!(summary) ⇒ Object
156 157 158 159 160 161 |
# File 'lib/hecks/storehouse.rb', line 156 def require_summary!(summary) return unless summary.nil? || summary.to_s.strip.empty? raise Runtime::TypeMismatch, "a one-line summary: is required on dispatch/query/state — it is what makes an audit row legible later" end |
.resolve!(cli, name, asking:) ⇒ Object
THE SAME ALIAS TABLE CliRunner RESOLVES A TYPED WORD AGAINST — a
short name when it's unambiguous, the qualified Aggregate.Verb
form always. Shared here so dispatch and query (and their error
messages) never drift from what a human typing bin/run sees.
146 147 148 149 150 151 152 153 154 |
# File 'lib/hecks/storehouse.rb', line 146 def resolve!(cli, name, asking:) pool = asking ? cli[:questions] : cli[:verbs] key = cli[:names][asking ? :question : :command][name] spec = pool[key] return spec if spec known = cli[:names][asking ? :question : :command].keys.sort.join(", ") raise Runtime::NotFound, "no such #{asking ? 'query' : 'command'}: #{name.inspect} — known: #{known}" end |
.state(runtime:, aggregate:, summary:, id: nil, source: nil) ⇒ Object
WHAT IS ACTUALLY STORED — no verb, no interpretation, the repository
itself. id: given answers one record (NotFound when it names
nothing); omitted answers every record the aggregate currently
holds. This is the difference query can't cover: a query answers a
DECLARED question, and an aggregate that never declared "list
everything" has no query this could reuse.
379 380 381 382 383 384 385 386 387 388 389 |
# File 'lib/hecks/storehouse.rb', line 379 def state(runtime:, aggregate:, summary:, id: nil, source: nil) bluebook = bluebook_for(runtime) outcome = perform_state(runtime, bluebook, aggregate, summary, id) record!(bluebook.name, tool: "state", summary: summary, source: source, outcome: outcome) outcome rescue *refusal_classes => e outcome = refused(e, summary: summary) record!(bluebook&.name, tool: "state", summary: summary, source: source, outcome: outcome) outcome end |
.valid_caller!(role, actor_id) ⇒ Object
actor_id NAMES WHO, role NAMES WHAT THEY HOLD — Hecks. as_caller requires the latter always, the former is additive
(Runtime::Caller::Current's own shape). An actor_id with no
role would silently do nothing rather than bind a real caller,
which is worse than refusing: a caller who thinks they've
identified themselves and haven't deserves to be told.
175 176 177 178 179 |
# File 'lib/hecks/storehouse.rb', line 175 def valid_caller!(role, actor_id) return unless actor_id && role.nil? raise Runtime::TypeMismatch, "actor_id: requires role: too — a caller names WHO through WHICH role they hold" end |
.valid_source!(source) ⇒ Object
163 164 165 166 167 |
# File 'lib/hecks/storehouse.rb', line 163 def valid_source!(source) return if source.nil? || SOURCE_TAGS.include?(source.to_s) raise Runtime::TypeMismatch, "source: #{source.inspect} is not one of #{SOURCE_TAGS.join(', ')}" end |
.validate(domain:, deep: false) ⇒ Object
ZOOM LEVEL THREE — is the wiring sound at all: every bind names a
declared aggregate, every adapter satisfies the port it claims, the
default adapter is usable. Registry#verify! (runtime/registry/ verification.rb) is the one place this repo already answers that
question, and Runtime::Loader.boot already calls it as the last
step of every boot — so THIS is the one method here that boots for
itself rather than taking a runtime: already in hand, because a
runtime that successfully reached this line already answered the
question. Given a domain PATH, not a booted runtime, deliberately:
asking "is this valid" about a domain that failed to boot at all
has to be askable without a runtime to hand it.
deep: true GOES PAST WIRING INTO LOGIC — Bluebook::ModelCheck,
the lightweight-formal-methods leg (dead lifecycle transitions, a
saga state no handler chain reaches, a dispatch to nowhere). Opt-in
and separate from the base check on purpose: a wiring defect is
"this cannot run at all", a model-check finding is "this runs, but
part of it can never fire" — two different questions, and the
first is far cheaper to ask on every boot.
ANY BOOT FAILURE ANSWERS THE QUESTION, not only WiringError — a
domain path with no .hecksagon, a malformed bluebook, is just as
much "not valid" as a real wiring mismatch, and this tool exists
precisely so none of those ever cross a projection of this bus as
a crash.
482 483 484 485 486 487 488 489 490 491 492 493 494 495 |
# File 'lib/hecks/storehouse.rb', line 482 def validate(domain:, deep: false) runtime = Hecks.boot(confine!(domain, "domain"), install_facade: false) result = { ok: true, domain: domain, valid: true } if deep require_relative "bluebook/model_check" findings = runtime.registry.bluebooks.values.flat_map { |bluebook| Bluebook::ModelCheck.call(bluebook) } result[:findings] = findings.map { |f| { kind: f.kind, severity: f.severity, subject: f.subject, message: f. } } end result rescue StandardError => e { ok: false, domain: domain, valid: false, error: "#{e.class}: #{e.}" } end |
.with_caller(role, actor_id, &block) ⇒ Object
BOUND FOR THE DURATION OF ONE CALL, THEN GONE — Hecks.as_caller
is itself a Thread.current-scoped ensure-guarded block, so
nothing here needs its own cleanup. role: nil yields unbound —
for query, exactly as before: CommandRules::Authorization# refuse_role_mismatch is OPT-IN on the domain side (return unless caller), and query authorization runs on a wholly separate
mechanism (authorize policy, tenant: :field, checked against an
explicit tenant: argument — see Runtime::TenantScope), so
binding a caller around a query has no effect on it TODAY; it is
still accepted here, for symmetry and for the audit log, against
the day a read model does check Caller.current. For dispatch,
require_caller_for_role_gated! (below) now refuses BEFORE this
is ever reached when the command declares a role and no caller is
bound — so an unbound dispatch here means either the command
declares no role at all, or a caller-side check let it through.
196 197 198 199 200 |
# File 'lib/hecks/storehouse.rb', line 196 def with_caller(role, actor_id, &block) return block.call if role.nil? Hecks.as_caller(role: role, actor_id: actor_id, &block) end |