diff --git a/.github/ci/error-shape-probe/ErrorShapeProbe.csproj b/.github/ci/error-shape-probe/ErrorShapeProbe.csproj
index 1ca51a9..01dda31 100644
--- a/.github/ci/error-shape-probe/ErrorShapeProbe.csproj
+++ b/.github/ci/error-shape-probe/ErrorShapeProbe.csproj
@@ -23,6 +23,8 @@
+
+
diff --git a/.github/ci/error-shape-probe/ReasonConstructors.cs b/.github/ci/error-shape-probe/ReasonConstructors.cs
new file mode 100644
index 0000000..a288a46
--- /dev/null
+++ b/.github/ci/error-shape-probe/ReasonConstructors.cs
@@ -0,0 +1,21 @@
+// compatibility.md §54, §55. From generator 0.18.0 the six exceptions a
+// business rule can refuse with take (string reason, string message) when the
+// spec's Error declares `reason`, as the kit's does. Calling each one here
+// makes a pin bump that drops a constructor, or a kit Error that stops
+// declaring `reason`, fail this build instead of a project's.
+using HelloOrders.Domain.Exceptions;
+
+namespace ErrorShapeProbe;
+
+internal static class ReasonConstructors
+{
+ internal static IEnumerable All() =>
+ [
+ new BadRequestException("invalid_filter", "The filter is malformed."),
+ new ForbiddenException("tenant_mismatch", "The order belongs to another tenant."),
+ new NotFoundException("not_found", "No order has this id."),
+ new ConflictException("template_in_use", "The template is used by an open order."),
+ new PreconditionFailedException("precondition_failed", "The order changed since it was read."),
+ new UnprocessableEntityException("order_closed", "A closed order takes no new lines."),
+ ];
+}
diff --git a/README.md b/README.md
index caf46ff..a307e4f 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.26.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.27.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 0ed4df6..95c1d14 100644
--- a/compatibility.md
+++ b/compatibility.md
@@ -56,7 +56,8 @@ This file tracks which Specfuse generator commits are known compatible with each
| `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) | 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. |
-| `v0.26.0` (incubating, current) | generator **`0.17.0`** — `Specfuse/generator-dist` release `v0.17.0`, asset `specfuse-generator-0.17.0.jar`, sha256 `2f2026fb…9ad3` | **⚠ BREAKING for C# consumers that reference a double-pluralized worker name or a `Var`-prefixed navigation, and for clients that relied on an individual list parameter being ignored.** *Minor: a generator pin bump with consumer-breaking C# renames, a list-query behaviour change and one new ERROR path (a nested list across pillars). No kit Spectral rule changes.* Measured on the released jar against `0.16.0` (§53): `hello-orders` (fresh bundle) validates with a JSON report byte-identical to `0.16.0`'s and generates 14 byte-identical files; a fresh `init` scaffold validates with the same five finding codes; `templates` is unchanged and `extensions` differs only in the order of one value set; `check-extension-vocabulary.py --require-jar` passes. **(1) Breaking C#:** worker registration files and classes are pluralized once (`Peoples` → `People`, `CompanyConsumerRegistration.g.cs` → `CompaniesConsumerRegistration.g.cs`, `#2646`); a navigation named after its target type keeps that name (`VarClient` → `Client`, `#2642`). Hand-written code referencing the old names must be renamed; no schema change. **(2) Individual list parameters bind** in the generated C# list (`#2648`): key, filterable, enum, searchable, range and repeated values each narrow the list (`API_Handbook.md` §10.3). An enum or value-object array parameter is not bound and is reported `OPERATION_LIST_PARAMETER_NOT_BOUND` (WARNING). **(3) Pillars** (`Vendor_Extensions.md` §14.6, §14.10): a filtered C# group with `event` + `service` now builds (`#2639`, closes §44 item 9); a nested list under another pillar's parent fails `validate` with `OPERATION_NESTED_LIST_NO_RELATIONSHIP` (`#2652`); `PROJECT_EVENT_RUNTIME_REQUIRED` counts only the operations a group's scope keeps (`#2651`). **(4) New finding:** `ENTITY_TAG_UNDECLARED_PUBLISHED` (WARNING), a safe read whose C# controller sends an `ETag` its response does not declare (`#2641`, `Vendor_Extensions.md` ETag table). **(5) Fixed without a kit change:** `OPERATION_NON_SEARCHABLE_QUERY_PARAM` checks a `Basic{Entity}` list item against its own entity (`#2665`); Dart and TypeScript test builders seed a declared date (`#2594`, `#2638`); C# timer triggers record a `ScheduleHeartbeat` event (`#2666`). **(6) Ships** the corrections of authoring#157. **Spec-author action:** rename consumer references to the C# names in (1); review list operations that declare individual parameters, since they now filter; declare `ETag` on reads `ENTITY_TAG_UNDECLARED_PUBLISHED` names; flatten any list nested under another pillar's parent. |
+| `v0.26.0` (incubating) | generator **`0.17.0`** — `Specfuse/generator-dist` release `v0.17.0`, asset `specfuse-generator-0.17.0.jar`, sha256 `2f2026fb…9ad3` | **⚠ BREAKING for C# consumers that reference a double-pluralized worker name or a `Var`-prefixed navigation, and for clients that relied on an individual list parameter being ignored.** *Minor: a generator pin bump with consumer-breaking C# renames, a list-query behaviour change and one new ERROR path (a nested list across pillars). No kit Spectral rule changes.* Measured on the released jar against `0.16.0` (§53): `hello-orders` (fresh bundle) validates with a JSON report byte-identical to `0.16.0`'s and generates 14 byte-identical files; a fresh `init` scaffold validates with the same five finding codes; `templates` is unchanged and `extensions` differs only in the order of one value set; `check-extension-vocabulary.py --require-jar` passes. **(1) Breaking C#:** worker registration files and classes are pluralized once (`Peoples` → `People`, `CompanyConsumerRegistration.g.cs` → `CompaniesConsumerRegistration.g.cs`, `#2646`); a navigation named after its target type keeps that name (`VarClient` → `Client`, `#2642`). Hand-written code referencing the old names must be renamed; no schema change. **(2) Individual list parameters bind** in the generated C# list (`#2648`): key, filterable, enum, searchable, range and repeated values each narrow the list (`API_Handbook.md` §10.3). An enum or value-object array parameter is not bound and is reported `OPERATION_LIST_PARAMETER_NOT_BOUND` (WARNING). **(3) Pillars** (`Vendor_Extensions.md` §14.6, §14.10): a filtered C# group with `event` + `service` now builds (`#2639`, closes §44 item 9); a nested list under another pillar's parent fails `validate` with `OPERATION_NESTED_LIST_NO_RELATIONSHIP` (`#2652`); `PROJECT_EVENT_RUNTIME_REQUIRED` counts only the operations a group's scope keeps (`#2651`). **(4) New finding:** `ENTITY_TAG_UNDECLARED_PUBLISHED` (WARNING), a safe read whose C# controller sends an `ETag` its response does not declare (`#2641`, `Vendor_Extensions.md` ETag table). **(5) Fixed without a kit change:** `OPERATION_NON_SEARCHABLE_QUERY_PARAM` checks a `Basic{Entity}` list item against its own entity (`#2665`); Dart and TypeScript test builders seed a declared date (`#2594`, `#2638`); C# timer triggers record a `ScheduleHeartbeat` event (`#2666`). **(6) Ships** the corrections of authoring#157. **Spec-author action:** rename consumer references to the C# names in (1); review list operations that declare individual parameters, since they now filter; declare `ETag` on reads `ENTITY_TAG_UNDECLARED_PUBLISHED` names; flatten any list nested under another pillar's parent. |
+| `v0.27.0` (incubating, current) | generator **`0.18.0`** — `Specfuse/generator-dist` release `v0.18.0`, asset `specfuse-generator-0.18.0.jar`, sha256 `b8ef150e…5e3b1ae` | **⚠ BREAKING for any project scaffolded by kit `≤ 0.26.0` that adopts the new `Error` shape, for clients that branch on `Error.code` as a rule name, and for a spec that nests a write route under another pillar's parent.** *Minor: a generator pin bump that adds C# exception constructors, turns one generate-time failure into a `validate` ERROR and adds three findings, shipped with the kit's `Error` shape change.* Measured on the released jar against `0.17.0` (§55): `hello-orders` (fresh bundle) validates with a JSON report byte-identical to `0.17.0`'s and generates 15 files, identical but for the timestamp in `Domain.generated-paths.json`; a fresh `init` scaffold validates with the same finding codes; `templates` is unchanged and `extensions` differs only in the order of one value set; `check-extension-vocabulary.py --require-jar` passes; the §14.10 pillar matrix gives the same verdict on every row. **(1) The kit's `Error`** carries the HTTP status in an integer `code` and the rule name in `reason`, drops `status`, makes `id` a plain string and adds `type` with an `ErrorType` enum (§54, authoring#159). Breaking on the wire for a client deployed against the old shape; §54 has the migration. A new prose rule, `specfuse-error-rule-name-in-code` (warn), lists the prose still writing a rule name into `code` (authoring#163). **(2) Reason constructors** (`clabonte/generator#2671`): the six exceptions a rule refuses with take `(string reason, string message)` and set `Code`, `Type` and `Reason`; `Error.Id` follows the schema (`API_Handbook.md` §11). Closes §54's generator follow-up. CI's error-shape probe now calls all six. **(3) Pillars** (`Vendor_Extensions.md` §14.3, §14.10, `Project_File.md` §8.13.2): a write route nested under another pillar's parent fails `validate` with `OPERATION_NESTED_CROSS_PILLAR` (ERROR, `#2680`), where it used to pass and fail `generate`; `PROJECT_HELD_ENTITY_MISSING` (warn, `#2682`) and `PROJECT_CONFIGURATION_CONVERTERS_MISSING` (warn, `#2673`) report a group that would not build; a Domain group with nothing to register builds (`#2678`); a value object or enum no entity reaches follows its own `x-domain` (`#2679`). **(4) Tenancy:** a marker named `ITenantScoped` or `ISubTenantScoped` is refused at load, `PROJECT_TENANCY_MARKER_RESERVED` (`#2672`, `Project_File.md` §4.1); closes §53 item 1. **(5) Fixed without a kit change:** a schema named after an event-runtime or implicitly imported .NET type compiles (FEAT-2026-0226); snapshot doc comments cite `Change{T}` (`#2701`); the Markdown channel topology and event catalog place publishers on the producing side and list every message a multi-message worker consumes (`#2677`, `#2704`). **(6) Ideation:** the dossier records `started:` with `graduated_to` (authoring#161). **Spec-author action:** adopt §54's `Error` shape and throw rule refusals with the reason constructors; move any write operation nested under another pillar's parent to the entity's own path; register `heldEntity` in each pillar that holds, and `heldEntityConfiguration` beside `infrastructureProject`; rename a tenancy marker called `ITenantScoped`. |
## How to update this matrix
@@ -1457,13 +1458,13 @@ The C# server prefixes every route with the path part of the server URL; the cli
**Found while measuring, not filed:**
-1. **A tenancy marker named like the generated one inherits itself.** With `tenancy.tenant.marker: "ITenantScoped"` and an entity named `Tenant`, the C# output declares `interface ITenantScoped : ITenantScoped` and fails `CS0529` / `CS0540`, on `0.16.0` and `0.17.0`. No `validate` finding reports it. Choose a marker name that differs from `I{Tenant}Scoped`.
-2. **`heldEntityConfiguration` does not compile in a Domain-only group.** It imports `{basePackage}.Converters` and uses `EnumValueConverter<>`, which only `infrastructureProject` emits, on both jars. The §14.10 artifact set therefore generates but does not build on its own; the earlier builds in this file were on unbound groups.
+1. **A tenancy marker named like the generated one inherits itself.** *Closed at `0.18.0`: refused at load, `PROJECT_TENANCY_MARKER_RESERVED` (§55).* With `tenancy.tenant.marker: "ITenantScoped"` and an entity named `Tenant`, the C# output declares `interface ITenantScoped : ITenantScoped` and fails `CS0529` / `CS0540`, on `0.16.0` and `0.17.0`. No `validate` finding reports it. Choose a marker name that differs from `I{Tenant}Scoped`.
+2. **`heldEntityConfiguration` does not compile in a Domain-only group.** *Reported from `0.18.0` as `PROJECT_CONFIGURATION_CONVERTERS_MISSING` (warn) when the replica maps a scalar enum; the fix is unchanged, register it beside `infrastructureProject` (§55).* It imports `{basePackage}.Converters` and uses `EnumValueConverter<>`, which only `infrastructureProject` emits, on both jars. The §14.10 artifact set therefore generates but does not build on its own; the earlier builds in this file were on unbound groups.
3. **`Error.code` and `domainExceptions`** are as §52 item 1 records, unchanged at `0.17.0`. Resolved kit-side in §54.
### 54. The kit's `Error` takes the HTTP status in `code` and the rule name in `reason` (authoring#159)
-**Status:** kit side **done**. Generator side: `clabonte/generator#2671`, open.
+**Status:** kit side **done**. Generator side **done** in `0.18.0` (`clabonte/generator#2671`, §55).
**The decision.** Through `0.26.0` the kit's `Error` had a snake_case string `code` naming the rule that failed, an integer `status` and a `format: uuid` `id`. That shape never compiled with the C# `domainExceptions` artifact (§52 item 1). It now matches the shape RestoManager has run from spec to C# Backend and Flutter since its first commit (2025-10-15), with `reason` added 2026-10-12:
@@ -1503,7 +1504,7 @@ The full Domain set does not build from `hello-orders` alone for unrelated reaso
4. Add `type` and `ErrorType` if the project registers, or will register, `domainExceptions`.
5. Clients: branch on `reason` where they branched on `code`, and read the status from `code` where they read `status`. This is a breaking wire change for any client already deployed against the old shape.
-**Generator follow-up (`#2671`):** `(string reason, string message)` constructors on the six exceptions a rule can refuse with, and `Id` following the schema's `format`. Until then, `reason` is set by building the `Error` and passing it to the `(Error error)` constructor.
+**Generator follow-up (`#2671`), shipped in `0.18.0`:** `(string reason, string message)` constructors on the six exceptions a rule can refuse with, and `Id` following the schema's `format`. Through `0.17.0`, `reason` was set by building the `Error` and passing it to the `(Error error)` constructor, which still works.
**Prose lint (authoring#163):** `specfuse-error-rule-name-in-code` (`warn`, `functions/openapiErrorReasonProse.js`) flags a `description` or `summary` anywhere in the document that assigns or compares a snake_case rule name to `code` (`error.code = template_in_use`, `errors[0].code == 'x_y'`, `"code": "x_y"`). Two choices keep it quiet on correct prose:
@@ -1512,6 +1513,24 @@ The full Domain set does not build from `hello-orders` alone for unrelated reaso
Response examples need no new rule: `spectral:oas` example validation already rejects a string `code`. Fixtures `error-reason-prose.yaml` (4 firings, 3 silences) and `error-reason-prose-string-code.yaml` (the gate) are asserted in `example-regen.yml`. `hello-orders` and the scaffold lint with no new findings.
+### 55. Generator `0.18.0` pin bump (kit `0.27.0`)
+
+**How it was measured.** On the released jar (`v0.18.0`, sha256 `b8ef150e…5e3b1ae`), with `0.17.0` (`2f2026fb…9ad3`) as the baseline, both downloaded from `Specfuse/generator-dist` and checksum-verified:
+
+- `hello-orders`, freshly bundled: project `validate` passes with `0` errors, `19` warnings, `11` suggestions on both jars; the spec-level JSON report is byte-identical; `generate` writes 15 files on both, identical except the `capturedAt` timestamp in `Domain.generated-paths.json`.
+- A fresh `init` scaffold validates on both jars with the same finding codes.
+- `templates` is identical. `extensions --format json` lists the same `valueObjects.classification` values in a different order, nothing else.
+- `check-extension-vocabulary.py --require-jar` passes.
+- The finding codes in the jar's sources gain `OPERATION_NESTED_CROSS_PILLAR`, `PROJECT_CONFIGURATION_CONVERTERS_MISSING` and `PROJECT_HELD_ENTITY_MISSING`, and the project loader gains the refusal `PROJECT_TENANCY_MARKER_RESERVED`, all in the CHANGELOG. None that `0.17.0` emitted was removed. `SCHEMA_NAME_IMPLICIT_USING_COLLISION` and `SCHEMA_NAME_EVENT_RUNTIME_COLLISION` were added and removed between the two releases; neither ships.
+- The error-shape probe (`.github/ci/error-shape-probe`): `hello-orders` with `domainExceptions` emits a `(string reason, string message)` constructor on `BadRequestException`, `ForbiddenException`, `NotFoundException`, `ConflictException`, `PreconditionFailedException` and `UnprocessableEntityException`, and `Id = Guid.NewGuid().ToString()`. The probe's new `ReasonConstructors.cs` calls all six; it builds on `0.18.0` and fails on `0.17.0` with six `CS1503` / `CS1729` errors, so a pin that loses them fails CI.
+- The §14.10 pillar matrix: 21 two-pillar variants of `hello-orders`, each run as a `core` and a `sales` group, `validate` then `generate`, on both jars. Every one of the 42 runs gave the same verdict on both. Every generating run wrote the same files, byte-identical but for `ConfigureServices.g.cs`, whose registrations are now fully qualified (`#2678`). The variants that carry a nested create gained `OPERATION_NESTED_CROSS_PILLAR`; no other finding changed. A variant with the nested create alone, no nested list, passes both `validate` levels on `0.17.0` and fails `generate`; on `0.18.0` both `validate` levels fail with `OPERATION_NESTED_CROSS_PILLAR`.
+
+**What the bump brings** (the version row has the summary): the reason constructors and the schema-following `Error.Id` of `#2671` (`API_Handbook.md` §11); `OPERATION_NESTED_CROSS_PILLAR` (`#2680`, `Vendor_Extensions.md` §14.9, §14.10, `Project_File.md` §8.13.2); `PROJECT_HELD_ENTITY_MISSING` (`#2682`) and `PROJECT_CONFIGURATION_CONVERTERS_MISSING` (`#2673`, §14.3, `Project_File.md` §8.13.2); `PROJECT_TENANCY_MARKER_RESERVED` (`#2672`, `Project_File.md` §4.1). Closes §53 item 1 and §54's generator follow-up, and gives §53 item 2 a finding.
+
+**Spectral.** No rule changes. `OPERATION_NESTED_CROSS_PILLAR` needs the pillar that owns each entity, which the census computes across the registry and a path's resolved parent; the three `PROJECT_*` findings read the project file, which Spectral does not see.
+
+**Not measured:** a C# project build of the generated pillar groups. The matrix compares generated output across the two jars; where it is identical, the `dotnet build` verdicts §53 recorded for `0.17.0` carry over, and the one changed file only qualifies names.
+
## Outstanding kit-side work
These are gaps in the kit itself, separate from the generator-side follow-ups above. Each represents an enforcement promise the handbooks make that has no implementation today.
diff --git a/generator.lock b/generator.lock
index 186bdb8..baeb796 100644
--- a/generator.lock
+++ b/generator.lock
@@ -1,12 +1,12 @@
{
- "kit_version": "0.26.0",
+ "kit_version": "0.27.0",
"min_java": 21,
"generator": {
- "version": "0.17.0",
- "asset": "specfuse-generator-0.17.0.jar",
- "sha256": "2f2026fb8e40ed7d9d72ba989a1ef7a38a6416df0792ef5a3baf83317a039ad3",
+ "version": "0.18.0",
+ "asset": "specfuse-generator-0.18.0.jar",
+ "sha256": "b8ef150e60e20c70ef54c0469edce681be3b91099e9492b23bf889c795e3b1ae",
"release_repo": "Specfuse/generator-dist",
- "release_tag": "v0.17.0"
+ "release_tag": "v0.18.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/API_Handbook.md b/handbooks/API_Handbook.md
index f974d6b..0645d70 100644
--- a/handbooks/API_Handbook.md
+++ b/handbooks/API_Handbook.md
@@ -2641,11 +2641,23 @@ The three identifying fields answer different questions:
`reason` is optional: a plain schema violation has no rule-level identity, and inventing one adds nothing. Where a handler refuses a request because of a named business rule, it sets `reason`, and the operation's response `description` lists the values it can take (section 2.1).
+**Automated-decision notices (optional).** Some jurisdictions require telling a person when a refusal was made by automated rules alone, without human review: Quebec's Law 25 (s. 12.1), and GDPR art. 22 is the nearest analogue. The kit's default `Error` carries no such field, because the duty is jurisdictional. A project that has it adds an optional `automatedDecision` member to `Error`, a `$ref` to a value object of its own (for example `summary`, `mainFactors`, `reviewAvailable`). It does not go in `details`, which is untyped and which clients cannot rely on. The generated reason constructors accept it (below).
+
**Why `code` is the HTTP status.** The generated C# `domainExceptions` set `Error.Code` from the exception's status, and Google's JSON error mapping (AIP-193) uses the same split: an integer `code` for the status, a separate `reason` for the machine-readable cause. An earlier kit `Error` put the rule name in a string `code` and the status in `status`. It never compiled with `domainExceptions` (`compatibility.md` §54, which has the migration).
**`type` and `ErrorType` are required by `domainExceptions`.** The generator refuses to generate that artifact (`ARTIFACT_SCHEMA_DEPENDENCY`, ERROR) unless `Error.type` is typed by an `ErrorType` enum with at least the eight values above. Add values as you need them (`rateLimitExceeded`; `unmaskForbidden` for a denied `?unmask=`, see `Vendor_Extensions.md` §1.5, `x-unmask`); `x-extensible: true` lets a client built before a new value ships still decode the body and fall back to the status.
-**Setting `reason` from generated C#.** Through generator `0.17.0` the generated exceptions take an `int errorCode` or a message, and neither sets `Reason`. Build the `Error` yourself and pass it to the exception's `(Error error)` constructor; `clabonte/generator#2671` asks for `(string reason, string message)` constructors.
+**Setting `reason` from generated C#.** From generator `0.18.0`, when `Error` declares `reason` (the kit's does), `BadRequestException`, `ForbiddenException`, `NotFoundException`, `ConflictException`, `PreconditionFailedException` and `UnprocessableEntityException` each take `(string reason, string message)`. The constructor sets `Code` to the exception's HTTP status, `Type` to its `ErrorType` and `Reason` to the rule name:
+
+```csharp
+throw new ConflictException("template_in_use", "The template is used by an open order.");
+```
+
+The `type` each one sets is fixed: `validationError`, `insufficientPrivileges`, `resourceNotFound`, `resourceConflict`, `preconditionFailed` and `businessRuleViolation`, in that order. A 409 whose `type` should be `businessRuleViolation` therefore still builds its `Error` and uses the `(Error error)` constructor.
+
+If `Error` also declares an `automatedDecision` property naming a schema, the constructor takes an optional `automatedDecision` of that type as a third argument. `InternalException` and `UnauthorizedException` get no reason constructor. Through `0.17.0` the exceptions took only an `int errorCode` or a message, so a project built the `Error` itself and passed it to the `(Error error)` constructor. That still works.
+
+**`id` follows the schema from `0.18.0`.** The exceptions set `Error.Id` to `Guid.NewGuid().ToString()` for the kit's plain-string `id`, and to `Guid.NewGuid()` for a `format: uuid` one. Through `0.17.0` it was `-`, which two errors raised in the same tick shared. Treat `id` as opaque: it identifies one error occurrence in logs and support requests, and its format is not a contract.
### Error Types and Common Reasons
diff --git a/handbooks/Project_File.md b/handbooks/Project_File.md
index fbd27d4..bb50261 100644
--- a/handbooks/Project_File.md
+++ b/handbooks/Project_File.md
@@ -365,6 +365,7 @@ The Python and Dart/Flutter artifact factories do **not** register a tenancy-mar
- `entity` must be PascalCase.
- `foreignKey` must be PascalCase **and** end in `Id`.
- `marker` must match `^I[A-Z][A-Za-z0-9]*$`.
+- `marker` must not be `ITenantScoped` or `ISubTenantScoped` (generator `0.18.0`, `PROJECT_TENANCY_MARKER_RESERVED`). Those are the abstract markers the C# `tenancyMarker` artifact always emits. A concrete marker of the same name was written to the same file as `interface ITenantScoped : ITenantScoped`, which failed `dotnet build` with `CS0529` while `validate` and `generate` passed. An entity named `Tenant` invites the name; pick another (`IHelloTenantScoped`, `ITenantOwned`).
**Cross-spec entity resolution**: The project-file loader does **not** check that `tenancy.tenant.entity` (or `subTenant.entity`) actually names an `x-entity` schema in the bundled OpenAPI spec. A typo or missing schema produces no load-time error. Detection moves downstream:
@@ -917,8 +918,8 @@ A group generates every domain in the bundle unless it says otherwise. These two
- The pillar's owned domains expand into an ordinary `include` filter, which then takes exactly the path a hand-written include list takes. **No second filtering mechanism exists** — nothing downstream learns about pillars.
- 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.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.
+- **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. From generator `0.18.0`, `validate` and `generate` warn `PROJECT_HELD_ENTITY_MISSING` when no C# group bound to the pillar registers `heldEntity`, and `PROJECT_CONFIGURATION_CONVERTERS_MISSING` when a group registers `heldEntityConfiguration` over a replica that maps a scalar enum, or `entityTypeConfiguration`, and no C# group with the same `basePackage` registers `infrastructureProject`. Both configurations call `EnumValueConverter`, which only `infrastructureProject` emits, so register `heldEntityConfiguration` in the Infrastructure group. 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. From generator `0.18.0` it also stops a child-create nested under a parent in another pillar, with `OPERATION_NESTED_CROSS_PILLAR` (ERROR), which spec-level `validate` reports too; through `0.17.0` that route passed both levels and failed `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.
diff --git a/handbooks/Vendor_Extensions.md b/handbooks/Vendor_Extensions.md
index 2cd3fb1..04eef3d 100644
--- a/handbooks/Vendor_Extensions.md
+++ b/handbooks/Vendor_Extensions.md
@@ -4414,7 +4414,7 @@ info:
3. Holding an entity in a domain the pillar **already owns** is redundant, not wrong: it is a warning, not an error.
4. **`holds` does not silence the cross-boundary census on its own.** What it does is make the crossing *resolvable*: a reference whose target the consuming pillar declared under `holds` resolves to that target's `Read{Entity}` replica instead of failing. An **undeclared** foreign edge still fails with the same code and the same remediation as before.
-**A hold means a persisted, event-fed replica, and it changes the holder's generated code.** It is not only a validation declaration. In a group bound with `groups[].pillar`, every reference to a held entity is rebound to its `Read{Entity}` replica. That applies to an `x-references` FK and to the FK column of a `belongsTo`. The holder's EF model then carries a non-owning relationship to a replica **table in the holder's own database**, kept current by the owner's snapshot events (§14.5). The group has to register the artifacts that emit that table and its feed: `heldEntity` and `heldEntityConfiguration`, and `heldEntityHydration` and `heldEntityHydrationTrigger` wherever the events are consumed (`Project_File.md` §11.1). Otherwise the rebound navigation is typed against a class nothing emits. A group with no `pillar` holds nothing and rebinds nothing.
+**A hold means a persisted, event-fed replica, and it changes the holder's generated code.** It is not only a validation declaration. In a group bound with `groups[].pillar`, every reference to a held entity is rebound to its `Read{Entity}` replica. That applies to an `x-references` FK and to the FK column of a `belongsTo`. The holder's EF model then carries a non-owning relationship to a replica **table in the holder's own database**, kept current by the owner's snapshot events (§14.5). The group has to register the artifacts that emit that table and its feed: `heldEntity` and `heldEntityConfiguration`, and `heldEntityHydration` and `heldEntityHydrationTrigger` wherever the events are consumed (`Project_File.md` §11.1). Otherwise the rebound navigation is typed against a class nothing emits. From generator `0.18.0` a missing `heldEntity` is reported as `PROJECT_HELD_ENTITY_MISSING` (warn). `heldEntityConfiguration` belongs in the group that registers `infrastructureProject`: a replica that maps a scalar enum calls `EnumValueConverter`, which only `infrastructureProject` emits, and from `0.18.0` the split is reported as `PROJECT_CONFIGURATION_CONVERTERS_MISSING` (warn). A group with no `pillar` holds nothing and rebinds nothing.
So `holds` is the right declaration only when the holder really keeps a copy. A pillar that reads a neighbour's entity through the owner's API and keeps no copy should not declare a hold. It declares the reference `resolve: remote` instead (§14.11). A `resolve: remote` reference is never rebound, even to a held target.
@@ -4537,11 +4537,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.17.0`, as at `0.15.0` and `0.16.0`, the census and pillar-bound generation agree on every edge kind measured in §14.10. A child-create route nested under another pillar's parent still passes the census and every `validate`, and fails `generate`. A nested list route fails `validate` from `0.17.0` (`OPERATION_NESTED_LIST_NO_RELATIONSHIP`). See §14.10.
+**Passing this census does not mean every pillar generates.** At generator `0.18.0`, as at `0.15.0` through `0.17.0`, the census and pillar-bound generation agree on every edge kind measured in §14.10. A route nested under another pillar's parent passes the census and fails `validate`: a nested list from `0.17.0` (`OPERATION_NESTED_LIST_NO_RELATIONSHIP`), a nested create, update, replace or delete from `0.18.0` (`OPERATION_NESTED_CROSS_PILLAR`). See §14.10.
-### 14.10 Cross-pillar edges at generator `0.17.0`: what generates
+### 14.10 Cross-pillar edges at generator `0.18.0`: what generates
-Everything below was measured at generator `0.15.0` and re-measured at `0.16.0` and `0.17.0`, on a copy of `examples/hello-orders` split into two pillars. Every row of the table gave the same verdict on all three jars, and every generating row produced byte-identical C# at `0.16.0` and `0.17.0`. The `0.16.0` and `0.17.0` re-measures held `Customer` only and reached `Tenant` through `resolve: remote`:
+Everything below was measured at generator `0.15.0` and re-measured at `0.16.0`, `0.17.0` and `0.18.0`, on a copy of `examples/hello-orders` split into two pillars. Every row of the table gave the same verdict on all four jars. Every generating row produced byte-identical C# at `0.16.0` and `0.17.0`, and at `0.18.0` differed from `0.17.0` in one file, `ConfigureServices.g.cs`, whose service registrations are now fully qualified (`clabonte/generator#2678`). The `0.16.0` and `0.17.0` re-measures held `Customer` only and reached `Tenant` through `resolve: remote`:
- `core` owns `tenant` and `customer`;
- `sales` owns `order`;
@@ -4549,7 +4549,7 @@ Everything below was measured at generator `0.15.0` and re-measured at `0.16.0`
- a row that says "held" means `sales` holds `Tenant` and `Customer`, with flat `ReadTenant` and `ReadCustomer` schemas and the owner's create, update and removal events;
- the nested `/customers/{customerId}/orders` routes were flattened to `/orders` (see the routes bullet below).
-"Holder" is `sales`, the child's pillar. "Owner" is `core`, the parent's pillar. Each verdict is `generate` for one C# group bound to that pillar, with `entity`, `valueObject`, `enum`, `domainModel`, `domainProject`, `heldEntity` and `heldEntityConfiguration` registered. The project-level `validate` gave the same verdict as `generate` on every row. The same spec run through the `0.14.0` jar reproduced the `0.14.0` column, the verdicts this section recorded at that pin.
+"Holder" is `sales`, the child's pillar. "Owner" is `core`, the parent's pillar. Each verdict is `generate` for one C# group bound to that pillar, with `entity`, `valueObject`, `enum`, `domainModel`, `domainProject`, `heldEntity` and `heldEntityConfiguration` registered. That set is for measuring generation, not a project layout: a project registers `heldEntityConfiguration` beside `infrastructureProject` (§14.3), and none of the measured replicas maps an enum, so `PROJECT_CONFIGURATION_CONVERTERS_MISSING` does not fire on it. The project-level `validate` gave the same verdict as `generate` on every row. The same spec run through the `0.14.0` jar reproduced the `0.14.0` column, the verdicts this section recorded at that pin.
| how `Order` refers to its parent | holder's group | owner's group | at `0.14.0` |
|---|---|---|---|
@@ -4567,8 +4567,8 @@ What follows from the table:
- **Never declare a reverse `hasMany`/`hasOne` across a pillar boundary.** A parent cannot navigate to children stored in another pillar's database. `PILLAR_CROSS_BOUNDARY_REFERENCE` now reports the reverse edge even when the child is held. Its fix says to drop the reverse edge, because the child's `belongsTo` is the crossing. It is a spec-level finding: the spec-level `validate` reported it too. Holding `Order` in `core` made no difference. `RELATIONSHIP_SYMMETRY` does not require the reverse edge when the child's pillar holds the parent, so dropping it is legal.
- **A cross-pillar `belongsTo` with a hold now generates on both sides.** The owner's group builds no inverse navigation from another pillar's child. In the holder's group the child's FK binds to the `Read{Parent}` replica. A child declared `type: entity` can keep its `belongsTo`. Measured with a parent in one pillar and a child in another, each pillar holding the other's entity: the child stayed an `entity` with `belongsTo`, and both groups generated (authoring#146). At `0.14.0` the owner's group failed this way, and the workaround was `x-references: ` with the child retyped as an `aggregate`. That form still generates, but it is no longer needed.
- **Replacing a `belongsTo` still needs the retype.** An `entity` with no `belongsTo` is `ENTITY_ORPHAN`, whatever its `x-references` say. Measured with `resolve: remote` in place of the `belongsTo` above: the child as an `entity` failed both groups with `ENTITY_ORPHAN`, and as an `aggregate` it generated both.
-- **Do not nest a child's routes under a parent owned by another pillar.** Neither group builds the cross-pillar edge as ownership: the holder sees a replica, the owner sees no inverse. Measured at `0.17.0` with `belongsTo` in place:
- - **A nested create** (`POST /customers/{customerId}/orders`) passes spec-level and project-level `validate`, and `generate` fails in **both** groups: `[OPERATION_PARENT_NOT_OWNER] Create operation 'CreateOrder' is rooted under aggregate 'Customer' which does not own 'Order'`. Same at `0.16.0`.
+- **Do not nest a child's routes under a parent owned by another pillar.** Neither group builds the cross-pillar edge as ownership: the holder sees a replica, the owner sees no inverse. Measured at `0.18.0` with `belongsTo` in place:
+ - **A nested create** (`POST /customers/{customerId}/orders`) fails spec-level and project-level `validate` from `0.18.0` with `OPERATION_NESTED_CROSS_PILLAR` (ERROR), naming both pillars and the fix: move the operation to the entity's own path (`clabonte/generator#2680`). The same applies to a nested update, replace or delete. Through `0.17.0` both `validate` levels passed and `generate` failed in **both** groups with `OPERATION_PARENT_NOT_OWNER`. A pillar-bound group that owns no entity the operation names no longer fails on it at `generate`, but the spec-level ERROR still stops `generate` unless it runs with `--skip-validation`.
- **A nested list** (`GET /customers/{customerId}/orders`) fails spec-level and project-level `validate` from `0.17.0` with `OPERATION_NESTED_LIST_NO_RELATIONSHIP` (ERROR), naming both pillars and the fix (`clabonte/generator#2652`). Because it is a spec-level ERROR, it stops `generate` in **every** pillar-bound group, including a pillar that owns neither entity. Generating that pillar anyway needs `--skip-validation`. At `0.16.0` both `validate` levels passed and `generate` failed in every group with `OPERATION_LIST_UNRESOLVED_RELATIONSHIP`, whose message wrongly said the child declares no `belongsTo`. The message now says the edge crosses a pillar boundary.
- With `resolve: remote` instead of `belongsTo`, the spec-level `validate` already fails, with `OPERATION_PARENT_NOT_OWNER`.
@@ -4582,7 +4582,7 @@ What follows from the table:
- **`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` compiles from generator `0.17.0`** (`clabonte/generator#2639`). Each filtered group, under `pillar` or `groups[].domains`, emits the `{Message}Event` classes of the entities it owns: an event is scoped by the domain of its `x-label.entity`. Measured with `dotnet build`: `core` gets the `Customer*Event` classes, `sales` the `Order*Event` and `OrderLine*Event` classes, and the event and service files of both groups build. At `0.16.0` and `0.15.0`, `event` emitted no `{Message}Event` class in a filtered group while `Default{Entity}Service` constructed them, so every such group failed `CS0246`. Consequences: an event on an in-scope channel whose labelled entity another group owns is dropped from that group, and the Markdown event catalog, the data-protection audit and the Python per-message events of a filtered group list its own events instead of none.
-These verdicts are tied to `0.17.0`; re-measure them at every pin bump (`compatibility.md` §44).
+These verdicts are tied to `0.18.0`; re-measure them at every pin bump (`compatibility.md` §44).
### 14.11 A pillar that reads without keeping a copy
diff --git a/pyproject.toml b/pyproject.toml
index 345f2a5..4b1d579 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "specfuse-authoring"
-version = "0.26.0"
+version = "0.27.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/specfuse/authoring/__init__.py b/specfuse/authoring/__init__.py
index 74993c5..020ff48 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.26.0"
+__version__ = "0.27.0"
def kit_root() -> Path: