Skip to content

feat: dispatch aggregate-DSL command groups over the command API (release 1.4.0)#32

Merged
MichalKrezelewski merged 10 commits into
mainfrom
feat/dispatch-aggregate-command-groups
Jun 24, 2026
Merged

feat: dispatch aggregate-DSL command groups over the command API (release 1.4.0)#32
MichalKrezelewski merged 10 commits into
mainfrom
feat/dispatch-aggregate-command-groups

Conversation

@MichalKrezelewski

Copy link
Copy Markdown
Contributor

Summary

Lands aggregate-DSL command-group dispatch over the HTTP command API — the fix originally written as #29, which was merged into a stranded feature branch (feat/command-group-aggregate-dsl) instead of main and so never took effect. This re-applies #29's 7 commits onto current main, plus a missing piece #29 left out, and releases 1.4.0.

What's included

Cherry-picked from #29 (7 commits):

  • yes-command-api deserializer resolves the <Context>::<Subject>::CommandGroups::<Name>::Command convention (generated by the command_group macro).
  • Controller expand_commands unwraps a CommandGroup for batch authorization/validation, while still passing the wrapped group to the bus so the Processor dispatches it as one atomic unit.
  • CommandGroup#to_h returns the flat payload + reserved keys (round-trips through Class.new(to_h)).
  • CommandGroupSerializer serializes aggregate-DSL groups (async queue path).
  • Processor#run_command refactor — drops reinstantiate_with_reserved_keys and the is_a?(CommandGroup) branch.
  • README docs + full spec coverage.

