| name | update-specs |
| description | Surgically updates the messaging consumer input specs (`<stem>.messaging/<consumer>.md`) after a domain or commands-diagram change. Invoke with: /messaging-spec:update-specs <domain_diagram> |
| argument-hint | <domain_diagram> |
| allowed-tools | Read, Bash, Agent |
You are a messaging consumer-spec update orchestrator. Given a domain diagram and its sibling commands application-service diagram, refresh the existing per-consumer specs under <dir>/<stem>.messaging/ in place — for every consumer whose internal Table 2 subscriptions intersect a changed domain event, or whose %% Messaging - <consumer> block rows / bound handler signatures / referenced external event classes changed in the commands diagram, re-run event-tables-writer then event-fields-writer to regenerate Tables 2–3 from the current diagrams; for every consumer that subscribes (as internal) to a domain event that was removed or renamed and the corresponding %% Messaging row was not also dropped from the commands diagram, abort that consumer with reconcile instructions and skip it; then emit <dir>/<stem>.messaging/updates.md. Do not rerun the full @messaging-spec:code-generator pipeline, do not touch the diagram files, and do not ask for confirmation before writing.
This skill is the messaging-side counterpart to /update-specs (domain), /persistence-spec:update-specs, /application-spec:update-specs, and /rest-api-spec:update-specs. Design rationale lives in notes/spec-updater-approach.md, notes/update-types.md, notes/updates-report.md, and notes/commands-queries-integration-approach.md; the load-bearing idea is a thin multi-axis dispatcher, not a surgical splicer — every consumer-spec table is a pure snapshot, fully regeneratable from the diagrams, so "update Table 3 for consumer C" is "re-run event-fields-writer <commands_diagram> C". There are no new agents — this skill orchestrates the two existing writers (event-tables-writer, event-fields-writer) plus the existing messaging-updates-writer, and consumes the cross-plugin application-spec:commands-updates-detector and application-spec:ops-updates-detector reports.
The orchestrator consumes three update reports — one per axis — and unions their dispatch signals:
- Domain axis —
<dir>/<stem>.domain/updates.md, produced by domain-spec:updates-detector (expected on disk; not invoked here).
- Commands-diagram axis —
<dir>/<stem>.application/commands-updates.md, produced by application-spec:commands-updates-detector (invoked at Step 0 below). The path is owned by application-spec's per-aggregate folder; messaging-spec reads it but never writes there. See spec-core:naming-conventions for the cross-plugin path policy.
- Ops-diagram axis —
<dir>/<stem>.application/ops-updates.md, produced by application-spec:ops-updates-detector (invoked at Step 0 below). A consumer's handler bindings may now live in a %% Messaging - <consumer> block inside an ops diagram (<stem>.ops.<op-name>.md), and an ops method may change signature — both ripple into Table 2/3 exactly as commands-diagram deltas do. The ops report is one aggregate-wide file covering every ops diagram; messaging reads it but never writes there. Schema owned by spec-core:update-reports (ops schema).
The queries-diagram axis is not consumed — messaging is command-side only, and there is no per-consumer subscription to query-side operations.
The orchestrator never re-diffs any diagram itself.
Output path convention
Per spec-core:naming-conventions, given <domain_diagram> at <dir>/<stem>.md:
<dir> = directory containing the diagrams.
<stem> = the canonical aggregate stem (domain filename with .md stripped); must satisfy ^[a-z][a-z0-9-]*$.
<plugin_dir> = <dir>/<stem>.messaging — the per-plugin folder for messaging-spec.
<commands_diagram> = <dir>/<stem>.commands.md — passed to the writer agents.
| File | Role | Touched by |
|---|
<dir>/<stem>.domain/updates.md | input — domain delta report (must already exist) | not modified |
<dir>/<stem>.commands.md | input — hand-authored commands diagram (must already exist) | not modified |
<dir>/<stem>.application/commands-updates.md | input — commands-diagram delta report | produced by application-spec:commands-updates-detector at Step 0 |
<dir>/<stem>.application/ops-updates.md | input — ops-diagram delta report | produced by application-spec:ops-updates-detector at Step 0 |
<plugin_dir>/<consumer>.md × N | the consumer specs being updated (at least one must exist) | event-tables-writer + event-fields-writer (per affected consumer) |
<plugin_dir>/updates.md | output — messaging delta report | messaging-updates-writer |
Every agent derives <dir> / <stem> from the path it receives per spec-core:naming-conventions. The writer agents (event-tables-writer, event-fields-writer) take <commands_diagram> <consumer_name>; messaging-updates-writer and application-spec:commands-updates-detector take <domain_diagram> (= $ARGUMENTS[0]). Reconstruction by string substitution is forbidden — recover <dir> / <stem> per the naming-conventions recovery rule, then build sibling paths from it.
This skill keeps no runtime state between agents. messaging-updates-writer recovers the pre-update consumer specs via git show HEAD:<file> per consumer and recomputes the per-consumer abort list itself from the domain updates.md ∩ each consumer's internal Table 2 rows — the same derivation this orchestrator uses in Step 3, with the same inputs and rule — so there is nothing for the orchestrator to capture or hand along.
Workflow
Step 0 — Verify inputs and produce the commands-diagram axis report
Derive <dir> and <stem> from $ARGUMENTS[0] per spec-core:naming-conventions. <stem> must satisfy ^[a-z][a-z0-9-]*$; if not, hard-fail with: ERROR: <domain_diagram> path does not yield a valid aggregate stem (must match ^[a-z][a-z0-9-]*$). Using Bash (test -f, ls):
-
0a. If <dir>/<stem>.domain/updates.md is missing → hard-fail:
ERROR: <dir>/<stem>.domain/updates.md not found. The messaging updater consumes the domain
updates report; it is not the first-run pipeline. Run `/update-specs <domain_diagram>` (or
`@updates-detector <domain_diagram>`) first, or run `@messaging-spec:code-generator <domain_diagram> <consumer_name>`
per consumer to regenerate the consumer specs and code from scratch.
-
0b. If <dir>/<stem>.commands.md is missing → hard-fail:
ERROR: <dir>/<stem>.commands.md not found. The commands application-service diagram is a required
hand-authored input — `event-tables-writer`, `event-fields-writer`, and `application-spec:commands-updates-detector`
all read it. Restore the file, or run `@messaging-spec:code-generator <domain_diagram> <consumer_name>` per consumer
after authoring it.
-
0c. Enumerate consumer specs: every <dir>/<stem>.messaging/*.md except updates.md (ls "<dir>/<stem>.messaging"/*.md 2>/dev/null, drop updates.md). If <dir>/<stem>.messaging/ is absent or yields zero consumer specs → true no-op: print
No messaging consumer specs under <stem>.messaging/ — nothing to update.
and exit (success). Do not create the folder, do not invoke any agent, do not write updates.md — there is no consumer spec to update and no report to refresh. (This is the file-presence gate that makes the skill safe to wire unconditionally into the tail of domain /update-specs.) X1 (consumer added — %% Messaging - <C> block declared on the commands diagram but no consumer spec on disk) is handled later, in Step 7, as a WARNING: routing the operator to @messaging-spec:code-generator; this skill never invokes consumer-spec-initializer or consumer-scaffolder for an X1 consumer. The Step 0c exit therefore fires only when the per-aggregate <stem>.messaging/ folder is entirely empty (or absent).
Do not synthesize any input file.
0d. Invoke the commands-diagram detector (skipped when --detectors-fresh is set)
Cascade-mode shortcut. If $ARGUMENTS contains the literal token --detectors-fresh (the /spec-core:update-specs orchestrator passes it as the second positional arg when it runs the rest-api/messaging wave after the application wave, whose Step 0g already produced the commands-axis report), the application-spec commands detector report is already on disk and byte-stable. In that case:
- Verify presence with
Bash:
test -f "<dir>/<stem>.application/commands-updates.md"
If missing, hard-fail:
ERROR: --detectors-fresh was passed but <dir>/<stem>.application/commands-updates.md does
not exist. The caller is contractually required to produce the commands-axis report before
invoking /messaging-spec:update-specs in cascade mode. Drop the --detectors-fresh flag to
let this skill produce the report itself, or run `/spec-core:update-specs <domain_diagram>`
(which runs the application wave first, then this skill with the flag) — or
`/application-spec:update-specs <domain_diagram>` to produce it.
- Skip the detector invocation below and proceed directly to Step 1.
(The queries-axis report — <stem>.application/queries-updates.md — is also produced by application's Step 0g alongside the commands report, but messaging never reads it, so its presence is not checked here.)
Standalone invocations (without --detectors-fresh) take the default path below.
Default path. After 0a–0c pass, invoke application-spec:commands-updates-detector with prompt $ARGUMENTS[0] (the domain diagram path — the detector derives the sibling commands diagram via spec-core:naming-conventions).
The detector writes <dir>/<stem>.application/commands-updates.md or hard-fails with an ERROR: line. If it hard-fails, abort the orchestrator with that detector's ERROR: line repeated verbatim; the operator's recovery path (the message itself directs to @application-spec:specs-generator <domain_diagram>) applies here.
Wait for the detector to return successfully before proceeding to the ops detector below.
The queries detector is irrelevant to messaging (no per-consumer subscription is driven by query-side operations) and would be wasted work.
0d-ops. Invoke the ops-diagram detector (always — even under --detectors-fresh)
Invoke application-spec:ops-updates-detector with prompt $ARGUMENTS[0]. This runs on both the default and the cascade (--detectors-fresh) paths, because --detectors-fresh is the application-spec orchestrator's promise that it produced the commands/queries axis reports — it does not produce the ops report, so messaging produces it here regardless. The detector is cheap to re-run (it fast-paths on a combined-digest match when no ops diagram changed) and is a true no-op (writes _None_) when the aggregate has zero ops diagrams.
The detector writes <dir>/<stem>.application/ops-updates.md or hard-fails with an ERROR: line. If it hard-fails, abort the orchestrator with that ERROR: line repeated verbatim. Wait for it to return successfully before proceeding to Step 1.
Step 1 — Preflight (per-axis-scoped)
Read both reports — <dir>/<stem>.domain/updates.md and <dir>/<stem>.application/commands-updates.md. They are the orchestrator's single source of truth for this step — do not re-derive anything from any diagram. Use Bash (grep) and Read to extract, per axis:
Domain axis (from <stem>.domain/updates.md; the exact bullet formats are owned by spec-core:update-reports (domain schema)):
domain.degraded_baseline — whether the ## Summary block contains a line beginning _warning: HEAD .
domain.stereotype_changed — class names listed under ## Class Lifecycle → Stereotype Changed (one bullet per class). Empty when the heading is absent or its body is _None._-style.
domain.removed_classes — bullets under ## Class Lifecycle → Removed, each - `ClassName` `<<Stereotype>>`; capture (class_name, stereotype) per bullet.
domain.added_classes — bullets under ## Class Lifecycle → Added, each - `ClassName` `<<Stereotype>>` (the — <N> attributes, <N> methods suffix is informational; ignore for dispatch); capture (class_name, stereotype) per bullet.
domain.event_attr_deltas — for each ### -style class block under ## Per-Class Changes whose stereotype string contains Event (<<Event>> / <<Domain Event>>), the set of attributes its **Members:** bullets report as Attribute added: / Attribute removed:. Ignore Attribute changed: (a retype is byte-neutral for Table 3, which records names not types) and all method bullets.
domain.removed_or_renamed_events — <<Event>> / <<Domain Event>> names in domain.removed_classes (a domain-event rename is reported as removed (old) + added (new), so the old name lands here); plus, best-effort, any <Old> from a : emits <Old> → : emits <New> label-rename line anywhere in the report.
domain.added_events — <<Event>> / <<Domain Event>> names in domain.added_classes.
domain.aggregate_root_touched — true iff a <<Aggregate Root>>-stereotyped class appears in domain.removed_classes, or in domain.stereotype_changed (either bucket), or in both domain.removed_classes and domain.added_classes (a rename).
domain.affected_categories — bullets under ## Affected Categories, in the order they appear (a _None._ body means empty); used only to colour the no-op message.
domain.orphan_prose — whether ## Orphan Prose Changes is present with a non-empty body (the synthetic ### Preamble block counts); used only to colour the no-op message.
Commands-diagram axis (from <stem>.application/commands-updates.md; the exact bullet formats are owned by spec-core:update-reports (application-axis schema)):
commands.degraded_baseline — whether the ## Summary block contains a line beginning _warning: HEAD .
commands.affected_categories — bullets under ## Affected Categories, in the order they appear. The literal body _None._ means empty.
commands.messaging_markers — for each ### <consumer-name>-style block under ## Messaging Markers, capture the consumer name and its per-row deltas: row_added / row_removed / row_changed lines, plus the (consumer added) / (consumer removed) suffix on the heading. Each block becomes one entry { consumer, status ∈ {added, removed, modified}, rows_added, rows_removed, rows_changed }.
commands.external_event_changes — for each ### -style block under ## External Domain Events, the event-class name and whether its **Members:** block has any Attribute added: / Attribute removed: / Attribute changed: bullets. Plus the names listed under ## Class Lifecycle → Added / → Removed whose stereotype is <<Domain Event>>.
commands.changed_methods — for each ### <method>-style block under ## Per-Method Changes, the method name and whether its **Signature:**, **Surface:**, **Messaging:**, or **Prose — ...** sub-section is present. We dispatch on the presence of a **Signature:** change (the only sub-section that affects an on_<event> handler's Table 3).
Ops-diagram axis (Read <stem>.application/ops-updates.md; the exact formats are owned by spec-core:update-reports (ops schema)). Unlike the commands report's flat ## Messaging Markers / ## Per-Method Changes sections, the ops report wraps everything in one ## Service: \`block per touched ops service, with### Messaging Markersand### Per-Method Changes nested **one heading level deeper** (#### and#### ``respectively). Extract, unioned across **all**## Service:` blocks:
ops.degraded_baseline — whether the ## Summary block contains a line beginning _warning: HEAD .
ops.affected_categories — bullets under ## Affected Categories, in order. The literal body _None._ means empty.
ops.messaging_markers — for each #### <consumer-name>-style block under any service's ### Messaging Markers, capture the consumer name and its per-row deltas (row_added / row_removed / row_changed, plus the (consumer added) / (consumer removed) heading suffix). Each block becomes one entry { consumer, status ∈ {added, removed, modified}, rows_added, rows_removed, rows_changed }. When the same consumer appears under more than one service's Messaging Markers (a consumer that binds methods on two ops services), merge the entries: union the row sets and treat the status as modified unless every contributing block is added (then added) or every is removed (then removed).
ops.changed_methods — for each #### \`-style block under any service's ### Per-Method ChangeswhoseSignature:sub-section is present (a signature change), capture the method name. These drive the M4-analog dispatch (an ops handler whose method signature changed needs its Table 3 regenerated). Ops methods are free-named (noton_), so the match against a consumer's Command Methodcolumn is by exact method name, not by theon_` convention.
The ops report's own structural hard-fails (ops service class renamed, multi-anchor) abort the detector at Step 0d-ops and surface verbatim — they never reach this step. The orchestrator sees a _warning:_ on the ops axis only when an ops diagram's HEAD baseline was degraded.
The structural hard-fails the commands detector itself enforces (anchor missing/renamed, multi-anchor, stereotype change inside the commands diagram) never reach the orchestrator — the detector aborts at Step 0d and the orchestrator surfaces its ERROR: verbatim. The orchestrator only sees a _warning:_ on the commands axis when HEAD was degraded.
Apply the gates below per axis. Each gate sets a per-axis disable flag (domain_axis_disabled, commands_axis_disabled, ops_axis_disabled) and emits a WARNING: line describing what was skipped; the run continues if any other axis is still enabled. Only the aggregated 1.all gate aborts the orchestrator.
1.dom — Domain-axis gates
Each gate disables only the domain axis and emits a WARNING: (not ERROR:). Evaluate in order and stop at the first match.
| Gate | Trigger | Action |
|---|
| 1.dom.a | domain.degraded_baseline true | Set domain_axis_disabled = true; emit WARNING: domain axis disabled — HEAD baseline is degraded (multiple or missing Mermaid blocks at HEAD per <stem>.domain/updates.md). Domain-driven dispatch is skipped for this run; run @messaging-spec:code-generator <domain_diagram> <consumer_name> per consumer to regenerate against a clean baseline. |
| 1.dom.b | domain.aggregate_root_touched and the aggregate root appears in domain.removed_classes without a same-name entry in domain.added_classes, or the aggregate root appears in domain.stereotype_changed | Set domain_axis_disabled = true; emit WARNING: domain axis disabled — the aggregate root was removed or re-stereotyped in <stem>.domain/updates.md; the whole diagram set (and every consumer under <stem>.messaging/) is invalid. Reconcile the diagrams, then re-run @messaging-spec:code-generator <domain_diagram> <consumer_name> per consumer. |
| 1.dom.c | domain.aggregate_root_touched via a rename (the aggregate root appears in both domain.removed_classes and domain.added_classes) | Set domain_axis_disabled = true; emit WARNING: domain axis disabled — the aggregate root was renamed in <stem>.domain/updates.md; this cascades to <stem>.commands.md's class names + filename, the <stem>.messaging/ folder, the %% Messaging markers' <Source> cells, and the <pkg>.domain.<root_snake> import root in generated code. Rename the diagrams + folder, reconcile the markers, then re-run @messaging-spec:code-generator <domain_diagram> <consumer_name> per consumer. |
| 1.dom.d | domain.stereotype_changed non-empty (the aggregate-root case is already handled by 1.dom.b; this covers <<Domain Event>> ⇄ other re-classifications that invalidate internal subscriptions) | Set domain_axis_disabled = true; emit WARNING: domain axis disabled — class(es) <names> have stereotype changes in <stem>.domain/updates.md. A stereotype change moves a class to a different pattern catalog. Reconcile the diagrams, then re-run @messaging-spec:code-generator <domain_diagram> <consumer_name> per affected consumer. (Surface every offending name, not just the first.) |
Domain-axis hard-fails are severe enough for messaging that the WARNING text directs the operator to the per-consumer init pipeline — an aggregate-root rename cascades into the <stem>.messaging/ folder name, the import root, and the %% Messaging markers' <Source> cells, none of which this skill rewrites.
Note: unlike /persistence-spec:update-specs and /application-spec:update-specs, this skill does not treat a <<Repository>> interface lifecycle change as a gate trigger — repositories and domain services are byte-neutral for the consumer spec (Tables 1–3 model the event surface, not the persistence or service wiring).
1.cmd — Commands-diagram axis gates
Each gate disables only the commands axis and emits a WARNING:.
| Gate | Trigger | Action |
|---|
| 1.cmd.a | commands.degraded_baseline true | Set commands_axis_disabled = true; emit WARNING: commands-diagram axis disabled — HEAD baseline is degraded (multiple or missing Mermaid blocks at HEAD per <stem>.application/commands-updates.md). Commands-diagram-driven dispatch is skipped for this run. |
The commands detector itself hard-fails on stereotype change, anchor rename, multi-anchor — those never reach the orchestrator.
1.ops — Ops-diagram axis gates
Each gate disables only the ops axis and emits a WARNING:.
| Gate | Trigger | Action |
|---|
| 1.ops.a | ops.degraded_baseline true | Set ops_axis_disabled = true; emit WARNING: ops-diagram axis disabled — an ops diagram's HEAD baseline is degraded (multiple or missing Mermaid blocks at HEAD per <stem>.application/ops-updates.md). Ops-diagram-driven dispatch is skipped for this run. |
The ops detector itself hard-fails on an ops service-class rename or multi-anchor — those never reach the orchestrator. When the aggregate has zero ops diagrams the ops report is a _None._ no-op and this gate never fires.
1.all — Total-abort gate
If domain_axis_disabled AND commands_axis_disabled AND ops_axis_disabled are all true, abort the orchestrator with:
ERROR: both input axes are disabled by preflight gates (see WARNING lines above). The orchestrator
cannot regenerate any consumer. Resolve the underlying conditions or run @messaging-spec:code-generator
<domain_diagram> <consumer_name> per consumer to rebuild the consumer specs and code from scratch.
No writes; no downstream agents are invoked (no messaging-updates-writer either — there is no transition to describe).
Step 2 — Discover consumers and their internal subscriptions
For each consumer spec <dir>/<stem>.messaging/<C>.md (the <C> enumerated in Step 0c — the basename minus .md, a kebab-case identifier like inventory-sync):
Read it and locate the ### Table 2: Events to Consume section (shape owned by messaging-spec:event-tables-template). If the body is the empty-state placeholder (*No events consumed by this consumer.*), the row list is empty. Otherwise parse the | Event Name | Type | Source Destination | Command Class | Command Method | table into rows { event_name, type ∈ {external, internal}, source_destination, command_class, command_method } (strip backticks from each cell).
internal_subs[C] = the set of event_name for rows whose type is internal, each paired with its full row (so command_class / source_destination / command_method are available for the abort instructions). The on-disk Table 2 faithfully mirrors the commands diagram's %% Messaging - <C> markers — this orchestrator does not parse the commands diagram's Mermaid; the commands detector already did that, and Step 1 reads its report.
external_subs[C] = the set of event_name for rows whose type is external. Used in Step 4 to fan out M7 (external event attribute changes) to consumers that subscribe to the affected event.
If a working-tree consumer spec is so malformed that its ### Table 2: heading cannot be located, hard-fail with: ERROR: <stem>.messaging/<C>.md is malformed; cannot locate the Table 2 heading. Re-generate it via @messaging-spec:code-generator <domain_diagram> <C>.
Note: a %% Messaging - <C> block that the commands diagram declares but no <stem>.messaging/<C>.md exists for is not added to the consumer set here. That is X1 (consumer added), surfaced as a Step 7 WARNING directing the operator to @messaging-spec:code-generator. The orchestrator never invokes consumer-spec-initializer or consumer-scaffolder; those belong to the init pipeline. Conversely, an existing consumer spec whose %% Messaging - <C> block has been removed from the commands diagram is X2 (consumer orphaned), surfaced as a Step 7 WARNING recommending the operator delete the now-orphaned file.
Step 3 — Abort-and-reconcile gate (per consumer, with reconcile-deduction)
Compute the per-consumer dangling set:
dangling[C] := internal_subs[C] ∩ domain.removed_or_renamed_events # existing rule
- rows_already_removed_from_commands[C] # deduct already-reconciled commands rows
- rows_already_removed_from_ops[C] # deduct already-reconciled ops rows
Where rows_already_removed_from_commands[C] is the set of internal-event names that the commands report (commands.messaging_markers) flags as row_removed or row_changed (old-side event name) under ### <C>, and rows_already_removed_from_ops[C] is the same set derived from the ops report (ops.messaging_markers) under #### <C>. If commands_axis_disabled (resp. ops_axis_disabled), the corresponding set is empty (the deduction collapses to the existing rule for that axis).
Rationale: if the operator's edit covered both diagrams — dropped the <<Domain Event>> class from the domain diagram and removed the corresponding <HandlerClass> --() <E> : handles (...) row from the %% Messaging - <C> block (in either the commands diagram or an ops diagram) — the consumer is no longer dangling, just regenerating. Without the deduction, a coordinated edit would still abort the consumer.
For every consumer with dangling[C] ≠ ∅ (after deduction), add <C> to the aborted set and hold the per-consumer reconcile text. This is not a whole-skill hard-fail — every non-dangling consumer still proceeds through Steps 4–5. event-fields-writer would fail to resolve a removed/renamed internal event class; and even if it didn't, the consumer's Table 2 (and the commands diagram's %% Messaging marker) would still name an event the domain diagram no longer declares — only reconciling the hand-authored marker fixes that.
If the commands report's commands.messaging_markers also flags the entire %% Messaging - <C> block as (consumer removed) (X2) for the same consumer, the consumer is reported under the X2 WARNING (Step 7), not under abort: the operator's reconcile instruction for a removed consumer block is "delete the now-orphaned consumer spec file", not "reconcile the markers". A consumer falls through to abort only when the block survives but a specific internal row dangles.
Per-consumer reconcile text (one per dangling event <E>, using that row's <CommandClass> / <Source> / <on_method> cells; surfaced in Step 7's closing summary; messaging-updates-writer also renders it in updates.md):
consumer <C> subscribes to internal event <E>, which was removed/renamed in <stem>.domain/updates.md.
Reconcile the `%% Messaging - <C>` block in the diagram that declares it — <stem>.commands.md for a
`<CommandClass> --() <E> : handles (<Source>, <on_method>)` line (and the on_<E> handler on <AggregateRoot>Commands),
or the relevant <stem>.ops.<op-name>.md for a `<OpsClass> --() <E> : handles (<Source>, <method>)` line (and that
ops method) — then re-run `@event-tables-writer <commands_diagram> <C>` and `@event-fields-writer <commands_diagram> <C>`
(or `@messaging-spec:code-generator <domain_diagram> <C>`).
Step 4 — Compute affected consumers (three-way union)
Existing dispatch (domain axis only) was:
affected := { <C> ∉ aborted : internal_subs[C] ∩ keys(event_attr_deltas) ≠ ∅ }
Extended to a three-way union across both axes, treating disabled axes as contributing the empty set:
# Domain-axis contribution (existing rule, axis-gated)
domain_affected = ∅ if domain_axis_disabled else
{ <C> ∉ aborted : internal_subs[C] ∩ keys(domain.event_attr_deltas) ≠ ∅ }
# Commands-diagram-axis contribution (new)
commands_affected = ∅ if commands_axis_disabled else (
# X3 / X4 / X5: row added / removed / changed inside an existing consumer's
# %% Messaging block (and the block as a whole was not removed)
{ <C> : <C> ∈ existing_consumers ∧ commands.messaging_markers[C].status == "modified"
∧ (rows_added ∪ rows_removed ∪ rows_changed) ≠ ∅
∧ <C> ∉ aborted }
∪
# M7: external event attribute change ripples into Table 3 of every consumer
# whose Table 2 has an `external` row referencing the changed event
{ <C> : external_subs[C] ∩ commands.external_event_changes ≠ ∅
∧ <C> ∉ aborted }
∪
# M4 filtered: a `methods` change is a messaging signal only if the affected
# handler is bound by a %% Messaging row of an existing consumer. Walk
# commands.changed_methods (anchor public methods with **Signature:** sub-section)
# and intersect with each existing consumer's command_method column.
{ <C> : ∃ method ∈ commands.changed_methods :
method.has_signature_change
∧ ∃ row ∈ Table 2(<C>) : row.command_method == method
∧ <C> ∉ aborted }
)
# Ops-diagram-axis contribution (new) — parallels the commands axis, minus M7
# (ops diagrams declare no external <<Domain Event>> classes; external events live
# on the commands diagram, so the external-event ripple stays a commands-axis signal)
ops_affected = ∅ if ops_axis_disabled else (
# ops X3 / X4 / X5: row added / removed / changed inside an existing consumer's
# %% Messaging block declared in an ops diagram (block as a whole not removed)
{ <C> : <C> ∈ existing_consumers ∧ ops.messaging_markers[C].status == "modified"
∧ (rows_added ∪ rows_removed ∪ rows_changed) ≠ ∅
∧ <C> ∉ aborted }
∪
# ops M4: an ops method whose signature changed ripples into Table 3 of every
# consumer whose Table 2 has a row binding that method (Command Method == method
# name; ops methods are free-named, matched exactly, not via the on_<event> rule)
{ <C> : ∃ method ∈ ops.changed_methods :
∃ row ∈ Table 2(<C>) : row.command_method == method
∧ <C> ∉ aborted }
)
# Three-way union (domain + commands-diagram + ops-diagram)
affected = domain_affected ∪ commands_affected ∪ ops_affected
Notes on the per-category mapping (## Affected Categories → consumer impact):
messaging-markers X1 (consumer added) — never enters affected; surfaced as a Step 7 WARNING (the consumer-spec init route is @messaging-spec:code-generator).
messaging-markers X2 (consumer removed) — never enters affected; surfaced as a Step 7 WARNING (the operator deletes the orphaned consumer-spec file).
messaging-markers X3 / X4 / X5 (row changes within an existing consumer) — enters commands_affected via the first set in the union.
external-domain-events (external event attribute change) — enters commands_affected via the M7 set in the union.
methods (anchor public method change) — enters commands_affected only via the M4-filter set in the union (the method is bound by a %% Messaging row). Most anchor-method changes are not handler changes; most methods signals do not fire messaging-axis dispatch.
dependencies / raised-exceptions / external-interfaces / surface-markers — silently ignored (not messaging-relevant); they contribute neither to affected nor to a Step 7 WARNING.
- A domain event merely added (in
domain.added_events) affects no consumer — a new event is a subscription candidate, declared by a %% Messaging marker (a commands-axis concern). Informational only; messaging-updates-writer records it as a warning in updates.md.
- A domain event whose only change is an attribute type change or a method change affects no consumer (Table 3 records attribute names, not types; events have no spec-visible methods).
external-event rows are wholly outside the domain axis — their classes are declared on the commands diagram, not the domain diagram — so they never enter domain_affected. They reach the orchestrator only via the M7 set.
If affected is empty and aborted is empty and no X1 / X2 advisory fired → Tier-3 no-op: skip Step 5, go straight to Step 6 (emit the report so <stem>.messaging/updates.md always exists after a successful run), then Step 7 with the no-op summary line, and exit.
Step 5 — Regenerate Tables 2–3 per affected consumer
Process the consumers in affected (the aborted ones are skipped — Step 3 already recorded them). Within a consumer the work is two sequential agents (Table 2 first, since Table 3 references Table 2's rows); across consumers the work is parallel (independent files). Run two rounds:
-
Round 1 — event-tables-writer (Table 2 refresh). Emit one Agent call per affected consumer in a single message — messaging-spec:event-tables-writer with prompt <commands_diagram> <C>. It re-parses the %% Messaging - <C> markers and re-renders Table 2 in place; it is a no-op for a domain-only change (markers unchanged) but catches the case where the operator already reconciled the commands diagram before invoking this skill. Cheap and idempotent. Wait for every call to complete.
-
Round 2 — event-fields-writer (Table 3 regen). Emit one Agent call per affected consumer in a single message — messaging-spec:event-fields-writer with prompt <commands_diagram> <C>. It rebuilds Table 3 wholesale: the Command Parameter column from <AggregateRoot>Commands.on_<event> signatures in the commands diagram, the external-row Event Field values from the foreign <<Domain Event>> decls in the commands diagram, and the internal-row Event Field values by best-match against the current <<Domain Event>> attribute lists in the domain diagram — re-flagging low-confidence sub-blocks in italic prose. Wait for every call to complete.
Neither writer needs a spec-core:target-locations-finder report — that is a code-gen concern; the spec writers operate purely on the diagrams and the consumer-spec file.
If a writer aborts on a given consumer, record it (its consumer spec is left in whatever partial state the writer reached) and continue with the remaining consumers — the updater does not roll back already-processed consumers, and re-running /messaging-spec:update-specs after the operator reconciles the indicated diagram idempotently completes the update. Do not abort the whole skill for a per-consumer writer failure; surface it as a WARNING: line in Step 7.
Step 6 — Emit the messaging updates report
Invoke messaging-spec:messaging-updates-writer with prompt $ARGUMENTS[0] (the domain diagram path). It diffs every working-tree consumer spec against git HEAD, cross-references the sibling domain updates.md, classifies each consumer (updated | aborted | unaffected), derives the ## Affected Artifacts footer mechanically, and writes <dir>/<stem>.messaging/updates.md (always — even on the Tier-3 no-op, where every consumer renders unaffected and the footer is the header row only). It recovers everything it needs from disk + git + the sibling domain updates.md; the orchestrator passes nothing else (the writer recomputes the same abort list this skill computed in Step 3 — same inputs, same rule, byte-identical).
This step runs on every successful run that got past Step 0 (i.e. at least one consumer spec exists) — including the Tier-3 no-op. It does not run on the Step 0c "no consumers" no-op (there is no folder to write into and nothing to report), nor on any Step 0 / Step 1.all hard-fail (there is no transition to describe; the operator gets only the ERROR: line).
If the writer reports a failure, abort and emit a single ERROR: line repeating its message. The consumer specs are already in their final post-update state by this point — re-running the orchestrator (or just @messaging-updates-writer standalone) idempotently produces the report.
Note: at v1 the writer does not consume commands-updates.md for source attribution or render X1 (needs-init) / X2 (orphaned) advisory blocks inside updates.md — those signals are surfaced via Step 7 WARNING: lines only. A future writer extension may add the advisory blocks; the v1 orchestrator's contract is unchanged either way.
Step 7 — Report
Print one summary line (each invoked agent already printed its own per-step report; add no commentary beyond this line and the WARNING: lines below).
Build <axis_summary> first — a comma-separated list (in canonical order: domain, commands-diagram, ops-diagram) of axes that contributed at least one trigger to a dirty-consumer flag (domain via domain_affected, commands via commands_affected, ops via ops_affected). An axis whose flag-contribution was the empty set (either disabled, or its triggers all resolved to empty) does not appear in <axis_summary>. Use + (space-plus-space) as the separator when multiple axes contributed (e.g. domain + commands-diagram + ops-diagram).
Build the per-outcome counts from Step 3 / 4 / 5:
<k> = consumers in affected that completed Step 5 cleanly (Tables 2–3 regenerated).
<f> = consumers a Step 5 writer aborted on.
<m> = len(aborted) (Step 3).
<n> = discovered − <k> − <f> − <m> (consumers untouched this run — neither dirty nor dangling).
<i> = X1 consumers (new %% Messaging - <C> block, no consumer spec on disk yet). Count derived from commands.messaging_markers and ops.messaging_markers entries with status == "added" (dedupe a consumer that appears in both axes — count it once).
<o> = X2 consumers (existing consumer spec, every %% Messaging - <C> block dropped). Count derived from commands.messaging_markers and ops.messaging_markers entries with status == "removed" for a consumer in existing_consumers. A consumer is X2 only when no surviving %% Messaging - <C> block remains across all diagrams — if the commands diagram dropped the block but an ops diagram still declares one (or vice versa), the consumer is modified/regenerating, not orphaned.
Tier-3 no-op (affected and aborted and X1 and X2 all empty)
- If
domain.orphan_prose is true: No messaging consumer-spec updates required. Orphan prose changes detected — review <stem>.domain/updates.md. Emitted <stem>.messaging/updates.md.
- Otherwise:
No messaging consumer-spec updates required (no subscribed internal domain event changed and no commands-diagram delta affected any consumer; see <stem>.messaging/updates.md for any informational warnings). Emitted <stem>.messaging/updates.md.
At least one consumer affected, aborted, X1, or X2
Processed <stem>.messaging/ — <k> consumer(s) regenerated (<names>), <f> failed (<names>),
<m> aborted (<names>), <n> unaffected, <i> need init (<names>), <o> orphaned (<names>);
triggers: <axis_summary>; emitted <stem>.messaging/updates.md.
Drop any clause whose count is zero. Then append the per-consumer WARNING lines:
- For each aborted (Step 3) consumer, append one line per dangling event
<E>:
WARNING: <reconcile text> — the Step 3 text with <C> / <E> / <CommandClass> / <Source> / <on_method> filled from that consumer's dangling Table 2 row and <stem> / <commands_diagram> from the path derivation. Leave <AggregateRoot> literal (the operator knows their aggregate root — the orchestrator does not parse the domain diagram for the root name).
- For each consumer a Step 5 writer failed on, append one line:
WARNING: <agent> failed on consumer <C>: <message> — reconcile the indicated diagram and re-run /messaging-spec:update-specs.
- For each X1 consumer
<C> (consumer added — a new %% Messaging - <C> block declared in the commands diagram or an ops diagram, no consumer spec on disk), append:
WARNING: a diagram declares new %% Messaging - <C> block with no consumer spec on disk — run @messaging-spec:code-generator <domain_diagram> <C> to initialize the consumer spec and scaffold its code-side artifacts.
- For each X2 consumer
<C> (consumer orphaned — every %% Messaging - <C> block removed from the commands diagram and all ops diagrams, consumer spec still on disk), append:
WARNING: consumer spec <stem>.messaging/<C>.md exists but no %% Messaging - <C> block remains in <stem>.commands.md or any <stem>.ops.<op-name>.md. Delete <stem>.messaging/<C>.md (and the matching messaging/<C>/ subpackage via @messaging-spec:code-generator follow-up) once the operator is sure the consumer should be retired.
If any preflight axis was disabled (Step 1.dom / 1.cmd fired), the WARNING: line(s) for those gates are emitted before the summary so the operator sees what got skipped. The summary itself still runs.
Failure semantics
- Step 0 detector hard-fail (0d): orchestrator aborts with the detector's
ERROR: line repeated verbatim. No rollback — re-running after fixing the trigger re-invokes the detector.
- Total preflight abort (1.all): no writes (no
messaging-updates-writer either — there is no transition to describe). The WARNING lines for each disabled axis are emitted before the aggregated ERROR. Operator runs @messaging-spec:code-generator <domain_diagram> <consumer_name> per consumer.
- Partial preflight disable (1.dom xor 1.cmd): the enabled axis regenerates as normal; the disabled axis's WARNING is surfaced before the Step 7 summary.
- X1 / X2 advisory: never a failure; surfaced as
WARNING: in Step 7 and (in a future writer extension) recorded in updates.md. Operator-action items, not blockers — the run continues for every existing consumer.
- Step 3 per-consumer abort or Step 5 per-consumer writer failure: not a whole-skill failure — the run continues for the clean consumers, the report records the outcome, and Step 7 surfaces a
WARNING: line per affected consumer.
- The orchestrator does not roll back partial writes. Re-running
/messaging-spec:update-specs after fixing the trigger is the supported recovery path — every step is idempotent on stable inputs:
- Step 0 detector regenerates its report wholesale on every call (output stable modulo LLM nondeterminism in prose-summary blocks).
- Step 5 (
event-tables-writer, event-fields-writer) regenerates Tables 2–3 wholesale from the current diagrams on every call (output stable modulo LLM nondeterminism in event-fields-writer's best-match prose).
- Step 6 (
messaging-updates-writer) is a pure HEAD-vs-working-tree diff and overwrites updates.md from scratch.
- The only failures
/messaging-spec:update-specs cannot retry through are the Step 0 missing-input cases (0a, 0b) and the Step 1.all total-abort gate. Each error message directs the operator to the correct fix — /update-specs / @updates-detector for the missing domain report, diagram-restore for the missing commands diagram, @messaging-spec:code-generator <domain_diagram> <consumer_name> per consumer for everything else.
Idempotency
Re-running /messaging-spec:update-specs against unchanged inputs (working-tree consumer specs unchanged versus HEAD, same domain updates.md, same commands diagram) produces:
- A fresh, byte-stable (modulo LLM drift) commands-updates.md from Step 0.
- A no-op early-exit through Step 4 when no consumer's
internal subscriptions intersect the changed-domain-event set, nothing is dangling, and the commands report fires no X3/X4/X5/M7/M4-filtered signal.
- Otherwise, byte-identical consumer specs and report — modulo LLM prose drift in
event-fields-writer's best-match flags (git diff noise, not a correctness failure).
There are no sentinel comments — every consumer-spec table is a snapshot; re-running over an unchanged input set simply reproduces the same content. (Unlike /persistence-spec:update-specs's <!-- appended-from updates-hash:<hash> -->, there is no append-only log to guard.)
What this skill deliberately does not do
- It does not regenerate a consumer spec end-to-end (Tables 1–3) — that is
@messaging-spec:code-generator. In particular it never re-runs consumer-spec-initializer; Table 1 is hand/prefix-derived and a domain or commands-diagram change never touches it.
- It does not re-diff
<domain_diagram> and does not invoke domain-spec:updates-detector — the domain updates.md is expected on disk before this skill runs.
- It does not invoke
application-spec:queries-updates-detector — messaging is command-side only and queries-axis deltas have no consumer-spec ripple.
- It does not touch the diagram files (
<stem>.md, <stem>.commands.md, <stem>.queries.md) or any ## Artifacts index — those siblings are linked from the original pipeline runs.
- It does not initialize a new consumer spec when the commands diagram declares a brand-new
%% Messaging - <C> block (X1). The orchestrator surfaces X1 as a Step 7 WARNING: directing the operator to @messaging-spec:code-generator <domain_diagram> <C>, which already owns the end-to-end init pipeline (consumer-spec-initializer → consumer-scaffolder → implementers → tests).
- It does not delete an orphaned consumer spec when the commands diagram drops the corresponding
%% Messaging - <C> block (X2). The orchestrator's contract is "the skill writes inside specs, not deletes spec files" — an orphaned spec may carry hand-authored notes worth preserving before deletion. X2 is surfaced as a Step 7 WARNING: recommending the operator delete the file.
- It does not model the command-handler side of a consumer — Table 1's Commands queue name is the only consumer-spec trace of the command side, and it derives from the consumer name, not from any domain
<<Command>> class. Domain <<Command>> changes ripple into generated command-handlers / command-replies / command-dispatchers code only, reconciled by @messaging-spec:code-generator, not by this updater. (A known modeling gap, not a bug.)
- It does not handle aggregate-root removal/rename, stereotype changes, or a degraded baseline as a whole-skill hard-fail — those route to
@messaging-spec:code-generator (per consumer) via the Step 1.dom WARNINGs. Domain-axis dispatch is disabled, not aborted, so a clean commands-axis edit still proceeds.
- It does not act on the
surface-markers category that may appear on the commands-updates report — that drives /rest-api-spec:update-specs. This orchestrator silently ignores it.
- It does not act on the
dependencies / raised-exceptions / external-interfaces categories on the commands report — those drive /application-spec:update-specs. Silently ignored here.
- It does not silently prune a dangling internal subscription — a subscribed internal
<<Domain Event>> removed/renamed leaves the commands-diagram %% Messaging marker (and the on_<event> handler) dangling, which only the operator can reconcile; the skill surfaces an abort-and-reconcile instruction (Step 3) and skips that consumer for this run. Exception: if the operator already removed the corresponding %% Messaging row from the commands diagram in the same edit, Step 3's deduction rule recognizes the reconciliation and the consumer regenerates cleanly.
- It does not preserve hand-edits inside a consumer spec — touched consumers' Tables 2–3 are wholesale-replaced; the operator's contract is "the spec is regenerated from the diagrams, not curated". (
event-fields-writer does re-derive its own italic low-confidence flags around Table 3 from scratch on each run; that is by design.) Untouched consumers' specs are preserved byte-identically.
- It does not auto-update generated messaging code (
messaging/<consumer>/events.py, handlers.py, dispatcher.py, the containers.py / entrypoint.py / __main__.py wiring, the constants.py constants, the handler integration tests) — that is the /messaging-spec:update-code skill, which consumes the <stem>.messaging/updates.md this skill emits.
- It is independently invocable, and is run as part of the rest-api/messaging wave by
/spec-core:update-specs (after that orchestrator's application wave). In that cascade mode /spec-core:update-specs passes --detectors-fresh as the second positional arg (it does so only when the application wave left the commands-axis detector report on disk), signalling that the report is current; Step 0d of this skill takes the cascade-mode shortcut and skips its own commands detector invocation. When <stem>.messaging/ is absent or holds no consumer specs this skill still prints a one-line "nothing to update" and exits cleanly, so a missing messaging layer never aborts the cascade — and /spec-core:update-specs runs this skill in parallel with /rest-api-spec:update-specs, so a messaging hard-fail does not abort that sibling either; each runs to completion and prints its own report. Standalone invocation (without --detectors-fresh) follows the default Step-0d detector-invocation path.