Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/ci/error-shape-probe/ErrorShapeProbe.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
<Compile Include="$(GenSrc)/Enums/*.g.cs" />
<Compile Include="$(GenSrc)/ValueObjects/*.g.cs" />
<Compile Include="$(GenSrc)/Converters/*.g.cs" />
<!-- Calls the 0.18.0 reason constructors (#2671). -->
<Compile Include="ReasonConstructors.cs" />
<!-- InternalException maps SqlException to transient failures. -->
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1" />
</ItemGroup>
Expand Down
21 changes: 21 additions & 0 deletions .github/ci/error-shape-probe/ReasonConstructors.cs
Original file line number Diff line number Diff line change
@@ -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<Exception> 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."),
];
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
29 changes: 24 additions & 5 deletions compatibility.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions generator.lock
Original file line number Diff line number Diff line change
@@ -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."
}
14 changes: 13 additions & 1 deletion handbooks/API_Handbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<entry assembly>-<UTC ticks>`, 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

Expand Down
5 changes: 3 additions & 2 deletions handbooks/Project_File.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 <project>.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<T>`, 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 <project>.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.

Expand Down
Loading
Loading