New in this PR (the gap #29 missed):

  • Configuration#command_group_guard_evaluator_class + group-aware Processor#guard_evaluator_exists?. A command_group registers its guard evaluator under the :command_group_guard_evaluator registry type, but the existence check consulted only :guard_evaluator — so a real DSL group dispatched through the bus raised UnregisteredCommand. feat(yes-command-api): dispatch aggregate-DSL command groups #29's specs missed this (they stub the guard-evaluator lookup / use plain dummy classes). Added a Processor spec that dispatches a real DSL group (Test::PersonalInfo's update_personal_info_group) through perform against the un-stubbed registry, plus a Configuration unit spec. Verified RED→GREEN.

Heads-up for consumers (authorization model)

The controller now expands an aggregate-DSL CommandGroup for authorization, so each sub-command is authorized individually (the legacy stateless Group already worked this way). A consumer exposing such a group over HTTP must ensure each sub-command's Cerbos action is granted.

Verification

  • yes-core full suite: 1184 examples, 0 failures (local).
  • gap-4 fix verified RED (raises UnregisteredCommand) → GREEN.
  • RuboCop clean on the changeset.
  • CI runs the full matrix (yes-core / yes-command-api / yes-read-api / yes-auth) on Ruby 3.4.5.

🤖 Generated with Claude Code

ncri and others added 10 commits June 24, 2026 12:04
`CommandGroup#to_h` previously returned the NESTED per-context/per-subject
form (mirroring legacy `Yes::Core::Commands::Group#to_h`). That broke
round-tripping: `Class.new(cmd.to_h)` produced a group whose `payload`
was nested — but the aggregate's group method expects the FLAT form.

This surfaces wherever the command travels through code that
reconstructs via `cmd.class.new(cmd.to_h.merge(...))`, notably:

- `Yes::Core::ActiveJobSerializers::CommandGroupSerializer#serialize` →
  `#deserialize` round-trip,
- `Yes::Command::Api::V1::CommandsController#add_metadata` round-trip
  for command groups.

Both of those now produce a group with the correct flat `payload`.

The legacy stateless `Yes::Core::Commands::Group` is untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssor

`Yes::Core::Commands::Processor#run_command` calls
`aggregate.public_send(name, payload, guards:)` to invoke the aggregate's
command method. Previously it always passed `cmd.to_h`, which for a
`CommandGroup` returns the nested per-context/per-subject payload.
Aggregate group methods expect the FLAT form (mirroring direct Ruby
invocation: `aggregate.create_apprenticeship(company_id:, user_id:, …)`).

Use `cmd.payload` (flat input minus reserved keys) when the command is a
`CommandGroup`; keep `cmd.to_h` for regular commands (Dry::Struct's `to_h`
is already the flat attribute hash).

Also extracts `Processor#reinstantiate_with_reserved_keys` from the
`commands.map!` step that injects origin/batch_id. The legacy
`cmd.class.new(cmd.to_h.merge(...))` pattern round-tripped through the
nested form for groups and produced a group whose `payload` was nested
— breaking subsequent dispatch. The new helper round-trips groups
through their FLAT `payload` and uses `to_h` for regular commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Makes HTTP `POST /v1/commands` requests targeting an aggregate-DSL
`command_group` route end-to-end the same way regular commands do.

Three small changes line up with the legacy stateless `Group` pattern:

1. **Deserializer** — add a `command_group_v2_class` candidate matching
   `<Context>::<Subject>::CommandGroups::<Name>::Command` (the path the
   `command_group` DSL macro generates). Tried after the V2 command path
   and before the legacy top-level group fallback.

2. **CommandsController#expand_commands** — also unwrap
   `Yes::Core::Commands::CommandGroup` (in addition to the legacy
   `Group`) so its sub-commands flow through `BatchAuthorizer` /
   `BatchValidator`. Each sub-command's existing per-command Authorizer
   and Validator run individually — no group-level authorizer or
   validator class needs to exist. The wrapped originals
   (`deserialize_commands`) still go to `command_bus.call`, so the
   Processor dispatches each group as a single atomic unit.

3. Test fixtures — add `Dummy::Activity::CommandGroups::DoTwoThings::Command`
   plus a method-missing path on `Dummy::Activity::Aggregate` that
   recognises group methods and returns a `CommandGroupResponse`.
   Register the dummy sub-commands in the configuration registry so
   `CommandGroup#sub_command_classes` can resolve them.

4. Specs — Deserializer spec exercises the new resolution candidate.
   Request spec posts a `DoTwoThings` action, asserts the group method
   is dispatched once on the aggregate with the FLAT payload (not the
   nested form), and that sub-command authorizers are invoked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a short note at the end of the Command Groups section explaining
that groups can be invoked over HTTP exactly like regular commands:
same request shape, group name as `command`, flat payload in `data`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the earlier fix that makes `CommandGroup#to_h` return the flat
form (payload merged with reserved keys, round-trippable through
`Class.new(to_h)`), the `cmd.is_a?(CommandGroup)` branch in
`Processor#run_command` is redundant — `cmd.to_h` already produces the
shape the aggregate's group method expects.

Bonus: dropping `reinstantiate_with_reserved_keys` and going back to the
direct `cmd.class.new(cmd.to_h.merge(origin:, batch_id:))` means
CommandGroups now ALSO get origin/batch_id propagated through the
reserved-key channel — previously the helper used `cmd.payload` which
stripped the reserved keys before merging, losing the original
command_id, metadata, and transaction.

Updates the related Processor and request-spec test descriptions to
drop the now-misleading `cmd.payload` references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Yes::Core::ActiveJobSerializers::CommandGroupSerializer#serialize?`
only matched the legacy `Yes::Core::Commands::Group`, so the new
aggregate-DSL `Yes::Core::Commands::CommandGroup` instances would fall
through to the default ActiveJob serializer, which can't round-trip a
non-trivial object.

Match both classes. Both round-trip cleanly via the `to_h` /
`Class.new(symbolized_hash)` pair the serializer already uses (the
aggregate-DSL form's `to_h` is the flat payload merged with reserved
keys after the earlier fix).

Adds a dedicated serializer spec covering:
- `#serialize?` matches both group flavours and rejects regular commands,
- the aggregate-DSL group round-trips: class, payload, reserved keys,
  and sub-command set are all preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additional test areas suggested by code review:

1. **Reserved-key propagation through Processor#perform** for command
   groups. The simpler `cmd.class.new(cmd.to_h.merge(origin:, batch_id:))`
   re-instantiation path (introduced by the previous refactor) is
   supposed to preserve origin, batch_id, command_id, metadata, and
   transaction through to the aggregate's group method. New processor
   spec context exercises each of these.

2. **End-to-end ActiveJob round-trip** for command groups via the
   Command API. Adds a `running async (ActiveJob round-trip)` context
   to the aggregate-DSL command-group request spec that switches the
   queue adapter to `:test`, asserts a CommandGroupSerializer-tagged
   argument is enqueued, and uses `perform_enqueued_jobs` to actually
   run the queued Processor job — proving the serializer round-trips
   end-to-end and the dispatched group method on the (stubbed)
   aggregate receives the equivalent command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Command API dispatches an aggregate-DSL CommandGroup to the Processor
un-expanded, so `ensure_guard_evaluators_exist?` checks the group itself. A
group registers its guard evaluator under the `:command_group_guard_evaluator`
registry type, but `Processor#guard_evaluator_exists?` consulted only
`:guard_evaluator`, so a valid group raised `UnregisteredCommand` on dispatch
(the async/non-stubbed path — the existing group specs stub the lookup, so it
went uncaught).

Add `Configuration#command_group_guard_evaluator_class` (pairing with the
existing `register_command_group_guard_evaluator_class`) and route CommandGroup
instances to it via a new `guard_evaluator_class_for` helper in the Processor.

Covered by a Processor spec that dispatches a real DSL command_group through
`perform` against the real (un-stubbed) registry, plus a Configuration unit spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- yes-core: resolve guard evaluators for aggregate-DSL command groups, flat
  CommandGroup#to_h round-trip, serialize groups for the async command queue
- yes-command-api: dispatch aggregate-DSL command groups over the HTTP command API

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he dummy group

The Processor now resolves a CommandGroup's guard evaluator via
Configuration#command_group_guard_evaluator_class. The request-spec dummy group
is hand-rolled (registers no evaluator), so stub the new lookup the same way
guard_evaluator_class is already stubbed for the dummy single commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MichalKrezelewski MichalKrezelewski merged commit d67e412 into main Jun 24, 2026
5 checks passed
@MichalKrezelewski MichalKrezelewski deleted the feat/dispatch-aggregate-command-groups branch June 24, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants