diff --git a/.github/workflows/example-regen.yml b/.github/workflows/example-regen.yml index 45098ed..5a203c6 100644 --- a/.github/workflows/example-regen.yml +++ b/.github/workflows/example-regen.yml @@ -231,6 +231,75 @@ jobs: fi echo "==> specfuse-async-ai-must-have-entities fired." + # An inline enum wrapped in allOf beside a description passed lint and + # generator validate, then failed generate (PROPERTY_INNER_ENUM). The + # generator reports it from 0.16.0 (INLINE_ENUM_SCHEMA, #2579); so does + # the kit. A wrapped $ref must stay silent. + - name: Inline enums, bare and allOf-wrapped (both directions) + run: | + set +e + set -uo pipefail + REPORT="${RUNNER_TEMP}/inline-enum.txt" + spectral lint \ + --ruleset schemas/spectral/specfuse-openapi.yaml \ + schemas/spectral/fixtures/inline-enum-wrapped.yaml \ + >"$REPORT" 2>&1 + STATUS=$? + cat "$REPORT" + [ $STATUS -ge 2 ] && { echo "::error::Spectral crashed on inline-enum-wrapped.yaml" >&2; exit 1; } + fail=0 + for prop in "properties.priority" "properties.channel.allOf"; do + if ! grep "specfuse-no-inline-enums" "$REPORT" | grep -q "Order.$prop"; then + echo "::error::specfuse-no-inline-enums did not fire on Order.$prop." >&2 + fail=1 + fi + done + if grep "specfuse-no-inline-enums" "$REPORT" | grep -q "Order.properties.status"; then + echo "::error::specfuse-no-inline-enums fired on an allOf-wrapped \$ref." >&2 + fail=1 + fi + [ $fail -eq 0 ] || exit 1 + echo "==> inline enums caught bare and wrapped; a wrapped \$ref is not." + + # x-ai.dataHandling (generator 0.16.0) and the closed x-ai shape. Every + # message names its operation through {{path}}, so each case is asserted + # by operation and rule, in both directions. + - name: x-ai shape and dataHandling rules (both directions) + run: | + set +e + set -uo pipefail + REPORT="${RUNNER_TEMP}/ai-data-handling.txt" + spectral lint \ + --ruleset schemas/spectral/specfuse-asyncapi.yaml \ + schemas/spectral/fixtures/ai-data-handling.yaml \ + >"$REPORT" 2>&1 + STATUS=$? + cat "$REPORT" + [ $STATUS -ge 2 ] && { echo "::error::Spectral crashed on ai-data-handling.yaml" >&2; exit 1; } + + fail=0 + for pair in \ + badRegionMissing:specfuse-async-ai-personal-data-region \ + badRegionThreeLetters:specfuse-async-ai-data-handling-shape \ + badPersonalDataString:specfuse-async-ai-data-handling-shape \ + badPersonalDataMissing:specfuse-async-ai-data-handling-shape \ + badDataHandlingUnknownKey:specfuse-async-ai-data-handling-shape \ + badMisspeltDataHandling:specfuse-async-ai-shape; do + op="${pair%%:*}"; rule="${pair#*:}" + if ! grep "$rule" "$REPORT" | grep -q "/operations/$op/"; then + echo "::error::$rule did not fire on $op." >&2 + fail=1 + fi + done + for op in goodPersonalWithRegion goodNoPersonalData goodLowerCaseRegion goodInformationalKeys; do + if grep -E "specfuse-async-ai-(shape|data-handling-shape|personal-data-region)" "$REPORT" | grep -q "/operations/$op/"; then + echo "::error::an x-ai rule fired on $op, which is valid." >&2 + fail=1 + fi + done + [ $fail -eq 0 ] || exit 1 + echo "==> x-ai rules fire on all six violations and stay silent on four valid blocks." + # Regression net for authoring issue #14. Asserts two things that a # passing hello-orders run cannot assert on its own, because the example # contains no nulls and no duplicate enums: @@ -608,6 +677,7 @@ jobs: # The one rule in this file that BREAKS EXISTING SPECS on its pin, # which is precisely why lint has to catch it before generate does. "specfuse-xprotection-unavailable-properties-required|PoisonedReadMissing|an encrypted property with no unavailableProperties member was accepted" + "specfuse-xprotection-unavailable-properties-required|ValueObjectOnlyEncryption|an encrypted flattened value object with no unavailableProperties member was accepted (the jar refuses it from 0.14.0)" # generator 0.15.0, PROTECTION_DIGEST_UNDECLARED (ERROR). The four # cases are exactly the four the jar reports on this fixture. "specfuse-xprotection-digest-undeclared|bareDigestHash|a *Hash with x-internal-only and no x-protection was accepted" @@ -665,16 +735,16 @@ jobs: fail=1 fi - # Value-object-only encryption: MEASURED against generator 0.13.0, - # which emits per-member ciphertext here and still requires nothing. - # The kit must not be stricter than the jar — that fails specs which - # generate cleanly, the adoption-blocking direction. If the generator - # closes this gap, this control flips to an EXPECTED row above. - if grep "$UP_RULE" "$REPORT" | grep -q "ValueObjectOnlyEncryption"; then - echo "::error::${UP_RULE} fired on ValueObjectOnlyEncryption. The jar does not" >&2 - echo "require the member for value-object-only encryption; the kit must not either." >&2 - fail=1 - fi + # Value-object encryption the kit must stay silent on: `single_json` + # (the jar never requires the member) and an inherited storage (the + # jar may, from 0.16.0, but the kit cannot see the value object's + # defaultStorage, and guessing would fail specs that generate). + for vo in ValueObjectEncryptionSingleJson ValueObjectEncryptionInheritedStorage; do + if grep "$UP_RULE" "$REPORT" | grep -q "$vo"; then + echo "::error::${UP_RULE} fired on $vo, which the kit must not flag." >&2 + fail=1 + fi + done # The retirement must not be silent on a scalar it does not apply to. # `not: {contains: ...}` is FALSE for a non-array in JSON Schema, so diff --git a/README.md b/README.md index 893189f..5338a5f 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ The kit is upstream of both: it defines *what* a Specfuse spec must look like. T ## Status -**Incubating** (`v0.24.0`), Apache-2.0. Handbooks, samples, schemas, the `project-init` template, the bundled `hello-orders` example, the `specfuse-authoring` plugin (in the `specfuse/specfuse` marketplace), and the `specfuse authoring` CLI are all in place. Generator-side alignment items are tracked in [`compatibility.md`](compatibility.md#outstanding-generator-side-follow-ups). +**Incubating** (`v0.25.0`), Apache-2.0. Handbooks, samples, schemas, the `project-init` template, the bundled `hello-orders` example, the `specfuse-authoring` plugin (in the `specfuse/specfuse` marketplace), and the `specfuse authoring` CLI are all in place. Generator-side alignment items are tracked in [`compatibility.md`](compatibility.md#outstanding-generator-side-follow-ups). The kit is distributed on PyPI as `specfuse-authoring` and hosted under [`Specfuse/authoring`](https://github.com/Specfuse/authoring). The code generator it drives is distributed separately as a pinned, checksum-verified release asset (see [`generator.lock`](generator.lock)); `specfuse authoring generate` resolves, verifies, and runs it on demand. diff --git a/compatibility.md b/compatibility.md index 6be89a0..716116b 100644 --- a/compatibility.md +++ b/compatibility.md @@ -54,7 +54,8 @@ This file tracks which Specfuse generator commits are known compatible with each | `v0.23.0` (incubating) | generator **`0.14.0`** — `Specfuse/generator-dist` release `v0.14.0`, asset `specfuse-generator-0.14.0.jar`, sha256 `5cd33122…f424cbd0` | **⚠ BREAKING for any spec declaring `info.x-services`, and for any project file whose groups declare `service`.** *Minor: a generator pin bump that renames one registry with no alias and adds a second form to another.* **(1) `info.x-services` becomes `info.x-pillars`, with no alias (generator FEAT-2026-0208).** A spec still declaring `info.x-services` is refused by name with the new ERROR `PILLAR_REGISTRY_LEGACY_KEY` — the key is never read, even beside `x-pillars`; the entry shape (`domains`, `holds`) is unchanged. A `project.json` group carrying `"service"` fails `Project.Load` naming `pillar`. All 14 `SERVICE_*` finding ids become `PILLAR_*` at unchanged severity, so any Spectral ruleset, CI filter, suppression list or findings baseline keyed on a `SERVICE_*` id must be re-keyed. The kit half — the `specfuse-pillars-*` rules, the new `specfuse-pillars-legacy-key` (error) and the handbook sections — landed in **#137** (`compatibility.md` §41). Measured on `examples/hello-orders` against the released jar: the only finding that changes from `0.13.0` is `SERVICE_REGISTRY_MISSING` → `PILLAR_REGISTRY_MISSING`. **(2) `info.x-roles` accepts a mapping form (FEAT-2026-0198).** The list form is unchanged and fully supported; the mapping form adds `grants` (`[.].` scope strings, or `*`), `implicit`, `superuser`, `delegable` and `description`. Nine coherence codes (`ROLE_REGISTRY_SHAPE_INVALID`, `OPERATION_ROLE_LACKS_SCOPE`, `ROLE_GRANT_INVALID`, `OPERATION_SCOPE_UNBOUND`, `ROLE_GRANT_UNBOUND`, `OPERATION_SCOPE_NOT_GRANTED`, `OPERATION_ROLE_UNLISTED`, `ROLE_DEAD_GRANT`, `SCOPE_UNHELD`) check that an operation's `x-roles` equals the roles whose grants cover its `x-scopes` — WARNING by default, ERROR under the new `project.json` `authorization.mode: scopes` (FEAT-2026-0200; `roles`, the default, is byte-identical to `0.13.0`). Every coherence rule except `OPERATION_SCOPE_UNBOUND` is inert on a list-form registry, so an existing list-form spec gains no new diagnostic. **(3) Masked fields are masked on the wire (FEAT-2026-0196).** A C# API group registering `apiMapper` now returns every `atRest: encrypted` string, and every property declaring `x-protection.masking`, masked on every response; a new `x-unmask` request parameter, authorization-gated, returns the clear value. New diagnostics: `MASKING_NON_STRING`, `UNMASK_PARAMETER_ON_LIST` and `UNMASK_VALUE_NOT_MASKED` (ERROR), `UNMASK_FORBIDDEN_TYPE_NOT_DECLARED` (WARNING), `MASKING_DEFAULT_NOT_APPLICABLE` and `UNMASK_UNDECLARED` (INFO). A generated C# `Replace` now keeps the stored value of a masked or `writeOnly` property its PUT body leaves `null`; clearing one is a PATCH. Documented in **#141** (`API_Handbook.md` §1.4.1, `Vendor_Extensions.md` §1.5, `Field_Encryption_Runtime.md` §7, `compatibility.md` §39/§42). **Three jar behaviours contradict the generator's own docs** and are filed upstream (§42): `MASKED_FIELD_WRITE_ONLY_REQUIRED` / `MASKED_FIELD_MASKING_REQUIRED` never fire (a regression from `0.13.0`, `clabonte/generator#2369`); `x-unmask` on a `{Resource}List` GET reports `UNMASK_VALUE_NOT_MASKED` instead of `UNMASK_PARAMETER_ON_LIST` (`#2370`); and a C# `Replace` refuses the `New{Resource}` body `API_Handbook.md` §1.4 prescribes, on `0.13.0` too (`#2371`). **(3b) A generated authorization runtime (FEAT-2026-0199, absent from the generator CHANGELOG — `clabonte/generator#2372`).** Opt-in artifacts `authorizationRuntime` and `authorizationConformanceTest`, the `x-kiosk-session` operation marker, and `project.json` `authorization.claims` / `authorization.kioskSession`. Adopting it removes the hand-written `UserResource` read bypass and the `Admin` short-circuit, so mark `superuser` / `implicit` in a mapping-form `info.x-roles` first. Authorization at this pin is documented in **#142** (`Vendor_Extensions.md` §3.1–§3.3, `Project_File.md` §16, `compatibility.md` §43). **(4) Two generation-time checks that used to pass silently.** An `x-trigger-when` predicate that cannot compile against its message's payload now fails the run with `TRIGGER_WHEN_UNKNOWN_FIELD` instead of emitting `if ()` / `if not :`; and a declared `If-Match` the generated code cannot enforce is reported as `CONCURRENCY_PRECONDITION_NO_GATE` (WARNING). **(5) A create's omitted defaults follow the spec.** A property's own `default:` now outranks the `x-default` its enum schema declares, in every language — `NewCompany.status: {allOf: [$ref CompanyStatus], default: draft}` generated as `Active` through `0.13.0` because `CompanyStatus` declares `x-default: active` — and a Dart `New*` DTO omits an unset optional property that declares a default instead of sending `null`, so the server's default applies. **Measured, not read:** `hello-orders` bundles, validates (`Status: PASSED`, 0 errors) and generates (14 files) on the released `0.14.0` jar; `check-extension-vocabulary.py --require-jar` passes against the real jar (CI runs it only against synthetic jars). **Spec-author action, required before regenerating:** rename `info.x-services` → `info.x-pillars` and every `groups[].service` → `groups[].pillar`, and re-key anything keyed on a `SERVICE_*` id. **Client action, for a C# API group registering `apiMapper`:** masking is not opt-in. Any client that reads an `atRest: encrypted` or `masking` field verbatim now reads `****` plus the last four characters, and a client that echoes it into a write stores that literal string (there is no echo detection). Find those call sites before regenerating. Everything else is opt-in, a WARNING, or a check that fires only on output that did not work before. | -| `v0.24.0` (incubating, current) | generator **`0.15.0`** — `Specfuse/generator-dist` release `v0.15.0`, asset `specfuse-generator-0.15.0.jar`, sha256 `acd0a862…49efe285` | **⚠ BREAKING for C# consumers (generated code drops AutoMapper and FluentAssertions; `If-Match: *` refused for ordinary callers), for Python consumers of generated events and AI workers, for TypeScript clients of a schema named after a JS/DOM global, and for any spec that crosses a pillar boundary with a reverse edge or declares an undeclared `*Hash` / `*Digest`.** *Minor: a generator pin bump that adds authorable keys (`x-references` object form, `x-copied-from`, `x-extensible`, `testGrants`, `kioskSession.role`, `modelNameMapping`), one Spectral rule id, and ERRORs that start failing specs which passed `0.14.0`.* Measured on generator main `51772a985` and re-measured on the released jar (§49). **(1) Mapperly replaces AutoMapper in generated C# (`clabonte/generator#464`).** No generated file references AutoMapper or `IMapper`; each aggregate's `service` gains `Mappers/{Aggregate}DomainMapper.g.cs`, and the Domain and Api `AutoMapperProfile.g.cs` are gone. Consumers reference `Riok.Mapperly` 4.x in Domain and Api, drop `AutoMapper` and `AddAutoMapper(...)`, stop passing `IMapper` to `Default*Service` subclasses, and port hand-written profiles to the generated `{Aggregate}DomainMapperHooks` partials. `apiMapper` now maps `created` / `updated` onto `CreatedAt` / `UpdatedAt`. The artifact `autoMapperTest` is renamed `mapperTest`, and the old id is a **silent** alias (measured: 1 file where `0.14.0` emitted 2, no warning). `templates` prints each aliased artifact once, so `event` no longer appears twice; **`event` is not removed** (measured: accepted, same 6 files). Documented in `Project_File.md` §11.1 and §11.1.1. **(2) AwesomeAssertions replaces FluentAssertions in generated tests (`#2506`).** Swap the test project's package for `AwesomeAssertions` ≥ 9.6.0 and switch hand-written `using FluentAssertions;` in the same change; importing both is ambiguous. **(3) `If-Match: *` is honoured only for a system caller and a `superuser: true` role (`#1562`).** Every generated gate answers any other caller's wildcard with `412`, a declared deviation from RFC 7232; the generated test `_WithWildcardIfMatch_ShouldSucceed` becomes `_ShouldReturn412` (`API_Handbook.md` Concurrency Control, `Vendor_Extensions.md` §3.1). **(4) A `204` C# `DELETE` on an `optimistic` child addressed by its own id is now gated (`#2481`, with `#2508`).** Through `0.14.0` it declared `If-Match` and `412` and enforced neither, with no finding. A hand-written subclass renames `Delete{X}` to `protected override Delete{X}Core` (`CS0534`). **(5) Python.** The generated event runtime no longer reads the legacy flat wire shape and raises the new `MalformedEventMessageError` (not a `ValueError`) for a message that cannot be an event (`#2139`). The envelope is read from broker metadata through `deserialize_message(message)` (`#2477`; read from the templates, not generated and run), and `EventEnvelope.aggregate_version` and `correlation_id` become optional. A received envelope missing `eventId` or `producedAt`, or a body that is not a UTF-8 JSON object, is a `MalformedEventMessageError` too (`#2511`, `#2512`). An AI worker's Repository Protocol takes the generated aggregate repository's shapes: `add_(entity)` and positional `update_(entity_id, **changes)` returning the row (`#2480`). A required value-object member with a default is now required by pydantic (`#2494`). **(6) TypeScript: a schema named after a JS/DOM global gets a `Model` suffix, with no alias (`#1740`, `#2450`).** The kit's own `Error` envelope becomes `ErrorModel` (measured on `hello-orders`); the new group key `modelNameMapping` picks another name (TypeScript only: a C# group accepts and ignores it, `0.14.0` fails the load on it). `Project_File.md` §3.3. **(7) `dartDtoMapper` is retired (`#1860`).** It emits nothing and warns at `validate` and `generate`; the next release refuses it with `UNKNOWN_ARTIFACT_TYPE` (`Project_File.md` §11.4.2). **(8) Cross-pillar edges (`compatibility.md` §44, `Vendor_Extensions.md` §14.10).** `#2392`, `#2394` and `#2400` are fixed: a cross-pillar `belongsTo` with a hold generates in **both** pillars' groups, the child may stay an `entity`, an unbound C# group of a held spec builds with 0 errors where `0.14.0` failed `CS0246` on `Read*`, Dart and TypeScript `dto` stop emitting `Read{Entity}`, and `x-references: none` on `tenantId` is no longer bound as a foreign key. `#2393` is replaced by a stricter rule: **`PILLAR_CROSS_BOUNDARY_REFERENCE` (ERROR) now fires on a cross-pillar reverse `hasMany` / `hasOne` even when the child is held, at spec-level `validate` too** — a spec that passed spec-level `validate` at `0.14.0` fails it now. The generator CHANGELOG has **no entry** for `#2392`, `#2393` / `#2394` or `#2400`; §14.10 is the record. A child-create nested under another pillar's parent still fails `generate` after a clean project-level `validate`. **(9) New, opt-in surface.** `x-references: { entity, resolve: remote }` (`#2397`): a cross-pillar reference with no replica — a plain uuid column, no `holds` needed, the tenant marker when it targets the tenant root, a dashed `(remote)` edge in `entityDiagram` — plus two generator-only WARNINGs, `PILLAR_REMOTE_REFERENCE_ALSO_HELD` and `_SAME_PILLAR` (`Vendor_Extensions.md` §1.7, §14.11); the kit's `specfuse-x-references-shape` now accepts the object form and checks it (it used `function: pattern`, which passed **any** object untested). `x-copied-from: { via, property }` (`#2348`) with `DDD_COPIED_FROM_VIA_NOT_REFERENCE` / `_UNKNOWN_PROPERTY` / `_TYPE_MISMATCH` (ERROR), silent on `0.14.0`, and **not** an exit for `REQUIRED_ENUM_MISSING_DEFAULT` (§1.10; §40). `x-extensible: true` on an enum (`#1865`), with `EXTENSIBLE_ON_NON_ENUM` / `EXTENSIBLE_NOT_BOOLEAN` (ERROR); an unmarked enum's output is unchanged apart from inert support code (§1.4.1). `project.json` `authorization.testGrants` (`#2402`; three `TEST_GRANTS_*` load failures; with `x-scopes`-only operations a generated `CustomersTest.g.cs` went from 7 to 13 tests) and `authorization.kioskSession.role`, which arms `KIOSK_SESSION_ROLE_UNSATISFIABLE`; `KIOSK_SESSION_INVALID_VALUE` fires on any value but `true` (`Project_File.md` §16, `Vendor_Extensions.md` §3.3). **(10) Rules that start failing a spec that passed `0.14.0`, each measured on a `hello-orders` copy:** `PROTECTION_DIGEST_UNDECLARED` on an `x-entity` schema's own inline string property named `*Hash` / `*Digest` (any case, **no** `date-time` exclusion) with no `x-protection` — mirrored by the new kit rule `specfuse-xprotection-digest-undeclared` (error, unresolved), which agrees with the jar on the fixture in both directions (`Vendor_Extensions.md` §1.3, §1.5); `OPERATION_RESPONSE_TYPE_OVERRIDDEN` (§6.1); `OPERATION_REPLACE_BODY_UNSUPPORTED` on a C# PUT taking the root, an `Update*` / `Basic*` or another entity's `New*` — and a PUT taking `New{Resource}` now **generates**, so `API_Handbook.md` §1.4's body rule and the jar agree (`#2371`); `MASKED_FIELD_WRITE_ONLY_REQUIRED` / `MASKED_FIELD_MASKING_REQUIRED` fire again, and `x-unmask` on a `{Resource}List` GET is `UNMASK_PARAMETER_ON_LIST` (`#2369`, `#2370`); `VALUE_OBJECT_ENCRYPTED_QUERYABLE` on a direct-`$ref` embed (still silent on the kit's one-item `allOf` embed) and `VALUE_OBJECT_FREE_FORM_MAP` on the CLI path; `ENTITY_CONCURRENCY_REASON_REQUIRED` for a body-only write; `ARAZZO_ACTOR_LACKS_SCOPE` (`#2418`), which under `mode: scopes` fires on a **sequence-form** registry too (unmodified `hello-orders` plus `mode: scopes`: `PASSED` → 3 ERRORs); under `mode: scopes`, `OPERATION_MISSING_SCOPES` replaces `OPERATION_MISSING_ROLES` (`#2402`). WARNING only: `ALLOF_TOO_MANY_ITEMS` / `ALLOF_MISSING_REF` / `ALLOF_STRUCTURAL_COMPANION`, `ENTITY_CONCURRENCY_WRITER_ROLE_UNREADABLE` (body-only writes), `OPERATION_INDIVIDUAL_FILTER_PARAMS` (never on the kit's `{Resource}List` shape). `DELETE_SOFT_TIMESTAMP_OVERLAP` no longer fires on `leftAt` (`#1441`). **Not** changed, despite the CHANGELOG: `SCHEMA_DOMAIN_CROSS_REFERENCED` fired on no measured spec, direct or through an alias. **(11) Finding-code changes.** `ENUM_PROPERTY_LEVEL_DEFAULT_IGNORED` is removed (it never fired); a malformed `x-entity` the parser rejects (every malformed `uniqueBy`, an unknown `concurrency` mode or `reason`, an unknown `delete` mode or `archiveVisibility`) is `INVALID_EXTENSION_VALUE` instead of `ENTITY_INVALID_CONFIG`, both ERROR, and its location no longer names the schema; `x-entity.requiresPagination` keeps its WARNING with a "retired" message (§48); `validate`, `generate` and `templates` text no longer cites internal `FEAT-` / `WU-` ids (`#2495`). **(12) Generated-behaviour fixes a client can observe:** a `style: form, explode: false` array query parameter is split by C# controllers (a value outside `items.enum` is a `400`) and comma-joined by Dart and TypeScript clients; an optional nested value object absent from stored JSON reads `null`, not `Empty`; the outbox writes a `required` null member instead of dropping it; a Dart typed error carries `ErrorType? errorType`; array-of-object `example`s seed builders element by element; an `atRest: encrypted` value object left out of a C# event snapshot is named in `redactedChanges` instead of failing generation with `REDACTED_CHANGE_FIELD_NOT_SCALAR` (`#2545`); the C# `UnprocessableEntityException`, `BadRequestException` and `PreconditionFailedException` doc comments state what their status means (`#2502`, `API_Handbook.md` §2.2). New Dart artifact `dartListTileTest`, a render-smoke test per list tile (`Project_File.md` §11.3). **Kit corrections found while measuring, true at `0.14.0` too:** `Vendor_Extensions.md` §1.4 said camelCase is the default enum value case (every language emits the value as written); `ENTITY_CONCURRENCY_UNDECLARED` and `_REASON_REQUIRED` are ERROR, not WARNING; `x-entity.concurrency.reason` vocabulary errors were `ENTITY_INVALID_CONFIG` at `0.14.0`, not `INVALID_EXTENSION_VALUE`; the data-protection audit missed an encrypted embed only when `x-entity.valueObjects` also listed it (`#2417`). `#2416` was closed as not planned. **Measured, not read:** `hello-orders` bundles, validates (`0` errors, `19` warnings, `11` suggestions, finding codes identical to `0.14.0`; message text differs only where `#2495` dropped planning ids) and generates (14 files; the only content change is the inert `x-extensible` support in `EnumMemberJsonConverter.g.cs`); a fresh `init` scaffold validates with codes identical to `0.14.0` (`0` errors, `ASYNC_CHANNEL_NO_MESSAGES` and `PILLAR_REGISTRY_MISSING`) and generates 0 files; `check-extension-vocabulary.py --require-jar` passes, and `extensions --format json` differs from `0.14.0` only by the new `enum` section (`x-extensible`, `x-enum-case`) and one deprecation reason string. **Spec-author action:** drop every cross-pillar reverse `hasMany` / `hasOne`; give every `*Hash` / `*Digest` entity property an `x-protection` (with its `x-classification`); make every C# PUT take `New{Resource}`; under `mode: scopes`, give every secured operation `x-scopes` and move `info.x-roles` to the mapping form before switching; delete `x-entity.requiresPagination`; re-key any filter on `ENTITY_INVALID_CONFIG` or `ENUM_PROPERTY_LEVEL_DEFAULT_IGNORED`; remove `dartDtoMapper` and prefer `mapperTest`; fix whatever else (10) reports before regenerating. **Client action:** C# — swap AutoMapper for Mapperly and FluentAssertions for AwesomeAssertions, rename overridden `Delete{X}` to `Delete{X}Core`, and make every client that sends `If-Match: *` send the last-read `ETag` unless it is a system caller or a superuser; Python — move AI-worker adapters and call sites to `add_` / positional `update_`, handle `MalformedEventMessageError`, and treat `aggregate_version` as optional; TypeScript — update imports of renamed `*Model` types, `Error` → `ErrorModel` first. | +| `v0.24.0` (incubating) | generator **`0.15.0`** — `Specfuse/generator-dist` release `v0.15.0`, asset `specfuse-generator-0.15.0.jar`, sha256 `acd0a862…49efe285` | **⚠ BREAKING for C# consumers (generated code drops AutoMapper and FluentAssertions; `If-Match: *` refused for ordinary callers), for Python consumers of generated events and AI workers, for TypeScript clients of a schema named after a JS/DOM global, and for any spec that crosses a pillar boundary with a reverse edge or declares an undeclared `*Hash` / `*Digest`.** *Minor: a generator pin bump that adds authorable keys (`x-references` object form, `x-copied-from`, `x-extensible`, `testGrants`, `kioskSession.role`, `modelNameMapping`), one Spectral rule id, and ERRORs that start failing specs which passed `0.14.0`.* Measured on generator main `51772a985` and re-measured on the released jar (§49). **(1) Mapperly replaces AutoMapper in generated C# (`clabonte/generator#464`).** No generated file references AutoMapper or `IMapper`; each aggregate's `service` gains `Mappers/{Aggregate}DomainMapper.g.cs`, and the Domain and Api `AutoMapperProfile.g.cs` are gone. Consumers reference `Riok.Mapperly` 4.x in Domain and Api, drop `AutoMapper` and `AddAutoMapper(...)`, stop passing `IMapper` to `Default*Service` subclasses, and port hand-written profiles to the generated `{Aggregate}DomainMapperHooks` partials. `apiMapper` now maps `created` / `updated` onto `CreatedAt` / `UpdatedAt`. The artifact `autoMapperTest` is renamed `mapperTest`, and the old id is a **silent** alias (measured: 1 file where `0.14.0` emitted 2, no warning). `templates` prints each aliased artifact once, so `event` no longer appears twice; **`event` is not removed** (measured: accepted, same 6 files). Documented in `Project_File.md` §11.1 and §11.1.1. **(2) AwesomeAssertions replaces FluentAssertions in generated tests (`#2506`).** Swap the test project's package for `AwesomeAssertions` ≥ 9.6.0 and switch hand-written `using FluentAssertions;` in the same change; importing both is ambiguous. **(3) `If-Match: *` is honoured only for a system caller and a `superuser: true` role (`#1562`).** Every generated gate answers any other caller's wildcard with `412`, a declared deviation from RFC 7232; the generated test `_WithWildcardIfMatch_ShouldSucceed` becomes `_ShouldReturn412` (`API_Handbook.md` Concurrency Control, `Vendor_Extensions.md` §3.1). **(4) A `204` C# `DELETE` on an `optimistic` child addressed by its own id is now gated (`#2481`, with `#2508`).** Through `0.14.0` it declared `If-Match` and `412` and enforced neither, with no finding. A hand-written subclass renames `Delete{X}` to `protected override Delete{X}Core` (`CS0534`). **(5) Python.** The generated event runtime no longer reads the legacy flat wire shape and raises the new `MalformedEventMessageError` (not a `ValueError`) for a message that cannot be an event (`#2139`). The envelope is read from broker metadata through `deserialize_message(message)` (`#2477`; read from the templates, not generated and run), and `EventEnvelope.aggregate_version` and `correlation_id` become optional. A received envelope missing `eventId` or `producedAt`, or a body that is not a UTF-8 JSON object, is a `MalformedEventMessageError` too (`#2511`, `#2512`). An AI worker's Repository Protocol takes the generated aggregate repository's shapes: `add_(entity)` and positional `update_(entity_id, **changes)` returning the row (`#2480`). A required value-object member with a default is now required by pydantic (`#2494`). **(6) TypeScript: a schema named after a JS/DOM global gets a `Model` suffix, with no alias (`#1740`, `#2450`).** The kit's own `Error` envelope becomes `ErrorModel` (measured on `hello-orders`); the new group key `modelNameMapping` picks another name (TypeScript only: a C# group accepts and ignores it, `0.14.0` fails the load on it). `Project_File.md` §3.3. **(7) `dartDtoMapper` is retired (`#1860`).** It emits nothing and warns at `validate` and `generate`; the next release refuses it with `UNKNOWN_ARTIFACT_TYPE` (`Project_File.md` §11.4.2). **(8) Cross-pillar edges (`compatibility.md` §44, `Vendor_Extensions.md` §14.10).** `#2392`, `#2394` and `#2400` are fixed: a cross-pillar `belongsTo` with a hold generates in **both** pillars' groups, the child may stay an `entity`, an unbound C# group of a held spec builds with 0 errors where `0.14.0` failed `CS0246` on `Read*`, Dart and TypeScript `dto` stop emitting `Read{Entity}`, and `x-references: none` on `tenantId` is no longer bound as a foreign key. `#2393` is replaced by a stricter rule: **`PILLAR_CROSS_BOUNDARY_REFERENCE` (ERROR) now fires on a cross-pillar reverse `hasMany` / `hasOne` even when the child is held, at spec-level `validate` too** — a spec that passed spec-level `validate` at `0.14.0` fails it now. The generator CHANGELOG has **no entry** for `#2392`, `#2393` / `#2394` or `#2400`; §14.10 is the record. A child-create nested under another pillar's parent still fails `generate` after a clean project-level `validate`. **(9) New, opt-in surface.** `x-references: { entity, resolve: remote }` (`#2397`): a cross-pillar reference with no replica — a plain uuid column, no `holds` needed, the tenant marker when it targets the tenant root, a dashed `(remote)` edge in `entityDiagram` — plus two generator-only WARNINGs, `PILLAR_REMOTE_REFERENCE_ALSO_HELD` and `_SAME_PILLAR` (`Vendor_Extensions.md` §1.7, §14.11); the kit's `specfuse-x-references-shape` now accepts the object form and checks it (it used `function: pattern`, which passed **any** object untested). `x-copied-from: { via, property }` (`#2348`) with `DDD_COPIED_FROM_VIA_NOT_REFERENCE` / `_UNKNOWN_PROPERTY` / `_TYPE_MISMATCH` (ERROR), silent on `0.14.0`, and **not** an exit for `REQUIRED_ENUM_MISSING_DEFAULT` (§1.10; §40). `x-extensible: true` on an enum (`#1865`), with `EXTENSIBLE_ON_NON_ENUM` / `EXTENSIBLE_NOT_BOOLEAN` (ERROR); an unmarked enum's output is unchanged apart from inert support code (§1.4.1). `project.json` `authorization.testGrants` (`#2402`; three `TEST_GRANTS_*` load failures; with `x-scopes`-only operations a generated `CustomersTest.g.cs` went from 7 to 13 tests) and `authorization.kioskSession.role`, which arms `KIOSK_SESSION_ROLE_UNSATISFIABLE`; `KIOSK_SESSION_INVALID_VALUE` fires on any value but `true` (`Project_File.md` §16, `Vendor_Extensions.md` §3.3). **(10) Rules that start failing a spec that passed `0.14.0`, each measured on a `hello-orders` copy:** `PROTECTION_DIGEST_UNDECLARED` on an `x-entity` schema's own inline string property named `*Hash` / `*Digest` (any case, **no** `date-time` exclusion) with no `x-protection` — mirrored by the new kit rule `specfuse-xprotection-digest-undeclared` (error, unresolved), which agrees with the jar on the fixture in both directions (`Vendor_Extensions.md` §1.3, §1.5); `OPERATION_RESPONSE_TYPE_OVERRIDDEN` (§6.1); `OPERATION_REPLACE_BODY_UNSUPPORTED` on a C# PUT taking the root, an `Update*` / `Basic*` or another entity's `New*` — and a PUT taking `New{Resource}` now **generates**, so `API_Handbook.md` §1.4's body rule and the jar agree (`#2371`); `MASKED_FIELD_WRITE_ONLY_REQUIRED` / `MASKED_FIELD_MASKING_REQUIRED` fire again, and `x-unmask` on a `{Resource}List` GET is `UNMASK_PARAMETER_ON_LIST` (`#2369`, `#2370`); `VALUE_OBJECT_ENCRYPTED_QUERYABLE` on a direct-`$ref` embed (still silent on the kit's one-item `allOf` embed) and `VALUE_OBJECT_FREE_FORM_MAP` on the CLI path; `ENTITY_CONCURRENCY_REASON_REQUIRED` for a body-only write; `ARAZZO_ACTOR_LACKS_SCOPE` (`#2418`), which under `mode: scopes` fires on a **sequence-form** registry too (unmodified `hello-orders` plus `mode: scopes`: `PASSED` → 3 ERRORs); under `mode: scopes`, `OPERATION_MISSING_SCOPES` replaces `OPERATION_MISSING_ROLES` (`#2402`). WARNING only: `ALLOF_TOO_MANY_ITEMS` / `ALLOF_MISSING_REF` / `ALLOF_STRUCTURAL_COMPANION`, `ENTITY_CONCURRENCY_WRITER_ROLE_UNREADABLE` (body-only writes), `OPERATION_INDIVIDUAL_FILTER_PARAMS` (never on the kit's `{Resource}List` shape). `DELETE_SOFT_TIMESTAMP_OVERLAP` no longer fires on `leftAt` (`#1441`). **Not** changed, despite the CHANGELOG: `SCHEMA_DOMAIN_CROSS_REFERENCED` fired on no measured spec, direct or through an alias. **(11) Finding-code changes.** `ENUM_PROPERTY_LEVEL_DEFAULT_IGNORED` is removed (it never fired); a malformed `x-entity` the parser rejects (every malformed `uniqueBy`, an unknown `concurrency` mode or `reason`, an unknown `delete` mode or `archiveVisibility`) is `INVALID_EXTENSION_VALUE` instead of `ENTITY_INVALID_CONFIG`, both ERROR, and its location no longer names the schema; `x-entity.requiresPagination` keeps its WARNING with a "retired" message (§48); `validate`, `generate` and `templates` text no longer cites internal `FEAT-` / `WU-` ids (`#2495`). **(12) Generated-behaviour fixes a client can observe:** a `style: form, explode: false` array query parameter is split by C# controllers (a value outside `items.enum` is a `400`) and comma-joined by Dart and TypeScript clients; an optional nested value object absent from stored JSON reads `null`, not `Empty`; the outbox writes a `required` null member instead of dropping it; a Dart typed error carries `ErrorType? errorType`; array-of-object `example`s seed builders element by element; an `atRest: encrypted` value object left out of a C# event snapshot is named in `redactedChanges` instead of failing generation with `REDACTED_CHANGE_FIELD_NOT_SCALAR` (`#2545`); the C# `UnprocessableEntityException`, `BadRequestException` and `PreconditionFailedException` doc comments state what their status means (`#2502`, `API_Handbook.md` §2.2). New Dart artifact `dartListTileTest`, a render-smoke test per list tile (`Project_File.md` §11.3). **Kit corrections found while measuring, true at `0.14.0` too:** `Vendor_Extensions.md` §1.4 said camelCase is the default enum value case (every language emits the value as written); `ENTITY_CONCURRENCY_UNDECLARED` and `_REASON_REQUIRED` are ERROR, not WARNING; `x-entity.concurrency.reason` vocabulary errors were `ENTITY_INVALID_CONFIG` at `0.14.0`, not `INVALID_EXTENSION_VALUE`; the data-protection audit missed an encrypted embed only when `x-entity.valueObjects` also listed it (`#2417`). `#2416` was closed as not planned. **Measured, not read:** `hello-orders` bundles, validates (`0` errors, `19` warnings, `11` suggestions, finding codes identical to `0.14.0`; message text differs only where `#2495` dropped planning ids) and generates (14 files; the only content change is the inert `x-extensible` support in `EnumMemberJsonConverter.g.cs`); a fresh `init` scaffold validates with codes identical to `0.14.0` (`0` errors, `ASYNC_CHANNEL_NO_MESSAGES` and `PILLAR_REGISTRY_MISSING`) and generates 0 files; `check-extension-vocabulary.py --require-jar` passes, and `extensions --format json` differs from `0.14.0` only by the new `enum` section (`x-extensible`, `x-enum-case`) and one deprecation reason string. **Spec-author action:** drop every cross-pillar reverse `hasMany` / `hasOne`; give every `*Hash` / `*Digest` entity property an `x-protection` (with its `x-classification`); make every C# PUT take `New{Resource}`; under `mode: scopes`, give every secured operation `x-scopes` and move `info.x-roles` to the mapping form before switching; delete `x-entity.requiresPagination`; re-key any filter on `ENTITY_INVALID_CONFIG` or `ENUM_PROPERTY_LEVEL_DEFAULT_IGNORED`; remove `dartDtoMapper` and prefer `mapperTest`; fix whatever else (10) reports before regenerating. **Client action:** C# — swap AutoMapper for Mapperly and FluentAssertions for AwesomeAssertions, rename overridden `Delete{X}` to `Delete{X}Core`, and make every client that sends `If-Match: *` send the last-read `ETag` unless it is a system caller or a superuser; Python — move AI-worker adapters and call sites to `add_` / positional `update_`, handle `MalformedEventMessageError`, and treat `aggregate_version` as optional; TypeScript — update imports of renamed `*Model` types, `Error` → `ErrorModel` first. | +| `v0.25.0` (incubating, current) | generator **`0.16.0`** — `Specfuse/generator-dist` release `v0.16.0`, asset `specfuse-generator-0.16.0.jar`, sha256 `dcd77128…7e8151` | **⚠ BREAKING for C# consumers with stored enums or encrypted enums, and for any C# project whose `service` emits events without `eventRuntime`.** *Minor: a generator pin bump that adds an authorable key (`x-ai.dataHandling`), four Spectral rules, and ERRORs that start failing specs which passed `0.15.0`.* Measured on the released jar against `0.15.0` (§52): `hello-orders` (fresh bundle) and a fresh `init` scaffold validate with finding lists identical to `0.15.0`'s; `hello-orders` generates 14 files whose only change is the namespace fix; `templates` is unchanged for all four languages; every new diagnostic code is in the CHANGELOG. **(1) `x-ai.dataHandling`** (#153): `personalData` + `providerRegion`, and the generated Python worker refuses to start when the configured provider region differs (`AsyncAPI_Handbook.md` §4.3). New kit rules `specfuse-async-ai-shape` (warn, closes `x-ai` over its ten documented keys), `specfuse-async-ai-data-handling-shape` (error) and `specfuse-async-ai-personal-data-region` (warn); fixture `ai-data-handling.yaml`. **(2) Breaking C#:** `PROJECT_EVENT_RUNTIME_REQUIRED` (`Project_File.md` §10); enum columns store the wire value and an encrypted enum is really encrypted, both needing data migrations (`Vendor_Extensions.md` §1.4.1, §1.5). **(3) New findings:** `ENTITY_MISSING_ID` (ERROR) / `ENTITY_MISSING_AUDIT_FIELD`; `ARAZZO_STEP_PAYLOAD_MISSING_REQUIRED` (ERROR) / `_UNDECLARED_PROPERTY`; `ASYNC_EMITS_NO_EMIT_OPERATION`, `ASYNC_WORKER_SELF_TRIGGER`, `FUNCTIONAL_TEST_TAG_UNGROUPED`; `TEST_GRANTS_GRANT_INVALID`, `TENANT_REMOTE_REFERENCE_FOREIGN_KEY_MISMATCH`, `INLINE_ENUM_SCHEMA` on an `allOf`-wrapped enum. **(4) Closed:** every §49 issue (`#2564`–`#2571`), §19, §44 item 8. **(5) Kit rules widened to match the jar:** `specfuse-xprotection-unavailable-properties-required` now covers an encrypted value object declared `storage: flatten` (the jar has required it since `0.14.0`); `specfuse-no-inline-enums` catches an `allOf`-wrapped inline enum. **(6) Ships** the business-retention docs and the `Error.code` note (§51, corrected in §52). **Spec-author action:** register `eventRuntime` or drop `x-emits` where `service` is generated; migrate enum columns and encrypted enums; give every entity an id; fix Arazzo step payloads; declare `dataHandling` on AI workers that touch personal data. | ## How to update this matrix @@ -379,7 +380,7 @@ Every vendor extension the kit closes with `additionalProperties: false` is a sc ### 19. `emit-*` coverage is enforced for one producer kind out of three (`clabonte/generator#1027`) -**Status:** generator-side, filed by a consumer. The kit-side half is documentation only and is done (`AsyncAPI_Handbook.md` §6.3). +**Status:** **closed at generator `0.16.0`.** `ASYNC_EMITS_NO_EMIT_OPERATION` (WARNING) covers OpenAPI write operations and `run-*` jobs, and `validate --producers` reads a `run-*` job's `x-emits` (`#2595`). `AsyncAPI_Handbook.md` §6.3 updated. Originally generator-side, filed by a consumer; the kit-side half was documentation only. The cross-spec validator requires a matching `emit-*` send operation for every `x-emits` on an **`on-*` receive operation**, and requires nothing for the other two producer kinds. Measured on the reporting consumer's bundle: 273 `x-emits` declarations, 212 distinct events, 177 `emit-*` operations, **37 events published with no declaration** — 35 from OpenAPI write operations, 2 from `run-*` jobs, 0 from `on-*` workers. The `on-*` half holding perfectly is what makes the other half's absence easy to miss. @@ -1216,7 +1217,9 @@ The kit keeps both at `error`. The kit's grammar is the authoritative one (the j - **Item 5 (nested route) still open, and now fails both groups.** `createOrder` at `/customers/{customerId}/orders` with `belongsTo` passes project-level `validate` and fails `generate` with the same `OperationResolutionException` in the holder's **and** the owner's group. With `resolve: remote` in place of `belongsTo`, spec-level `validate` catches it (`OPERATION_PARENT_NOT_OWNER`, `OPERATION_NESTED_LIST_NO_RELATIONSHIP`). - **Item 6** unchanged. - **Item 7 closed:** the cross-pillar failures project-level `validate` alone reported are gone, and the one that remains (the reverse edge) is spec-level. -- **Item 8, new: `snapshot` in a pillar-bound C# group emits other pillars' snapshots**, at `0.15.0` and `0.14.0` alike. `SnapshotArtifact` is `GLOBAL` and loads every `*Snapshot.yaml` with no domain filter, so the holder's group fails `CS0246` in `CustomerSnapshot.g.cs` / `TenantSnapshot.g.cs` and the owner's in `OrderSnapshot.g.cs`. Nothing in the spec avoids it. Filed as `clabonte/generator#2568` (§49). Separately, a pillar-bound `event` emitted no `*Event` classes in the repro while `service` referenced them; that may be the repro's bundled AsyncAPI and is not documented. +- **Item 8, new: `snapshot` in a pillar-bound C# group emits other pillars' snapshots**, at `0.15.0` and `0.14.0` alike. `SnapshotArtifact` is `GLOBAL` and loads every `*Snapshot.yaml` with no domain filter, so the holder's group fails `CS0246` in `CustomerSnapshot.g.cs` / `TenantSnapshot.g.cs` and the owner's in `OrderSnapshot.g.cs`. Nothing in the spec avoids it. Filed as `clabonte/generator#2568` (§49); **closed at `0.16.0`**. Separately, a pillar-bound `event` emitted no `*Event` classes in the repro while `service` referenced them; that may be the repro's bundled AsyncAPI and is not documented. + +**Status at `0.16.0`:** item 8 is closed by `clabonte/generator#2568`: a pillar-bound group's `snapshot` and `snapshotContext` emit only owned entities, and no snapshot file fails to compile. Item 5 is unchanged: `validate` is clean at both levels, and `generate` throws `OperationResolutionException` in both groups. Items 1–4, 6 and 7 re-measured as at `0.15.0`. The tenancy-column trap is now `TENANT_REMOTE_REFERENCE_FOREIGN_KEY_MISMATCH` (`#2569`). **Item 9, new, open, not filed:** any domain-filtered C# group (`pillar` or `domains.include`) with `event` emits no `{Message}Event` classes while `service` references them (`CS0246`), at `0.16.0` and `0.15.0`. The unfiltered control builds clean, which settles item 8's aside: the repro's AsyncAPI is not the cause. Re-measured on a split holding `Customer` only, with `Tenant` reached through `resolve: remote`. **What generates on both sides at `0.15.0`:** every form in §14.10 except a cross-pillar reverse edge and a child route nested under another pillar's parent. **Tenancy** has two working forms, `belongsTo` with a hold and `resolve: remote`; the latter must sit on the column named by `tenancy.tenant.foreignKey` (`clabonte/generator#2569`, §49). The Spectral follow-up below is done: `specfuse-x-references-shape` accepts and checks the object form. @@ -1324,7 +1327,7 @@ Of follow-up 23's four keys, `cascadeDelete` and `children` (§30) and now `requ **Why the rest carries over.** None of the 22 commits touches a validation rule, the pillar or tenancy code, the mappers, the concurrency gate, the snapshot artifact or the enum emitter. They change test templates (Dart list-tile and form tests, the C# role-sweep and wildcard-`If-Match` theories, TypeScript fakes), Arazzo poll parameters, the Python event envelope and worker stub, and three C# exception doc comments. Each consumer-visible one is reflected in the kit: `#2502` (`API_Handbook.md` §2.2, §46), the stricter Python envelope reader (`AsyncAPI_Handbook.md` §0.8: a body that is not a UTF-8 JSON object, or an envelope missing `eventId` or `producedAt`, is a `MalformedEventMessageError`; `correlation_id` becomes optional), `dartListTileTest`, and `#2545` (an encrypted value object left out of a C# snapshot is reported in `redactedChanges` instead of failing generation). -**Generator CHANGELOG.** The `0.15.0` section still has no entry for `#2392`, `#2393` / `#2394` or `#2400`, and does not list the held reverse edge that now fails `validate` with `PILLAR_CROSS_BOUNDARY_REFERENCE`. The version row and `Vendor_Extensions.md` §14.10 are the record; `clabonte/generator#2571` asks for the entries. +**Generator CHANGELOG.** *(Closed at `0.16.0`: `#2571` backfilled the entries under `0.15.0`, and the release check now traces bug fixes too.)* The `0.15.0` section still has no entry for `#2392`, `#2393` / `#2394` or `#2400`, and does not list the held reverse edge that now fails `validate` with `PILLAR_CROSS_BOUNDARY_REFERENCE`. The version row and `Vendor_Extensions.md` §14.10 are the record; `clabonte/generator#2571` asks for the entries. **What the bump brings, by area** (the version row has the full list): @@ -1341,13 +1344,13 @@ Of follow-up 23's four keys, `cascadeDelete` and `children` (§30) and now `requ **Generator issues found while staging**, filed 2026-10-12 and each re-checked on the released jar: -1. **`#2568`:** `snapshot` ignores pillar scope. `SnapshotArtifact` is `GLOBAL` and emits every `{Entity}Snapshot`, so every pillar-bound C# group with `snapshot` fails `CS0246` on the other pillars' entities (§44 item 8). Same on `0.14.0`. -2. **`#2569`:** a `resolve: remote` reference to the tenant root on a column not named `tenancy.tenant.foreignKey` (`tenantRef`) adds the tenant marker, and the build fails `CS0535` (`ITenantBound.TenantId` not implemented). Nothing validates the name. -3. **`#2567`, a regression:** `INVALID_EXTENSION_VALUE` for a malformed `x-entity` reports `x-entity.uniqueBy` / `x-entity.concurrency.reason` as its location and names no schema in the message; `0.14.0`'s `ENTITY_INVALID_CONFIG` located `components.schemas..x-entity`. In a multi-entity spec the finding cannot be traced. -4. **`#2565`:** `SCHEMA_DOMAIN_CROSS_REFERENCED` never fires on a bundled spec, direct or through an alias, via the spec or the project file, on either jar. The rule reads `property.get$ref()` on a fully-resolved parse; FEAT-2026-0213 did not convert it, and the CHANGELOG's alias fix therefore changes nothing observable. -5. **`#2566`:** `VALUE_OBJECT_ENCRYPTED_QUERYABLE` misses an inherited `defaultQueryable` on a one-item `allOf` embed — the kit's own embed form — while it now catches a direct `$ref` embed. -6. **`#2570`, reshaped:** `customer.*` is not a wildcard grant by design (in the spec it is `ROLE_GRANT_INVALID`; grants take `[.].` or `*`). The real gap is that `testGrants` grants are never shape-checked: an invalid grant silently moves its role into the forbidden-role tests, so the generated suite expects 403 for a role the author meant to allow. -7. **`#2564`:** every generated C# namespace takes the project `name`, not `basePackage`, so a hyphenated name such as `hello-orders` gives namespaces that do not compile (138 errors on `examples/hello-orders`). Same on `0.14.0`. +1. **`#2568`:** `snapshot` ignores pillar scope. `SnapshotArtifact` is `GLOBAL` and emits every `{Entity}Snapshot`, so every pillar-bound C# group with `snapshot` fails `CS0246` on the other pillars' entities (§44 item 8). Same on `0.14.0`. **Fixed at `0.16.0`:** each pillar-bound group emits only its own snapshots (§44). +2. **`#2569`:** a `resolve: remote` reference to the tenant root on a column not named `tenancy.tenant.foreignKey` (`tenantRef`) adds the tenant marker, and the build fails `CS0535` (`ITenantBound.TenantId` not implemented). Nothing validates the name. **Fixed at `0.16.0`:** `TENANT_REMOTE_REFERENCE_FOREIGN_KEY_MISMATCH` (ERROR) at project-level `validate` and `generate`; spec-level `validate` stays silent. +3. **`#2567`, a regression:** `INVALID_EXTENSION_VALUE` for a malformed `x-entity` reports `x-entity.uniqueBy` / `x-entity.concurrency.reason` as its location and names no schema in the message; `0.14.0`'s `ENTITY_INVALID_CONFIG` located `components.schemas..x-entity`. In a multi-entity spec the finding cannot be traced. **Fixed at `0.16.0`:** the location is `components.schemas..x-entity.` again; the message still names no schema. +4. **`#2565`:** `SCHEMA_DOMAIN_CROSS_REFERENCED` never fires on a bundled spec, direct or through an alias, via the spec or the project file, on either jar. The rule reads `property.get$ref()` on a fully-resolved parse; FEAT-2026-0213 did not convert it, and the CHANGELOG's alias fix therefore changes nothing observable. **Fixed at `0.16.0`:** fires from the CLI for a direct, `items` and alias reference, located on the owning schema (`Vendor_Extensions.md` §0). +5. **`#2566`:** `VALUE_OBJECT_ENCRYPTED_QUERYABLE` misses an inherited `defaultQueryable` on a one-item `allOf` embed — the kit's own embed form — while it now catches a direct `$ref` embed. **Fixed at `0.16.0`:** fires through a one-item `allOf` embed. The same fix makes `UNAVAILABLE_PROPERTIES_NOT_DECLARED` fire on a usage site that inherits `flatten` (§52). +6. **`#2570`, reshaped:** `customer.*` is not a wildcard grant by design (in the spec it is `ROLE_GRANT_INVALID`; grants take `[.].` or `*`). The real gap is that `testGrants` grants are never shape-checked: an invalid grant silently moves its role into the forbidden-role tests, so the generated suite expects 403 for a role the author meant to allow. **Fixed at `0.16.0`:** `TEST_GRANTS_GRANT_INVALID` fails `Project.Load` (`Project_File.md` §16). +7. **`#2564`:** every generated C# namespace takes the project `name`, not `basePackage`, so a hyphenated name such as `hello-orders` gives namespaces that do not compile (138 errors on `examples/hello-orders`). Same on `0.14.0`. **Fixed at `0.16.0`:** a non-identifier segment is PascalCased; `hello-orders` now generates `namespace HelloOrders.Domain…`. Lower priority, noted: `OPERATION_INDIVIDUAL_FILTER_PARAMS` never matches the kit's `{Resource}List` (`data` of `Basic{Resource}`) shape; the concurrency census does not count the new `#2481` `DELETE` gate ("0 get a generated gate"); generated functional-test URLs on a `hello-orders` copy showed a doubled `/v1/v1/` prefix (pre-existing). The last was the example's own doing, not the generator's; fixed in §50. @@ -1382,7 +1385,52 @@ The C# server prefixes every route with the path part of the server URL; the cli - The §1.1 table gains `DELETE_SOFT_CLASSIFIED_NO_RETENTION`. **Generator follow-up, not filed:** a structured `x-compliance.retention` (`period`, `from` as a date property, `action: delete | clear`, allowed per property) that the generator could turn into a sweep. Then `DELETE_SOFT_CLASSIFIED_NO_RETENTION` could accept either retention. The consumer has not asked for it yet. File when a second project needs it. -2. **`Error.code` vs the HTTP status.** A consumer's `Error.code` was `type: integer` (the status), while its copy of `API_Handbook.md` §2.1 called `code` a snake_case rule name. As a result, 17 operation descriptions named codes the wire cannot carry. The consumer added a string `reason` field. **The kit does not have this contradiction:** the `hello-orders` `Error` types `code` as a snake_case string and carries the status in a separate integer `status`, and §2.1 matches. §2.1 now says so explicitly. The proposed generator rule (warn on `error.code = ` prose when `code` is an integer) depends on the consumer's own schema and is not forwarded. +2. **`Error.code` vs the HTTP status.** A consumer's `Error.code` was `type: integer` (the status), while its copy of `API_Handbook.md` §2.1 called `code` a snake_case rule name. As a result, 17 operation descriptions named codes the wire cannot carry. The consumer added a string `reason` field. The kit's own `Error` types `code` as a snake_case string and carries the status in a separate integer `status`, and §2.1 matches. **Corrected at the `0.16.0` pin bump (§52):** the conclusion drawn here, that the contradiction was the consumer's alone, is wrong. The generator's C# `domainExceptions` set `Error.Code` to an integer, so the consumer's integer `code` was the generator's shape, and the kit's `Error` does not compile with that artifact. The proposed generator rule (warn on `error.code = ` prose when `code` is an integer) is still not forwarded. + +### 52. Generator `0.16.0` pin bump (kit `0.25.0`) + +**How it was measured.** On the released jar (`v0.16.0`, sha256 `dcd77128…7e8151`), with `0.15.0` as the baseline: + +- `hello-orders`, freshly bundled, validates with `0` errors, `19` warnings, `11` suggestions, a JSON report byte-identical to `0.15.0`'s. It generates 14 files. Their only difference is `#2564`: `namespace hello-orders.Domain…` (uncompilable) became `HelloOrders.Domain…`. +- A fresh `init` scaffold validates with the same five finding codes as on `0.15.0`. +- `check-extension-vocabulary.py --require-jar` passes. `extensions --format json` gains an `x-ai` section (`enabled`, `task`, `entities`, `dataHandling`) and changes nothing else. `templates` is identical for all four languages. +- The new diagnostic codes in the jar's validation classes (`AI_PERSONAL_DATA_REGION_MISSING`, `ARAZZO_STEP_PAYLOAD_*`, `ASYNC_EMITS_NO_EMIT_OPERATION`, `ASYNC_WORKER_SELF_TRIGGER`, `ENTITY_MISSING_ID`, `ENTITY_MISSING_AUDIT_FIELD`, `TENANT_REMOTE_REFERENCE_FOREIGN_KEY_MISMATCH`) are each in the CHANGELOG. None was removed. +- Each handbook claim tied to `0.15.0` was re-measured on copies of `hello-orders`: the pillar matrix (§44), value objects, enums and the schema-domain rule (§49), and each new rule's severity. + +**What the bump brings, by area** (the version row has the summary): + +- **`x-ai.dataHandling` (#153, RestoManager handoff 2026-10-12).** The parser (`AiDefinition`) accepts `personalData` as a boolean and `providerRegion` as `[A-Za-z]{2}`, which it upper-cases. Anything else is `INVALID_EXTENSION_VALUE`. Kit decisions: + - **The region is case-insensitive in lint, as in the jar.** The handbook asks for upper case. + - **`personalData` is required inside the block**, because an empty `dataHandling` declares nothing. + - **The block itself is not required.** A project with a residency promise requires it in its overlay. RestoManager does, at error. + - **`x-ai` is closed at WARN** over the four parser keys plus the six informational keys the handbook documents. The six are recorded as `kit_only` in `vocabulary-exceptions.yaml`. + + `AsyncAPI_Handbook.md` §4.3, `AI_Access_Policy_Framework.md`, the `create-worker` skill. +- **Breaking, C#.** + - `PROJECT_EVENT_RUNTIME_REQUIRED`: adding `service` to `hello-orders`' Domain group gives 6 ERRORs (`Project_File.md` §10). + - `#2584`: enum columns store the wire value, and reads accept the legacy member name (`Vendor_Extensions.md` §1.4.1, which also corrects the stale "persistence still throws" bullet). + - `#2560`/`#2627`: an encrypted enum was silently plaintext through `0.15.0`. It is now encrypted and `[Sensitive]` (§1.5). +- **New findings, documented where their neighbours are:** + - `ENTITY_MISSING_ID` / `ENTITY_MISSING_AUDIT_FIELD` (§1.1). The generator does not read `mutability`, so the WARNING still fires on `appendOnly`. + - `ARAZZO_STEP_PAYLOAD_*` (`Arazzo_Handbook.md` §9.2). A step asserting a 4xx is exempt. + - `ASYNC_EMITS_NO_EMIT_OPERATION` and `ASYNC_WORKER_SELF_TRIGGER` (`AsyncAPI_Handbook.md` §6.3). §6.3 also stops saying that `run-*` jobs never use `x-emits`. That sentence had been false since `#2595`, and the retention sweeps of §51 need it. + - `FUNCTIONAL_TEST_TAG_UNGROUPED`, together with `functionalTests`, which `Project_File.md` had never documented. + - `TEST_GRANTS_GRANT_INVALID` (§16). + - `TENANT_REMOTE_REFERENCE_FOREIGN_KEY_MISMATCH` (§4.1, `Vendor_Extensions.md` §14.11). + - Arazzo date tokens in `in: query` values (`#2557`, `Arazzo_Handbook.md` §8.4). +- **Closed:** §19, §44 item 8, and every §49 item (`#2564`–`#2571`); each item now carries its `0.16.0` verdict. + +**Spectral.** + +- **Three new rules for `x-ai`:** `specfuse-async-ai-shape` (warn), `specfuse-async-ai-data-handling-shape` (error) and `specfuse-async-ai-personal-data-region` (warn, the lint-time twin of `AI_PERSONAL_DATA_REGION_MISSING`). The `fixtures/ai-data-handling.yaml` fixture has six violations and four valid blocks, each asserted by operation in `example-regen.yml`. +- **`specfuse-xprotection-unavailable-properties-required` widened.** The kit had kept a flattened encrypted value object out of scope "to match the jar", but the jar has required the member there since `0.14.0` (`#2097`, closed 2026-09-25). Measured on `0.13.0`–`0.16.0`. The rule now fires on an `x-entity.valueObjects` usage site that declares `storage: flatten` with an encrypted `protection`, in either shape. `ValueObjectOnlyEncryption` flips from a silent control to an expected finding. Two new silent controls cover the rest. `single_json` never needs the member. An inherited `flatten` (`#2566`, `0.16.0`) is the jar's to report, because the kit cannot see the value object's `defaultStorage`. +- **`specfuse-no-inline-enums` gains a second `given`** for an inline enum inside an `allOf`/`oneOf`/`anyOf` member (`#2579`). Through `0.15.0` that form passed `validate` and failed `generate` with `PROPERTY_INNER_ENUM`. Fixture `inline-enum-wrapped.yaml`: a bare enum and a wrapped one must fire; a wrapped `$ref` must not. +- **No rule** for the other new findings: each needs the project file or spans two documents. `ENTITY_MISSING_ID` is already covered more strictly by `specfuse-main-resource-has-id`. + +**Found while measuring, not filed:** + +1. **`Error.code` (corrects §51 item 2).** The C# `domainExceptions` templates set `Error.Code` to an integer, the HTTP status or an `int errorCode`, and set `Error.Id` to a string. The kit's `Error` types `code` as a snake_case string and `id` as `format: uuid`, so a project that registers `domainExceptions` fails `CS0029` on 22 assignments, measured on a `hello-orders` copy. `hello-orders` does not register the artifact. The consumer's integer `code` plus a string `reason` is the shape that compiles. `API_Handbook.md` §2.1 now says so. **Open decision:** does the kit's `Error` move to an integer `code`, a string `id` and a `reason` field, or does the generator move to a string `code`? Same shape as the `#2371` decision. +2. **§44 item 9.** A domain-filtered C# group (`pillar` or `domains.include`) with `event` and `service` emits no `{Message}Event` classes, while the service constructs them: `CS0246`, at `0.16.0` and `0.15.0`. The unfiltered control builds clean. ## Outstanding kit-side work diff --git a/generator.lock b/generator.lock index 3e33388..07650a8 100644 --- a/generator.lock +++ b/generator.lock @@ -1,12 +1,12 @@ { - "kit_version": "0.24.0", + "kit_version": "0.25.0", "min_java": 21, "generator": { - "version": "0.15.0", - "asset": "specfuse-generator-0.15.0.jar", - "sha256": "acd0a862d10c6b8146502b1197e2552aa170f6692bca91b079045c7e49efe285", + "version": "0.16.0", + "asset": "specfuse-generator-0.16.0.jar", + "sha256": "dcd7712870042b52cd5221e0f4d272aa67e28352b8cb456f5ff2cee92c7e8151", "release_repo": "Specfuse/generator-dist", - "release_tag": "v0.15.0" + "release_tag": "v0.16.0" }, "_note": "Pin the kit<->generator contract here. On each generator release: set version/asset/sha256/release_tag, then publish a matching kit patch. sha256=PENDING means no generator is bundled yet; `specfuse authoring generate` will refuse with a clear message until a real jar is pinned." } diff --git a/handbooks/AI_Access_Policy_Framework.md b/handbooks/AI_Access_Policy_Framework.md index d032437..d9278f0 100644 --- a/handbooks/AI_Access_Policy_Framework.md +++ b/handbooks/AI_Access_Policy_Framework.md @@ -334,6 +334,7 @@ The flowchart is biased toward restriction by design. | `x-classification: [pii \| sensitive]` | PII/sensitive fields are not auto-excluded from AI read, but they trigger snapshot acknowledgement rules (`x-snapshot-pii-acknowledged`) when included in event snapshots. See `AsyncAPI_Handbook.md §2.3`. | | `x-ai-safe` (operations) | **Read by nothing — do not gate on it.** It was intended to operate at the HTTP operation level (may an AI agent invoke this endpoint without approval) where `aiAccess` operates at the entity/repository level, but no generated code and no lint rule enforces it. `aiAccess` is enforced; `x-ai-safe` is documentation. The live per-tool control is `x-mcp.safeForAutoInvoke` on an Arazzo scenario (`Arazzo_Handbook.md` §4.8). See `Vendor_Extensions.md` §4.1 and `compatibility.md` §25. | | `x-ai.entities` (AsyncAPI workers) | When an AsyncAPI worker declares `x-ai.entities.{reads,creates,updates,deletes}`, every listed entity MUST have a matching `aiAccess` block granting the corresponding operation. The cross-spec validator enforces this. See `AsyncAPI_Handbook.md §4.3`. | +| `x-ai.dataHandling` (AsyncAPI workers) | Declares whether the worker sends personal data to its LLM provider (`personalData`) and the country that provider must run in (`providerRegion`). Generator `0.16.0`: the generated Python worker refuses to start when the configured provider region differs. It checks configuration, not where the endpoint actually runs. Complementary to `aiAccess`: `aiAccess` decides which data an agent may touch, `dataHandling` where that data may go. See `AsyncAPI_Handbook.md §4.3`. | | `filterableProperties` / `searchableProperties` | Govern HTTP query surfaces, not AI repository methods. AI filtering/search allow-lists are derived separately from `readableProperties`. | --- diff --git a/handbooks/API_Handbook.md b/handbooks/API_Handbook.md index 98f33d1..57ba7c2 100644 --- a/handbooks/API_Handbook.md +++ b/handbooks/API_Handbook.md @@ -1126,7 +1126,9 @@ Content-Type: application/json ``` **Required fields**: -- `code`: Machine-readable error code (snake_case). A **string** naming the rule that failed, not the HTTP status. The status has its own integer field, `status`. +- `code`: Machine-readable error code (snake_case): a **string** naming the rule that failed. The kit's `Error` carries the HTTP status in its own integer field, `status`. + +> **⚠ The generated C# exceptions disagree (generator `0.16.0`).** The `domainExceptions` templates set `Error.Code` to an **integer**, either the HTTP status (`Code = StatusCode`) or an `int errorCode` constructor argument, and set `Error.Id` to a string. Generated against the kit's `Error` (`code` a string, `id` a `format: uuid`), the exceptions fail to compile with `CS0029`. `hello-orders` does not register `domainExceptions`, which is why its build is clean. Until the kit and the generator agree on the shape (`compatibility.md` §52), a C# project that uses `domainExceptions` types `code` as an integer and `id` as a plain string, and carries the rule name in a separate string field. `reason` (`^[a-z][a-z0-9_]*$`) is the name one consumer chose. Never document a snake_case value as `error.code` on such a project, because the wire cannot carry it there. - `message`: Human-readable explanation - `retryable`: Usually `false` for business rule violations - `requiredAction`: Guidance on how to resolve (recommended) @@ -2768,7 +2770,7 @@ async function retryWithBackoff(operation, maxRetries = 3) { - **PATCH collections are complete sets**: a collection property present in an `Update{Resource}` body replaces the stored set — value-object arrays wholesale, child-entity arrays by identity reconcile (known `id` updates in place, no `id` creates, unknown `id` is `404`, omitted children are permanently deleted). Give `Update{Child}` an optional `id`, and say in the property description that omitted children are not recoverable. See §1.5.1. **Don't** -- Don't inline enums. +- Don't inline enums, including one wrapped in a one-item `allOf` beside a `description`. Generator `0.16.0` reports that form as `INLINE_ENUM_SCHEMA`; through `0.15.0` it passed `validate` and failed `generate`. The kit's `specfuse-no-inline-enums` catches both. - Don't embed subresources unless explicitly asked. - Don't place `x-entity` on derivatives or simple types. - Don't use anonymous objects within a resource. diff --git a/handbooks/Arazzo_Handbook.md b/handbooks/Arazzo_Handbook.md index 3124a95..f8b174e 100644 --- a/handbooks/Arazzo_Handbook.md +++ b/handbooks/Arazzo_Handbook.md @@ -929,6 +929,8 @@ Date tokens are resolved in a restricted set of fields only. Tokens appearing an - Step `requestBody.payload.*` values - Step `parameters.*` values (inside the payload, not in path expressions) +**`in: query` values are expanded at run time from generator `0.16.0` (C#).** This works whether the token is the whole value (`@now-1h`) or embedded in a larger one, such as a filter (`date ge '@today+1w' and date le '@today+2w'`). Each token becomes an ISO-8601 value: `yyyy-MM-dd` for a calendar day, the round-trip form for an instant. Through `0.15.0` the C# emitters percent-encoded the macro text at generation time, so the server received `'@today+1w'` literally and rejected it (`clabonte/generator#2557`). + **Rationale:** restricting the resolution surface keeps the runtime resolver simple and predictable. --- @@ -955,12 +957,16 @@ This means: | 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; from generator `0.15.0`, never 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 | +| Step payload carries every required property (`ARAZZO_STEP_PAYLOAD_MISSING_REQUIRED`) | Step `requestBody.payload` | Called operation's request schema (through `allOf`; a runtime expression satisfies a property; a server-assigned `x-primary-key` is never required) | Error, from generator `0.16.0`. Skipped when the step asserts a 4xx (`$statusCode == 4NN`) | +| Step payload declares no unknown property (`ARAZZO_STEP_PAYLOAD_UNDECLARED_PROPERTY`) | Step `requestBody.payload` | Called operation's request schema | Warning, from generator `0.16.0`; the typed request DTO drops the property | | `$setup.outputs.X` resolves | Expression | Recipe `outputs` map | Error | | Step graph acyclic; all inputs satisfied | Workflow steps | Internal | Error | | Recipe `extends` chain valid, depth <= 6, no cycles | Recipe | Other recipes | Error | | No output namespace collisions in recipe chains | Recipe outputs | Parent recipe outputs | Error | | `x-mcp.toolName` globally unique | All scenarios | All scenarios | Error | +A step that deliberately sends an invalid body, to prove the server rejects it, must assert the 4xx in `successCriteria`. That assertion is what exempts it from the payload checks. A step that asserts a 2xx, or no status, is checked. A missing required property there fails `validate`, and `generate` then refuses the whole project. + ### 9.3 Violation Example ```yaml diff --git a/handbooks/AsyncAPI_Handbook.md b/handbooks/AsyncAPI_Handbook.md index 24107eb..a27c67e 100644 --- a/handbooks/AsyncAPI_Handbook.md +++ b/handbooks/AsyncAPI_Handbook.md @@ -956,7 +956,8 @@ x-emits: - Required on every `on-*` receive operation that publishes events via `emit-*` send operations - The generator uses `x-emits` to wire publish calls into the handler class - The cross-spec validator resolves `x-emits` entries to their matching `emit-*` operations via `x-label` matching -- `run-*` scheduled jobs do NOT use `x-emits` — they publish directly via their channel reference +- `run-*` scheduled jobs declare `x-emits` only for events they publish besides their trigger message (§6.3) +- An `on-*` worker whose `x-emits` names an event it also subscribes to is `ASYNC_WORKER_SELF_TRIGGER` (WARNING, generator `0.16.0`). On a shared topic it is handed its own output and runs again. A deliberate converging loop is legitimate; say in the operation's `description` what makes it terminate. #### `x-subscription` (required on `receive` operations from `event-topic` channels) @@ -1066,6 +1067,9 @@ x-ai: reads: [Order, Customer] creates: [OrderRecommendation] updates: [Order] + dataHandling: + personalData: true + providerRegion: CA ``` | Field | Required | Type | Description | @@ -1073,6 +1077,7 @@ x-ai: | `enabled` | Yes | boolean | `true` to mark as AI-powered | | `task` | Yes | string | One of: `generation`, `classification`, `extraction`, `summarization`, `analysis`, `translation`, `validation`, `recommendation` | | `entities` | When `enabled: true` | object | Declares every entity the worker reads/writes (see below) | +| `dataHandling` | Recommended when the worker reads or creates personal data | object | Whether the worker processes personal data, and where its LLM provider must run (see below). Generator `0.16.0` | | `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`. **Informational, read by nothing** — `streaming` and `multiTurn` generate no streaming transport or conversation state | @@ -1093,6 +1098,27 @@ Declares the complete data surface of the AI worker for cross-spec validation an At least one of the four arrays must be non-empty. The trigger entity (from the incoming message's `x-label.entity`) must appear in at least `reads`. The code generator validates each entity against its `aiAccess` in the OpenAPI spec and fails the build on mismatches. +**`dataHandling` field** (generator `0.16.0`): + +Declares whether the worker sends personal data to an LLM provider, and the country that provider must run in. Set it on every worker that reads or creates an entity holding personal data, for example any entity with a classified property (`Vendor_Extensions.md` §1.5). + +| Sub-field | Type | Description | +|-----------|------|-------------| +| `personalData` | boolean | `true` when the worker processes personal information. | +| `providerRegion` | string | ISO 3166-1 alpha-2 country code (`CA`, `US`, `FR`) the LLM provider must run in. Write it in upper case. The generator accepts lower case and upper-cases it; anything other than two letters is `INVALID_EXTENSION_VALUE` (ERROR). | + +What the generator does with it: + +- **`validate`** warns `AI_PERSONAL_DATA_REGION_MISSING` when `personalData: true` is declared without `providerRegion`. +- **The generated Python worker** carries the declaration as `ClassVar`s on its `*Settings` class (`personal_data`, `required_provider_region`), which no environment variable can override. `BaseWorkerSettings` gains `primary_provider_region` and `fallback_provider_region`, set at deploy time through `_PRIMARY_PROVIDER_REGION` and `_FALLBACK_PROVIDER_REGION`. +- **When a region is declared, the worker refuses to start** if the configured primary region is unset or differs. The same applies to the fallback region when a `fallback_model` is configured. **Set the deploy-time variables before you regenerate with a region.** Otherwise the worker that declares it stops starting. + +> **What this does not check.** The generator compares the configured region with the declared one. It cannot see where an endpoint actually runs, so a provider configured as `CA` that runs elsewhere passes. The declaration is evidence for a residency promise, not proof of it. + +Whether `dataHandling` is *required* is a project decision. The kit's `specfuse-async-ai-personal-data-region` (warn) catches the missing region, and `specfuse-async-ai-data-handling-shape` (error) checks the shape. Neither requires the block itself. A project that has made a residency promise, for example in a published privacy policy, should require it on every `enabled: true` worker in its overlay, at error. + +**Only the four keys above are read by the generator.** `enabled`, `task`, `entities` and `dataHandling` are the keys `extensions --format json` lists. `model`, `promptTemplate`, `capabilities`, `estimatedTokens`, `maxLatency` and `fallback` are informational: the parser ignores them. The kit's `specfuse-async-ai-shape` (warn) accepts these ten keys and flags any other key. A misspelt `datahandling` would otherwise lint clean, and the worker would start with no region check. + The code generator uses `x-ai` to decide whether the worker needs AI infrastructure (prompt management, structured-output validation, token budgeting). The target language is chosen by the generator, not the spec. **`entities` also shapes the generated Python worker's Repository Protocol** (`_generated/protocols.py`): one async method per entity the worker creates, updates or deletes. From generator `0.15.0` those methods have the same shapes as the generated aggregate repository's (`clabonte/generator#2480`), so a worker whose writes all land on one aggregate can pass that repository where the Protocol is expected, with no adapter. A create is `add_(entity) -> ` (was `create_(*, entity_id, **fields) -> None`). An update is `update_(entity_id, **changes) -> `, with `entity_id` positional and snake_case attribute names (was keyword-only `entity_id` and camelCase fields, returning `None`). A delete takes `entity_id` positionally. `protocols.py` now imports the ORM row class of each created or updated entity, and the `InMemoryRepository` test stub follows the same shapes. **Python migration:** a hand-written adapter that implements `create_*` or keyword-only `update_*`, and every handler call site, moves to the new names and signatures. @@ -1365,11 +1391,11 @@ The `event` value (`{Entity}.{Action}`) must match an AsyncAPI event message's ` 2. For every `x-emits.event` on an `on-*` async operation, finds the matching `emit-*` send operation via `x-label` — fails if missing 3. For every AsyncAPI event message, identifies the OpenAPI operations that emit it (reverse lookup) — informational only. Events can also be published by scheduled jobs or other workers, so having no OpenAPI emitter is valid. -> **Known gap — `emit-*` coverage is enforced for one producer kind out of three.** Check 2 runs only over `on-*` producers. An OpenAPI write operation may declare `x-emits: X.Y` with no `emit-*` declaring `X.Y`, and nothing fails: the message file and the channel `messages` entry are enough to make the event work. The consequence is that the set of `emit-*` operations — which ought to be the index of what is on the bus — silently under-reports, and whether an event has one becomes a question of which domain authored it. Until the validator covers every producer kind, treat "every declared event has an `emit-*`" as an authoring rule you enforce, not one you inherit. Tracked in `compatibility.md`, follow-up 19. +> **`emit-*` coverage covers every producer kind from generator `0.16.0`.** Check 2 used to run only over `on-*` producers (`ASYNC_ON_EMITS_UNMATCHED`, ERROR). An OpenAPI write operation or a `run-*` job declaring `x-emits: X.Y` with no `emit-*` for `X.Y` is now `ASYNC_EMITS_NO_EMIT_OPERATION` (WARNING), so the `emit-*` set can be trusted as the index of what is on the bus. It starts at WARNING because existing bundles carry a backlog. Treat it as an authoring rule anyway. `compatibility.md` follow-up 19. **There is no `triggerOperation` field on the AsyncAPI side.** The link is computed from `x-emits`, not duplicated. -**`run-*` scheduled jobs do NOT use `x-emits`** — they publish directly via their channel reference. The generator knows they are publishers from the `action: send` + `x-worker` + `x-scheduled-job` combination. +**A `run-*` job declares `x-emits` for the events it publishes besides its own trigger message**, for example a retention sweep that publishes `{Entity}Deleted` per row (`API_Handbook.md` §2, "Business Retention"). The trigger message is not listed: the generator already knows the job publishes it from the `action: send` + `x-worker` + `x-scheduled-job` combination. From generator `0.16.0`, `validate --producers` lists such a job among the event's `emit-*/run-*` producers, and `ASYNC_EMIT_OPERATION_NO_PRODUCER` no longer fires for an event whose only producer is a `run-*` job's `x-emits`. ### 6.4 Aggregate References @@ -1408,7 +1434,7 @@ currency: - `on-*` (`action: receive`, has `x-worker`) → event subscriber worker - `run-*` (`action: send`, has `x-worker`, message has `x-scheduled-job`) → scheduled job worker - `emit-*` (`action: send`, no `x-worker`) → not a worker; a publish method wired into each declared producer -- **Linking `emit-*` to its producers** — the generator resolves `x-emits` (on `on-*` async operations and on OpenAPI write operations) to find which producers publish which declared event. The relationship is many-to-one and computed by reverse lookup on `{Entity}.{Action}`; an `emit-*` belongs to no single operation. For `run-*` operations, the publish target is the channel referenced directly on the operation. +- **Linking `emit-*` to its producers** — the generator resolves `x-emits` (on `on-*` async operations, on `run-*` jobs from generator `0.16.0`, and on OpenAPI write operations) to find which producers publish which declared event. The relationship is many-to-one and computed by reverse lookup on `{Entity}.{Action}`; an `emit-*` belongs to no single operation. For `run-*` operations, the publish target is the channel referenced directly on the operation. - **Project layout** — decided by the generator config per language ### 7.3 Cross-Spec Wiring diff --git a/handbooks/Project_File.md b/handbooks/Project_File.md index c93010a..2d80f16 100644 --- a/handbooks/Project_File.md +++ b/handbooks/Project_File.md @@ -65,6 +65,7 @@ Language-coupling column: ✱ means the field is honoured only for a specific la | `authorization.kioskSession.scheme` | string | optional | ✱ C# only | Authentication scheme of a kiosk-session principal. Absent makes `x-kiosk-session` inert. | | `authorization.kioskSession.role` | string | optional | validation | **From generator `0.15.0`.** The role a kiosk-session principal carries. Arms `KIOSK_SESSION_ROLE_UNSATISFIABLE`. See §16. | | `authorization.testGrants` | string (path) | optional | ✱ C# tests only | **From generator `0.15.0`.** A role-grants file for the generated authorization tests of a `scopes`-mode project whose spec declares no grants. See §16. | +| `functionalTests` | object | optional | ✱ C# tests | xUnit collection assignment for the generated API functional tests: `collectionNamePattern` (placeholders `{tag}` / `{group}`; by default every test is in one collection, `ApiFunctionalTest`) and `collectionGroups` (group name → list of operation tags). See §9 for `FUNCTIONAL_TEST_TAG_UNGROUPED`. | | `groups[]` | array | yes for generation | — | One artifact group per output tree. | | `groups[].name` | string | recommended | — | Human-readable group label; used by `--group` and diagnostics. | | `groups[].description` | string | optional | — | Free-form description. | @@ -329,7 +330,7 @@ Measured on `examples/hello-orders`: on a TypeScript group the entry renames the ### 4.1 `tenancy` *(C# only today)* -**Purpose**: Declares the project's multi-tenant entity topology. In the C# target, it drives marker-interface generation (`I{Tenant}Scoped` / `I{SubTenant}Scoped`) and entity partial-class derivation from `x-entity.belongsTo`. From generator `0.15.0`, a property declaring `x-references: { entity: , resolve: remote }` also gives its entity the tenant marker, with no `belongsTo`. The property must be named after `tenant.foreignKey`, which the marker's property is. The `subTenant` tier still follows `belongsTo` only. See `Vendor_Extensions.md` §14.11. It also feeds AsyncAPI validators (tenant FK ordering) and broker tenant scoping. +**Purpose**: Declares the project's multi-tenant entity topology. In the C# target, it drives marker-interface generation (`I{Tenant}Scoped` / `I{SubTenant}Scoped`) and entity partial-class derivation from `x-entity.belongsTo`. From generator `0.15.0`, a property declaring `x-references: { entity: , resolve: remote }` also gives its entity the tenant marker, with no `belongsTo`. The property must be named after `tenant.foreignKey`, which the marker's property is; from `0.16.0` any other name is `TENANT_REMOTE_REFERENCE_FOREIGN_KEY_MISMATCH` (ERROR) at project-level `validate`. The `subTenant` tier still follows `belongsTo` only. See `Vendor_Extensions.md` §14.11. It also feeds AsyncAPI validators (tenant FK ordering) and broker tenant scoping. The Python and Dart/Flutter artifact factories do **not** register a tenancy-marker artifact today and emit no `I{Tenant}Scoped` equivalents. The block is still useful for those languages indirectly: AsyncAPI validation rules (e.g. tenancy ordering on `belongsTo`, the "events from emitting entities should include the tenant in their chain" suggestion) consult `tenancy` regardless of which language group consumes the result. @@ -917,7 +918,7 @@ A group generates every domain in the bundle unless it says otherwise. These two - Membership is checked when the filter is applied, not when the project file is parsed: the registry is built from the spec, which project-file validation cannot see. - **Unlike an unregistered domain name, an unresolvable pillar is fail-closed.** `UNKNOWN_PILLAR_FILTER` when the registry is absent, empty, or does not declare the pillar; `EMPTY_PILLAR_DOMAINS` when the named pillar owns zero domains. There is nothing to expand the name to, and generating everything is worse than any explicit value. - **Binding to a pillar does not by itself soften the cross-domain edge check.** A pillar-bound group whose owned domains reach a foreign entity still fails with `CROSS_DOMAIN_ENTITY_REFERENCE`. What softens it is a **declared hold**, or, from generator `0.15.0`, a reference declared `x-references: { entity: X, resolve: remote }`, which is a plain uuid column and never an edge (`Vendor_Extensions.md` §14.11). A hold works when the group's pillar declares `holds: [X]` and `X`'s owner publishes a `ReadX` schema: the reference resolves to that replica, and the group emits a read-only `ReadX` with no write surface. The group must register `heldEntity` and `heldEntityConfiguration` for the replica to exist; otherwise the rebound navigation names a class nothing emits. See `Vendor_Extensions.md` §14.3. -- **Validate the project file, then generate each pillar.** `validate .json` builds each group's context and reports a cross-domain edge the group cannot generate (`CROSS_DOMAIN_ENTITY_REFERENCE`); spec-level `validate` does not. It is still not the whole bar: a child-create nested under a parent in another pillar passes it and fails `generate`. At generator `0.15.0`, a cross-pillar `belongsTo` with a hold generates in both pillars' groups, and a cross-pillar reverse `hasMany` fails both with `PILLAR_CROSS_BOUNDARY_REFERENCE`, which spec-level `validate` reports too. At `0.14.0` the `belongsTo` failed in the owner's group (`CROSS_DOMAIN_ENTITY_REFERENCE`) and the reverse edge failed in both. `Vendor_Extensions.md` §14.10 has the measured matrix and the forms that generate. +- **Validate the project file, then generate each pillar.** `validate .json` builds each group's context and reports a cross-domain edge the group cannot generate (`CROSS_DOMAIN_ENTITY_REFERENCE`); spec-level `validate` does not. It is still not the whole bar: a child-create nested under a parent in another pillar passes it and fails `generate`. At generator `0.16.0`, as at `0.15.0`, a cross-pillar `belongsTo` with a hold generates in both pillars' groups, and a cross-pillar reverse `hasMany` fails both with `PILLAR_CROSS_BOUNDARY_REFERENCE`, which spec-level `validate` reports too. At `0.14.0` the `belongsTo` failed in the owner's group (`CROSS_DOMAIN_ENTITY_REFERENCE`) and the reverse edge failed in both. `Vendor_Extensions.md` §14.10 has the measured matrix and the forms that generate. **Declare `pillar` even in a split-bundle topology, where the domain filter is redundant.** The identity is not redundant: resolving a reference to a held entity asks *"is this target held by **this group's** pillar"*, and a per-pillar bundle that preserves the whole registry for ownership context has more than one candidate to choose from. @@ -953,6 +954,8 @@ The project file fails to load when any of the following conditions hold. All er The OpenAPI / AsyncAPI / Arazzo spec contents are validated separately by the per-spec validators after the project file loads. +From generator `0.16.0`, `validate` also warns `FUNCTIONAL_TEST_TAG_UNGROUPED` when `functionalTests.collectionNamePattern` uses `{group}` and an operation tag is in no `collectionGroups` entry. That tag silently becomes its own collection (measured: `Tag 'Customers' is in no functionalTests.collectionGroups entry, so its functional tests get their own collection 'CustomersFunctionalTests'`), and nothing in the consumer's test project defines it. A pattern without `{group}` opts out. + --- ## 10. Field Interactions and Cross-Field Behaviour @@ -960,6 +963,7 @@ The OpenAPI / AsyncAPI / Arazzo spec contents are validated separately by the pe - **`name` ⟷ `dbContextClassname`** — when `dbContextClassname` is unset, the C# templates derive `{name}DbContext`. When `name` is also unset, they fall back to `AppDbContext`. - **`name` ⟷ `configs.applicationPackage`** — the C# event-contract test artifact derives `{name}.Application` when no explicit value is set; if both are absent, the artifact fails with a hard error. - **`groups[]` ⟷ event-runtime opt-in** — when any group registers the `eventRuntime` artifact (or the language equivalents `eventRuntimeImpl` / `eventRuntimeFunctions`), the generator stamps a project-wide `hasEventRuntime: true` additional property visible to every artifact in every group. This is the unambiguous opt-in marker for event-runtime emission. +- **`service` ⟷ `eventRuntime` (generator `0.16.0`, breaking)** — a C# group rendering `service` for an operation whose `x-emits` links to an AsyncAPI message requires some group to register `eventRuntime` (or `configs.hasEventRuntime`). Otherwise `validate` and `generate` fail with `PROJECT_EVENT_RUNTIME_REQUIRED` (ERROR), one finding per operation, naming its messages and the group. Before `0.16.0` such a project generated service methods that read `beforeSnapshot` / `afterSnapshot` without declaring them, and failed only at `dotnet build` (CS0103). Measured: adding `service` to `hello-orders`' Domain group gives six findings, one per `x-emits` write. **Fix:** register `eventRuntime` in a C# Domain group (it requires a project-level `tenancy` block, §4.1), or remove the operation's `x-emits`. - **`namingOverrides` ⟷ `x-enumCase`** — `x-enumCase: preserve` on a schema wins over a project-wide `enumValue` override. - **`tenancy` ⟷ `x-entity.belongsTo`** — entities whose `belongsTo` references the tenancy entities receive the corresponding `I{Tenant}Scoped` marker interface in the C# target. From generator `0.15.0`, so does an entity with a `resolve: remote` reference to the tenant entity (§4.1). The project-file loader does not validate that `tenancy.tenant.entity` resolves to a real `x-entity` schema; see §4.1 for the downstream symptoms. - **`broker.outboxDialect` ⟷ event-runtime functions artifact** — `postgres` emits a stub in the drainer; only `sqlServer` produces a working runtime today. @@ -1350,8 +1354,9 @@ Measured on a `hello-orders` copy in `mode: scopes`, with list-form `info.x-role | `TEST_GRANTS_FILE_UNREADABLE` | the file is missing, or is not a map of role name to role metadata. Measured: `authorization.testGrants file './nope.yaml' cannot be read: file not found`. | | `TEST_GRANTS_CONFLICTS_WITH_SPEC_GRANTS` | the spec's `info.x-roles` already carries grants — two sources of truth for what a role may do. | | `TEST_GRANTS_REQUIRES_SCOPES_MODE` | `mode` is absent or `roles`, where nothing reads the file. | +| `TEST_GRANTS_GRANT_INVALID` | **From generator `0.16.0`.** A grant is neither `*` nor a well-shaped `[.].` scope, the shape `ROLE_GRANT_INVALID` enforces on `info.x-roles`. `customer.*` is not a wildcard. Measured: `role 'Manager' declares the grant 'customer.*', which is neither the '*' wildcard nor a well-shaped [.]. scope, so it would match nothing.` Through `0.15.0` such a grant loaded and matched nothing. Its role then moved from the allowed-role tests to the forbidden-role tests, so the generated suite expected `403` for a role the author meant to admit. | -All three fail `Project.Load`, like the codes under "Where it is checked" below, and the CLI prints the message, not the code. **Prefer grants in the spec** (the mapping form, `Vendor_Extensions.md` §3.1) when you can. `testGrants` is for a project whose grants are owned elsewhere, and it does not make the generated runtime enforce them. +All four fail `Project.Load`, like the codes under "Where it is checked" below, and the CLI prints the message, not the code. **Prefer grants in the spec** (the mapping form, `Vendor_Extensions.md` §3.1) when you can. `testGrants` is for a project whose grants are owned elsewhere, and it does not make the generated runtime enforce them. ### `mode` diff --git a/handbooks/Vendor_Extensions.md b/handbooks/Vendor_Extensions.md index ac4c388..7558051 100644 --- a/handbooks/Vendor_Extensions.md +++ b/handbooks/Vendor_Extensions.md @@ -75,7 +75,7 @@ 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. 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. **`validate` does not report it on a bundled spec**, though: the rule reads each property's `$ref`, which the validator's parse has already resolved. Measured on a copy of `hello-orders` with an `order` entity property referencing the `customer` domain's status enum, directly and through a bundler alias (`CrmStatus: {$ref: …/CustomerStatus}`): no finding on `0.14.0` or on a build of generator main, through the project file or the spec alone. Generator `0.15.0` follows the alias to its target (`clabonte/generator#1929`), which matters only once the rule reads the resolved document. Find cross-domain references by review until then. +- 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. **`validate` reports it from generator `0.16.0`** (`clabonte/generator#2565`). Through `0.15.0` it reported nothing on a bundled spec, because the rule read each property's `$ref` after the validator's parse had already resolved it. Measured on a copy of `hello-orders` with an `order` entity property referencing the `customer` domain's status enum, directly, through `items`, and through a bundler alias (`CrmStatus: {$ref: …/CustomerStatus}`): `0.15.0` is silent, and `0.16.0` gives one WARNING. The WARNING is located on the **owning** schema (`components.schemas.CustomerStatus`) and names the referencing domains. An alias is reported under its target, never under its own name. Expect new warnings on a spec that validated clean at `0.15.0`. **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. @@ -119,6 +119,15 @@ declared non-mutable write policy exempts the entity from the requirement. `id` and `createdAt` remain required regardless; only `updatedAt` is meaningless for rows that never change. +The generator agrees on identity and disagrees on `updatedAt`. From generator +`0.16.0`, an `x-entity` with no `id` or `{Entity}Id` (including through `allOf`) +is `ENTITY_MISSING_ID` (ERROR), and a missing `createdAt` or `updatedAt` is +`ENTITY_MISSING_AUDIT_FIELD` (WARNING). The generator does not read +`mutability`, so the WARNING still fires on an `appendOnly` or `immutable` +entity without `updatedAt` (measured), and the generated entity still carries +the column. Accept that WARNING on non-mutable entities. It does not mean the +declaration is wrong. + This is not an escape hatch from the `updatedAt` requirement. The value is validated against the closed set above by `specfuse-xentity-shape`, so mislabelling a mutable entity to dodge the rule is a visible, reviewable edit @@ -828,9 +837,9 @@ list, and a non-string `name` are each an `ENTITY_INVALID_CONFIG` through `0.14.0`, and an `INVALID_EXTENSION_VALUE` from generator `0.15.0` (both ERROR; measured on all four). A CI filter or findings baseline keyed on `ENTITY_INVALID_CONFIG` should match both codes. The new finding's location is -`x-entity.uniqueBy` (or `x-entity.uniqueBy.name`) **without the schema name**, -and the message does not name it either, so in a multi-entity spec find the -entity by the offending value. A retired `x-entity` key such as +`x-entity.uniqueBy` (or `x-entity.uniqueBy.name`) **without the schema name** +through `0.15.0`. From `0.16.0` it is `components.schemas..x-entity.uniqueBy` +(`clabonte/generator#2567`; measured). The message still does not name the schema. A retired `x-entity` key such as `encryptedProperties` keeps `ENTITY_INVALID_CONFIG`. `specfuse-xentity-shape` catches the same four at lint, and additionally rejects a repeated member within one tuple and an unknown member on a constraint — both @@ -1205,7 +1214,8 @@ ShipmentCarrier: **What it does not do.** -- **Persistence still throws.** The C# column converters (`EnumValueConverter`, `EnumListValueConverter`) and the route/query `CustomEnumConverter` are not gated by the key: a row read as `UNKNOWN` and saved back would write `"unknown"` into the column. A renamed stored value is still a data migration. +- **Persistence decodes, and writes the sentinel back.** From generator `0.16.0` the C# column converters (`EnumValueConverter`, `EnumListValueConverter`) store the `[EnumMember]` wire value. On read they try the wire value first, then the member name case-insensitively, so rows written by `0.15.0` and earlier, which stored the C# member name, still load. Anything else falls back to the JSON converter, which returns `UNKNOWN` on a marked enum and throws on an unmarked one. A row read as `UNKNOWN` and saved back writes `"unknown"` into the column. A renamed stored value is still a data migration. +- **C# enum columns change stored form at `0.16.0` (breaking for stored data, `clabonte/generator#2584`).** Every enum column whose wire value differs from its member name, even by letter case alone, now gets new rows in the wire form beside legacy rows in the member form. With the kit's lower-case wire values, `CustomerStatus.Active` is now stored as `active` next to legacy `Active` rows. On SQL Server's default case-insensitive collation, filters still match a case-only difference, but not a difference beyond case (`_10to20` vs `10to20`). On a case-sensitive collation or PostgreSQL, filters, unique indexes and `GROUP BY` all treat the two forms as different values. Before relying on them, run a one-off `UPDATE` per enum column and member that rewrites the member name to the wire value. - **`unknown` is decode-only.** The original string is not kept, and sending `unknown` back to the server is undefined. A client must not echo it. - **A spec member literally named `unknown` folds into the sentinel** rather than producing a second one. Test support (fixtures, builders, fakes) never seeds the sentinel. - **A Dart `switch` over a marked enum gains an arm, and a TypeScript `Record` over it widens.** Marking an existing enum is a source change for every consumer that does either. @@ -1490,7 +1500,7 @@ Person: **Spec-author action on upgrading to the `0.13.0` pin.** Search for `atRest: encrypted` and add the member to every entity that declares it. Kit Spectral mirrors the rule as `specfuse-xprotection-unavailable-properties-required` (error), so lint names the schema before a generate run does. -> **It is required for directly-declared encrypted properties only.** An entity whose *only* encrypted target is a flattened value object does **not** need the member — measured against `0.13.0`, which emits per-member ciphertext for that case and asks for nothing. Treat that as the generator's current scope rather than as a statement that those members cannot fail: they can, and the gap is filed as `clabonte/generator#2097`. The kit deliberately does not widen past the jar, because a lint rule stricter than the generator fails specs that generate cleanly. +> **A flattened value object counts too, from generator `0.14.0`.** An entity whose only encrypted target is a value-object usage site stored `flatten` also needs the member (`clabonte/generator#2097`; on `0.13.0` it was asked for nothing). Both `protection` shapes trigger it. Through `0.15.0` the storage had to be declared at the usage site; from `0.16.0` a `flatten` inherited from the value object's `defaultStorage` counts too (`#2566`). A `single_json` usage site does not trigger it. Measured on `0.13.0` through `0.16.0`. The kit's mirror covers the usage site that declares `storage: flatten` itself. It cannot see an inherited default, so `validate` is the check for that case. #### What `atRest: encrypted` emits, measured against generator `0.13.0` @@ -1544,7 +1554,9 @@ The value-object path was silent through `0.12.0` (`clabonte/generator#1993`); i | Encrypted | Refused — warns `PROTECTION_ENCRYPTED_NON_STRING_TARGET`, column stays plaintext | |---|---| -| `string`, `boolean`, any integer width, `format: uuid`, `format: date` | `double`, `float` (binary round-trip through a canonical string is not guaranteed), `decimal` (no canonical string form), a bare `format: date-time` (ambiguous between a calendar date and an instant — declare `format: date` if you mean a date), arrays, multi-branch composites | +| `string`, `boolean`, any integer width, `format: uuid`, `format: date`, an enum (from generator `0.16.0`) | `double`, `float` (binary round-trip through a canonical string is not guaranteed), `decimal` (no canonical string form), a bare `format: date-time` (ambiguous between a calendar date and an instant — declare `format: date` if you mean a date), arrays, multi-branch composites | + +**An encrypted enum was silently plaintext through `0.15.0`.** The declaration was dropped with no finding (`clabonte/generator#2560`). From `0.16.0` the value is encrypted under its `[EnumMember]` wire value into a `Ciphertext` column, and the property carries `[Sensitive]`. Adopting the pin needs an EF migration and a backfill for each encrypted enum. The regenerated entity no longer reads the plaintext column, so the backfill must read it directly, before the column is dropped. **A value object is all-or-nothing.** The rule refuses *"a value object carrying a member in one of those categories"*, so one `decimal` member leaves **every** member of that value object in plaintext, including the sound ones. Worth knowing before adding a price to an encrypted address. @@ -1621,7 +1633,7 @@ The two sides split cleanly, and the split is the generator's own: `PiiClassific | 14 | `sad`, and `atRest: never_persist` / `never-persist`, MUST NOT appear on an `x-entity` property — those are persisted by construction. | generator — `PROTECTION_SAD_MAPPED` / `PROTECTION_NEVER_PERSIST_MAPPED`; mirrored by kit `specfuse-xprotection-sad-not-persisted` / `-never-persist-not-mapped` | | 6 | A property carrying `x-classification: [exposed]` MUST carry a non-empty `description` justifying why exposure is safe. | kit — `specfuse-classification-exposed-needs-description` (error) | | 7 | A property the validator reads as PII MUST declare `x-classification` (see "Where it is required" above). | generator — `PII_FIELD_MISSING_CLASSIFICATION` (error); mirrored in the editor by kit `specfuse-classification-pii-required` | -| 15 | An entity declaring `x-protection.atRest: encrypted` on one of its own properties MUST expose a `readOnly` string array `unavailableProperties`. Generator `0.13.0`; **breaks specs that validated on `0.12.0`**. Not required when the only encrypted target is a flattened value object. | generator — `UNAVAILABLE_PROPERTIES_NOT_DECLARED` (error); mirrored by kit `specfuse-xprotection-unavailable-properties-required` | +| 15 | An entity declaring `x-protection.atRest: encrypted` on one of its own properties MUST expose a `readOnly` string array `unavailableProperties`. Generator `0.13.0`; **breaks specs that validated on `0.12.0`**. From generator `0.14.0` this includes an encrypted value-object usage site stored `flatten`; from `0.16.0`, also when `flatten` is inherited from `defaultStorage`. `single_json` is exempt. | generator — `UNAVAILABLE_PROPERTIES_NOT_DECLARED` (error); mirrored by kit `specfuse-xprotection-unavailable-properties-required` | | 16 | A `type: string` property of an `x-entity` schema whose name ends in `hash` or `digest` (case-insensitive; `api_key_hash` counts) MUST declare `x-protection`, whether or not it is classified. `atRest: hashed` is the expected answer for a stored digest (with `x-internal-only`, rule 13), `atRest: none` with a rationale for a public content digest. Presence only — any `x-protection` silences it. Out of scope: a `$ref`-typed or `allOf`-wrapped property, a non-string, and a non-entity schema. **No temporal exclusion**, unlike `SENSITIVE_FIELD_IN_RESPONSE`: a `format: date-time` property named `*Hash` fires. Generator `0.15.0`; **opens at ERROR, so it can break a spec that validated on `0.14.0`** — `x-internal-only` or `writeOnly` alone used to be enough for such a property. | generator — `PROTECTION_DIGEST_UNDECLARED` (error); mirrored by kit `specfuse-xprotection-digest-undeclared` | Rules 5 and 6 were documented here for some time and enforced by **nothing** on either side — rule 5 even named a finding id (`CLASSIFICATION_EXPOSED_CONTRADICTION`) that exists in neither the kit nor the jar. They are kit Spectral rules now. Rule 2 warns rather than fails: a refused target generates, in plaintext, so read the warning as a protection decision that did not happen. @@ -2067,7 +2079,7 @@ 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. -> **Do not count on `validate` to catch the inherited case.** Measured on a copy of `hello-orders`: a usage site declaring a non-empty `queryable` itself is reported on `0.14.0` and after it. An inherited `defaultQueryable` is not reported on `0.14.0`. From generator `0.15.0` it is, when the embed is a direct `$ref`, but measured on a build of generator main it is still not reported through a one-item `allOf` embed, the form this section uses. Declare `queryable: []` at every encrypted usage site. +> **`validate` catches the inherited case from generator `0.16.0`.** Measured on a copy of `hello-orders`: a usage site declaring a non-empty `queryable` itself is reported on `0.14.0` and after it. An inherited `defaultQueryable` is not reported on `0.14.0`. Generator `0.15.0` reports it through a direct `$ref` embed only. `0.16.0` also reports it through a one-item `allOf` embed, the form this section uses (`clabonte/generator#2566`; measured on both). Still declare `queryable: []` at every encrypted usage site: it states what you meant instead of relying on the rule. **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**: @@ -4471,7 +4483,7 @@ A replica is fed by the owner's events, so declaring one puts requirements on th `project.json` gains `groups[].pillar`, which binds a group to a pillar name in `info.x-pillars`; the pillar's owned domains expand into the ordinary include-filter that `groups[].domains` writes by hand. `pillar` and `domains` are **mutually exclusive**. See `Project_File.md` §8.13.2. -Only a pillar-bound group applies holds (§14.3). At generator `0.15.0`, a cross-pillar `belongsTo` or `x-references` with a hold, or a `resolve: remote` reference with none, generates in both pillars' groups. A cross-pillar reverse `hasMany`/`hasOne` and a child route nested under another pillar's parent still fail. See §14.10. +Only a pillar-bound group applies holds (§14.3). At generator `0.16.0`, as at `0.15.0`, a cross-pillar `belongsTo` or `x-references` with a hold, or a `resolve: remote` reference with none, generates in both pillars' groups. A cross-pillar reverse `hasMany`/`hasOne` and a child route nested under another pillar's parent still fail. See §14.10. ### 14.7 One bundle or many — the choice this vocabulary does *not* make for you @@ -4497,7 +4509,7 @@ Adoption is opt-in and the checks are gated on a declaration, so there is no for 5. **Re-run validation and use its output as the work list.** Do not plan from a count someone measured against an older bundle — the ranking moves. 6. **Validate the project file, then generate every pillar.** `validate` on a bundled spec runs the pillar rules in §14.9. Some failures only appear when a group's context is built for one pillar: `validate .json` builds it, while spec-level `validate` and the Spectral layers do not. A spec can be green at the spec level and still fail to generate for every pillar. Give the project file one group per pillar, each with `groups[].pillar`, and validate that file. - Even that is not the whole bar. At generator `0.15.0`, a child-create operation nested under a held parent's path still passes project-level `validate` and fails `generate`, in both pillars' groups (§14.10). Run `generate` for each pillar-bound group before calling the topology done. §14.10 lists the shapes that fail at generator `0.15.0`. + Even that is not the whole bar. At generator `0.16.0`, as at `0.15.0`, a child-create operation nested under a held parent's path still passes project-level `validate` and fails `generate`, in both pillars' groups (§14.10). Run `generate` for each pillar-bound group before calling the topology done. §14.10 lists the shapes that fail at generator `0.16.0`. ### 14.9 Kit Spectral rules and their generator counterparts @@ -4522,11 +4534,11 @@ The kit lints this vocabulary in the editor; the generator validates it at gener **`PILLAR_CROSS_BOUNDARY_REFERENCE` is the one finding that can turn a green `validate` red.** It reports every reference whose target is owned by a different pillar and is not covered by a `Read{Entity}` + `holds` pair. It fires **only** on a spec that declares `info.x-pillars`, so it cannot affect a project that has not adopted the vocabulary — but once you do adopt, it is an ERROR, not a warning. It is satisfiable by authoring the pairs, by declaring the reference `x-references: { entity: , resolve: remote }` (generator `0.15.0`, §14.11), or by declaring it opaque with `x-references: none` (§1.7). The census accepts the first two and skips the third. Since generator `0.15.0` it also reports a cross-pillar `hasMany`/`hasOne` **even when the target is held**: the child's `belongsTo` is the crossing, and the reverse edge must go (§14.10). -**Passing this census does not mean every pillar generates.** At generator `0.15.0` the census and pillar-bound generation agree on every edge kind measured in §14.10. A child route nested under another pillar's parent still passes the census and fails `generate`. See §14.10. +**Passing this census does not mean every pillar generates.** At generator `0.16.0`, as at `0.15.0`, the census and pillar-bound generation agree on every edge kind measured in §14.10. A child route nested under another pillar's parent still passes the census and fails `generate`. See §14.10. -### 14.10 Cross-pillar edges at generator `0.15.0`: what generates +### 14.10 Cross-pillar edges at generator `0.16.0`: what generates -Everything below was measured at generator `0.15.0`, on a copy of `examples/hello-orders` split into two pillars: +Everything below was measured at generator `0.15.0` and re-measured at `0.16.0`, on a copy of `examples/hello-orders` split into two pillars. Every row gave the same verdict on both jars, and the `belongsTo`-with-hold groups generated byte-identical C#. The `0.16.0` re-measure held `Customer` only and reached `Tenant` through `resolve: remote`: - `core` owns `tenant` and `customer`; - `sales` owns `order`; @@ -4555,9 +4567,10 @@ What follows from the table: - **Do not nest a child's routes under a parent owned by another pillar.** With `POST /customers/{customerId}/orders` and `belongsTo` in place, the project-level `validate` passed and `generate` failed in **both** groups: "Create operation 'CreateOrder' is rooted under aggregate 'Customer' which does not own 'Order'". Neither group builds the cross-pillar edge as ownership: the holder sees a replica, the owner sees no inverse. With `resolve: remote` instead of `belongsTo`, the spec-level `validate` already fails, with `OPERATION_PARENT_NOT_OWNER` and `OPERATION_NESTED_LIST_NO_RELATIONSHIP`. Put the child's create and list operations at the child's own path, with the parent id in the body or as a query filter. - **The tenancy root has two working forms.** `belongsTo` to the tenant with a hold generates on both sides (second row), and the child carries the `I{Tenant}Scoped` marker. A `resolve: remote` reference to the tenant carries the marker with no `belongsTo` and no hold (§14.11). Measured with a `tenancy` block: under `belongsTo` and under `resolve: remote`, `Order` implemented the tenant marker. Under `x-references: Tenant` and under `x-references: none`, it did not. The generated `Order` events carried the marker in all four cases. - **The C# Domain no longer maps `Read{Entity}`** (`clabonte/generator#2392`). At `0.15.0` the generated C# maps through Mapperly, and no group emits an `AutoMapperProfile.g.cs`. In the measured groups, no generated file names a `Read{Entity}` except the replica's own entity and configuration and the holder's entity whose FK binds to it. Dart and TypeScript `dto` groups no longer emit a `Read{Entity}` client type. Checked with `dotnet build` on an unbound C# group of the held spec, with the domain artifacts it needs to compile (`domainValidation`, `domainExceptions`, `serviceInterface`, `service`, `repositoryInterface`, `event`, `eventRuntime`, `tenancyMarker`, `snapshot`, and a `tenancy` block). At `0.15.0` it built with 0 errors. At `0.14.0` it failed with `CS0246` on `ReadTenant`, `ReadOrder` and `ReadCustomer` in `AutoMapperProfile.g.cs`. -- **A pillar-bound C# group with `snapshot` does not compile, at `0.15.0` or `0.14.0`.** `snapshot` emits every `{Entity}Snapshot` under the AsyncAPI source root, not only the pillar's, and each one maps from its entity. In the holder's group, `CustomerSnapshot.g.cs` and `TenantSnapshot.g.cs` failed with `CS0246` on `Customer`, `CustomerStatus` and `Tenant`. `OrderSnapshot.g.cs` failed the same way in the owner's group. This is unrelated to replicas, and nothing in the spec avoids it. +- **`snapshot` follows the pillar from generator `0.16.0`** (`clabonte/generator#2568`). A pillar-bound group emits only the `{Entity}Snapshot` and `{Entity}SnapshotChanges` of the entities it owns: `core` gets `CustomerSnapshot`, `sales` gets `OrderSnapshot` and `OrderLineSnapshot`. At `0.15.0` and `0.14.0` every group emitted every snapshot, and each foreign one failed `CS0246` on the entity it maps from. An unbound group emits exactly what it did before. +- **A pillar-bound C# group with `event` and `service` still does not compile, at `0.16.0` or `0.15.0`.** `event` emits the event infrastructure but no `{Message}Event` class, while `Default{Entity}Service` constructs them, so the build fails with `CS0246` on `OrderCreatedEvent`, `CustomerUpdatedEvent` and the rest. The same happens under `groups[].domains`. The same artifacts in an unfiltered group emit every event class and build with 0 errors. Nothing in the spec avoids it (`compatibility.md` §44 item 9). -These verdicts are tied to `0.15.0`; re-measure them at every pin bump (`compatibility.md` §44). +These verdicts are tied to `0.16.0`; re-measure them at every pin bump (`compatibility.md` §44). ### 14.11 A pillar that reads without keeping a copy @@ -4582,7 +4595,7 @@ Measured at `0.15.0` on the two-pillar `hello-orders` split in §14.10, with `Or - **Both groups generate.** `PILLAR_CROSS_BOUNDARY_REFERENCE` accepts `resolve: remote` with no `holds` and no `Read{Entity}`. The holder's `Order` has `public Guid CustomerId { get; set; } = Guid.Empty;` and no navigation, FK configuration or replica. - **The relationship stays visible.** The `entityDiagram` Markdown artifact lists the column as ``→ `Customer` (remote)`` and draws a dashed edge to `Customer`. `x-references: none` records no target, so it cannot do this. - **It carries tenancy.** With a `tenancy` block naming `Tenant` (`Project_File.md` §4.1), a `resolve: remote` reference to `Tenant` makes `Order` implement the `I{Tenant}Scoped` marker with no `belongsTo`. `TenancyDerivationValidationRule` accepts it as the tenant chain. `Order`'s events carried the marker too. Checked with `dotnet build` on an unbound group: 0 errors. This is the case `x-references: none` could not express (§14.10 tenancy bullet). - - **Name the column after `tenancy.tenant.foreignKey`.** The marker's property is the tenancy block's `foreignKey`, not the column that carries the remote reference. With the reference on `tenantRef`, generation passed, and `dotnet build` failed with `CS0535`: `Order` does not implement the marker's `TenantId`. Validation does not catch it. + - **Name the column after `tenancy.tenant.foreignKey`.** The marker's property is the tenancy block's `foreignKey`, not the column that carries the remote reference. From generator `0.16.0` (`clabonte/generator#2569`), a reference on any other column is `TENANT_REMOTE_REFERENCE_FOREIGN_KEY_MISMATCH` (ERROR) at project-level `validate` and at `generate`. The finding names the entity, the column and the expected name; case, `_` and `-` are ignored. Spec-level `validate` does not report it, because it cannot see the `tenancy` block. Rename the column; no alias is generated. At `0.15.0`, `tenantRef` generated, gained the marker, and failed `dotnet build` with `CS0535`. - Only the tenant tier is read from a remote reference. In the generator source, a `subTenant` tier still needs `belongsTo`. - **Two warnings catch a contradictory topology.** Measured, both WARNING: - `PILLAR_REMOTE_REFERENCE_ALSO_HELD`: the referencing pillar also `holds` the target, so one edge has two strategies. The column stays a plain uuid; the hold does not rebind it. diff --git a/plugins/specfuse-authoring/skills/create-worker/SKILL.md b/plugins/specfuse-authoring/skills/create-worker/SKILL.md index b81a2ae..9cf7b6a 100644 --- a/plugins/specfuse-authoring/skills/create-worker/SKILL.md +++ b/plugins/specfuse-authoring/skills/create-worker/SKILL.md @@ -146,10 +146,15 @@ x-ai: reads: [{Entity1}, {Entity2}] creates: [{Entity3}] updates: [{Entity4}] + dataHandling: + personalData: {true|false} + providerRegion: {CA} # ISO 3166-1 alpha-2; required when personalData: true ``` **MANDATORY cross-check:** every entity listed must have the corresponding `aiAccess.operations` in its OpenAPI `x-entity`. If not, stop and flag to the user before proceeding. +**Declare `dataHandling`** whenever the worker reads or creates an entity holding personal data (any classified property). Ask the user for the provider region rather than guessing: it is a residency promise. Tell them that the generated worker will refuse to start until `_PRIMARY_PROVIDER_REGION` is set at deploy time (`AsyncAPI_Handbook.md` §4.3). + ### Step 6: Update OpenAPI operations (if event is API-triggered) If the triggering event comes from a REST write operation, ensure that operation has `x-emits` listing the event. @@ -184,5 +189,6 @@ Fix any errors before presenting the result. - [ ] Root `asyncapi.yaml` updated with new channels and operations - [ ] Flow documentation created/updated - [ ] AI workers have `x-ai.entities` cross-checked against `aiAccess` +- [ ] AI workers that touch personal data declare `x-ai.dataHandling`, with `providerRegion` when `personalData: true` - [ ] No removed v1 extensions used - [ ] Validation passes with 0 errors diff --git a/pyproject.toml b/pyproject.toml index 697566f..66b6cf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfuse-authoring" -version = "0.24.0" +version = "0.25.0" description = "Specfuse authoring kit: handbooks, samples, Claude assets, project bootstrap, and the generator launcher." readme = "README.md" requires-python = ">=3.10" diff --git a/schemas/spectral/fixtures/ai-data-handling.yaml b/schemas/spectral/fixtures/ai-data-handling.yaml new file mode 100644 index 0000000..bc3e8a4 --- /dev/null +++ b/schemas/spectral/fixtures/ai-data-handling.yaml @@ -0,0 +1,143 @@ +# ============================================================================= +# Fixture: x-ai shape and x-ai.dataHandling (generator 0.16.0) +# ============================================================================= +# Each operation name says what CI expects of it (example-regen.yml): +# good* — none of the three x-ai rules may fire; +# bad* — the rule named in its summary must fire on it. +# Values outside the x-ai blocks are only there to make the document parse. +# ============================================================================= +asyncapi: 3.0.0 + +info: + title: x-ai data handling fixture + version: 1.0.0 + +channels: + aiChannel: + address: ai.fixture + messages: + aiMessage: + name: AiMessage + payload: + type: object + +operations: + goodPersonalWithRegion: + summary: personal data, region declared + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: generation + entities: { reads: [Order] } + dataHandling: + personalData: true + providerRegion: CA + + goodNoPersonalData: + summary: no personal data, so no region is needed + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: classification + entities: { reads: [Order] } + dataHandling: + personalData: false + + goodLowerCaseRegion: + summary: the jar upper-cases the region, so lower case is accepted + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: classification + entities: { reads: [Order] } + dataHandling: + personalData: true + providerRegion: ca + + goodInformationalKeys: + summary: every documented key, parser-read and informational + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: recommendation + model: claude-sonnet + promptTemplate: order/enrich-order + capabilities: [structuredOutput] + estimatedTokens: { input: 8000, output: 3000 } + maxLatency: 30s + fallback: queue + entities: { reads: [Order] } + + badRegionMissing: + summary: specfuse-async-ai-personal-data-region + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: generation + entities: { reads: [Order] } + dataHandling: + personalData: true + + badRegionThreeLetters: + summary: specfuse-async-ai-data-handling-shape + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: generation + entities: { reads: [Order] } + dataHandling: + personalData: true + providerRegion: CAN + + badPersonalDataString: + summary: specfuse-async-ai-data-handling-shape + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: generation + entities: { reads: [Order] } + dataHandling: + personalData: "yes" + providerRegion: CA + + badPersonalDataMissing: + summary: specfuse-async-ai-data-handling-shape + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: generation + entities: { reads: [Order] } + dataHandling: + providerRegion: CA + + badDataHandlingUnknownKey: + summary: specfuse-async-ai-data-handling-shape + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: generation + entities: { reads: [Order] } + dataHandling: + personalData: false + retention: P30D + + badMisspeltDataHandling: + summary: specfuse-async-ai-shape + action: receive + channel: { $ref: '#/channels/aiChannel' } + x-ai: + enabled: true + task: generation + entities: { reads: [Order] } + datahandling: + personalData: true + providerRegion: CA diff --git a/schemas/spectral/fixtures/classification.yaml b/schemas/spectral/fixtures/classification.yaml index af8cc8e..44908cf 100644 --- a/schemas/spectral/fixtures/classification.yaml +++ b/schemas/spectral/fixtures/classification.yaml @@ -475,12 +475,9 @@ components: ValueObjectOnlyEncryption: type: object - # MUST NOT fire — MEASURED against generator 0.13.0, which generates - # per-member ciphertext for this case and still asks for nothing. The - # asymmetry is a generator gap — clabonte/generator#2097 — but a kit rule stricter than the - # jar fails specs that generate cleanly, which is the adoption-blocking - # direction. If the jar closes the gap, this control flips and the scope - # note on the rule comes off. + # MUST fire — the jar requires the member for an encrypted flattened + # value object from generator 0.14.0 (clabonte/generator#2097; silent on + # 0.13.0). Measured on 0.14.0-0.16.0: UNAVAILABLE_PROPERTIES_NOT_DECLARED. x-entity: domain: crm type: aggregate @@ -498,6 +495,46 @@ components: residenceAddress: type: object + ValueObjectEncryptionSingleJson: + type: object + # MUST NOT fire — a `single_json` usage site does not require the member + # on any jar (measured 0.15.0, 0.16.0). + x-entity: + domain: crm + type: aggregate + valueObjects: + residenceAddress: + storage: single_json + classification: [pii] + protection: + atRest: encrypted + rationale: Residential address of a natural person. + required: [id] + properties: + id: { type: string, format: uuid } + residenceAddress: + type: object + + ValueObjectEncryptionInheritedStorage: + type: object + # MUST NOT fire in the kit — no `storage` at the usage site. The jar + # requires the member from 0.16.0 when the value object's defaultStorage + # is flatten (#2566), but the kit cannot see that default from here. + x-entity: + domain: crm + type: aggregate + valueObjects: + residenceAddress: + classification: [pii] + protection: + atRest: encrypted + rationale: Residential address of a natural person. + required: [id] + properties: + id: { type: string, format: uuid } + residenceAddress: + type: object + # ---- Digest-shaped names (PROTECTION_DIGEST_UNDECLARED, generator 0.15.0). # Every case below was measured against the candidate jar: the four that # fire here are exactly the four the jar reports, and the jar is silent on diff --git a/schemas/spectral/fixtures/inline-enum-wrapped.yaml b/schemas/spectral/fixtures/inline-enum-wrapped.yaml new file mode 100644 index 0000000..6da8fed --- /dev/null +++ b/schemas/spectral/fixtures/inline-enum-wrapped.yaml @@ -0,0 +1,35 @@ +# ============================================================================= +# Fixture: inline enums, bare and wrapped (specfuse-no-inline-enums) +# ============================================================================= +# The generator reports an inline enum wrapped in a one-item `allOf` beside a +# `description` as INLINE_ENUM_SCHEMA from 0.16.0 (#2579). CI asserts: +# Order.properties.priority (bare inline enum) — MUST fire +# Order.properties.channel (allOf-wrapped enum) — MUST fire +# Order.properties.status (allOf-wrapped $ref) — MUST NOT fire +# Not a Specfuse-conformant example — do not copy. +# ============================================================================= +openapi: 3.0.3 +info: + title: Inline enum fixture + version: "1.0.0" +paths: {} +components: + schemas: + OrderStatus: + type: string + enum: [draft, placed] + Order: + type: object + properties: + priority: + type: string + enum: [low, high] + channel: + description: Where the order came from. + allOf: + - type: string + enum: [web, phone] + status: + description: Lifecycle state. + allOf: + - $ref: '#/components/schemas/OrderStatus' diff --git a/schemas/spectral/functions/openapiClassification.js b/schemas/spectral/functions/openapiClassification.js index b9b6087..7b7c65c 100644 --- a/schemas/spectral/functions/openapiClassification.js +++ b/schemas/spectral/functions/openapiClassification.js @@ -236,21 +236,45 @@ function checkPiiRequired(schema, basePath) { // could not decrypt — without it, a decryption failure has nowhere to go but // an exception or, worse, a silently empty value. // -// Scope deliberately matches the jar and no more: it fires on a property whose -// OWN `x-protection` says encrypted. An entity whose only encrypted target is a -// flattened value object does NOT trip it — measured against 0.13.0, which -// generates per-member ciphertext for that case and still asks for nothing. -// That asymmetry is the generator's, not the kit's; widening it here would fail -// specs that generate cleanly, which is the adoption-blocking direction. +// Scope: a property whose OWN `x-protection` says encrypted, and (from +// generator 0.14.0, clabonte/generator#2097) an `x-entity.valueObjects` usage +// site that declares `storage: flatten` with an encrypted `protection`, in +// either shape (scalar `{atRest}` or per-member map). Measured on 0.14.0-0.16.0. +// `single_json` does not trip it. A usage site that declares no `storage` and +// inherits `flatten` from the value object's `defaultStorage` trips the jar from +// 0.16.0 (#2566), but the kit cannot see the value object's default from the +// entity, so it stays silent there: guessing `flatten` would fail specs whose +// default is `single_json`, which generate cleanly. +function isEncrypted(decl) { + return !!decl && typeof decl === "object" && typeof decl.atRest === "string" && + decl.atRest.toLowerCase() === "encrypted"; +} + +function encryptedFlattenedValueObjects(schema) { + const xEntity = schema["x-entity"]; + const sites = xEntity && typeof xEntity === "object" ? xEntity.valueObjects : null; + if (!sites || typeof sites !== "object" || Array.isArray(sites)) return []; + const out = []; + for (const [name, site] of Object.entries(sites)) { + if (!site || typeof site !== "object") continue; + if (typeof site.storage !== "string" || site.storage.toLowerCase() !== "flatten") continue; + const protection = site.protection; + if (!protection || typeof protection !== "object") continue; + const encrypted = isEncrypted(protection) || + Object.values(protection).some((member) => isEncrypted(member)); + if (encrypted) out.push(name); + } + return out; +} + function checkUnavailablePropertiesRequired(schema, basePath) { const encrypted = []; for (const [name, prop] of entityProperties(schema)) { const protection = prop && typeof prop === "object" ? prop["x-protection"] : null; - if (!protection || typeof protection !== "object") continue; - const atRest = protection.atRest; - if (typeof atRest === "string" && atRest.toLowerCase() === "encrypted") { - encrypted.push(name); - } + if (isEncrypted(protection)) encrypted.push(name); + } + for (const name of encryptedFlattenedValueObjects(schema)) { + encrypted.push(`${name} (flattened value object)`); } if (encrypted.length === 0) return []; diff --git a/schemas/spectral/specfuse-asyncapi.yaml b/schemas/spectral/specfuse-asyncapi.yaml index 72bb83c..68672fa 100644 --- a/schemas/spectral/specfuse-asyncapi.yaml +++ b/schemas/spectral/specfuse-asyncapi.yaml @@ -893,6 +893,73 @@ rules: - required: [updates] - required: [deletes] + # x-ai was open: a misspelt key (`datahandling`) linted clean and the + # generator ignored it, so the worker started with no region check. The set + # is the four keys the parser reads (`extensions --format json`, generator + # 0.16.0) plus the six informational keys AsyncAPI_Handbook §4.3 documents, + # which the parser ignores but the handbook teaches; those six are recorded + # under `kit_only` in vocabulary-exceptions.yaml. WARN, not error: a project + # may carry its own informational keys, and the generator does not refuse + # unknown ones. A project can raise it to error in its overlay. + specfuse-async-ai-shape: + description: "x-ai declares only keys the generator reads or the handbook documents" + message: "x-ai at '{{path}}': {{error}}. Known keys: enabled, task, entities, dataHandling (read by the generator); model, promptTemplate, capabilities, estimatedTokens, maxLatency, fallback (informational)." + severity: warn + given: $.operations[*].x-ai + then: + function: schema + functionOptions: + schema: + type: object + additionalProperties: false + properties: + enabled: {} + task: {} + entities: {} + dataHandling: {} + model: {} + promptTemplate: {} + capabilities: {} + estimatedTokens: {} + maxLatency: {} + fallback: {} + + # Mirrors the generator's parser (0.16.0, AiDefinition): `personalData` is a + # boolean and `providerRegion` two ASCII letters, else INVALID_EXTENSION_VALUE. + # The jar upper-cases the region, so `ca` is accepted; the handbook asks for + # upper case. `personalData` is required here and not by the jar: a + # dataHandling block that does not say whether personal data is processed + # declares nothing. + specfuse-async-ai-data-handling-shape: + description: "x-ai.dataHandling: personalData (boolean, required) and providerRegion (ISO 3166-1 alpha-2)" + message: "x-ai.dataHandling at '{{path}}': {{error}}" + severity: error + given: $.operations[*].x-ai.dataHandling + then: + function: schema + functionOptions: + schema: + type: object + additionalProperties: false + required: [personalData] + properties: + personalData: + type: boolean + providerRegion: + type: string + pattern: "^[A-Za-z]{2}$" + + # Lint-time twin of the generator's AI_PERSONAL_DATA_REGION_MISSING (WARNING). + # The filter sits where dataHandling is the child being filtered (authoring #73). + specfuse-async-ai-personal-data-region: + description: "x-ai.dataHandling with personalData: true should declare providerRegion" + message: "x-ai.dataHandling at '{{path}}' declares personalData: true and no providerRegion, so nothing constrains where the LLM provider runs." + severity: warn + given: $.operations[*].x-ai[?(@property === 'dataHandling' && @.personalData === true)] + then: + field: providerRegion + function: truthy + # =========================================================================== # PAYLOAD QUALITY RULES # =========================================================================== diff --git a/schemas/spectral/specfuse-openapi.yaml b/schemas/spectral/specfuse-openapi.yaml index d704568..c02ee96 100644 --- a/schemas/spectral/specfuse-openapi.yaml +++ b/schemas/spectral/specfuse-openapi.yaml @@ -1651,8 +1651,14 @@ rules: severity: error # Unresolved: a property that $refs a standalone enum schema must not be # flagged; only genuinely authored inline enums should fire. + # The second path catches an inline enum wrapped in an allOf/oneOf/anyOf + # member beside a `description` — the generator's INLINE_ENUM_SCHEMA from + # 0.16.0 (#2579); through 0.15.0 it passed validate and failed generate with + # PROPERTY_INNER_ENUM. A wrapped $ref has no `enum` and stays silent. resolved: false - given: $.components.schemas[*].properties[?(@ && @.enum)] + given: + - $.components.schemas[*].properties[?(@ && @.enum)] + - $.components.schemas[*].properties[*][allOf,oneOf,anyOf][?(@ && @.enum)] then: function: falsy @@ -2577,13 +2583,12 @@ rules: # using `atRest: encrypted` fails until it adds the member — which is # exactly why it is worth catching at lint time. # - # Scope matches the jar exactly: a property whose OWN x-protection says - # encrypted. An entity whose only encrypted target is a flattened value - # object does NOT trip it, measured against 0.13.0 — that case generates - # per-member ciphertext and is asked for nothing. That is a generator gap, - # filed as clabonte/generator#2097; widening the kit past the jar would fail specs that - # generate cleanly, which is the adoption-blocking direction this ruleset - # exists to avoid. + # Scope: a property whose OWN x-protection says encrypted, and an + # x-entity.valueObjects usage site declaring `storage: flatten` with an + # encrypted protection (the jar requires it there from 0.14.0, + # clabonte/generator#2097). An inherited `flatten` (#2566, 0.16.0) is the + # jar's alone: the kit cannot see the value object's defaultStorage from the + # entity. See functions/openapiClassification.js. message: "{{error}}" severity: error given: $.components.schemas[?(@ && @["x-entity"])] diff --git a/specfuse/authoring/__init__.py b/specfuse/authoring/__init__.py index b81421d..4c873e2 100644 --- a/specfuse/authoring/__init__.py +++ b/specfuse/authoring/__init__.py @@ -3,7 +3,7 @@ from importlib.resources import files from pathlib import Path -__version__ = "0.24.0" +__version__ = "0.25.0" def kit_root() -> Path: diff --git a/templates/project-init/.specfuse/authoring/vocabulary-exceptions.yaml b/templates/project-init/.specfuse/authoring/vocabulary-exceptions.yaml index d55a04c..ccf8f0f 100644 --- a/templates/project-init/.specfuse/authoring/vocabulary-exceptions.yaml +++ b/templates/project-init/.specfuse/authoring/vocabulary-exceptions.yaml @@ -31,3 +31,23 @@ kit_only: and misleading about the key's purpose: removing it from the ruleset would cost a working lint rule and change nothing about generated output. Revisit if the generator gains a write-policy concept. + # The six informational x-ai keys. AsyncAPI_Handbook §4.3 documents them as + # informational and read by nothing, and `extensions --format json` (from + # generator 0.16.0) deliberately leaves them out, so the drift report would + # flag each one. `specfuse-async-ai-shape` accepts them so a misspelt + # parser key is caught without failing the keys the handbook teaches. + x-ai: + - key: model + reason: Informational; documented in AsyncAPI_Handbook §4.3, ignored by the parser. + - key: promptTemplate + reason: Informational; documented in AsyncAPI_Handbook §4.3, ignored by the parser. + - key: capabilities + reason: Informational; documented in AsyncAPI_Handbook §4.3, ignored by the parser. + - key: estimatedTokens + reason: Informational; documented in AsyncAPI_Handbook §4.3, ignored by the parser. + - key: maxLatency + reason: Informational; documented in AsyncAPI_Handbook §4.3, ignored by the parser. + - key: fallback + reason: >- + Ignored by the parser, but its value set is checked by the kit's + `specfuse-async-ai-fallback-values`; documented in AsyncAPI_Handbook §4.3.