From 8cb9235ffda99b7da2a7c02c74de4ec9407b16ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Fri, 9 Oct 2026 16:34:19 -0400 Subject: [PATCH 1/4] docs: subscription filters use sys.Label, not bare Label (RestoManager handoff) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a Service Bus SQL filter a bare identifier is user. scope, so a bare `Label = 'X'` silently matches nothing. The generator has always emitted sys.Label; the kit docs said otherwise. - handbooks, samples, create-job/create-worker skills: every filter-expression Label -> sys.Label (26 lines), incl. the entity-cap clause wording - AsyncAPI_Handbook §0.8: state the scope rule once (sys.Label / user., bare identifier defaults to user. and matches nothing); pointer from the Vendor_Extensions x-subscription filterOverride section - specfuse-asyncapi.yaml: new warn rule specfuse-subscription-filter-sys-label on x-subscription.filterOverride - fixtures/subscription-filter-sys-label.yaml + ci.yml step asserting it fires on four bare forms and stays silent on two sys.Label forms Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 34 +++++++++ handbooks/AsyncAPI_Handbook.md | 32 +++++---- handbooks/Vendor_Extensions.md | 12 ++-- .../skills/create-job/SKILL.md | 2 +- .../skills/create-worker/SKILL.md | 2 +- samples/message-samples.yaml | 10 +-- .../subscription-filter-sys-label.yaml | 71 +++++++++++++++++++ schemas/spectral/specfuse-asyncapi.yaml | 18 +++++ 8 files changed, 154 insertions(+), 27 deletions(-) create mode 100644 schemas/spectral/fixtures/subscription-filter-sys-label.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 247931f..63cdecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -635,6 +635,40 @@ jobs: [ $fail -eq 0 ] || exit 1 echo "==> snapshot guardrails verified in both directions." + # A bare `Label` in a Service Bus SQL filter is `user.` scope and matches + # nothing; the label is `sys.Label`. The generator copies a filterOverride + # verbatim and never checks it, so this warn rule is the only net + # (RestoManager handoff). Assert it fires on every bare form AND stays + # silent on qualified filters — a rule that flags `sys.Label` is noise. + - name: filterOverride sys.Label scope (regression) + run: | + set +e + set -uo pipefail + REPORT="${RUNNER_TEMP}/sys-label.txt" + spectral lint --ruleset schemas/spectral/specfuse-asyncapi.yaml \ + schemas/spectral/fixtures/subscription-filter-sys-label.yaml >"$REPORT" 2>&1 + STATUS=$? + cat "$REPORT" + [ $STATUS -ge 2 ] && { echo "::error::Spectral crashed on the sys.Label fixture" >&2; exit 1; } + + fail=0 + for bad in on-bare-equality on-bare-or-chain on-bare-like on-mixed; do + if ! grep "specfuse-subscription-filter-sys-label" "$REPORT" \ + | grep -q "operations\.$bad\.x-subscription\.filterOverride"; then + echo "::error::specfuse-subscription-filter-sys-label did not flag $bad." >&2 + fail=1 + fi + done + for clean in on-sys-label on-sys-label-header; do + if grep "specfuse-subscription-filter-sys-label" "$REPORT" \ + | grep -q "operations\.$clean\.x-subscription\.filterOverride"; then + echo "::error::$clean qualifies the label as sys.Label but was flagged." >&2 + fail=1 + fi + done + [ $fail -eq 0 ] || exit 1 + echo "==> sys.Label scope rule verified in both directions." + # A bootstrapped project must validate cleanly with the scripts the kit # hands it. Until now CI only linted examples/hello-orders/, so defects # fixed there could survive in the template it came from — and did: every diff --git a/handbooks/AsyncAPI_Handbook.md b/handbooks/AsyncAPI_Handbook.md index 96ea91e..fb8f750 100644 --- a/handbooks/AsyncAPI_Handbook.md +++ b/handbooks/AsyncAPI_Handbook.md @@ -163,32 +163,34 @@ Where: **Why two-segment labels:** 1. **Stable public contract.** Labels never carry tenant identity, so a message published in 2026 still routes correctly when the tenant is migrated, deleted, or merged. -2. **Filters become predicates over `Label = ''`** (or `Label LIKE '.%'` for entity-wildcard) — no `LIKE 'X.%.%'` traps, no SQL-LIKE-vs-dots confusion. +2. **Filters become predicates over `sys.Label = ''`** (or `sys.Label LIKE '.%'` for entity-wildcard) — no `LIKE 'X.%.%'` traps, no SQL-LIKE-vs-dots confusion. 3. **Per-tenant subscribers are header-based**, not label-based — easier to add, remove, or deactivate without changing message metadata. **Subscription filter patterns:** ```sql -- Standard: single-action subscriber -Label = 'Order.Submitted' +sys.Label = 'Order.Submitted' -- All actions for one entity -Label LIKE 'Order.%' +sys.Label LIKE 'Order.%' -- Multi-action subscriber (subset of one entity's actions) -Label = 'Order.Submitted' OR Label = 'Order.Cancelled' +sys.Label = 'Order.Submitted' OR sys.Label = 'Order.Cancelled' -- Multi-entity subscriber (one worker covering several aggregates) -Label LIKE 'Customer.%' OR Label LIKE 'Order.%' OR Label LIKE 'Refund.%' +sys.Label LIKE 'Customer.%' OR sys.Label LIKE 'Order.%' OR sys.Label LIKE 'Refund.%' -- Tenant-scoped subscriber (header-driven, AND-merged with derived label filter) -Label = 'Order.Submitted' AND user.tenantId = '550e8400-e29b-41d4-a716-446655440000' +sys.Label = 'Order.Submitted' AND user.tenantId = '550e8400-e29b-41d4-a716-446655440000' -- Channel-aware subscriber for promoted-header values (see x-envelope-promote) -Label = 'NotificationJob.Created' AND user.channel = 'email' +sys.Label = 'NotificationJob.Created' AND user.channel = 'email' ``` -**Authors do not write filters.** The generator derives the filter from the operation's `messages:` list as an OR-chain over `Label =` (or `LIKE '.%'` for entity-wildcard patterns), then AND-merges any `requiredHeaders` declared on `x-subscription`. The legacy authored `filter` field is forbidden — see `Vendor_Extensions.md §12.3`. +**Filter scope: the label is `sys.Label`, headers are `user.`.** The event label travels as the broker's system property (the native `Subject`), which a Service Bus SQL filter reads as `sys.Label`. Envelope headers (`tenantId`, promoted fields) are application properties, read as `user.`. A bare identifier defaults to `user.` scope, so a bare `Label = 'Order.Submitted'` asks for an application property named `Label` that no producer sets: the filter is accepted, raises no error, and the subscription silently receives nothing. The generator has always emitted `sys.Label`; write it the same way anywhere you hand-author a filter (`x-subscription.filterOverride`, provisioning scripts, IaC topology). The kit Spectral rule `specfuse-subscription-filter-sys-label` warns on an unqualified `Label` in `filterOverride`. + +**Authors do not write filters.** The generator derives the filter from the operation's `messages:` list as an OR-chain over `sys.Label =` (or `LIKE '.%'` for entity-wildcard patterns), then AND-merges any `requiredHeaders` declared on `x-subscription`. The legacy authored `filter` field is forbidden — see `Vendor_Extensions.md §12.3`. ### 0.9 v2.1 Design Summary @@ -268,7 +270,7 @@ Explain to the user *why* the simpler model is insufficient for the specific sce Every event message is published to the single shared topic `{project}.events` (§0.7). There is no per-aggregate or per-domain event topic. Consumers subscribe with SQL-like filters over the `Label` property (§0.8) to receive only the events they care about. -**Multi-entity subscribers are a first-class pattern.** A single `on-*` operation can list multiple message `$ref`s and compose an OR-chain SQL filter (e.g., `Label LIKE 'Customer.%' OR Label LIKE 'Order.%' OR Label LIKE 'Refund.%'`). This is preferred over splitting the same logical worker into multiple sibling operations; it keeps cross-aggregate logic (projection rebuilds, sync workers, notification orchestrators) in one cohesive handler with one subscription, one DLQ, and one retry budget. +**Multi-entity subscribers are a first-class pattern.** A single `on-*` operation can list multiple message `$ref`s and compose an OR-chain SQL filter (e.g., `sys.Label LIKE 'Customer.%' OR sys.Label LIKE 'Order.%' OR sys.Label LIKE 'Refund.%'`). This is preferred over splitting the same logical worker into multiple sibling operations; it keeps cross-aggregate logic (projection rebuilds, sync workers, notification orchestrators) in one cohesive handler with one subscription, one DLQ, and one retry budget. **Rationale:** @@ -979,7 +981,7 @@ x-subscription: | Mode | Author writes | Generator emits | |---|---|---| -| **Derived (default)** | `messages: [E1.Created.yaml, E2.Updated.yaml]` only | `Label = 'E1.Created' OR Label = 'E2.Updated'` | +| **Derived (default)** | `messages: [E1.Created.yaml, E2.Updated.yaml]` only | `sys.Label = 'E1.Created' OR sys.Label = 'E2.Updated'` | | **`requiredHeaders`** | `messages:` list + `requiredHeaders: { channel: email }` | ` AND user.channel = 'email'` | | **`filterOverride`** | Raw SQL + `description` justification | The override verbatim | @@ -987,11 +989,11 @@ x-subscription: | Subscriber shape | What you write | Resulting filter | |---|---|---| -| Single-action subscriber | `messages: [OrderSubmitted.yaml]` | `Label = 'Order.Submitted'` | -| Wildcard-all-actions on one entity | `messages: [*.yaml for that entity]` (or `filterOverride: "Label LIKE 'Order.%'"`) | `Label LIKE 'Order.%'` | -| Multi-entity subscriber | `messages:` list spanning entities | `Label LIKE 'Customer.%' OR Label LIKE 'Order.%' OR ...` | +| Single-action subscriber | `messages: [OrderSubmitted.yaml]` | `sys.Label = 'Order.Submitted'` | +| Wildcard-all-actions on one entity | `messages: [*.yaml for that entity]` (or `filterOverride: "sys.Label LIKE 'Order.%'"`) | `sys.Label LIKE 'Order.%'` | +| Multi-entity subscriber | `messages:` list spanning entities | `sys.Label LIKE 'Customer.%' OR sys.Label LIKE 'Order.%' OR ...` | | Tenant-scoped subscriber | `messages:` list + `requiredHeaders: { tenantId: '' }` | ` AND user.tenantId = ''` | -| Channel-aware subscriber (uses `x-envelope-promote`) | `messages: [NotificationJobCreated.yaml]` + `requiredHeaders: { channel: email }` | `Label = 'NotificationJob.Created' AND user.channel = 'email'` | +| Channel-aware subscriber (uses `x-envelope-promote`) | `messages: [NotificationJobCreated.yaml]` + `requiredHeaders: { channel: email }` | `sys.Label = 'NotificationJob.Created' AND user.channel = 'email'` | **Multi-entity subscribers compose an OR chain** — there is no shorter form. Keep under the cap in §4.5. @@ -1135,7 +1137,7 @@ Consumers using `requiresSession: true` see the same prefixed session IDs. Autho #### 4.5.2 Subscription filter entity-pattern cap -The effective filter on a subscription (derived from the operation's `messages:` list, plus any `requiredHeaders` / `filterOverride`) may reference **at most 10 distinct entity patterns** — counted as distinct `Label = '.'` clauses in the derived OR-chain or distinct `LIKE '.` prefixes inside a `filterOverride`. More than 10 is a smell: the worker is probably doing too much and should be split along a natural seam. The cap also keeps filters well under the transport's filter-size limit (e.g., 2048 chars on ASB). +The effective filter on a subscription (derived from the operation's `messages:` list, plus any `requiredHeaders` / `filterOverride`) may reference **at most 10 distinct entity patterns** — counted as distinct `sys.Label = '.'` clauses in the derived OR-chain or distinct `LIKE '.` prefixes inside a `filterOverride`. More than 10 is a smell: the worker is probably doing too much and should be split along a natural seam. The cap also keeps filters well under the transport's filter-size limit (e.g., 2048 chars on ASB). Recall that authors do NOT write a raw `filter` field directly (forbidden — see §4.3); the rule operates on the derived filter the generator produces. diff --git a/handbooks/Vendor_Extensions.md b/handbooks/Vendor_Extensions.md index 104e11a..de4a8d5 100644 --- a/handbooks/Vendor_Extensions.md +++ b/handbooks/Vendor_Extensions.md @@ -3236,7 +3236,7 @@ x-label: action: Submitted # PascalCase past-tense action verb ``` -The runtime Label is exactly two segments: `{entity}.{action}` (e.g., `Order.Submitted`). **Tenancy never appears in the label** — `tenantId`, and any other tenant routing fields live in envelope ApplicationProperties. Subscription filters use `Label = '{Entity}.{Action}'` for single-action subscribers, `Label LIKE '{Entity}.%'` for wildcard-on-entity, or AND-merge `user.tenantId = ''` for tenant-scoped subscribers (see `x-subscription.requiredHeaders`). +The runtime Label is exactly two segments: `{entity}.{action}` (e.g., `Order.Submitted`). **Tenancy never appears in the label** — `tenantId`, and any other tenant routing fields live in envelope ApplicationProperties. Subscription filters use `sys.Label = '{Entity}.{Action}'` for single-action subscribers, `sys.Label LIKE '{Entity}.%'` for wildcard-on-entity, or AND-merge `user.tenantId = ''` for tenant-scoped subscribers (see `x-subscription.requiredHeaders`). **Action must be past-tense PascalCase.** `Created`, `Approved`, `Submitted`, `Archived`, `LinkedToNewTenant`, `QueuedForEmail`. Imperative verbs (`Create`, `Approve`) are not events — they are commands, which v2 architecture does not support. @@ -3386,7 +3386,7 @@ properties: x-envelope-promote: true ``` -**Why**: Filter granularity benefits from a small set of indexable headers (e.g., `channel` for `NotificationJob.*` events lets one channel-specific worker subscribe with `Label = 'NotificationJob.Created' AND user.channel = 'email'`). Without `x-envelope-promote`, the dispatcher would need entity-specific code to know which fields to promote — a leaky abstraction. With it, the generator emits generic stamping logic from the declaration. +**Why**: Filter granularity benefits from a small set of indexable headers (e.g., `channel` for `NotificationJob.*` events lets one channel-specific worker subscribe with `sys.Label = 'NotificationJob.Created' AND user.channel = 'email'`). Without `x-envelope-promote`, the dispatcher would need entity-specific code to know which fields to promote — a leaky abstraction. With it, the generator emits generic stamping logic from the declaration. **Rules:** - Only scalar properties may be promoted (string, int, bool, enum, UUID). Objects and arrays are forbidden. @@ -3489,17 +3489,19 @@ x-subscription: | `lockDuration` | No | duration | Lock duration during processing. Default: 30s. | | `requiresSession` | No | boolean | Set `true` when the referenced message has `x-partition-key`. Default: false. | -**Filters are derived, not authored.** The generator computes the SQL filter from the operation's `messages:` list as an OR-chain over `Label` equality (or `LIKE` for wildcard-on-entity patterns). The legacy `filter` field is **forbidden** — Spectral rejects it. Three modes: +**Filters are derived, not authored.** The generator computes the SQL filter from the operation's `messages:` list as an OR-chain over `sys.Label` equality (or `LIKE` for wildcard-on-entity patterns). The legacy `filter` field is **forbidden** — Spectral rejects it. Three modes: | Mode | Author writes | Generator emits | |---|---|---| -| **Derived (default)** | `messages: [E1.Created.yaml, E2.Updated.yaml]` only | `Label = 'E1.Created' OR Label = 'E2.Updated'` | +| **Derived (default)** | `messages: [E1.Created.yaml, E2.Updated.yaml]` only | `sys.Label = 'E1.Created' OR sys.Label = 'E2.Updated'` | | **`requiredHeaders`** | `messages:` list + `requiredHeaders: { channel: email }` | ` AND user.channel = 'email'` | | **`filterOverride`** | Raw SQL + mandatory `description` justification | The override verbatim | `requiredHeaders` and `filterOverride` are mutually exclusive — Spectral rejects both on one operation. -**Filter cap**: a single filter may reference at most 10 distinct entity patterns (counting derived `Label = 'X.Y'` clauses or `LIKE 'X.` prefixes in an override). More than 10 is a smell; the worker likely needs splitting along a natural seam. Enforced by `asyncapi-subscription-filter-entity-cap` at error severity. +**A `filterOverride` must write the label as `sys.Label`**, never a bare `Label`: a bare identifier is `user.` scope and silently matches nothing. See the scope rule in `AsyncAPI_Handbook.md §0.8`; Spectral rule `specfuse-subscription-filter-sys-label` warns. + +**Filter cap**: a single filter may reference at most 10 distinct entity patterns (counting derived `sys.Label = 'X.Y'` clauses or `LIKE 'X.` prefixes in an override). More than 10 is a smell; the worker likely needs splitting along a natural seam. Enforced by `asyncapi-subscription-filter-entity-cap` at error severity. **Subscription name = operation file stem.** Spectral rule `specfuse-async-subscription-name-mismatch` validates this. Free-form kebab-case naming is no longer permitted. diff --git a/plugins/specfuse-authoring/skills/create-job/SKILL.md b/plugins/specfuse-authoring/skills/create-job/SKILL.md index 2f7a337..252bb0b 100644 --- a/plugins/specfuse-authoring/skills/create-job/SKILL.md +++ b/plugins/specfuse-authoring/skills/create-job/SKILL.md @@ -198,7 +198,7 @@ x-observability: alertOnDlq: true x-subscription: # REQUIRED on event-topic receivers name: on-{item-event-description} # MUST equal the operation file stem - # NO 'filter' — derived from messages: → Label = '{Entity}.{Action}' + # NO 'filter' — derived from messages: → sys.Label = '{Entity}.{Action}' # AND-merge tenant scoping via requiredHeaders: { tenantId: '' } if needed ``` diff --git a/plugins/specfuse-authoring/skills/create-worker/SKILL.md b/plugins/specfuse-authoring/skills/create-worker/SKILL.md index 93c592c..b81a2ae 100644 --- a/plugins/specfuse-authoring/skills/create-worker/SKILL.md +++ b/plugins/specfuse-authoring/skills/create-worker/SKILL.md @@ -82,7 +82,7 @@ x-observability: x-subscription: # REQUIRED on event-topic receivers name: on-{event-description} # MUST equal the operation file stem # NO 'filter' field — filters are derived from messages: above. - # Generator emits: Label = '{Entity}.{Action}' (or OR-chain for multi-message subscribers) + # Generator emits: sys.Label = '{Entity}.{Action}' (or OR-chain for multi-message subscribers) # AND-merge tenant/channel scoping via requiredHeaders: # requiredHeaders: { tenantId: '' } → AND user.tenantId = '' # requiredHeaders: { channel: email } → AND user.channel = 'email' (requires x-envelope-promote on snapshot field) diff --git a/samples/message-samples.yaml b/samples/message-samples.yaml index 63efdc8..e80ea72 100644 --- a/samples/message-samples.yaml +++ b/samples/message-samples.yaml @@ -279,7 +279,7 @@ _eventHandlerOperation: x-subscription: # REQUIRED on event-topic receivers name: on-order-submitted # MUST equal the operation file stem # NO 'filter' field — filters are derived from the messages: list above. - # The generator emits: Label = 'Order.Submitted' + # The generator emits: sys.Label = 'Order.Submitted' # AND-merge tenant/channel scoping via requiredHeaders (see sample 5c below). # ============================================================================= @@ -318,7 +318,7 @@ _eventHandlerWithEmits: alertOnDlq: true x-subscription: name: on-customer-created-for-enrichment # MUST equal operation file stem - # NO 'filter' — derived from messages: → Label = 'Customer.Created' + # NO 'filter' — derived from messages: → sys.Label = 'Customer.Created' # ============================================================================= # 5c. CHANNEL-AWARE EVENT HANDLER (uses requiredHeaders + x-envelope-promote) @@ -360,12 +360,12 @@ _channelAwareHandler: name: on-notification-job-dispatch-email # MUST equal operation file stem requiredHeaders: # Declarative — AND-merged with derived label filter channel: email - # Generator emits: Label = 'NotificationJob.Created' AND user.channel = 'email' + # Generator emits: sys.Label = 'NotificationJob.Created' AND user.channel = 'email' # Alternative for forward-compat or unusual SQL predicates: # x-subscription: # name: on-... -# filterOverride: "Label LIKE 'Customer.%' OR Label LIKE 'Order.%'" +# filterOverride: "sys.Label LIKE 'Customer.%' OR sys.Label LIKE 'Order.%'" # Requires `description` ≥ 40 chars on the operation justifying why derivation is insufficient. # Mutually exclusive with requiredHeaders. @@ -475,7 +475,7 @@ _aiPoweredOperation: alertOnDlq: true x-subscription: name: on-customer-created-for-enrichment # MUST equal operation file stem - # NO 'filter' — derived from messages: → Label = 'Customer.Created' + # NO 'filter' — derived from messages: → sys.Label = 'Customer.Created' x-ai: # Marks this as AI-powered enabled: true task: generation # generation | classification | extraction | etc. diff --git a/schemas/spectral/fixtures/subscription-filter-sys-label.yaml b/schemas/spectral/fixtures/subscription-filter-sys-label.yaml new file mode 100644 index 0000000..0f104f3 --- /dev/null +++ b/schemas/spectral/fixtures/subscription-filter-sys-label.yaml @@ -0,0 +1,71 @@ +# ============================================================================= +# Regression fixture: filterOverride must write the label as sys.Label +# ============================================================================= +# In a Service Bus SQL filter a bare identifier is `user.` (application +# property) scope. The event label is the system property `sys.Label`, so a +# bare `Label = 'X'` names a property no producer sets and the subscription +# silently receives nothing. The generator emits `sys.Label` for derived filters +# but copies a filterOverride verbatim, so specfuse-subscription-filter-sys-label +# is the only check (RestoManager handoff, restomanager-specs #433). +# +# on-bare-equality flagged: Label = 'X' +# on-bare-or-chain flagged: (Label = 'X' OR Label = 'Y') +# on-bare-like flagged: Label LIKE 'X.%' +# on-mixed flagged: one sys.Label clause, one bare Label clause +# on-sys-label clean — sys.Label = / OR / LIKE +# on-sys-label-header clean — sys.Label AND user.
+# +# The rule must fire on the first four AND stay silent on the last two, or it +# is either inert or noise. Not a Specfuse-conformant example — do not copy. +# ============================================================================= +asyncapi: 3.0.0 +info: + title: Subscription filter sys.Label fixture + version: "1.0.0" +channels: + domainEvents: + address: domain-events + messages: {} +operations: + on-bare-equality: + action: receive + description: Fixture override; exists only to exercise the sys.Label scope rule in CI. + channel: { $ref: '#/channels/domainEvents' } + x-subscription: + name: on-bare-equality + filterOverride: "Label = 'Order.Submitted'" + on-bare-or-chain: + action: receive + description: Fixture override; exists only to exercise the sys.Label scope rule in CI. + channel: { $ref: '#/channels/domainEvents' } + x-subscription: + name: on-bare-or-chain + filterOverride: "(Label = 'Order.Submitted' OR Label = 'Order.Cancelled')" + on-bare-like: + action: receive + description: Fixture override; exists only to exercise the sys.Label scope rule in CI. + channel: { $ref: '#/channels/domainEvents' } + x-subscription: + name: on-bare-like + filterOverride: "Label LIKE 'Order.%'" + on-mixed: + action: receive + description: Fixture override; exists only to exercise the sys.Label scope rule in CI. + channel: { $ref: '#/channels/domainEvents' } + x-subscription: + name: on-mixed + filterOverride: "sys.Label = 'Order.Submitted' OR Label = 'Order.Cancelled'" + on-sys-label: + action: receive + description: Fixture override; exists only to exercise the sys.Label scope rule in CI. + channel: { $ref: '#/channels/domainEvents' } + x-subscription: + name: on-sys-label + filterOverride: "(sys.Label = 'Order.Submitted' OR sys.Label = 'Order.Cancelled') OR sys.Label LIKE 'Refund.%'" + on-sys-label-header: + action: receive + description: Fixture override; exists only to exercise the sys.Label scope rule in CI. + channel: { $ref: '#/channels/domainEvents' } + x-subscription: + name: on-sys-label-header + filterOverride: "sys.Label = 'NotificationJob.Created' AND user.channel = 'email'" diff --git a/schemas/spectral/specfuse-asyncapi.yaml b/schemas/spectral/specfuse-asyncapi.yaml index 8edfd3b..72bb83c 100644 --- a/schemas/spectral/specfuse-asyncapi.yaml +++ b/schemas/spectral/specfuse-asyncapi.yaml @@ -739,6 +739,24 @@ rules: then: function: asyncSubscriptionFilterEntityCap + # The event label is the broker's system property: a Service Bus SQL filter + # reads it as `sys.Label`. A bare identifier defaults to `user.` scope, so a + # bare `Label = 'X'` names an application property no producer sets — the + # filter is accepted and the subscription silently receives nothing. The + # generator always emits `sys.Label` for derived filters but copies a + # filterOverride verbatim and does not check it, so this rule has no generator + # counterpart. Warn, not error: the override is the author's escape hatch. + # See AsyncAPI_Handbook.md §0.8. + specfuse-subscription-filter-sys-label: + description: "x-subscription.filterOverride must reference the label as sys.Label; a bare Label is user scope and matches nothing." + message: "filterOverride uses an unqualified 'Label', which Service Bus reads as the application property user.Label and so matches nothing. Write sys.Label." + severity: warn + given: $.operations[*].x-subscription.filterOverride + then: + function: pattern + functionOptions: + notMatch: "(^|[^.A-Za-z_])Label\\b" + specfuse-async-subscription-trigger-mode-values: description: "x-trigger-mode must be: auto or explicit" message: "x-trigger-mode '{{value}}' is not valid. Must be 'auto' (default — generator-emitted dispatcher) or 'explicit' (typed service method, required when payload.context is present)." From ce603cd526380e18a74943aed1cc7bef3aa65a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Fri, 9 Oct 2026 16:38:37 -0400 Subject: [PATCH 2/4] docs: x-actors ref is the principal identity; x-emits: [] declares a silent write; role-grant corrections (RestoManager handoffs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arazzo x-actors..ref (arazzo-actor-ref-identity handoff): - Arazzo_Handbook §4.6: .ref is the principal identity, the id the tokens carry as authorization.claims.userId (Project_File §16), never a domain row's id. Note on the failure mode (self-scoped steps run as an unresolvable principal), the recipe pattern (identity linked to the domain row, separate *UserId and *Id outputs), device actors, and the generator asks (clabonte/generator#2414, #2352). - Examples renamed to customerUserId / agentUserId / managerUserId; the foundational-recipe table exposes them; §4.6 recipe prose, §13 Do and §15.2 aligned. x-emits: [] (x-emits-empty-silent-writes handoff): - AsyncAPI_Handbook §6.3 and Vendor_Extensions §6.1: [] declares a deliberately silent write, distinct from an absent x-emits; a justifying YAML comment is mandatory, as for x-self-scoped. Documents the kit rule (error, truthy, [] passes, no x-manual skip) and generator 0.14.0 WRITE_OPERATION_MISSING_X_EMITS (WARNING, presence-only, skips x-manual; census asked in clabonte/generator#2415). The :search POST question is recorded as open. - schemas/README: optional project rule confining [] to an allowlist. Ruleset behaviour unchanged. Role grants (role-grants-and-scope-mode handoff): - Vendor_Extensions §3.1: delegable is published in role-grants.json and enforced by nothing at 0.14.0 (FEAT-2026-0203, clabonte/generator#2180). - API_Handbook §14: replaces "enforcement occurs in implementation" with what 0.14.0 enforces (mapping form, coherence, authorization.mode). - Arazzo_Handbook §4.6 and §9.2: ARAZZO_ACTOR_LACKS_SCOPE, scopes mode only; measured not firing on a mapping-form hello-orders copy. Co-Authored-By: Claude Opus 5.5 --- handbooks/API_Handbook.md | 11 +++++++- handbooks/Arazzo_Handbook.md | 47 ++++++++++++++++++++++++---------- handbooks/AsyncAPI_Handbook.md | 25 ++++++++++++++++++ handbooks/Vendor_Extensions.md | 20 ++++++++++++--- schemas/README.md | 19 ++++++++++++++ 5 files changed, 105 insertions(+), 17 deletions(-) diff --git a/handbooks/API_Handbook.md b/handbooks/API_Handbook.md index 4f61136..bd4a5b1 100644 --- a/handbooks/API_Handbook.md +++ b/handbooks/API_Handbook.md @@ -2781,7 +2781,16 @@ The role set is project-defined; declare it in `info.x-roles` (see `Vendor_Exten - `x-roles`: privileged + administrative roles - `x-scopes`: `[..write]`, or `[..delete]` for a DELETE — `delete` is a distinct action, not a subset of `write` -> These extensions document intent for reviewers and generators; enforcement occurs in implementation and/or gateway policy. +**Roles can declare their grants.** From generator `0.14.0`, `info.x-roles` may be a mapping of role name to `{ grants, implicit, superuser, delegable, description }` instead of a list of names (`Vendor_Extensions.md` §3.1). With grants declared, the templates above stop being a convention to remember: the generator checks each operation's `x-roles` against the roles whose grants cover its `x-scopes`, and the `authorizationMatrix` artifact shows where the two differ (`Project_File.md` §11.5). A list stays valid; it declares no grants, so there is nothing to compare. + +**What is enforced, at generator `0.14.0`:** + +- **`x-roles`** — membership in `info.x-roles` (`OPERATION_UNKNOWN_ROLE`, ERROR) in either shape; and, under the default `authorization.mode: roles`, the generated C# controller gate (`[RoleRequired(…)]`). +- **`x-scopes`** — the kit's Spectral rules own the grammar (`error`). The generator checks shape and registry binding, and, against a mapping-form registry, role/scope coherence: WARNING by default, ERROR under `authorization.mode: scopes` (`Vendor_Extensions.md` §3.2). +- **`authorization.mode: scopes`** (`Project_File.md` §16) moves the generated controller gate from roles to scopes (`[ScopesRequired(…)]`), and `shadow` runs both while the role decision still governs. The generated runtime is the opt-in `authorizationRuntime` artifact. +- **`delegable`** is declared and published, not enforced (`Vendor_Extensions.md` §3.1). + +What the generator does not emit — gateway policy, an identity provider's role assignment, the tenant-resource check behind the generated `ITenantResourceCheck` seam — remains the implementation's. --- diff --git a/handbooks/Arazzo_Handbook.md b/handbooks/Arazzo_Handbook.md index d842710..4958a5d 100644 --- a/handbooks/Arazzo_Handbook.md +++ b/handbooks/Arazzo_Handbook.md @@ -208,11 +208,11 @@ x-actors: customer: role: Customer description: "The customer who placed the original order" - ref: $setup.outputs.customerId + ref: $setup.outputs.customerUserId agent: role: SupportAgent description: "The support agent who reviews the refund request" - ref: $setup.outputs.agentId + ref: $setup.outputs.agentUserId x-setup: recipe: completed-order-with-customer @@ -301,22 +301,22 @@ x-doc: ### 4.6 `x-actors` (required on scenario workflows, forbidden on recipes) -Declares the actors who perform steps in the scenario. Each actor is bound to a role from the project's closed role set and optionally to an entity seeded by a setup recipe. +Declares the actors who perform steps in the scenario. Each actor is bound to a role from the project's closed role set and optionally to the principal identity a setup recipe provisions. ```yaml x-actors: customer: role: Customer description: "The customer who placed the order" - ref: $setup.outputs.customerId + ref: $setup.outputs.customerUserId agent: role: SupportAgent description: "The support agent reviewing the request" - ref: $setup.outputs.agentId + ref: $setup.outputs.agentUserId manager: role: SupportManager description: "The support manager who oversees escalations" - ref: $setup.outputs.managerId + ref: $setup.outputs.managerUserId ``` | Field | Required | Type | Description | @@ -324,15 +324,35 @@ x-actors: | `` | — | object | Unique key within the workflow (camelCase) | | `.role` | Yes | string | Role from the project's closed role set (see below) | | `.description` | No | string | Human-readable description | -| `.ref` | No | expression | Binds the actor to a recipe-seeded entity via `$setup.outputs.X` | +| `.ref` | No | expression | The actor's **principal identity**, via `$setup.outputs.X`: the id the project's tokens carry as the `authorization.claims.userId` claim (default `sub`, `Project_File.md` §16). Never the id of a domain row the principal is associated with. | + +**`ref` is who the step runs as, not what it acts on.** The generated C# scenario tests (`scenarioFunctionalTest`, generator `0.14.0`) mint each actor as a principal whose user-id claim — the claim type `authorization.claims.userId` names — is the `ref` value, verbatim. Bind it to a domain row (a `Customer`, an `Employee`) and every self-scoped step — a `/me/*` route, an `x-self-scoped` operation (`API_Handbook.md` §10.7) — runs as a principal the API cannot resolve, and answers `403` or `404` on a happy path. Nothing reports the binding: Spectral, the Arazzo validator and the cross-spec linker all accept any `$setup.outputs.X` that resolves. + +The recipe pattern that makes the kind visible: + +1. Create the identity (the user, account or device the auth system knows) and link it to the domain row — `NewCustomer.userId`, say. +2. Expose the identity id and the domain id as **separate** outputs: `customerUserId` for `ref`, `customerId` for the path parameters, bodies and event matches that need the domain row. +3. Name the outputs by kind. `*UserId` in `ref`, `*Id` everywhere else, makes a wrong binding visible in review. + +When the principal is a **device** — a kiosk, a terminal signed in as itself — `ref` is that device's identity id, and the actor's `role` is the device role the operation's `x-roles` lists. A person's actor on a device-only route fails the role gate instead. + +A single `ref` carries one id. A token whose role claim also carries a domain id (an `employeeId` inside an object-shaped roles claim) is not minted from `ref`; that shape is a generator question (`clabonte/generator#2352`). Checking that a `ref` output traces back to the identity entity, and that an actor's `role` is in each of its steps' operation `x-roles`, is a generator ask (`clabonte/generator#2414`); until it lands, review both by hand. **Role enum (project-defined, closed set):** The role values are project-specific. The project declares its closed role set in the OpenAPI `info.x-roles` registry (see `Vendor_Extensions.md` §3.1), and the Arazzo validator enforces that every `x-actors.*.role` is a member of that set. The illustrative roles used throughout this handbook (`Customer`, `SupportAgent`, `SupportManager`, `Admin`, `Authenticated`) are examples only -- replace them with your project's actual role values. +**Actor grants (`ARAZZO_ACTOR_LACKS_SCOPE`, generator `0.14.0`).** When `info.x-roles` declares grants (the mapping form, `Vendor_Extensions.md` §3.1), the generator's cross-spec validator can check that an actor's role covers the `x-scopes` of every operation it calls: a step whose actor's role grants miss one of them is an ERROR naming the uncovered scopes. It is the Arazzo counterpart of `OPERATION_ROLE_LACKS_SCOPE`, with three limits: + +- it runs only under `authorization.mode: scopes` (`Project_File.md` §16); under the default `roles` it never runs; +- it compares grants, so it means something only against the mapping form; a `superuser: true` role and a step whose operation declares no `x-scopes` are skipped; +- it checks scopes, not membership: whether the actor's role is in the operation's `x-roles` is not checked by anything (`clabonte/generator#2414`). + +Measured against the released `0.14.0` jar on a mapping-form copy of `hello-orders` under `mode: scopes`, with a `Customer` whose grants miss the scenario's `order.*` scopes, it did **not** fire, while `OPERATION_ROLE_LACKS_SCOPE` reported the same gap on the operations. Do not count on it as the check that catches an under-granted actor yet. + **Recommended convention:** projects that distinguish pre-business-role flows (e.g., self-service signup, invitation acceptance, where the user has a valid auth token but no assigned role yet) should include an `Authenticated` role for that case. -**Why recipes don't have `x-actors`:** recipes execute as an implicit `$system` actor mapped to the project's highest-privileged role (typically `Admin`), which has broad enough permissions to create any fixture the API permits. Scenarios authenticate their own actors against recipe-seeded entities via `x-actors..ref: $setup.outputs.X`. This is the only coupling between recipe execution identity and scenario execution identity. +**Why recipes don't have `x-actors`:** recipes execute as an implicit `$system` actor mapped to the project's highest-privileged role (typically `Admin`), which has broad enough permissions to create any fixture the API permits. Scenarios authenticate their own actors as recipe-provisioned identities via `x-actors..ref: $setup.outputs.X`. This is the only coupling between recipe execution identity and scenario execution identity. **Scheduled-job (system-initiated) scenarios:** Cron-triggered scenarios have no human actor that initiates the workflow -- the job runs as the system. These scenarios still require `x-actors`, but the actor serves as an **observer** who verifies the outcome after the job runs, not as an initiator. Use a role with sufficient read access to inspect results. Apply `x-as: $observer` on the verification steps. @@ -840,8 +860,8 @@ Each project defines a small set of foundational recipes that form the base of i | Recipe | Provides | Extends | |--------|----------|---------| | `minimal-tenant` | `tenantId`, `adminUserId` | (none) | -| `minimal-customer` | `customerId` + parent outputs | `minimal-tenant` | -| `basic-orders` | `orderId1`, `orderId2`, `agentId` + parent outputs | `minimal-customer` | +| `minimal-customer` | `customerId`, `customerUserId` + parent outputs | `minimal-tenant` | +| `basic-orders` | `orderId1`, `orderId2`, `agentUserId` + parent outputs | `minimal-customer` | Domain-specific recipes extend foundational ones to add domain fixtures (e.g., `submitted-order` extends `basic-orders` and finalizes one of the orders). @@ -931,7 +951,8 @@ This means: | `operationId` exists | Arazzo step | OpenAPI `operationId` | Error | | Event `{Entity}.{Action}` exists | `x-async.emit` / `x-async.await` | AsyncAPI message `x-label` | Error | | Status code assertions don't contradict OpenAPI response codes | `successCriteria` | OpenAPI operation responses | Error | -| Actor `role` in closed set | `x-actors.*.role` | Project's OpenAPI `info.x-roles` registry | Error | +| Actor `role` in closed set (`ARAZZO_INVALID_ACTOR_ROLE`) | `x-actors.*.role` | Project's OpenAPI `info.x-roles` registry | Error | +| Actor role's grants cover the step operation's `x-scopes` (`ARAZZO_ACTOR_LACKS_SCOPE`) | `x-actors.*.role` + step `operationId` | Mapping-form `info.x-roles` grants, operation `x-scopes` | Error, under `authorization.mode: scopes` only (§4.6) | | `x-domain` value valid | `x-domain` | Project's active domain list + `cross-domain` | Error | | `cross-domain` only in `scenarios/cross-domain/` | File path + `x-domain` | Directory structure | Error | | `$setup.outputs.X` resolves | Expression | Recipe `outputs` map | Error | @@ -1042,7 +1063,7 @@ These files are the authoritative templates for new scenarios and recipes. When - **Do** use `{Entity}.{Action}` PascalCase format for event references in `x-async`, matching AsyncAPI `x-label`. - **Do** declare `x-version` on every workflow with an accurate `status`. - **Do** include `x-as` on every scenario step to identify the acting user. -- **Do** bind actors to recipe-seeded entities via `x-actors..ref: $setup.outputs.X`. +- **Do** bind actors to recipe-provisioned principal identities via `x-actors..ref: $setup.outputs.X` — the user id the tokens carry, not a domain row's id (§4.6). - **Do** assert at least one observable outcome (event or REST state) for critical-path AI worker flows. - **Do** use the `critical-path` tag on scenarios that must block PR merges. - **Do** follow the Mermaid diagram test (Section 2.2) when deciding whether to split or merge scenarios. @@ -1154,7 +1175,7 @@ Arazzo-generated tests sit at the **top of the testing pyramid** -- integration/ - **Tenant isolation:** fresh disposable tenant provisioned per CI session. No shared state between runs. - **Setup:** recipes use real OpenAPI operations to create fixtures. No database seeding or backdoors. -- **Actor auth:** seeded via the identity API using recipe-provisioned user entities. +- **Actor auth:** each actor runs as the recipe-provisioned identity its `x-actors..ref` names (§4.6). ### 15.3 CI Integration diff --git a/handbooks/AsyncAPI_Handbook.md b/handbooks/AsyncAPI_Handbook.md index fb8f750..01928d1 100644 --- a/handbooks/AsyncAPI_Handbook.md +++ b/handbooks/AsyncAPI_Handbook.md @@ -1304,6 +1304,31 @@ post: description: Emitted when the order is submitted ``` +##### Deliberately silent writes: `x-emits: []` + +A write that publishes nothing **by design** declares an empty list, with a YAML comment saying why: + +```yaml +post: + operationId: logCallNote + # Deliberately silent: CRM writes are recorded in the in-transaction audit + # table, not published (see the crm domain's architecture decision). + x-emits: [] +``` + +`x-emits: []` and an absent `x-emits` mean different things. `[]` is a decision: this write publishes no event, and a reviewer has seen it. Absence is an authoring gap: nobody has said what the write publishes. The accompanying YAML comment is mandatory, as for `x-self-scoped` (`API_Handbook.md` §10.7): it is the only record of why the write is silent, so a reviewer can verify the declaration is warranted. Nothing checks that the comment is there. + +What reads it today: + +| | absent `x-emits` | `x-emits: []` | `x-manual: true` | +|---|---|---|---| +| Kit Spectral `specfuse-emits-required-on-writes` (`error`, `truthy` on the field) | error | passes (an empty list is truthy) | still checked — no exemption | +| Generator `WRITE_OPERATION_MISSING_X_EMITS` (`0.14.0`) | WARNING | passes (it checks the key is present, not its length) | skipped | + +Neither side lists or counts the `[]` declarations, so a silent write is visible only in review. A generator-side census of declared-silent writes is requested in `clabonte/generator#2415`. A project that wants `[]` confined to a known set of operations can add its own rule; see `schemas/README.md` → "What the project must provide". + +**Read-only `POST`s are an open question.** A `:search` `POST` is a write to both checks above, so it needs `x-emits` — `[]` is the honest value for one that publishes nothing. It cannot be exempted by declaring `x-operation.category: query`: a `query` on anything but `GET` is `QUERY_MUST_BE_GET` (ERROR). Whether the kit or the generator should exempt such operations, and how, is undecided. + #### AsyncAPI → AsyncAPI (async receive operations) Every `on-*` receive operation that publishes events via `emit-*` send operations declares `x-emits` to link them: diff --git a/handbooks/Vendor_Extensions.md b/handbooks/Vendor_Extensions.md index de4a8d5..09e9c8c 100644 --- a/handbooks/Vendor_Extensions.md +++ b/handbooks/Vendor_Extensions.md @@ -2101,7 +2101,7 @@ info: | `grants` | string[] | Scopes this role holds, in §3.2's grammar, or `"*"`. | | `implicit` | boolean | Every authenticated principal holds this role without a claim. The scope-model counterpart of the `Authenticated` convention below. | | `superuser` | boolean | Passes every coherence and runtime check regardless of its grants — a declared fact, replacing an `Admin`-by-name short-circuit. | -| `delegable` | boolean | A role another role may grant. Read by the generated authorization runtime, not by any validation rule. | +| `delegable` | boolean | A role another role may grant. **Declared, not enforced** at `0.14.0`: it is published per role in the `role-grants.json` companion of `authorizationMatrix` (`Project_File.md` §11.5), and read by no validation rule and none of the generated C# `Authorization/` runtime files. Enforcement is planned generator-side (`FEAT-2026-0203`, `clabonte/generator#2180`). | | `description` | string | Free text. Read by nothing. | Every key is optional. **Membership validation is identical in both shapes**: @@ -2152,7 +2152,8 @@ no coherence diagnostic to a sequence-form spec. Measured on `hello-orders` `OPERATION_SCOPE_UNBOUND` reads only `x-scopes` and `info.x-domains`, so it runs on either shape, and it overlaps the kit's `specfuse-auth-scopes-registry` (§3.2). Arazzo carries the same check for actors: `ARAZZO_ACTOR_LACKS_SCOPE` -mirrors `OPERATION_ROLE_LACKS_SCOPE`. +mirrors `OPERATION_ROLE_LACKS_SCOPE`, under `authorization.mode: scopes` only +(`Arazzo_Handbook.md` §4.6 records its limits). `OPERATION_ROLE_UNLISTED` is the one to read twice. The coherence target is that an operation's `x-roles` **equals** the set of roles whose grants cover its @@ -2567,7 +2568,7 @@ patch: **Scope**: All write operations (POST, PUT, PATCH, DELETE) -**Required**: Yes — every write operation must declare at least one event. +**Required**: Yes — every write operation declares `x-emits`. A write that deliberately publishes nothing declares `x-emits: []`, with a YAML comment saying why (below). ```yaml post: @@ -2597,6 +2598,19 @@ x-emits: description: All line items in the order are locked from edits ``` +**Deliberately silent writes**: An empty list declares that the write publishes nothing, on purpose: +```yaml +post: + operationId: logCallNote + # Deliberately silent: recorded in the in-transaction audit table, not published. + x-emits: [] +``` +- `x-emits: []` is a decision; an absent `x-emits` is an undeclared write. They are not interchangeable. +- The accompanying YAML comment is mandatory: it documents why the write is silent so a reviewer can verify the declaration, as for `x-self-scoped` (§7.6). Nothing enforces its presence. +- Kit Spectral `specfuse-emits-required-on-writes` is an `error` using `truthy` on the field: an empty list passes, and `x-manual: true` is not exempt. +- Generator `WRITE_OPERATION_MISSING_X_EMITS` (`0.14.0`) is a WARNING that checks only that the key is present, so `[]` passes; it skips `x-manual: true` operations. It has no exemption by `x-operation.category` or path, and it does not report how many writes are declared silent (requested: `clabonte/generator#2415`). +- See `AsyncAPI_Handbook.md` §6.3 for the open question on read-only `POST`s such as `:search`. + **Code generation impact**: - The code generator wires up event publishing in the API layer (outbox pattern) - Each event in `x-emits` generates a publish call at the end of the successful operation diff --git a/schemas/README.md b/schemas/README.md index fc101a7..51a9d26 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -57,6 +57,7 @@ The kit's rules are deliberately **structural** for values that are project-defi | Domain list | `specfuse-arazzo-domain-shape` (Arazzo), `specfuse-async-channel-domain-kebab` (AsyncAPI), `specfuse-async-operation-tag-pascal` (AsyncAPI) | An enumeration rule constraining `x-domain` (kebab-case) and the AsyncAPI operation tag (PascalCase) to the project's declared domain list. | | Channel address prefix | `specfuse-async-channel-address-format` (shape only) | A pattern rule pinning the prefix (e.g., `^myproject\.events$` for the shared event topic, `^myproject\.scheduling\.[a-z-]+$` for scheduled triggers). | | Path-pattern action endpoints | `specfuse-post-201-location` (excludes `/bulk/` and `/_system/` only) | Optional: exclusions for the project's bespoke action verbs (e.g., `/dismiss`, `/process`). | +| Deliberately silent writes | `specfuse-emits-required-on-writes` (`truthy`: `x-emits: []` passes) | Optional: a `length` rule confining `x-emits: []` to the operations the project allows to be silent (below). | A minimal project overlay looks like: @@ -87,6 +88,24 @@ rules: match: "^myproject\\.events$" ``` +### Optional: confining `x-emits: []` + +`x-emits: []` declares a write that deliberately publishes nothing (`AsyncAPI_Handbook.md` §6.3). The kit rule accepts it anywhere, because which writes may be silent is a project decision. A project that has made that decision for a known set of routes can reject `[]` everywhere else. Scope the `given` to your own allowlist — the `/crm/` prefix here is an example, not a convention: + +```yaml +rules: + myproject-emits-empty-only-where-allowed: + description: "x-emits: [] (a deliberately silent write) is allowed only under /crm/" + severity: error + given: $.paths[?(!@property.match("^/crm/"))][post,put,patch,delete].x-emits + then: + function: length + functionOptions: + min: 1 +``` + +Measured with Spectral against a three-operation spec: one error, on the `[]` outside `/crm/`; none on the `[]` under it, or on a non-empty list. + ## Running Spectral so a crash cannot pass as clean **The failure mode of a validation tool is silence.** When Spectral cannot run — From 7510177c8910408005469af6676c61b03edfce8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Fri, 9 Oct 2026 16:36:05 -0400 Subject: [PATCH 3/4] docs: mark unread codeGenHints and x-ai streaming; enum reuse; x-domain and value-object encryption touch-ups (RestoManager handoffs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vendor_Extensions §5.1: "read by nothing" banner for x-operation.codeGenHints (cacheable, auditRequired, eventEmission, transactional, plus async) — parsed into OperationDefinition, read by no code or template. Caching design tracked in clabonte/generator#2173. Phantom CacheLayer/CacheAside/ReadThrough/ CacheableService claims removed. reference/admin/discovery marked removed (generator 0.5.4, hard error); category enum, defaults and example corrected; Code Generation/Patterns lists qualified as intent. - x-ai.capabilities (Vendor_Extensions, AsyncAPI_Handbook): informational, read by nothing; streaming and multiTurn generate nothing. - API_Handbook: webhook placeholder replaced with a not-yet-specified note (clabonte/generator#2169 inbound, #2170 outbound). - API_Handbook enums: reuse-before-define guidance and how to read ENUM_VALUE_SET_DUPLICATE (SUGGESTION, Jaccard >= 0.5); cross-ref AsyncAPI §6.5. - Vendor_Extensions x-domain: SCHEMA_DOMAIN_CROSS_REFERENCED exempts entity projections; bundle-is-the-contract sentence. - project-init CLAUDE.md template: domain registry is also enforced on OpenAPI schemas (SCHEMA_DOMAIN_REQUIRED/UNREGISTERED, ERROR since 0.13.0). - Vendor_Extensions value-object protection: second declaration site is the entity's embed property (x-value-object.protection is not read); stale "nothing warns / #1995 filed" replaced with VALUE_OBJECT_PROTECTION_DECLARED_TWICE (WARNING, 0.13.0); embed-only form is encrypted but absent from the audit (clabonte/generator#2417), so prefer the usage site. Co-Authored-By: Claude Opus 5.5 --- handbooks/API_Handbook.md | 26 ++-------- handbooks/AsyncAPI_Handbook.md | 2 +- handbooks/Vendor_Extensions.md | 61 +++++++++++------------ templates/project-init/CLAUDE.md.template | 2 +- 4 files changed, 36 insertions(+), 55 deletions(-) diff --git a/handbooks/API_Handbook.md b/handbooks/API_Handbook.md index bd4a5b1..915c99d 100644 --- a/handbooks/API_Handbook.md +++ b/handbooks/API_Handbook.md @@ -30,6 +30,8 @@ This handbook defines authoritative rules for designing REST APIs and OpenAPI sp - Never inline inside a model. Define as a separate schema named `{Resource}{EnumName}` (e.g., `OrderStatus`). - Enum values are **camelCase**. - Clients must tolerate new/unknown enum values. + - **Reuse before you define.** Check the shared enum files (`common/enums.yaml`, and the enums of the domain you are in) before adding one. An enum that is identical across domains belongs in `common/` with `x-domain: common`, defined once. Identical values with a different meaning stay separate: a cost scale and a confidence scale can both be `low/medium/high` and are still two enums. + - **Reading `ENUM_VALUE_SET_DUPLICATE`.** The generator compares every pair of enums and reports those whose value sets overlap with a Jaccard ratio of 0.5 or more, ranked higher when the two names share a word, and capped at 50 findings. The message shows the ratio, e.g. `(Jaccard 0.67, shared name token)`. **Jaccard 1.00** means identical value sets: consolidate, unless the meanings differ as above. **Below 1.00** is usually two different enums that share some values; leave them. It is a SUGGESTION, never a build failure. AsyncAPI's equivalent guidance is `AsyncAPI_Handbook.md` §6.5 *Shared Enums*. --- @@ -3122,29 +3124,9 @@ parameters: Advanced patterns for enterprise-scale AI integration: -#### 8. Webhook Subscriptions -**Purpose**: Real-time notifications for AI agents. +#### 8. Webhooks — not yet specified -```yaml -components: - schemas: - WebhookSubscription: - type: object - properties: - id: { type: string, format: uuid } - url: { type: string, format: uri } - events: - type: array - items: - enum: ["customer.created", "customer.updated", "order.submitted"] - secret: { type: string, description: "For signature verification" } - active: { type: boolean, default: true } - filters: - type: object - properties: - tenantId: { type: string } - customerId: { type: string } -``` +Webhooks have no Specfuse vocabulary yet: nothing in the kit or the generator declares, validates or generates them. Tracked in `clabonte/generator#2169` (inbound) and `clabonte/generator#2170` (outbound); until they land, do not model a webhook surface in the spec. #### 9. Transaction Support **Purpose**: Multi-step operations with rollback capability. diff --git a/handbooks/AsyncAPI_Handbook.md b/handbooks/AsyncAPI_Handbook.md index 01928d1..bce89fa 100644 --- a/handbooks/AsyncAPI_Handbook.md +++ b/handbooks/AsyncAPI_Handbook.md @@ -1065,7 +1065,7 @@ x-ai: | `entities` | When `enabled: true` | object | Declares every entity the worker reads/writes (see below) | | `model` | No | string | Preferred model (informational) | | `promptTemplate` | No | string | Path relative to `prompts/` | -| `capabilities` | No | array | One or more of: `structuredOutput`, `toolUse`, `rag`, `vision`, `streaming`, `multiTurn`, `batchProcessing` | +| `capabilities` | No | array | One or more of: `structuredOutput`, `toolUse`, `rag`, `vision`, `streaming`, `multiTurn`, `batchProcessing`. **Informational, read by nothing** — `streaming` and `multiTurn` generate no streaming transport or conversation state | | `estimatedTokens` | No | object | `{input, output}` | | `maxLatency` | No | string (duration) | Max acceptable latency for the AI call | | `fallback` | No | string | `skip`, `queue`, `default`. Default: `queue` | diff --git a/handbooks/Vendor_Extensions.md b/handbooks/Vendor_Extensions.md index 09e9c8c..adec5bc 100644 --- a/handbooks/Vendor_Extensions.md +++ b/handbooks/Vendor_Extensions.md @@ -74,8 +74,8 @@ Error: # shared shape, no owning domain - **Exempt:** `x-entity` schemas, pure `$ref` aliases, and the derived shapes of an entity (`New{E}`, `Basic{E}`, `Update{E}`, `{E}List`) — the generator resolves those to their main model's domain. - **`common` is reserved.** It means *shared, no owning domain*, and it MUST NOT itself be registered in `info.x-domains` — registering it is `SCHEMA_DOMAIN_UNREGISTERED` against the registry. -- **What counts is `components.schemas` after bundling.** A shape you only `$ref` from an operation file is hoisted there by `redocly bundle`, so it needs the key in its source file even though no hand-written `components` block names it. -- A schema that references a schema owned by **another** domain is reported as `SCHEMA_DOMAIN_CROSS_REFERENCED` (WARNING) — a boundary hint, not a failure; `common` is never a cross-reference. +- **What counts is `components.schemas` after bundling.** A shape you only `$ref` from an operation file is hoisted there by `redocly bundle`, so it needs the key in its source file even though no hand-written `components` block names it. The bundle is the contract: folder layout does not survive bundling, so a schema's `domains/{domain}/` location says nothing to the generator, and the key is the only record of its owner that does. +- A schema that references a schema owned by **another** domain is reported as `SCHEMA_DOMAIN_CROSS_REFERENCED` (WARNING) — a boundary hint, not a failure; `common` is never a cross-reference. **Entity projections are exempt**: a `Basic{E}` (or other derived shape of a registered entity) embedded in another domain's schema — a work item holding a `BasicUser` summary — is normal DDD and is not reported. Referencing the full entity, or a domain-owned enum or value shape, across the boundary is. **Spec-author action on the `0.13.0` pin.** Run `validate` on a fresh bundle and add `x-domain` to every schema it names. This rule shipped in `0.13.0` and the kit missed it at that pin bump (`compatibility.md`, `v0.20.0` row); the kit's own example and new-project scaffold both failed it until the fix that added this paragraph. No kit Spectral mirror yet — the derived-shape exemption needs the entity set, which a schema-local rule does not have. @@ -1899,24 +1899,26 @@ The disambiguation is the generator's own (`ProtectionDefinition.isSubKey` over The trap is the word *effective*: the set is the usage site's `queryable` **if it declares one**, and the definition's `defaultQueryable` otherwise. So a usage site that declares no `queryable` at all inherits one and fails, and the fix is to declare `queryable: []` explicitly rather than to remove the key. -**The value object's own schema is a second declaration site, and the usage site wins outright.** From generator `0.13.0` an `x-protection` on the `x-value-object` definition emits the same columns the usage-site form does. Where both declare, **the embed's block is discarded in full — not layered underneath**: +**The embed property is a second declaration site, and the usage site wins outright.** From generator `0.13.0` an `x-protection` on the entity's own embed property — `components.schemas..properties.`, where `x-classification` already sits — emits the same columns the usage-site form does. (A `protection` key under `x-value-object` on the value object's own schema is not one of the sites: the generator does not read it.) Where both declare, **the embed's block is discarded in full — not layered underneath**: ```yaml -PostalAddress: - x-value-object: - defaultStorage: flatten - protection: # definition site - line1: { atRest: hashed, rationale: ... } - city: { atRest: none, rationale: ... } - Person: x-entity: valueObjects: residenceAddress: protection: { atRest: encrypted, rationale: ... } # usage site — this one wins + properties: + residenceAddress: # embed — second declaration site + allOf: [ { $ref: '#/components/schemas/PostalAddress' } ] + x-classification: [pii] + x-protection: + line1: { atRest: hashed, rationale: ... } + city: { atRest: none, rationale: ... } ``` -Measured: every member is `encrypted`. The definition's per-member map does not survive, and **nothing warns**. So a usage site declaring a scalar over an embed that declared per-member nuance silently discards it — and can *weaken* protection at a site whose author believed they were inheriting. A warning for the both-declared case is filed as `clabonte/generator#1995`; the precedence itself is settled and intentional. +Every member is `encrypted`; the embed's per-member map does not survive. So a usage site declaring a scalar over an embed that declared per-member nuance discards it — and can *weaken* protection at a site whose author believed they were inheriting. From generator `0.13.0` this is reported as `VALUE_OBJECT_PROTECTION_DECLARED_TWICE` (WARNING): declare protection at one site. The precedence itself is settled and intentional. + +**Prefer the usage site even when it is the only declaration.** The embed form alone — `x-classification` plus `x-protection: { atRest: encrypted }` on an `allOf` embed of a value object whose members all have a sound canonical round-trip — **is** encrypted per member at generator `0.14.0`, but it is missing from `data-protection-audit.md` entirely (`clabonte/generator#2417`). Until that lands, `x-entity.valueObjects..protection` is the form to use: it is the one the audit sees. > **The audit is coarser than the storage, deliberately.** A value object protected by a scalar usage-site declaration appears as **one row keyed by the embed property name** in both section 1 and the section 2 matrix of `data-protection-audit.md`, while `0.13.0` encrypts **per member**. An assessor reading `residenceAddress | encrypted` is seeing a summary of several columns, not one. @@ -2426,6 +2428,12 @@ Outside enum schemas, prefer the standard `default` keyword on its own. Use `x-d **Scope**: Applied to operation objects (GET, POST, PUT, PATCH, DELETE) +> **⚠ `codeGenHints` is read by nothing. Do not rely on it.** +> +> The generator parses all four hints — `cacheable`, `auditRequired`, `eventEmission`, `transactional` (plus the undocumented `async`) — into fields on its operation definition, and the category defaults below are applied the same way, but **no generator code or template reads those fields**. Declaring `cacheable: true` caches nothing, `auditRequired: true` logs nothing, `eventEmission: true` emits nothing and `transactional: true` opens no transaction; the spec lints and validates clean either way. Treat the block as a note to human readers. Caching design is tracked in `clabonte/generator#2173`. +> +> **Only four categories exist**: `aggregate`, `coordination`, `resource`, `query`. `reference`, `admin` and `discovery` were removed in generator `0.5.4` and are now a hard error (`compatibility.md`, kit `v0.5.4` row); they are kept below, struck through, only so older specs can be read. The "Code Generation" and "Patterns" lists describe design intent, not generated artifacts: what the category actually drives is operation resolution, whether an application service is generated, and the `QUERY_MUST_BE_GET` / `COORDINATION_NOT_GET` checks. + **Schema**: ```yaml x-operation: @@ -2434,9 +2442,9 @@ x-operation: properties: category: type: string - enum: [aggregate, reference, coordination, resource, admin, discovery, query] + enum: [aggregate, coordination, resource, query] # reference, admin, discovery: removed in generator 0.5.4 description: Operation category for code generation patterns - codeGenHints: + codeGenHints: # read by nothing — see the banner above type: object properties: cacheable: @@ -2464,9 +2472,7 @@ x-operation: - Patterns: CommandHandler, EventSourcing, UnitOfWork - Examples: `POST /tenants/{tenantId}/customers`, `PATCH /tenants/{tenantId}/settings` -- **`reference`**: Read-only operations on system-wide reference data - - Code Generation: Repository, CacheLayer, ReadOnlyService - - Patterns: QueryHandler, CacheAside, ReadThrough +- ~~**`reference`**: Read-only operations on system-wide reference data~~ — **removed in generator `0.5.4`; use `query` (a GET).** No cache layer or read-through pattern was ever generated for it. - Examples: `GET /catalogs`, `GET /catalogs/{catalogId}` - **`coordination`**: Operations that coordinate across multiple aggregates @@ -2479,35 +2485,29 @@ x-operation: - Patterns: CRUD, ResourceManager, AccessControl - Examples: `GET /attachments/{attachmentId}`, `PATCH /attachments/{attachmentId}` -- **`admin`**: Administrative operations with elevated permissions +- ~~**`admin`**: Administrative operations with elevated permissions~~ — **removed in generator `0.5.4`**; gate the operation with `x-roles`/`x-scopes` instead - Code Generation: AdminService, AuditLogger, ElevatedPermissions - Patterns: AdminCommand, SystemOperation, ComplianceTracking - Examples: `POST /admin/templates`, `DELETE /admin/templates/{templateId}` -- **`discovery`**: Public-facing discovery and search operations +- ~~**`discovery`**: Public-facing discovery and search operations~~ — **removed in generator `0.5.4`; use `query`** - Code Generation: QueryService, SearchEngine, PublicAPI - Patterns: ReadModel, SearchIndex, PublicEndpoint - Examples: `GET /catalogs/items?category=foo`, `GET /tenants?region=us-east` - **`query`**: Computational operations that process input parameters to generate derived data - - Code Generation: QueryProcessor, ComputationEngine, CacheableService + - Code Generation: QueryProcessor, ComputationEngine (nothing is cached — see the banner) - Patterns: QueryHandler, Calculator, Preview, Projection - Examples: `GET /templates/preview`, `GET /reports/analytics`, `GET /calculations/pricing` -**Default Values by Category**: +**Default Values by Category** (applied to fields nothing reads — see the banner): ```yaml aggregate: cacheable: false, auditRequired: true, eventEmission: true, transactional: true -reference: - cacheable: true, auditRequired: false, eventEmission: false, transactional: false coordination: cacheable: false, auditRequired: true, eventEmission: true, transactional: true resource: cacheable: false, auditRequired: true, eventEmission: false, transactional: true -admin: - cacheable: false, auditRequired: true, eventEmission: true, transactional: true -discovery: - cacheable: true, auditRequired: false, eventEmission: false, transactional: false query: cacheable: true, auditRequired: false, eventEmission: false, transactional: false ``` @@ -2534,15 +2534,15 @@ The code generator will automatically categorize operations using the following # Minimal specification (uses defaults) get: x-operation: - category: reference - # Defaults applied: cacheable=true, auditRequired=false, eventEmission=false, transactional=false + category: query + # Defaults recorded: cacheable=true, auditRequired=false, eventEmission=false, transactional=false (read by nothing) # Override specific hints post: x-operation: category: coordination codeGenHints: - cacheable: true # Override default for this read-heavy coordination operation + cacheable: true # Accepted, read by nothing — this caches nothing # Automatic categorization (no x-operation specified) patch: @@ -2555,7 +2555,6 @@ patch: **Integration with Existing Extensions**: - Works with `x-entity` metadata for aggregate/entity detection -- Respects `x-roles` and `x-scopes` for admin operation identification - Leverages HTTP verb semantics for read-only operation detection --- @@ -3571,7 +3570,7 @@ x-ai: deletes: [] # Entities the worker deletes (must have aiAccess with 'delete') ``` -Valid `capabilities`: `structuredOutput`, `toolUse`, `rag`, `vision`, `streaming`, `multiTurn`, `batchProcessing`. +Valid `capabilities`: `structuredOutput`, `toolUse`, `rag`, `vision`, `streaming`, `multiTurn`, `batchProcessing`. **Informational only — `capabilities` is read by nothing**: no generator code reads the array and no kit rule checks it. In particular `streaming` and `multiTurn` generate no streaming transport and no conversation state; the worker is generated the same with or without them. **`entities` field** (required when `enabled: true`): diff --git a/templates/project-init/CLAUDE.md.template b/templates/project-init/CLAUDE.md.template index 27dd1ac..763db02 100644 --- a/templates/project-init/CLAUDE.md.template +++ b/templates/project-init/CLAUDE.md.template @@ -43,7 +43,7 @@ Active domains in this project: - `{initial-domain}` — (one-line description of what this domain owns) -Add new domains here as the project grows. The Spectral validator loads this list to enforce `x-domain` values on AsyncAPI channels and Arazzo workflows. +Add new domains here as the project grows. The Spectral validator loads this list to enforce `x-domain` values on AsyncAPI channels and Arazzo workflows. On OpenAPI, the generator enforces the `info.x-domains` registry in `api/specs/v1/openapi.yaml` (keep it in step with this list) on schemas too: every non-entity schema in `components.schemas` declares `x-domain` with a registered key or `common` (`SCHEMA_DOMAIN_REQUIRED` / `SCHEMA_DOMAIN_UNREGISTERED`, ERROR since generator 0.13.0), and every entity's `x-entity.domain` must be registered. ### Role registry From 4a049ff922c2625a9edd514314b03dde74fde426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Fri, 9 Oct 2026 16:39:53 -0400 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20compatibility=20=C2=A745=20for=20th?= =?UTF-8?q?e=20RestoManager=20handoff=20batch;=20cite=20generator#2418?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compatibility.md §45 records what this batch corrected in the kit, what it forwarded (clabonte/generator#2414-#2418), what is deferred and what is still open. Arazzo_Handbook.md §4.6 and §9.2 cite #2418: ARAZZO_ACTOR_LACKS_SCOPE does not fire at 0.14.0. Co-Authored-By: Claude Opus 5.5 --- compatibility.md | 34 ++++++++++++++++++++++++++++++++++ handbooks/Arazzo_Handbook.md | 4 ++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/compatibility.md b/compatibility.md index dc424ca..99f3263 100644 --- a/compatibility.md +++ b/compatibility.md @@ -1201,6 +1201,40 @@ Each finding was reported first by a consumer adopting the vocabulary. **At the next pin bump:** re-run the §14.10 matrix (two pillars on `hello-orders`, generate each group) and retire rows as `#2392`/`#2393`/`#2394`/`#2400` close. When `#2397` ships, rewrite §14.11 around `resolve: remote` and add a Spectral rule for the object form of `x-references`. +### 45. RestoManager handoff batch 2026-10-09: what the kit corrected, what it forwarded + +**Status:** kit side **done** for every item that needed no generator design (`AsyncAPI_Handbook.md` §0.8 and §6.3, `Arazzo_Handbook.md` `x-actors` and §4.6, `API_Handbook.md` §8, §14 and Enums, `Vendor_Extensions.md` §3.1, §5.1, §6.1, `x-domain`, `x-ai`, value-object protection; new Spectral rule `specfuse-subscription-filter-sys-label`). Every generator claim was checked against the released `0.14.0` jar or its release commit `bd96ddeaf`, not against the SNAPSHOT a handoff named. Five of the nine handoffs rested on stale generator behaviour. + +**Corrected in the kit because they were wrong at the pin:** +- **Subscription filters used a bare `Label`.** Service Bus reads that as `user.Label`, so it matches nothing. The generator has emitted `sys.Label` since at least `0.11.0`. +- **`x-actors..ref` was described as a domain entity.** It is minted into the principal's `claims.userId` claim. +- **§6.1 forbade `x-emits: []`.** Both the kit rule and the generator accept it, so it is now defined as a deliberately silent write. +- **`delegable` was described as read by the runtime.** It is only published in `role-grants.json`; enforcement is planned (`#2180`, FEAT-2026-0203). +- **`codeGenHints` (all five keys) and `x-ai.capabilities` are parsed and read by nothing.** Three §5.1 categories removed in `0.5.4` were still listed as live. +- **The second value-object protection site was given as `x-value-object.protection`.** It is the embed property's `x-protection`; the generator never reads protection on a value object's own schema. + +**Forwarded:** +- `clabonte/generator#2414` — warn when an actor `ref` is not the principal identity, or the actor's role is not in the step operation's `x-roles`. +- `#2415` — a `WRITE_OPERATION_DECLARED_SILENT` census for `x-emits: []`. +- `#2416` — split `ENUM_VALUE_SET_DUPLICATE` (SUGGESTION) into identical and overlapping, and report identical groups once. +- `#2417` — the data-protection audit omits an encrypted value-object embed property; the usage-site form is reported correctly. +- `#2418` — **`ARAZZO_ACTOR_LACKS_SCOPE` never fires at `0.14.0`.** Under `mode: scopes`, with an actor whose grants miss its steps' scopes, the scenario got no finding while `OPERATION_ROLE_LACKS_SCOPE` reported the same gap ×5. §43 lists the code as shipped; it is in the jar but inert. + +**Deferred, waiting on unscheduled generator design:** +- caching: `x-cache` and `x-entity.cache` (`#2173`–`#2177`); +- streaming: `x-stream`, `client-stream`, webhooks (`#2163`, `#2164`, `#2167`, `#2169`, `#2170`); +- client-side grants (`#2180`); +- a durable-run convention, which waits on `#2164`. + +None of these is settled enough to document ahead of the pin (§24 precedent). + +**Open questions:** +- Whether a read-only `:search` POST may omit `x-emits`. A category-based exemption cannot work, because `query` on POST is `QUERY_MUST_BE_GET` (ERROR). +- `examples/hello-orders` binds `x-actors.*.ref` to the `createCustomer` domain id (`place-order.arazzo.yaml:51`). That is the shape the handbook now calls wrong. The example has no identity entity to bind to, so fixing it is a modelling change, not a rename. +- Two `Vendor_Extensions.md` lines found during the batch and not yet checked against the generator: §5.1 "Automatic Categorization" says an uncategorisable operation is an error, while the code defaults it to `Resource`; and the value-object section says protection keys "exist at both levels". + +**At the next pin bump:** re-test `ARAZZO_ACTOR_LACKS_SCOPE` (`#2418`), then drop the "does not fire" notes in `Arazzo_Handbook.md` §4.6 and §9.2. + --- ## Outstanding kit-side work diff --git a/handbooks/Arazzo_Handbook.md b/handbooks/Arazzo_Handbook.md index 4958a5d..409cf23 100644 --- a/handbooks/Arazzo_Handbook.md +++ b/handbooks/Arazzo_Handbook.md @@ -348,7 +348,7 @@ The role values are project-specific. The project declares its closed role set i - it compares grants, so it means something only against the mapping form; a `superuser: true` role and a step whose operation declares no `x-scopes` are skipped; - it checks scopes, not membership: whether the actor's role is in the operation's `x-roles` is not checked by anything (`clabonte/generator#2414`). -Measured against the released `0.14.0` jar on a mapping-form copy of `hello-orders` under `mode: scopes`, with a `Customer` whose grants miss the scenario's `order.*` scopes, it did **not** fire, while `OPERATION_ROLE_LACKS_SCOPE` reported the same gap on the operations. Do not count on it as the check that catches an under-granted actor yet. +Measured against the released `0.14.0` jar on a mapping-form copy of `hello-orders` under `mode: scopes`, with a `Customer` whose grants miss the scenario's `order.*` scopes, it did **not** fire, while `OPERATION_ROLE_LACKS_SCOPE` reported the same gap on the operations. Do not count on it as the check that catches an under-granted actor yet (`clabonte/generator#2418`). **Recommended convention:** projects that distinguish pre-business-role flows (e.g., self-service signup, invitation acceptance, where the user has a valid auth token but no assigned role yet) should include an `Authenticated` role for that case. @@ -952,7 +952,7 @@ This means: | Event `{Entity}.{Action}` exists | `x-async.emit` / `x-async.await` | AsyncAPI message `x-label` | Error | | Status code assertions don't contradict OpenAPI response codes | `successCriteria` | OpenAPI operation responses | Error | | Actor `role` in closed set (`ARAZZO_INVALID_ACTOR_ROLE`) | `x-actors.*.role` | Project's OpenAPI `info.x-roles` registry | Error | -| Actor role's grants cover the step operation's `x-scopes` (`ARAZZO_ACTOR_LACKS_SCOPE`) | `x-actors.*.role` + step `operationId` | Mapping-form `info.x-roles` grants, operation `x-scopes` | Error, under `authorization.mode: scopes` only (§4.6) | +| Actor role's grants cover the step operation's `x-scopes` (`ARAZZO_ACTOR_LACKS_SCOPE`) | `x-actors.*.role` + step `operationId` | Mapping-form `info.x-roles` grants, operation `x-scopes` | Error, under `authorization.mode: scopes` only; does not fire at `0.14.0` (§4.6, `clabonte/generator#2418`) | | `x-domain` value valid | `x-domain` | Project's active domain list + `cross-domain` | Error | | `cross-domain` only in `scenarios/cross-domain/` | File path + `x-domain` | Directory structure | Error | | `$setup.outputs.X` resolves | Expression | Recipe `outputs` map | Error |