Skip to content

feature/api-spec-first - #300

Open
dapolach wants to merge 52 commits into
mainfrom
feature/api-spec-first
Open

feature/api-spec-first#300
dapolach wants to merge 52 commits into
mainfrom
feature/api-spec-first

Conversation

@dapolach

Copy link
Copy Markdown
Member

No description provided.

dapolach and others added 30 commits July 24, 2026 11:43
EventCategory (id + orisId + name + optional fee) replaces String in
Event.categories. Covers domain, persistence (memento + DDL), REST DTOs,
HAL-FORMS affordances, and frontend event forms/detail. Registration still
references categories by name — changed in a later iteration.

Implements iteration 1 of openspec change event-category-identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EventRegistration.category (String) becomes categoryId (EventCategoryId).
The display name is resolved from Event.categories at read time; a
registration whose category was removed reads back with category: null
(orphaned). Covers domain, persistence, REST DTOs, sort-by-category,
HAL-FORMS categoryId affordance, RegistrationEditedEvent, and frontend
registration select/display.

Implements iteration 2 of openspec change event-category-identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Import populates EventCategory.orisId from EventClass.id. Sync now merges
incoming categories by orisId (Event.mergeCategoriesFromOris): a match
updates the name in place, keeping the local id and attached
registrations, so a rename in ORIS no longer orphans registrations.
Manually added categories (orisId null) are never removed by sync.
Removal warning rewritten to compare by orisId.

Also marks task group 3 (data migration) N/A: the project runs on
in-memory H2 with no production data and a consolidated V001 schema.

Implements iteration 4 of openspec change event-category-identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EventCreatedEvent and EventUpdatedEvent now carry List<EventCategory>
(id, name, fee) instead of List<String>. RegistrationEditedEvent already
references categories by id. No cross-module consumer reads the categories
field, so the shape change is contained.

Implements iteration 5 of openspec change event-category-identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move EventCategory and EventCategoryId to the events root package so the
cross-module domain events no longer reference an internal domain-package
type. Add DB unique constraints on event_categories (event_id,name) and
(event_id,oris_id) as defense-in-depth for the aggregate invariant.
Replace a hardcoded currency literal with a named constant on the frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The event form field factory intercepted the CategoryRequest field type
unconditionally, hijacking the array-level categories field and bypassing
HalFormsCollectionField. The create form showed a single row with no
add/remove and could not submit, and edit did not load existing
categories. Guard the single-row branch with isMultipleProperty so the
array-level call falls through to the collection field. Adds a regression
integration test rendering the real collection field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finalizes task tracking: coverage (6.2), pricing-proposal alignment (6.4),
and browser QA (6.5) done; data-migration group marked N/A for the
in-memory-H2 project.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sync the event-categories and event-registrations delta specs into the
main specs (stable category identity, category fee, ORIS origin-based
sync matching, registrations kept across category renames) and move the
completed change to the archive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion recursion

Refactor `halFormsFieldsFactory` to support a 3rd `customFactory` parameter, enabling seamless composition of custom field types within `multi` collections. Replace `conf.fieldFactory` threading with an explicit `fieldFactory` prop on `HalFormsCollectionField`. Migrate existing custom factories to leverage the new mechanism, removing the `isMultipleProperty` guard in `eventFormFieldsFactory
…ieldsFactory

- Introduced optional 3rd parameter `customFactory` to allow custom field types to be consulted before built-in switch logic
- Added `fullFactory` helper to properly wire custom factory into multi/collection branch recursion
- Updated HalFormsFieldFactory.test.tsx with test coverage for the new contract
- Fixed eventFormFieldsFactory.test.tsx to exercise the updated factory signature

This is task group 1 of the refactor-halforms-field-factory proposal.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
… to explicit parameter

HalFormsCollectionField now takes the custom-aware field factory as an
explicit required prop instead of reading it off shared HalFormsInputProps.
Removed fieldFactory from HalFormsInputProps and from HalFormsForm's config
threading. Task group 2 of 5 for refactor-halforms-field-factory proposal.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…mechanism

Migrated klabisFieldsFactory, its member-filtered variant, and eventFormFieldsFactory
to compose custom field logic via the new 3rd-parameter mechanism (expandHalFormsFieldFactory
now built on fullFactory). Removed isMultipleProperty guard from eventFormFieldsFactory.

Reworked memberIdFieldRenderer to stop special-casing multi-select as a single checkbox-group.
It now always returns the single-field HalFormsMemberId component (extended with read-only
branch showing resolved member name), so multi MemberId/UUID fields route through standard
collection mechanism like any other multi field. This intentional UI behavior change is
documented as design decision D5 in design.md (multi-select member pickers now render
row-per-member instead of combined checkbox list).

This is task group 3 of 5 for refactor-halforms-field-factory proposal.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…, and multi-dispatch order

Updated hal-navigator-patterns skill component-api.md to document:
- New halFormsFieldsFactory(fieldType, conf, customFactory?) 3-param signature
- fullFactory(customFactory) and expandHalFormsFieldFactory composition helpers
- Multi-first dispatch: collection field routes multi-properties before consulting custom factory
- Custom field types must not special-case isMultipleProperty; re-express as single-field components so standard collection mechanism handles multi-value rendering per-row

This completes task group 4 (documentation) of the refactor-halforms-field-factory proposal.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…onField prop, not part of HalFormsInputProps

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…s-field-factory

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…fication

Collapsed redundant fullFactory/expandHalFormsFieldFactory alias into a single
public expandHalFormsFieldFactory export. Extracted shared ReadOnlyDisplay
component from HalFormsForm.tsx so HalFormsMemberId's read-only branch reuses
the same wrapper/dash/renderMode logic as generic read-only field rendering
instead of duplicating it. Removed dead prop.readOnly checks in HalFormsMemberId's
write-path now that the read-only branch intercepts first. Exported
HalFormsCollectionFieldProps so its test imports the real type instead of a
duplicate. No behavior change — full test suite (1915/1915) and tsc --noEmit
confirmed clean after cleanup.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…tory

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…ype logic

Updated `UpdateTrainingGroupRequest.trainers` to use `MemberId` type, fixing frontend rendering bug (`[object Object]` issue). Simplified backend handling and removed `case "List"` dead code in `KlabisFieldsFactory.tsx`. Verified no behavior or schema change.
UpdateTrainingGroupRequest.trainers now uses MemberId instead of raw
UUID strings, matching the domain layer and fixing the HAL-FORMS
template so the frontend renders a member picker instead of broken
"[object Object]" text inputs. Removes the now-dead case "List"
branch in KlabisFieldsFactory.tsx and the manual UUID parsing on the
controller side.

Implements openspec/changes/unify-multi-member-field-rendering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ndering

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaced individual `minAge` and `maxAge` fields with `AgeRangeResponse` object across backend and frontend to streamline age range management. Updated OpenAPI schema, backend responses, frontend types, and rendering logic accordingly. Added relevant test coverage.
…Response directly

MemberResource had only one implementation. Cross-module link processors
now depend directly on MemberDetailsResponse via the members.rest named
interface, which Spring Modulith already permits.
Added configuration classes for various domains (Calendar, Events, Groups, Members, Membership Fees) to replace domain-specific ID representations with UUIDs in OpenAPI specs using `SpringDocUtils`.
…tion

Groundwork for migrating the REST API from code-first to spec-first.
Purely additive — the API is still code-first and klabis-full.json is
still produced by springdoc, byte-identical to before.

- docs/openapi/spec/: skeleton of the hand-written spec (root document,
  HAL/HAL-FORMS and ProblemDetail shared components, README)
- tools/openapi-bundle/: bundles module specs via $ref, validates
  x-klabis-* and x-hal-* extensions, and compares the springdoc output
  against the hand-written spec during the migration (31 Vitest tests)
- springdoc output moved to docs/openapi/generated/ (gitignored);
  copyGeneratedOpenApiSpec keeps klabis-full.json where consumers expect it
- new Gradle tasks: openapiBundle, openapiDriftCheck

Drift check currently reports all 117 operations as not yet migrated,
which is the expected starting state.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
First module migrated to spec-first. The drift check now reports all 7
members operations as matching the implementation; 110 operations across
the other modules remain code-first.

- docs/openapi/spec/members.yaml: MemberController plus registerMember,
  with x-klabis-* field security and x-hal-* link/template relations read
  off the controller postprocessors
- .claude/skills/klabis-api-spec: authoring guide for the extensions,
  module layout and the spec-first workflow
- fix drift check: local $refs into components/parameters and
  components/responses were not dereferenced, so every operation using a
  reusable component looked mismatched

Two places where springdoc misreports the wire format are documented in
the spec rather than mirrored: PatchField<T> is transparent on the wire
(springdoc reports an object with a `provided` flag), and HAL link/page
metadata fields are genuinely required.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
openapi-typescript only understands standard OpenAPI, so link relations
and HAL-FORMS template names never reached the generated types — the part
of the contract the frontend actually navigates by. A second generator
now emits them.

- tools/openapi-bundle/haltypes.mjs: renders per-operation *Rels
  constants, *Hal interfaces and *Resource types (16 tests, 50 total)
- npm run openapi runs it after openapi-typescript, producing
  frontend/src/api/halTypes.ts
- MemberDetailPage consumes the generated rels through a typed identity
  helper instead of string literals; renaming a rel in the spec now fails
  the type-check (verified by renaming one and watching tsc fail)
- src/api/types.ts stays hand-written and documents why: it describes the
  HAL-FORMS media type, which is defined by the standard, not by Klabis

Emits .ts rather than .d.ts — the *Rels constants are runtime values, and
a declaration file type-checks but throws on import.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
MemberDetailsResponse and its nested records are now generated from
docs/openapi/spec/members.yaml instead of being hand-written. The
generated records carry the same field-level security as before —
18 @OwnerId/@OwnerVisible/@HasAuthority annotations, matching the
hand-written version exactly — plus Bean Validation derived from
standard OpenAPI keywords.

- custom pojo.mustache emits Java records (the spring generator only
  does mutable classes) with x-klabis-* and bean-validation annotations
- generated into the existing restapi package, not a subpackage: five
  cross-module link processors depend on MemberDetailsResponse
- DTOs now carry wire types: id is UUID (was MemberId) and
  MedicalCourseDto.validityDate is LocalDate (was Optional<LocalDate>);
  mappers and processors adjusted accordingly
- spec fixes found by the generator: _links belongs to the EntityModel
  envelope rather than the payload, and required strings need
  minLength: 1 to reject "" the way @notblank did

Validation messages are now Bean Validation's defaults — OpenAPI cannot
express a custom message, so one assertion follows the default text.

Tests: 164 run, 0 failures, 2 skipped.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix(openapi): resolve node executable by absolute path in Gradle Exec tasks

IDE-launched Gradle daemons don't inherit the interactive shell's PATH,
so a bare "node" command fails to start when Node is installed via nvm
(not present in system PATH). Resolve the executable path once via
nodeExecutable/NODE_EXECUTABLE override or a PATH scan, and reuse it
across bundleSpecForCodegen, openapiBundle, and openapiDriftCheck.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(openapi): use nvm-aware shell wrapper instead of node executable lookup

The previous fix (resolving an absolute node path from PATH) still failed
under IntelliJ IDEA: its Gradle daemon process doesn't necessarily expose
nvm's install directories on PATH at all, so no scan can find them.

Replace it with tools/openapi-bundle/run-node.sh, a wrapper that sources
nvm.sh itself and runs `nvm use 24` before exec'ing node — independent of
whatever PATH the calling process was launched with. Gradle's three Exec
tasks (bundleSpecForCodegen, openapiBundle, openapiDriftCheck) now invoke
this script instead of a bare/resolved node binary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(openapi): add npm install step to run-node.sh

* build(gradle): remove unused mockitoAgent configuration which was failing reimport of project

* fix(openapi): use node 22 in run-node.sh to match GitHub Actions runner

CI failed with "N/A: version \"v24\" is not yet installed" — GitHub's
ubuntu-latest runners ship nvm with Node 22 preinstalled, not 24.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(openapi): auto-install npm deps in run-node.sh, fix broken exec chain

CI failed with "Cannot find package 'yaml'" — tools/openapi-bundle has no
CI step installing its npm dependencies, so node_modules never existed on
the runner. A prior commit attempted to fix this with `exec npm install`,
but `exec` replaces the current process, so the following `exec node "$@"`
line could never run — the script would always just run npm install and
exit, never actually invoking the target script.

Install node_modules only when missing, without exec, so control still
reaches the final `exec node "$@"`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…dyAdvice (#299)

* feat(hateoas): wrap plain payload DTOs into HAL models via ResponseBodyAdvice

Controllers returning a plain payload DTO (the shape the OpenAPI generator can
produce) now get their HAL representation assembled in HalResponseBodyAdvice
instead of building EntityModel/PagedModel themselves.

The controller stores its domain object(s) in HalResponseContext; the advice
picks them back up, wraps the payload in EntityModelWithDomain and runs the
existing RepresentationModelProcessor postprocessors. This unblocks generating
API interfaces from the spec, which cannot express ResponseEntity<EntityModel<T>>
(hateoas=true is incompatible with records, schemaMappings cannot express
generics, and responseWrapper nests in the wrong order).

Spring HATEOAS's own postprocessing only triggers on return values that already
are a RepresentationModel, so it is skipped for plain DTOs — this advice is what
invokes the postprocessors in that case.

Applied to MemberController.getMember and listMembers only; every other
controller keeps building its models the existing way and is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(hateoas): address PR review comments on HAL response advice

Ignore non-HAL content types in HalResponseBodyAdvice instead of
wrapping every response — only HAL/HAL-FORMS media types get the
EntityModel/PagedModel treatment.

Move the collection self-link and affordance building for
GET /api/members out of the controller and into HalResponseBodyAdvice
(self link, derived from the request path/params — no separate
authorization check needed since the controller already passed one for
this exact request) and a new MemberListPostprocessor (affordances,
still authorization-sensitive via klabisAfford). HalResponseContext no
longer carries a self-link supplier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(backend-patterns): document HalResponseBodyAdvice as the HATEOAS pattern

Replace the controller-builds-EntityModel/PagedModel-directly pattern with
the new approach introduced alongside HalResponseBodyAdvice: controllers
return plain DTOs and stash the domain object(s) in HalResponseContext,
hypermedia wrapping (EntityModel/PagedModel, self links, postprocessors)
happens afterwards in the advice. Documents both ModelWithDomainPostprocessor
(per-item) and the collection-level RepresentationModelProcessor<PagedModel<...>>
pattern used for collection self-link affordances.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(design-decisions): add ADR-002 for HalResponseBodyAdvice

Records the decision to have controllers return plain DTOs and let
HalResponseBodyAdvice wrap them into HAL models, including the three
generator mechanisms (hateoas=true, schemaMappings, responseWrapper)
evaluated and rejected before landing on this approach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(members): dedupe self-link/affordance logic in HAL postprocessors

MemberDetailsPostprocessor and MemberSummaryPostprocessor built an
identical self link + status-dependent suspend/resume affordance,
differing only in DTO generic type. Extracted into
MemberSelfLinkSupport.addSelfLinkWithAffordances, which only touches
Member and RepresentationModel.add — neither depends on the DTO type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Comment thread tools/openapi-bundle/lib/haltypes.mjs Fixed
Phase 4 of the API-spec-first migration: two modules (members, event-types)
are now generated from docs/openapi/spec/ instead of being described by
springdoc annotations after the fact.

- generate *Api interfaces and payload DTOs per module via openApiModule()
- drive endpoint and ownership authorization from x-klabis-authority /
  x-klabis-owner-id / x-klabis-owner-visible
- resolve security annotations across the interface boundary
- bridge plain payload DTOs back into the HAL postprocessor pipeline,
  including unpaginated CollectionModel responses
- document the findings in the klabis-api-spec skill

Wire format is unchanged: EventTypeControllerTest is bit-identical to its
pre-migration version.

Known debt: springdoc annotations stay on the controllers because springdoc
cannot see them through the generated interface. They go away in phase 7,
when openapiBundle takes over klabis-full.json.
Phase 5, first half: the two exploratory modules, 14 operations.

- finance (6): proves a module can own paths under another module's URL
  prefix (/api/members/**), and converts imperative "owner OR FINANCE:MANAGE"
  authorization into x-klabis-owner-visible + x-klabis-authority
- calendar (8): first non-HAL endpoint. IcalFeedController serves a raw
  RFC 5545 string outside /api/ and stays hand-written; the advice passes
  text/calendar through untouched

Also fixes a real wire defect predating both modules: a 201/204 response with
no content block answers 406 to any client sending
Accept: application/prs.hal-forms+json. Four operations were affected,
including updateEventType/deleteEventType from phase 4.

Two silent generator behaviours are now documented in the klabis-api-spec
skill: multi-word tags are dropped without warning, and a class-level
@RequestMapping path concatenates with the generated interface's absolute
paths into /api/foo/api/foo.

Full suite green: 3203 tests, 0 failures. klabis-full.json unchanged.
* feat(openapi): migrate events module to spec-first

Adds docs/openapi/spec/events.yaml as the source of truth for the events
module (20 operations) and reworks the four controllers to implement the
generated *Api interfaces.

Two tags had to be renamed to survive code generation:
- "Event Registrations" -> "EventRegistrations", because the generator
  silently drops multi-word tags: the build succeeds and the interface
  simply never appears.
- OrisEventController moved off the shared "Events" tag onto its own
  "OrisEvents" tag. The generator emits one interface per tag, so two
  controllers cannot each implement half of one. URLs are unchanged.

Three operations stay hand-written and are excluded from generation,
while remaining fully described in the spec:
- getEvent embeds a second, independently-shaped collection alongside
  the main payload via HalModelBuilder; HalResponseContext supports only
  a single domain object or a flat list.
- the accommodation-list path serves two produces variants (HAL JSON and
  text/csv) from one operation, which the generator cannot express as a
  single method signature.
Same precedent as IcalFeedController in the calendar module.

Endpoints acting on the caller's own registration via @ActingMember, and
those authorizing imperatively through EventAffordanceSupport (a
"coordinator OR authority" rule @HasAuthority cannot express), carry no
x-klabis-authority. Declaring one would add a restriction the code does
not have.

Also fills in the calendar.yaml event link with operation: getEvent, left
dangling until the events spec existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): migrate membershipfees module to spec-first

Adds docs/openapi/spec/membershipfees.yaml as the source of truth for the
module (25 operations across 5 controllers) and reworks the controllers to
implement the generated *Api interfaces. Ten hand-written request DTOs are
replaced by generated ones; the mapping logic they carried moves to
MembershipFeesRequestMapper.

The five member-scoped operations keep their imperative
assertMemberAccessingSelf checks and gain no x-klabis-owner-* extensions.
That check permits only the member themselves, whereas owner-visible has
OR-with-authority semantics — declaring it would widen access.

getGroup stays hand-written and out of generation: it embeds a second,
independently-shaped collection (_embedded.members, per @relation) via
HalModelBuilder, which HalResponseContext cannot express. Same precedent
as getEvent in the events module.

Fee rule types and the campaign status filter stay plain strings rather
than named enum schemas. The generator emits an empty Java file for a
freshly generated top-level enum on a record DTO — enums only work when
mapped onto an existing domain enum via schemaMappings, and neither of
these has one.

Collection-level postprocessors run for every CollectionModel response
regardless of content type, so MembershipFeeTierListPostprocessor is
gated on a request attribute to avoid contaminating unrelated endpoints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skill): record generator pitfalls found migrating membershipfees

Six failure modes that cost time in this module, four of which compile and
pass tests while being wrong at runtime: freshly generated top-level enums
come out empty, a stripped @RequestBody silently drops HAL-FORMS inline
options, collection-level postprocessors run for every CollectionModel in
the app, a new port in an @MvcComponent breaks unrelated @WebMvcTest slices,
and openapiDriftCheck rewrites the file it claims to only check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): migrate groups module to spec-first

Adds docs/openapi/spec/groups.yaml as the source of truth for all four
group controllers (30 operations) and reworks them to implement the
generated *Api interfaces. Eleven hand-written request DTOs are replaced
by generated ones.

Codegen is registered as three openApiModule calls rather than one,
because the module spans three Java packages (familygroup, freegroup,
traininggroup) and the helper sets modelPackage and apiPackage from a
single pkg argument. The spec stays a single file; only the registration
is split, which keeps generated types next to the controllers using them.

Three authorization patterns coexist here and are transcribed as-is:
declarative @HasAuthority becomes x-klabis-authority (7 MEMBERS_MANAGE,
8 GROUPS_TRAINING); FreeGroupController's twelve operations carry no
authority because ownership is enforced in the domain service, which
receives @ActingMember; and getFamilyGroup checks MEMBERS:MANAGE OR group
membership imperatively, an OR-rule x-klabis-authority cannot express.

The three detail getters stay hand-written and out of generation. Their
responses are records whose fields are List<EntityModel<X>>, each item
carrying its own _links — a shape HalResponseContext cannot reproduce.

TrainingGroupController's AddMemberRequest is renamed
TrainingGroupAddMemberRequest: component names share one global namespace
after bundling and it collided with the family-group request of the same
name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): migrate common and oris modules to spec-first

Adds docs/openapi/spec/common.yaml and oris.yaml, completing the spec-first
migration of every REST controller in the backend (9 operations here).

Two multi-word tags are renamed because the generator silently drops them:
"My Profile" -> MyProfile, "Password Setup" -> PasswordSetup.
PermissionController had no @tag at all and gains "Permissions" — without
a tag there is no interface to generate.

The oris tag is "OrisImport", not "ORIS": the generator's `apis` filter
matches tags by substring, so "ORIS" would also pull in events.yaml's
OrisEvents operations. Its `models` list holds a single nonexistent
placeholder rather than being empty — like `apis`, an empty models
property makes the generator emit every schema in the bundled document
instead of none.

RootController and DashboardController stay hand-written with no generated
interface: both return an EntityModel with no domain object, populated
entirely by per-module postprocessors, and each is the only operation on
its tag. Their spec entries declare both hal+json and prs.hal-forms+json,
matching what the controllers actually produce.

PasswordSetupController's three operations carry no x-klabis-authority.
WebSecurityCommonConfiguration permits /api/auth/password-setup/** to all
callers — they are account-activation endpoints reached before the user
has a password, secured by the setup token itself. A YAML comment records
this so it is not later "fixed" into a locked-out activation flow.

MvcExceptionHandler gains a ConstraintViolationException handler. A
generated *Api interface is class-level @validated, so a constrained
@RequestParam is rejected by the method-validation interceptor before MVC
argument resolution, which the existing HandlerMethodValidationException
handler never sees — the response would otherwise be a 500 instead of 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skill): record pitfalls found migrating common and oris

Three more silent failure modes: the generator's apis filter matches tags
by substring (so "ORIS" also claims "OrisEvents"), an empty models list
generates every schema rather than none, and a generated interface's
class-level @validated routes constrained @RequestParam failures to
ConstraintViolationException instead of the handler MVC would have used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): generate APIs for the last hand-written controllers

Root, Dashboard and the iCal feed were left without generated interfaces in
the earlier phase-5 commits. All three now implement one, so every REST
controller in the backend is generated from the spec.

Root and Dashboard follow the same plain-payload pattern as every other
migrated module: the interface returns RootModel/DashboardModel and
HalResponseBodyAdvice builds the envelope. wrapSingle() only wraps when the
request context holds a non-null domain object, and these two markers have
none of their own, so each controller registers a placeholder string — never
serialized, since EntityModelWithDomain.domainItem is @JsonIgnore. The
resulting EntityModelWithDomain<RootModel, String> is an
EntityModel<RootModel>, which is what the nine contributing
RepresentationModelProcessor beans are typed on, so the link index is
unaffected.

Returning a plain payload does mean the controllers no longer own an
EntityModel to add links to inline, so two links move into postprocessors to
keep the responses identical: Root's admin link, and Dashboard's self link
(wrapSingle adds a self link for collections only, not for single items).

The iCal feed keeps text/calendar and a raw String body; a String response is
as generatable as a DTO. Its tag is renamed "Calendar Feed" -> IcalFeed
because the generator silently drops multi-word tags. The ?token= parameter
stays a plain @RequestParam with no authority — authentication comes from the
token value via IcalTokenAuthenticationFilter.

Adds RootControllerTest: GET /api had no test at all, and the existing
per-module processor tests invoke process() directly, so none of them would
notice the payload no longer being wrapped — the link index would serialize
empty with the whole suite still green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): declare fee controllers' owner-only access in the spec

MemberFeeSummaryController and MemberFeeChoiceController enforced
owner-only access imperatively via assertMemberAccessingSelf. That is
exactly what x-klabis-owner-visible expresses when no x-klabis-authority
is paired with it: HasAuthorityMethodInterceptor computes authorityGranted
as `requiredAuthority != null && hasAuthority(...)`, so with no authority
declared, ownership is the only path to proceed(). Declaring it alone
narrows access to the owner rather than widening it.

Replaces the helper on all 5 operations with the declarative extension and
adds regression tests asserting a MEMBERS:MANAGE holder still gets 403 —
nothing else in the suite distinguishes owner-only from owner-OR-MANAGE.

Also corrects the reasoning recorded for the group detail getters: they
are not undeclarable because x-klabis-authority is "AND-only", but because
group membership is a set on the aggregate, not an owner ID nameable as a
parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(events): drop authorization annotations duplicated by the spec

events.yaml already declares x-klabis-authority: EVENTS_REGISTRATIONS and
x-klabis-owner-visible: memberId on editRegistration and getRegistration,
and the bundler emits @HasAuthority, @OwnerVisible and @OwnerId onto the
generated EventRegistrationsApi. The copies on the controller were a second
source of truth for the same rule, which the klabis-api-spec skill forbids.

Enforcement is unchanged: MethodSecurityAnnotations resolves all three
annotations across the interface boundary, re-deriving the @OwnerId
parameter index per method, so the differing parameter order between the
two operations is handled by construction. The springdoc annotations stay
on the concrete class, where springdoc can still see them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(openapi): correct the claim that collection processors ignore item type

Both the skill and MembershipFeeTierListPostprocessor stated that
RepresentationModelProcessorInvoker matches CollectionModel processors by
the outer type only, so every one runs for every collection response. That
is wrong: it resolves the full generic signature, including for empty
collections. A processor typed on EntityModel<X> never sees another
endpoint's EntityModel<Y>.

The guidance derived from it was wrong too — it prescribed request-attribute
gating against cross-contamination that cannot happen. Gating is warranted
only when the processor needs data the model cannot carry, which is why
RegistrationListPostprocessor reads eventId from a request attribute: an
empty registration list has no item to recover it from.

No behaviour change; the surviving guard is documented as defensive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): return payload DTOs from the group detail getters

The three group detail getters were mapped to RepresentationModel<?>, so their
response shape was the one thing the spec did not enforce. Restructure their
schemas as EntityModelX = allOf[X, envelope] — the pattern every other migrated
operation already uses — and point schemaMappings at the payload X, so the
generated interfaces declare FamilyGroupResponse / GroupResponse /
TrainingGroupResponse and the HAL envelope stays a runtime concern of
HalResponseBodyAdvice.

The records themselves stay hand-written: their collection fields are
List<EntityModel<Item>>, so each item carries its own _links/_templates, which
the generator cannot express and the frontend reads directly.

The collection rel moves into the *DetailsPostprocessor, since the controller no
longer has a model to add it to. This drops the hand-written hasMembersManage /
hasTrainingAuthority gates in favour of klabisLinkTo, which omits the link for
callers lacking the authority the target operation declares — MEMBERS_MANAGE for
listFamilyGroups, GROUPS_TRAINING for listTrainingGroups. Covered in both
directions by existing tests.

Also correct comments across groups.yaml, build.gradle.kts and
MembershipFeeGroupController that claimed these operations were "excluded from
generation" or "NOT wired into" their Api interface. They were always generated
and @Override-ed; only the return type had been untyped.

Wire format unchanged: 3212 tests, 0 failures; klabis-full.json untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): return payload DTOs from getEvent and fee-group getGroup

These were the last two operations mapped to RepresentationModel<?>. Both attach
a second, differently-shaped collection under _embedded — registrations for
events, group members for fee groups — which is why their return type had stayed
untyped while everything else moved to the spec.

_embedded is envelope, not payload, so it now takes the same deferred path as
_links and _templates: the controller declares the collection via
HalResponseContext.embed(...), and HalResponseBodyAdvice wraps the processed
model through HalModelBuilder once the postprocessors have run. The generated
interfaces declare EventDto and MembershipFeeGroupResponse, and schemaMappings
point at those payload types.

Declaring the embed on the context rather than in the postprocessor is
deliberate. Building either collection needs a port the controller already holds
(@MvcComponent beans are scanned by every @WebMvcTest, so injecting ports there
would force unrelated slice tests to mock them), and a postprocessor is keyed on
payload type, not endpoint: MembershipFeeGroupDetailsPostprocessor is shared with
FeeSelectionCampaignController.listGroupsForYear, whose items carry no members
collection.

Both controllers also stop injecting their own postprocessor to invoke it by
hand. That existed because processors do not recurse into models nested by
HalModelBuilder; now the model goes through the pipeline normally.

No generated *Api interface declares RepresentationModel any more.

Wire format unchanged: 3212 tests, 0 failures; klabis-full.json untouched. The
empty-collection case is now asserted explicitly — MembershipFeeGroupControllerTest
checked only memberCount despite its name, so nothing covered the itemType
fallback that keeps an empty list rendering as [] under the right relation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): restore @notblank via x-klabis-not-blank extension

The migration to spec-first replaced hand-written @notblank with a
`pattern: '^(?!\s*$).+'` workaround, and on the payment rules' ruleType
dropped it entirely, leaving only @NotNull.

OpenAPI has no keyword meaning "not blank", so add a Klabis extension:
x-klabis-not-blank on a schema property, whitelisted in validate.mjs and
emitted as @notblank by the overridden pojo.mustache. 12 pattern
workarounds on generated schemas are converted; the field keeps the
redundant @NotNull from `required`, which is harmless.

Two places deliberately keep the pattern: the validatePasswordSetupToken
`token` parameter (only pojo.mustache is overridden, so the extension
would be dropped on the stock api template — validate.mjs now rejects it
there rather than letting it pass as a no-op), and the schemas whose Java
records are still hand-written (CreateEventRequest, UpdateEventRequest and
their nested types), where the real @notblank already lives in the record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(common): cover ConstraintViolationException handler, drop orphan DTO

MvcExceptionHandler.handleConstraintViolationException had no direct
test — only indirect coverage through a controller slice. Add one that
asserts the 400 and the parameter-name-only formatting, plus the
empty-violations edge case. Messages are not asserted: constraint
messages are localised in this project.

Also removes a stale comment in PasswordSetupControllerTest claiming the
endpoint returns 500 where the test asserts 400, and deletes
PasswordSetupRequest, which has had zero references since it was
superseded by the generated SetPasswordRequest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): generate UpdatePermissionsRequest from the spec

UpdatePermissionsRequest was a record nested inside PermissionController
purely for historical reasons — the generator can only emit top-level
types, so it had to be redirected via schemaMappings.

Declaring the Authority enum in the spec (wire values from its @jsonvalue,
not the Java constant names) and marking authorities as uniqueItems lets
the generator produce the identical Set<Authority> signature, so the
mapping and the nested record both go away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): generate the members request DTOs from the spec

RegisterMemberRequest, AddressRequest and SuspendMembershipRequest were
mapped onto hand-written records although nothing about them required it
— unlike UpdateMemberRequest, none uses PatchField<T> or a cross-field
rule. They date from the first migrated module, before generating request
DTOs became the convention.

Generating them needed three gaps closed: a new x-klabis-past extension
for @past (which OpenAPI cannot express, and which Member does not
re-check), the two Address regexes the spec had been missing entirely,
and a maxLength for the suspension note. SuspendMembershipRequest.note
becomes a plain String — Optional only ever existed to be unwrapped at
the call site, and @ValidOptionalSize existed only to validate it, so
both are removed.

Custom validation messages do not survive generation, so ten assertions
move to the Bean Validation defaults. Two were the only Czech messages in
the backend; user-facing localisation belongs in the frontend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs(openspec): propose routing HAL affordances at generated Api interfaces

Affordances resolve against the concrete controller, but Java does not
inherit parameter annotations from an interface — so every override has
to re-declare @RequestBody, and an override that forgets silently loses
its HAL-FORMS field metadata. Two overrides are missing it today and are
harmless only by coincidence.

Spec-free: the wire format does not change. Affordance names come from
method names identical on both types, URLs from @RequestMapping on the
interface, and the conditions gating each affordance stay in the
controllers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(openspec): add design and tasks for affordance interface routing

Design records the measured scope (196 production call sites, 97 methods,
all with an interface counterpart; only 1 of 24 test call sites converts)
and four decisions: which plain linkTo calls to convert and why, keeping
@parameter on controllers while @RequestBody moves to the interface, a
reflective guard test instead of per-endpoint assertions, and a
module-by-module rollout starting with the two known-defective overrides.

Scope also covers updating the backend-patterns skill, which currently
teaches methodOn(XController.class) in eight examples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(common): route affordances at the generated Api interfaces

Java does not inherit parameter annotations from an interface, so
HalFormsSupport's lookup for @RequestBody on the method recorded by
methodOn(...) failed whenever a controller override omitted it — and on
failure it returns the affordance unmodified, skipping the input payload
metadata entirely.

PermissionController.updatePermissions is such an override, so its
template rendered authorities as readOnly: the one field that PUT exists
to change. Pointing methodOn at PermissionsApi/DashboardApi fixes it;
the field now carries type and multi instead.

Also drops @Valid @RequestBody from three overrides that no longer need
to repeat what the interface declares. Spring MVC resolves both from the
interface, which the bean-validation tests confirm.

Pilot for the affordance-interface-routing change; 19 modules follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(openspec): mark baseline and pilot tasks complete

Records that 1.1 was done with a throwaway @WebMvcTest capture rather
than a running server — no OAuth flow needed and the diff is exact. Each
module now encodes its finding as a permanent assertion instead of
keeping capture files around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(finance): route affordances at FinanceApi instead of the controller

methodOn(MemberAccountController.class) recorded the implementation method,
where Java does not inherit the interface's @RequestBody. HalFormsSupport
therefore skipped HalFormsInputPayloadMetadata and rendered every property of
the deposit, charge and reverse templates as readOnly — including the required
amount field, which is the only reason those endpoints exist.

Captured the HAL bodies before and after: the sole difference is 8 vanished
"readOnly": true entries. Links, targets, affordance names and types are
byte-identical.

Adds regression assertions on all three templates; verified red by pointing
the deposit affordance back at the controller.

* refactor(members): route affordances at MembersApi and RegistrationApi

No latent defect in this module — the overrides did carry @RequestBody, so the
HAL bodies for getMember and the members list are byte-identical before and
after. The conversion is preventive: the interface becomes the single place an
endpoint's contract is declared.

Removing @RequestBody turned out not to be optional here. Hibernate Validator
rejects an override that redefines the parameter constraint configuration of the
method it overrides (HV000151), evaluating the parameter list as a whole, so
dropping @RequestBody while leaving @NotNull on the path variable fails at
request time rather than at compile time. Both were removed; @Valid is exempt
from that rule and stays on listMembers. Recorded in design.md D2.

MemberPermissionsLinkProcessor's cross-module reference to PermissionController
now targets PermissionsApi instead, consistent with the rest of the change.

* refactor(calendar): route affordances at CalendarApi and IcalTokenApi

Third module with the same defect: createCalendarItem and updateCalendarItem
rendered all four properties — name, description, startDate, endDate — as
readOnly, so neither form could be filled in. The overrides never declared
@RequestBody, so HalFormsSupport skipped HalFormsInputPayloadMetadata.

Captured before and after: the only difference is 12 vanished "readOnly": true
entries. No Bean Validation constraints on these overrides, so the HV000151
rule from design.md D2 does not apply here and nothing was removed.

The cross-module link to the event detail now targets EventsApi.

* refactor(membershipfees): route affordances at the generated *Api interfaces

Five controllers, 38 call sites. No latent defect in this module: the overrides
already declared @RequestBody, so the templates rendered correctly. Removing it
is the point — the interface becomes the single declaration site.

No Bean Validation constraints on these overrides, so the HV000151 rule from
design.md D2 does not apply.

The module's existing assertions on _templates.addRule and editRule
options.inline cover this conversion directly: that metadata comes from
HalFormsInputPayloadMetadata, which is exactly what a wrong routing would skip.
280 tests pass.

* refactor(events): route affordances at the generated *Api interfaces

Six controllers across events and oris, 52 call sites. The overrides carried
@RequestBody, so no template was rendering wrong here; removing it makes the
interface the single declaration site.

cancelEvent's @RequestBody(required = false) was checked against EventsApi
before removal — the interface declares the same optionality, so dropping it
does not make the body mandatory.

getAccommodationListAsCsv keeps pointing nowhere new: it is not a methodOn
target, so its missing interface counterpart is irrelevant to this change.

796 tests pass, including the affordance and CSV suites.

* refactor(groups): route affordances at the generated *Api interfaces

Four controllers, 62 call sites — the largest module. No latent defect: these
overrides carried @RequestBody.

FreeGroupController implements GroupsApi rather than the FreeGroupsApi its name
would suggest, so the conversion is not a mechanical name substitution here.
cancelInvitation's @RequestBody(required = false) was checked against the
interface before removal.

424 tests pass.

* test(hateoas): guard that affordances resolve against the *Api interface

Two complementary guards, since the failure mode is silent — a template simply
comes back without its fields, and no existing assertion notices.

AffordanceRoutingArchitectureTest checks the invariant structurally: no
production call site may pass a *Controller.class to methodOn(...). It reads
sources rather than bytecode because the target type is an argument, which
ArchUnit cannot see.

AffordanceAuthorizationTest gains a case exercising the real mechanism: an
override that deliberately omits @RequestBody, an affordance recorded against
the interface, and an assertion that the resulting field is not readOnly. That
is precisely the shape that produced the three defects this change fixed.

Both verified red before green.

Deviates from tasks.md 5.1, which proposed one reflective test over all
controller beans: instantiating affordances outside a request context is not
something HalFormsSupport supports.

* docs(hateoas): record the interface-routing rule in the skill and an ADR

backend-patterns now states the rule first in its HATEOAS section, with a table
of which annotations belong on the interface and which on the controller — the
springdoc exception makes "remove whatever the interface already has" wrong, and
HV000151 makes partial removal fail at request time rather than at compile time.

ADR-003 records why the rule exists, including the three endpoints that had
shipped with unusable forms.

The seven methodOn(XController.class) examples in the skill were corrected.
tasks.md said eight; the eighth occurrence is prose, not a call site.

* docs(openspec): close out affordance-interface-routing verification

Full suite 3221/0/0 against a 3214 baseline — the delta is this change's own
tests. Both generated baselines byte-identical.

Also documents the guard regex's naming assumption: nothing enforces the
*Controller suffix, so a controller named otherwise would slip past the
architecture test.

* fix(events): withhold the accommodation list's event link from callers who cannot read it

The `event` rel was built with plain linkTo, bypassing klabisLinkTo's
authorization check. Reaching the accommodation list needs EVENTS:REGISTRATIONS
or being the event coordinator; reading the event itself needs EVENTS:READ.
Neither implies the other, so a coordinator without EVENTS:READ was handed a
link that answers 403.

The earlier design doc recorded this as an open question and preserved the
bypass. It is a defect, not a deliberate choice, so it is fixed here rather
than deferred.

Two tests cover both sides of the authorization split; the negative one was
verified red against the previous linkTo. No frontend consumer reads this rel —
AccommodationListPage builds the event URL itself.

Full suite 3223/0/0 (was 3221 before these two tests).

* chore(openspec): archive affordance-interface-routing change

All 7 task sections complete and shipped in PR #305.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(events): return a plain payload from getAccommodationList

Last controller still returning a Spring HATEOAS model. The spec now maps the
response envelope onto Collection<AccommodationListItemDto> like every other
list endpoint, and HalResponseBodyAdvice does the HAL wrapping.

The collection's `event` relation moves into AccommodationListPostprocessor,
keeping klabisLinkTo so it stays withheld from a coordinator without
EVENTS:READ. eventId comes from the URI template because an event with no
registrations yields an empty collection with no item to recover it from.

The advice also contributes a `self` link the endpoint previously lacked;
declared in the spec and asserted in the test. Items stay bare — an
accommodation row is not an addressable resource — and the _embedded key is
unchanged, coming from @relation on the payload record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* refactor(common): replace PatchField with JsonNullable

PatchField<T> was a hand-rolled tri-state wrapper (absent / present-null /
present-value) with its own Jackson deserializer and Bean Validation
ValueExtractor. jackson-databind-nullable 0.2.11 provides the same semantics as
JsonNullable<T>, and since 0.2.10 ships a Jackson 3 module — the Jackson 2
artifacts it also declares stay off the classpath because both are `provided`
upstream. That removes ~200 lines of bespoke plumbing and, more importantly,
makes these DTOs generatable from the OpenAPI spec later: JsonNullable is what
openapi-generator emits for nullable properties.

Type swap only. The DTOs stay hand-written; generating them from the spec is a
separate change.

The API maps one-to-one, verified against the library sources rather than
assumed: notProvided/undefined, isProvided/isPresent, throwIfNotProvided/
orElseThrow, ifProvided/ifPresent, and patchValue(orig)/orElse(orig) — the last
matters because Events relies on an explicit null clearing a field, so orElse
must return a present null rather than falling back.

RequestBodyFieldAuthorizationAdvice is retyped first and deliberately: it skips
any component that is not the wrapper type, so a field that lost its wrapper
would silently get no authorization check at all. isPresent() preserves the gate
exactly — absent skips, present-null still checks and can 403.

PatchFieldTest and PatchFieldDeserializerTest are dropped; they exercised the
deleted implementation, which the library now owns. PatchFieldValidationTest is
kept as JsonNullableValidationTest because it proves our own extractor wiring:
absent evaluates no constraints, present-null fires @notblank but not @SiZe.

Full suite 3185 tests, 0 failures; the 38-test drop is exactly the two deleted
classes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(members): honour the PATCH tri-state and stop a null ageRange from 500ing

Two bugs the JsonNullable swap made addressable, both previously masked.

Members collapsed the tri-state: UpdateMemberRequestMapper mapped absent and
explicit-null alike to plain null, and Member.update read null as "leave alone".
No optional member field could be cleared through the API, though the spec had
always said an explicit null clears. Events honoured the tri-state; Members did
not.

Member.UpdateMember now carries JsonNullable for the optional fields, so the
distinction survives into the domain. The required PersonalInformation fields
stay plain — firstName, lastName, dateOfBirth and gender have no cleared state.
A compact constructor normalises a plain null to undefined, which keeps the 27
existing builder call sites working without naming every field.

Clearing is validated, not blindly applied: validateContactInformation,
validateGuardianForMinors and validateBirthNumberNationality all run against the
resolved values, so clearing an email with no guardian to cover it, a minor's
guardian, or a Czech national's birth number is still rejected. The birth-number
audit event now compares the applied value against the previous one, so it fires
on a clear and stays quiet when the submitted value is unchanged.

UpdateTrainingGroupRequest.ageRangeDomain() dereferenced a present null inside
map — which applies the mapper on presence, not on nullness — turning the
domain's Assert.notNull rejection into an NPE and a 500. It now forwards the
null so the intended 400 surfaces.

Spec descriptions updated to say which fields can and cannot be cleared, and the
stale PatchField prose replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): generate the members and training-group PATCH DTOs from the spec

UpdateMemberRequest (17 components) and UpdateTrainingGroupRequest (3) were the
last request DTOs held back from codegen. They are now generated, leaving only
UpdateEventRequest hand-written.

The generator cannot supply the wrapper on its own. openApiNullable keys off
`nullable`, which openapi-generator 7.18.0 ignores in OpenAPI 3.1 mode — checked
against both `nullable: true` and the 3.1 spelling `type: [x, "null"]`, and
neither produced a JsonNullable. So the wrapper comes from a new
x-klabis-patch-field extension consumed by pojo.mustache, following the
x-klabis-not-blank precedent, and openApiNullable stays off.

Properties are inlined rather than sharing the named PatchField* schemas, which
are deleted. Seven members properties are plain strings with different limits —
email 255, chipNumber 50 plus a digit pattern, firstName 100 — and one shared
schema can only carry one set of them.

Annotation fidelity was diffed component by component against the deleted
records: all 18 survive, including every @HasAuthority(MEMBERS_MANAGE). The only
additions are @Valid markers the stock beanValidation partial puts on
non-primitives, matching what RegisterMemberRequest already carries.

`required: [firstName, lastName]` is dropped. On a PATCH body it would have
forced both fields into every request, which contradicts partial updates and was
never enforced by the hand-written record.

ageRangeDomain() and the UUID-to-MemberId conversion move into
TrainingGroupController, keeping the null-forwarding so an explicit null still
reaches the domain's Assert as a 400 rather than NPEing into a 500.

PatchRequestWrapperArchitectureTest guards the invariant that every component
stays wrapped: RequestBodyFieldAuthorizationAdvice skips anything that is not,
so an unwrapped field loses its authorization check silently.

Custom constraint messages are lost to Bean Validation defaults; the tests
already asserted the defaults.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(openapi): use the generator's own nullability instead of a custom extension

The x-klabis-patch-field extension added in the previous commit was unnecessary.
openApiNullable does work — the earlier conclusion that OpenAPI 3.1 nullability
was broken came from testing it with our custom pojo.mustache still active,
which ignores isNullable and prints the bare type. The generator had been
setting the flag correctly all along.

Two things were actually needed: openApiNullable=true, and the 3.1 spelling
`type: [x, "null"]`. The 3.0 `nullable: true` keyword really is ignored in a 3.1
document, which is what the first experiment kept hitting. pojo.mustache now
mirrors the stock template's branch on openApiNullable + isNullable, so the
record form stays in step with what upstream does for classes.

gender is the one exception and keeps allOf: the generator strips property-level
vendor extensions from a composed schema, so the nullable oneOf would have cost
it x-klabis-authority and made an admin-only field writable by anyone. It gives
up nothing — gender has no cleared state, so the wrapper was never used.
PatchRequestWrapperArchitectureTest records the exception and asserts the field
still declares the authority it was excepted for, so the allow-list cannot
quietly become a hole.

Generated output is otherwise identical, and the custom extension is gone from
the specs and the bundler's allow-list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skill): document PATCH nullability in klabis-api-spec

The skill said nothing about PATCH bodies, so anyone adding one would reach for
`nullable: true` — the 3.0 keyword, silently ignored in these 3.1 specs. The
endpoint would then quietly lose the ability to tell "absent" from "clear this
field", with no warning from anywhere.

Adds the 3.1 spelling for both scalar and $ref properties, the rule that `oneOf`
strips property-level x-klabis-* (and why keeping the authority beats keeping
the wrapper), and the JsonNullable.map trap that turns an intended 400 into a
500. Four matching anti-patterns.

Also records, on the "inventing a new extension" entry, that a generator feature
must be tested against stock templates before being declared broken — an
x-klabis-patch-field extension was built and then removed once openApiNullable
turned out to work, the earlier verdict having come from testing with our own
pojo.mustache, which ignored the flag.

Every claim checked against the code rather than from memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skill): correct the oneOf extension-stripping rule

Re-checked the previous commit's claims by running them rather than by grepping
for their presence, and one was wrong.

I had written that scalar unions keep their extensions "and only compositions
lose them", implying a scalar `oneOf` is safe. It is not: rewriting birthNumber
as `oneOf: [{type: string}, {type: 'null'}]` keeps the wrapper but drops
@HasAuthority, exactly as a $ref branch does. The stripping is a property of the
oneOf keyword itself, not of what it contains — `type: [x, 'null']` is what keeps
both. The $ref case is a dilemma only because a $ref has no union form.

Also verified by execution rather than assertion: map runs the mapper on a
present null and skips it when absent, orElse returns a present null instead of
falling back, allOf + nullable yields the bare type, and the three scalar-union
properties keep authority and wrapper together.

Adds a warning found while checking the response-side advice: several response
schemas still carry a leftover `nullable: true`, inert only because the 3.0
keyword is ignored. Converting one to the 3.1 spelling would wrap a response DTO.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(openapi): generate the event PATCH DTO from the spec

UpdateEventRequest was the last hand-written PATCH request. Generating it
needed two codegen hooks that did not exist:

  - x-klabis-url, mirroring x-klabis-not-blank/x-klabis-past, since OpenAPI
    has no keyword for @url. It must not be paired with `format: uri`, which
    switches the Java type to java.net.URI that @url cannot constrain.
  - x-klabis-class-constraint, a schema-level hook naming a class-level
    annotation. A generated record has no method bodies, so the cross-field
    @AssertTrue isDeadlinesOrdered() could not survive migration; it becomes
    @DeadlinesOrdered, which reads `deadlines` reflectively and so serves the
    JsonNullable-wrapped PATCH shape as well as a plain one.

The nested CategoryRequest/RankingRequest/EntryFeeRequest records are dropped
in favour of the spec's existing top-level schemas, and the mapper now
converts wire types (UUID, Set<UUID>) to domain types itself.

CreateEventRequest stays hand-written: it carries domain conversion methods
EventController calls directly. Its own nested EntryFeeRequest is removed,
though — it shadowed the newly generated top-level record of the same name in
the same package, which compiled only by luck of scoping rules.

* feat(openapi): generate the event create DTO from the spec

CreateEventRequest was the last hand-written request DTO. Its blocker was
never the cross-field @AssertTrue — @DeadlinesOrdered already covers that —
but the domain conversion methods it carried (toRegistrationDeadlines,
toCategories, CategoryRequest.toDomain, EntryFeeRequest.toMoney) that
EventController called directly, which a generated record cannot have.

Those move to CreateEventRequestMapper, mirroring the update path. The four
conversions both mappers need are shared via EventRequestConversions rather
than duplicated.

Generating the record also makes @Valid cascade into categories and fees for
the first time — the hand-written one had no @Valid anywhere, so a blank
category name reached the domain and failed Assert.hasText as a 500, and a
negative fee amount was never checked at all. Both are now 400s; the two new
EventControllerTest cases pin that.

* Updated tests configuration to use SyncTaskExecutor for predictable test outcomes (#307)

* test: run async listeners synchronously in tests (SyncTaskExecutor)

* test: remove local SyncTaskExecutor configuration from MemberLifecycleE2ETest (use global test config)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
dapolach and others added 15 commits July 31, 2026 17:05
…son (#308)

* feat(openapi): make the hand-written spec the source of klabis-full.json

openapiBundle now produces docs/openapi/klabis-full.json, which the frontend
generates its TypeScript types from. Removes copyGeneratedOpenApiSpec (springdoc
-> klabis-full.json) and openapiDriftCheck, whose job was comparing the two
documents while both existed. The springdoc plugin stays, but only to dump what
the running app serves into gitignored generated/; nothing in the build depends
on it.

The task writes by default now instead of only validating, since it is the sole
producer of the file. -PopenapiCheck validates without writing; -PopenapiOut
redirects; passing both fails rather than silently ignoring the latter.

Effect on the published document: 6 Actuator paths drop out (springdoc
auto-discovered them and they do not belong in a hand-written API spec) and 18
dead PatchField* schemas go with them. No API operation is lost. Frontend
halTypes.ts grows 12 -> 129 exported types, pre-existing drift catching up.

Also fixes a field-authorization hole this exposed. UpdateMemberRequest.gender
was specified as allOf + x-klabis-authority, which generated a bare Gender
rather than JsonNullable<Gender> — and RequestBodyFieldAuthorizationAdvice skips
every component that is not JsonNullable, so its @HasAuthority(MEMBERS_MANAGE)
was never evaluated and any caller could change a member's gender. The allOf was
chosen to dodge oneOf stripping the extension; it keeps the extension but costs
the wrapper, which defeats the check just as thoroughly. Inlining the enum keeps
both, at the cost of a schemaMappings entry for the generated
UpdateMemberRequest_gender name.

Wire semantics are unchanged: absent and explicit-null both still mean "leave
alone" (PersonalInformation rejects a null gender outright). Only the
authorization check changes, from bypassed to enforced.

The @disabled test in UpdateMemberApiTest was recording this as a prod-code gap;
it is enabled, narrowed to gender, and passes. PatchRequestWrapperArchitectureTest
loses its UNWRAPPED_BY_DESIGN exception — which encoded the same wrong
assumption, asserting the annotation was present rather than reachable — and now
pins the exact set of privileged components per PATCH DTO.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(openapi): give the fee-group operation a unique operationId

Two modules declared operationId getGroup — GET /api/groups/{id} in groups and
GET /api/membership-fee-groups/{id} in membershipfees. OpenAPI requires the id to
be unique across the document, and haltypes.mjs names its exported TypeScript
types after it, so the collision emitted GetGroupResource and GetGroupRels twice
and failed the frontend build.

The duplicate predates the cutover; making klabis-full.json spec-derived is what
first propagated it into the generated types.

membershipfees takes the new name because groups' getGroup belongs to a
consistent listGroups/createGroup/updateGroup/deleteGroup family. Renaming an
operationId also renames the generated *Api method, so this covers the
@OverRide in MembershipFeeGroupController and three methodOn call sites. The
domain port's own getGroup is untouched — it is not part of the API surface.

The bundler now rejects duplicate operationIds, naming both offending
operations, so this fails at openapiBundle rather than in CI.

Also corrects two comments claiming this operation was excluded from
generation. It is not; only its response schema is documentation-only, mapped
down to the bare payload so _embedded stays out of the Java return type.

No wire-contract change: HAL rel names and paths are identical, only the
operationId and the TypeScript type names derived from it move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(members): cover the gender authority check with a test that can fail

Restores the four-field body in shouldRejectUpdateAdminOnlyFieldsWithoutAdmin,
per review. Narrowing it to gender was not a simplification but a loss: the
caller in that test is neither the owner nor an admin, so its 403 comes from the
endpoint's own @HasAuthority(MEMBERS_MANAGE) before any field is read. Verified
by sending only chipNumber, which carries no field authority at all — still 403.
The test never exercised field-level authorization, so trimming it to the one
privileged field was pointless.

That left the gender fix untested. The endpoint is annotated
@HasAuthority(MEMBERS_MANAGE) *and* @OwnerVisible, so per-field checks decide
something for exactly one caller: the owner, who passes the endpoint but holds
no authority. Adds that pair to MemberSelfEditTests — own gender is refused,
own chipNumber goes through.

Both directions verified against the spec: dropping x-klabis-authority while
keeping the wrapper fails the gender test, and restoring the allOf does not even
compile, because the mapper expects a JsonNullable.

Also corrects "writable by anyone" in the architecture test's javadoc and in two
places in the klabis-api-spec skill. The endpoint still gates strangers; it is
the owner who silently gains write access. More importantly the skill was
recommending exactly the construct that caused this — "keep allOf + the
extension and give up the wrapper" — which protects nothing, since the advice
skips unwrapped components. It now says to inline the type and map
<Parent>_<property> in schemaMappings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs(openapi): move suspendMember 409 contract into the spec

The springdoc annotations on the controllers duplicate the spec, and the
plan is to delete them. Auditing what they say against the bundled
document first turned up one place where the spec was not merely thinner
but wrong: suspendMember's 409.

MembersExceptionHandler answers both suspension blockers with a bespoke
JSON record — LastOwnerWarning (which groups need a new owner) or
OutstandingDebtWarning (balance plus a link to the account) — while the
spec pointed at the shared Conflict response, which promises
application/problem+json and a ProblemDetail. A client generated from
that would have mis-parsed every blocked suspension.

Also adds resumeMember's missing description and the sole-owner blocker
to suspendMember's.

The rest of the annotation text needs no migration: the "(requires
MEMBERS:MANAGE)" style summaries are already carried structurally by
x-klabis-authority, every path/query parameter the controllers document
is present in the spec with a description, and the CSV accommodation
list is modelled as a text/csv variant of getAccommodationList rather
than a second operation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB

* refactor(openapi): generate springdoc annotations from the spec

The 26 controllers implementing a generated *Api each hand-maintained
@Operation/@ApiResponse/@Parameter/@Tag/@securityrequirement that
restate what docs/openapi/spec/ already says. Flipping
documentationProvider from "none" to "springdoc" makes the generator
emit them onto the interface instead, so /v3/api-docs becomes
spec-derived like klabis-full.json already is, and the duplicates go.

Springdoc does resolve annotations through the interface: the override
inherits the declaration that carries them. The skill text claiming
otherwise ("springdoc scans the concrete class") was never tested, and
led to 616 lines of hand-written documentation that could only drift.

Measured by generating /v3/api-docs before and after: 117 operations
both times, none lost. Summaries 82 -> 117, descriptions 51 -> 111,
parameter descriptions 95 -> 133, no response description lost. The
generated set also carries security, tags, @content schemas and
@parameter(hidden = true) on the injected arguments, none of which the
hand-written ones had.

Two apparent losses are springdoc inaccuracies the spec corrects: a
bogus 200 "OK" on ResponseEntity<Void> endpoints, now the real 201/204;
and a 410 attached to every operation of PasswordSetupController because
it sat on an @ExceptionHandler, including requestNewPasswordSetupToken,
which throws TokenValidationException and can never return 410.

The blocker was that documentationProvider renders each response's
baseType into @Schema(implementation = <baseType>.class), and the
envelope schemaMappings target generic types — Collection<X>.class is
not legal Java. openApiModule now drops the whole content block for
those responses so springdoc falls back to the return type, which still
carries the type argument. Erasing to the raw type or leaving a bare
@content instead both degrade 39 response schemas; only removing the
block keeps PageMemberSummaryResponse and the array shapes. The regex is
registered via inputs.property because a doLast body is not part of a
task's cache fingerprint and GenerateTask is @CacheableTask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#310)

* refactor(openapi): emit @HasAuthority from api.mustache, not the bundler

x-klabis-authority on an operation used to be rewritten during bundling into
x-operation-extra-annotation carrying a fully-qualified Java string. The
published klabis-full.json therefore stated each endpoint's authority twice:
once in the spec's own vocabulary, and once as an annotation derived from it.

api.mustache now reads the key directly, the same way pojo.mustache already
does for schema properties, and applyOperationAuthorityAnnotations is deleted.
x-operation-extra-annotation drops from 71 occurrences to 11 — the remaining
ones all belong to x-klabis-owner-visible, which spans two nodes (operation
plus the named parameter) and still needs a rewrite.

The template is a verbatim fork of the stock JavaSpring api.mustache, verified
byte-identical before editing, with one section added. Nothing detects drift
against upstream, so both the file header and docs/openapi/spec/README.md carry
the re-diff instruction for generator upgrades.

Generated *Api.java output was diffed before and after: the set of emitted
Klabis annotations is byte-identical (66 @HasAuthority, 11 @OwnerVisible,
11 @OwnerId). Only @OwnerVisible's position relative to @HasAuthority changes —
they were one multi-line string, now two separate annotations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB

* refactor(openapi): emit @OwnerVisible/@OwnerId from the templates

x-klabis-owner-visible named the parameter carrying the owner ID, and the
bundler synthesised both annotations from that one key: x-field-extra-annotation
on the parameter, x-operation-extra-annotation on the operation. Because
parameters are shared by $ref — AccountMemberIdParam serves six operations — it
also had to inline the named one so @OwnerId reached only the opted-in operation.

None of that is necessary: @OwnerId is inert on its own. Both readers
(HasAuthorityMethodInterceptor.checkOwnership and
RequestBodyFieldAuthorizationAdvice) only consult it for a method or field
already marked @OwnerVisible, so it can sit on the shared parameter and the six
operations that never opt into ownership are unaffected.

Each half now sits on its own node and is read by its own template:
x-klabis-owner-visible: true on the operation (api.mustache), x-klabis-owner-id:
true on a path parameter (pathParams.mustache, forked for this). Neither
template can see the other, so validate.mjs takes over the pairing invariant:
an operation declaring owner-visible needs exactly one owner-id parameter —
zero denies instead of resolving ownership, and two would silently resolve
against whichever the generator emitted first.

It must also be a path parameter. Only pathParams.mustache has a branch for the
key, so on a query or header parameter it would be dropped without a word,
reproducing the unenforced-@OwnerVisible failure one step later. That check also
covers page/size/sort, which x-spring-paginated folds into a single Pageable
argument.

With this, both derived extensions are gone from the published contract:
x-operation-extra-annotation 71 -> 0, x-field-extra-annotation 11 -> 0. The
bundler is now a plain merge, 141 lines lighter.

@OwnerId rises 11 -> 17 in the generated interfaces; the six additions are the
inert shared-parameter cases (charge, deposit, reverse, getMember, resumeMember,
suspendMember). @HasAuthority (66) and @OwnerVisible (11) are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB

* test(events): stop registering members on hard-coded event dates

Event.registerMember() rejects a registration made on or after the event date,
comparing against LocalDate.now(). Three tests built their event on a fixed 2026
date and then registered a member on it, so each was a dated bomb:
shouldLogWarningWhenSyncRemovesCategoriesWithRegistrations used 2026-08-01 and
went off today; the two EventTest cases used 2026-09-10 and would have followed
on 10 September.

They now derive the date from LocalDate.now(), which is what the rest of the
suite already does (134 call sites) and what EventTest's own DEFAULT_DATE was
already for.

The other fixed dates in these files are left alone. Only registerMember and its
siblings reach a LocalDate.now() comparison — dates in June and July 2026 have
passed and their tests still pass — so the fix is scoped to the operations that
actually cross it, found by pairing every registerMember / editRegistration /
unregisterMember call with the event date in scope. EventJdbcRepositoryTest
already used relative dates throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
)

* feat(openapi): make each module spec a standalone OpenAPI document

The nine module files had only `paths:` and `components:` at the top level, so none of
them could be opened in Swagger UI or Redoc on its own — you had to bundle first to
look at any one module's API.

Each now carries `openapi`, `info` and `servers`, plus `components.securitySchemes`.
That last one is not decoration: operations carry `security: [KlabisAuth: [...]]`, and a
security requirement naming a scheme the document does not define is a hard error.
`members.yaml`, `groups.yaml` and `event-types.yaml` now pass `redocly lint` clean.

The bundler output is byte-identical — modules are pulled in through a `#/paths/...`
pointer, so nothing outside `paths` and the referenced `components` is ever read.

That is also what makes the duplication dangerous: a module drifting from the root would
keep bundling cleanly and only mislead whoever opened that one file. `validateModuleDocuments`
pins each module's `openapi`, `info.version` and `securitySchemes` to klabis.yaml's, and
derives its module list from the paths klabis.yaml routes rather than globbing the
directory, so a scratch .yaml left there is not mistaken for a module.

`securitySchemes` stays inlined in klabis.yaml rather than shared from `_shared/`: a root
component that only $refs another file collapses to a self-referencing
`$ref: '#/components/securitySchemes/KlabisAuth'`, because a ref into `#/components/` is
localized rather than expanded.

Left alone as pre-existing and out of scope: 29 `nullable: true` keys (OpenAPI 3.0 syntax
in 3.1 documents) and finance.yaml referencing PageParam/SizeParam/SortParam, which are
defined in common.yaml. Both predate this change — klabis.yaml itself lints identically
before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB

* fix(openapi): OpenAPI 3.1 nullable syntax and finance.yaml's undefined param refs

Two problems the previous commit's redocly lint run surfaced and deliberately left
out of scope, now fixed.

`nullable: true` is OpenAPI 3.0 syntax, invalid in these 3.1.0 documents. Rewritten to
the 3.1 union spelling `type: [X, 'null']` across 28 occurrences (oris.yaml, calendar.yaml,
membershipfees.yaml, events.yaml). None of the 11 affected schemas generate Java from the
spec today — confirmed by diffing regenerated UpdateEventRequest (the one schema in this
set that DOES generate) against both old and new spec content: byte-identical except the
@generated timestamp. openapi-typescript already treated `nullable: true` as `| null`
before this change too, so the only functional frontend diff is the parameter added below.

One occurrence needed a different rewrite: MemberFeeSummaryResponse.currentGroup had
`allOf: [$ref] + nullable: true`, which doesn't compose the way it looks like it should.
Rewritten to `oneOf: [{$ref}, {type: 'null'}]` — confirmed openapi-typescript still emits
a plain `CurrentGroupResponse | null`, not a wrapper.

finance.yaml referenced PageParam/SizeParam/SortParam as local $refs, but they were
defined in members.yaml's components.parameters — invisible as long as the bundler merged
everything into one namespace, but exactly what breaks a module meant to stand alone.
PageParam/SizeParam are generic paging params also duplicated in events.yaml, so both now
live in new _shared/pagination.yaml and both files' local copies are gone. SortParam is
NOT centralized: its description enumerates resource-specific sortable fields, and
finance.yaml was $ref-ing members.yaml's version verbatim — a transactions endpoint
documented as sortable by firstName/lastName. Gave it its own TransactionSortParam
describing TransactionResource's actual fields instead.

Verified: klabis-full.json diff is exactly the 29 leaf changes above plus the one
parameter substitution — confirmed via structural diff, not line count. Backend
--compile-only succeeds; full suite confirmed via JUnit XML directly (3200 tests, 0
failures, 0 errors, 14 skipped, matching the pre-change baseline). Frontend `tsc -b &&
vite build` succeeds; klabisApi.d.ts regenerated. redocly lint: finance.yaml goes from 3
no-unresolved-refs errors to fully valid; the four nullable-bearing files lose their
struct errors.

Reviewed by developer:code-reviewer: no blocking or warning findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…312)

* updated project structure for IDE

* chore(openapi): remove klabis-full.json from git, generate on demand

The bundled spec is a pure build artifact of docs/openapi/spec/ and
required developers to remember a manual regenerate-and-commit step on
every spec change. Backend codegen already bundled fresh into
build/generated/ on every build, so this only affects frontend type
generation and API docs publishing.

- gitignore klabis-full.json instead of committing it
- npm run openapi now bundles the spec first, so a single command is
  enough instead of the previous two-step gradlew+npm sequence
- publish-api.yaml triggers on docs/openapi/spec/** changes and
  bundles the spec itself before publishing Swagger UI
- update docs that described the file as committed
Proposal-only (spec-free schema) for removing the last 33 manual
schemaMappings/importMappings entries and the per-module models allow-list
from backend/build.gradle.kts, so backend codegen is driven entirely by the
API specs. Covers domain enum substitutions, the SuspensionBlockedWarning
oneOf case, Page<T> pagination mappings, cross-package application-layer
types, envelope mappings for no-application/json-sibling operations, and
nested Java class mappings (PaymentRuleResponse, OrisEventSummary) — the
last of which surfaced a real blocker during investigation (OpenAPI 3.1's
nullable spelling is indistinguishable from PATCH tri-state semantics for
the generator), documented as an open question for design.
…s (all modules) (#313)

* refactor(openapi): give HAL responses an application/json sibling for backend codegen

Every HAL/HAL+FORMS response in the spec referenced an envelope schema
(EntityModel*/PagedModel*/CollectionModel*), which backend codegen then had to
manually redirect back onto the plain Java payload type via a large
schemaMappings table in build.gradle.kts. Add a plain application/json
content entry to each such response, and have bundleSpecForCodegen strip
each HAL content type's schema (keeping the key so Spring's `produces`
still advertises it) whenever an application/json sibling exists. Backend
codegen now reads the bare payload schema directly, so most envelope
schemaMappings entries for a migrated module become unnecessary.

Piloted on the members module only. klabis-full.json (openapiBundle, what
the frontend reads) is unaffected by the new --strip-hal flag.

* refactor(openapi): application/json sibling for event-types module

Adds a plain application/json content entry alongside the HAL/HAL+FORMS
response for listEventTypes and getEventType, following the members
module pilot. listEventTypes needed a new named EventTypeDtoList schema
(rather than an inline array) so schemaMappings could retarget it onto
Collection<EventTypeDto> — an inline array has no schema name to map
from and the generator infers List<EventTypeDto> directly from it,
bypassing the mapping and changing the controller's return type.

* docs(openapi): generalize collection schemaMappings guidance beyond Page<T>

The event-types module migration hit the same inline-array pitfall for a
plain Collection<T> return type, not just Page<T> — the skill only
documented the paginated case. Broaden it to any collection response whose
Java type isn't bare List<T>.

* refactor(openapi): application/json sibling for finance module

Every HAL response in finance.yaml now also declares a plain
application/json content entry, so backend codegen can resolve the
Java payload type directly from the spec instead of the schemaMappings
table. listTransactions needed a named TransactionResourceList array
schema (mapped to Page<TransactionResource>) since an inline array
would let the generator infer List<T> and silently break the
interface's return type.

* refactor(openapi): application/json sibling for events module

Every HAL response in events.yaml now also declares a plain
application/json content entry, so backend codegen can resolve the
Java payload type directly from the spec instead of the schemaMappings
table. listEvents, listRegistrations, listPresets and
getAccommodationList needed named array schemas (EventSummaryDtoList,
RegistrationSummaryDtoList, CategoryPresetDtoList,
AccommodationListItemDtoList) since an inline array would let the
generator infer List<T>/bypass the Page<T>/Collection<T> mapping.

getEvent deliberately keeps only its HAL/HAL+FORMS content entry: the
bundler alphabetizes content keys, so an application/json sibling
would always sort ahead of application/prs.hal-forms+json and become
the schema haltypes.mjs picks for GetEventResource — silently dropping
the _embedded.registrationDtoList typing that getEvent's response
actually carries (contributed by the controller via
HalResponseContext.embed, not the generated Java return type).

* fix(openapi): halTypes.ts must type *Resource off the HAL envelope, not application/json

collectHalResources picked its schema from Object.values(content)[0] — whichever
content-type key came first. Bundled documents sort keys alphabetically, so the new
application/json sibling (added for backend codegen, see klabis-api-spec skill) always
won over application/prs.hal-forms+json. Every *Resource type for a collection/paged
endpoint silently lost its _embedded/page typing across the members, event-types,
finance, and events module migrations — caught while migrating events, where the
schema swap would have also dropped getEvent's embedded registrations typing.

Now explicitly prefers application/prs.hal-forms+json / application/hal+json, falling
back to any content schema only if neither carries one.

* refactor(openapi): application/json sibling for calendar module

Adds a plain application/json content entry alongside the HAL/HAL+FORMS
response for listCalendarItems, getCalendarItem, getTokenState and
generateToken, following the members module pilot. listCalendarItems
needed a new named CalendarItemDtoList schema (rather than an inline
array) so schemaMappings could retarget it onto Collection<CalendarItemDto>
— an inline array has no schema name to map from and the generator infers
List<CalendarItemDto> directly from it, bypassing the mapping. The iCal
feed endpoint (text/calendar) and createCalendarItem/updateCalendarItem/
deleteCalendarItem (bodyless 201/204) are untouched.

* refactor(openapi): application/json sibling for membershipfees module

Adds a bare-payload application/json content entry alongside every
HAL/HAL+FORMS response in the membershipfees spec, letting backend
codegen resolve Java return types directly instead of through
schemaMappings. Collection endpoints (Collection<T> return types) get
a named top-level array schema per klabis-api-spec skill guidance.

getFeeGroup keeps no application/json sibling, mirroring events'
getEvent precedent: a bare MembershipFeeGroupResponse sibling would
win frontend type resolution over
EntityModelMembershipFeeGroupResponseWithMembers and silently drop
_embedded.members from the generated GetFeeGroupResource type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB

* refactor(openapi): application/json sibling for groups module

Add application/json content siblings to the four collection GET responses
(listFamilyGroups, listGroups, getPendingInvitations, listTrainingGroups)
across all three groups Gradle registrations (groupsFamily/groupsFree/
groupsTraining), each backed by a new named top-level array schema since
the controllers return Collection<T>, not List<T>.

getFamilyGroup/getGroup/getTrainingGroup are deliberately left HAL-only:
their payload records carry List<EntityModel<X>> fields (parents/members/
owners/trainers each with per-item _links), the same getEvent precedent
already documented in the module header comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAsjrEgsny8rxTyPLq3sBB

* refactor(openapi): application/json sibling for common module

getUserPermissions gets a plain-payload sibling like every other migrated
module. rootNavigation/dashboard are deliberately skipped: RootModel and
DashboardModel are empty marker records, and adding a third content-type
entry (application/json alongside the existing application/hal+json and
application/prs.hal-forms+json) made the generator's return-type resolution
collapse to ResponseEntity<Void>, breaking the controllers' overrides.
Confirmed by generating with two vs. three content types on the same schema.

PermissionControllerTest's HATEOAS assertions needed an explicit Accept
header — getUserPermissions now produces application/json first, so content
negotiation without a header could pick the bare payload over HAL-FORMS.

* docs(openapi): document gotchas found while migrating all 8 modules

Adds the lessons learned rolling the application/json-sibling convention
out to event-types, finance, events, calendar, membershipfees, groups, and
common (oris needed no change — it was never HAL to begin with):

- Only add the sibling where a HAL envelope actually exists.
- haltypes.mjs must type *Resource off the HAL content type, not
  whichever content type sorts first (already fixed in code; documents why).
- A response already carrying two content types on the same schema
  (application/hal+json + application/prs.hal-forms+json) can collapse to
  ResponseEntity<Void> if a third is added — skip the sibling there.
- MockMvc tests asserting _links/_templates need an explicit Accept header
  once an endpoint also produces application/json.

* refactor(openapi): eliminate event-types schemaMappings entirely

Empirically confirmed (docs/technicalAnalysis/openapi-generator-list-types.md):
the spring generator (7.18.0) never generates a wrapper class for a type: array
schema, named or inline — it always emits List<T> directly, with no mapping
needed at all. The "List is a reserved schemaMappings target" pitfall only
applies to explicitly mapping something else onto the string "java.util.List";
leaving an array schema unmapped was safe the whole time.

EventTypeController.listEventTypes now returns List<EventTypeDto> (was
Collection<EventTypeDto> — the underlying value was already a List via
.toList(), only the declared type changes) to stay covariant with the
generated interface. EntityModelEventTypeDto's mapping is also gone: its
schema is emptied for backend codegen by stripHal.mjs, so schemaMappings
never needs to see it, and the application/json sibling (EventTypeDto)
resolves with no mapping since the schema name already matches the class.

This is the reference pattern for eliminating schemaMappings module by
module — Page<T> mappings still need to stay (no array shape carries
pagination metadata), but every List<T>/single-resource mapping is now
provably unnecessary.

* docs(openapi): correct schemaMappings guidance — List<T> never needs a mapping

Supersedes the earlier (wrong) claim that an inline type: array schema
"has no schema name to map from" and needs a named schema + schemaMappings
entry to avoid some broken fallback. Empirically verified against the
pinned generator (7.18.0, see docs/technicalAnalysis/openapi-generator-
list-types.md): a type: array schema, named or inline, always generates
List<T> directly with no wrapper class — no mapping is ever needed for it.

Only Page<T> (pagination metadata no array shape can carry) still needs a
named array schema + mapping. A controller returning Collection<T> should
be migrated to List<T> instead of kept mapped, since there's no
generator-native way to produce Collection<T> from an array schema.

* refactor(openapi): eliminate finance module single-resource schemaMappings

Empirically confirmed (docs/technicalAnalysis/openapi-generator-list-types.md):
EntityModelMemberAccountResource and EntityModelTransactionResource are HAL
envelope schemas emptied by stripHal.mjs, so their application/json siblings
(MemberAccountResource, TransactionResource) resolve directly to the matching
hand-written record with no mapping needed — schema name already equals the
Java class name.

PagedModelEntityModelTransactionResource -> Page<T> and TransactionResourceList
-> Page<T> both stay: listTransactions is genuinely paginated
(x-spring-paginated: true), and removing TransactionResourceList's mapping was
verified to degrade the generated FinanceApi.listTransactions from
Page<TransactionResource> to List<TransactionResource>, since the plain-JSON
sibling's inferred type wins resolution for the operation. No array shape
carries pagination metadata, so both mappings targeting Page<T> are required.

No controller migration needed — finance has no plain Collection<T>-returning
endpoint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(openapi): rename TransactionResourceList to TransactionResourcePage

The array-shaped application/json sibling for listTransactions is named
after its Java resolution target (Page<TransactionResource>, via
schemaMappings), not its JSON wire shape (a plain array) — "...List" reads
as if it should resolve to List<T> with no mapping needed, which is
exactly the pattern eliminated elsewhere in this module (see event-types).
Pure rename: still type: array + schemaMappings onto Page<T>, same
mechanism, no behavior change. Confirmed via regenerated FinanceApi.java
(listTransactions still returns Page<TransactionResource>) and
compileJava.

* refactor(openapi): eliminate events module schemaMappings

Per the corrected pattern (docs/technicalAnalysis/openapi-generator-list-types.md):
a type: array schema, named or inline, never needs a schemaMapping — it always
generates List<T> directly. Removed the now-unnecessary envelope mappings
(EntityModelEventSummaryDto, EntityModelRegistrationDto, EntityModelCategoryPresetDto)
and named-array-sibling mappings (AccommodationListItemDtoList,
RegistrationSummaryDtoList, CategoryPresetDtoList) that were added under the
disproven belief that a named array sibling needed retargeting.

CategoryPresetController.listPresets, EventController.getAccommodationList and
EventRegistrationController.listRegistrations now return List<T> instead of
Collection<T> to match the generated interface — each already built a List
internally via .toList(), only the declared return type changes.

Kept: PagedModelEntityModelEventSummaryDto + EventSummaryDtoList (both, per the
finance-module precedent — listEvents is genuinely paginated and removing either
mapping degrades the generated type to List<T>), EntityModelEventDtoWithRegistrations
(schema name differs from EventDto, and the _embedded block is contributed
out-of-band via HalResponseContext.embed), and the four BulkSyncResult/BulkImportResult
mappings (application-layer types whose package never matches infrastructure.restapi).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(openapi): eliminate calendar module schemaMappings

listCalendarItems' Collection<T>/CalendarItemDtoList mappings existed only
to preserve an incidental Collection<T> controller signature; migrate the
controller to List<T> instead, which the generator produces natively from
an array schema with no mapping needed.

* refactor(openapi): eliminate membershipfees module schemaMappings

Empirically confirmed: EntityModel*/CollectionModel* HAL envelope mappings and
all *List named-array-schema mappings targeting Collection<T> are unnecessary
once the application/json sibling is present — the bare payload schema either
already matches its target class name (single resources) or resolves directly
to List<T> (named array schemas, per
docs/technicalAnalysis/openapi-generator-list-types.md).

Migrated listTiers, listRules, listPublications, listGroupsForYear and
listGroupRules from Collection<T> to List<T> return types to match.

Two mappings stay, both for schema names that genuinely differ from their
Java target:
- PaymentRuleResponse -> MembershipFeeTierResponse.PaymentRuleResponse
  (nested class; the bare schema name alone would resolve to a bogus
  top-level class)
- EntityModelMembershipFeeGroupResponseWithMembers -> MembershipFeeGroupResponse
  (getFeeGroup embeds a second, independently-shaped collection via
  HalModelBuilder that HalResponseContext can't express as one object; no
  application/json sibling exists for this operation, same precedent as
  events.yaml's getEvent)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(openapi): eliminate groups module schemaMappings

Collection<T> list/collection endpoints across the three groups Gradle
modules (listFamilyGroups, listGroups, listTrainingGroups,
getPendingInvitations) had schemaMappings redirecting their named array
siblings onto Collection<T>. Migrate the four controllers to List<T>
instead and drop the now-unnecessary mappings, per the corrected
List<T>-needs-no-mapping guidance. The three "get single group" envelope
mappings (EntityModelFamilyGroupResponse/EntityModelGroupResponse/
EntityModelTrainingGroupResponse) stay: those operations have no
application/json sibling (per-item _links on nested collections can't be
expressed in a bare payload), so backend codegen still sees the full
envelope schema.

* refactor(openapi): eliminate common module schemaMappings

EntityModelPermissionsResponse was redundant: getUserPermissions's
application/json sibling already resolves PermissionsResponse directly
by matching schema name, so only the bare PermissionsResponse mapping
is needed. EntityModelRootModel/EntityModelDashboardModel mappings
verified empirically still required — without them the generated
return type collapses to the never-generated envelope class.

* docs(openapi): review every remaining schemaMappings entry with reasoning

Full audit of all 33 mapping entries left in backend/build.gradle.kts after
the schemaMappings-elimination round, split into the pre-existing domain-enum
substitution mechanism (8 entries, unrelated to HAL/JSON-split work) and the
genuine envelope/response-shape redirections (19 + 5 extraImportMappings
entries). Verified two previously-untested cases in this pass: Gender (enum
category, representative case) and OrisEventSummary (nested-class category) —
both fail to compile when removed, confirming they're genuinely required.

Every remaining entry now has documented, verified justification.

* refactor(openapi): move PaymentRuleResponse and OrisEventSummary to top-level classes

Both were nested classes requiring a schemaMappings redirect since a bare
OpenAPI schema name never auto-resolves to a nested class. Moving them to
top-level files in the same package lets the generator resolve them directly,
eliminating both mappings entries.

* Revert "refactor(openapi): move PaymentRuleResponse and OrisEventSummary to top-level classes"

This reverts commit 92308ad.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…only DTOs (#314)

Several response-only schemas (OrisEventSummary, IcalTokenResponse,
EventSyncEntry, EventImportEntry, RegistrationDto, AccommodationListItemDto,
PaymentRuleResponse, FeeSelectionCampaignResponse, MemberInGroupResponse,
MemberFeeChoiceResponse, MemberFeeSummaryResponse) used the `type: [x, 'null']`
/ oneOf-null spelling reserved for JsonNullable<T> PATCH tri-state semantics,
even though none of them are PATCH request bodies. A plain optional property
(absent from `required`) already expresses "may be null/absent" without the
wrapper. Confirmed via audit that the only legitimate uses of the null-union
spelling are the three actual PATCH bodies: UpdateMemberRequest,
UpdateEventRequest, UpdateTrainingGroupRequest.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Escape backslashes in haltypes.mjs quote() (CodeQL: incomplete string
escaping). Remove unused locals/params flagged by github-code-quality
in CalendarController, UpdateEventRequestMapper,
MembershipFeesRequestMapper and HalResponseBodyAdviceTest (the latter
now actually uses the injected Pageable instead of a hardcoded one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
// Mirrors a generated @Validated *Api interface method whose @RequestParam carries constraints.
static class ValidatedApi {
@SuppressWarnings("unused")
public void validateToken(@NotBlank @Pattern(regexp = "^(?!\\s*$).+") String token) {
* implementation would find no body annotation and lose its input metadata.
*/
@RequestMapping(method = RequestMethod.PATCH, value = "/api/afford-test/iface-routed/{id}")
ResponseEntity<Void> updateIfaceRouted(@PathVariable UUID id, @RequestBody AffordanceTestRequest body);
* implementation would find no body annotation and lose its input metadata.
*/
@RequestMapping(method = RequestMethod.PATCH, value = "/api/afford-test/iface-routed/{id}")
ResponseEntity<Void> updateIfaceRouted(@PathVariable UUID id, @RequestBody AffordanceTestRequest body);
interface AffordanceApi {

@HasAuthority(Authority.MEMBERS_MANAGE)
ResponseEntity<Void> updateIfaceHasAuthority(UUID id, AffordanceTestRequest body);
interface AffordanceApi {

@HasAuthority(Authority.MEMBERS_MANAGE)
ResponseEntity<Void> updateIfaceHasAuthority(UUID id, AffordanceTestRequest body);
dapolach and others added 2 commits August 3, 2026 01:01
The custom pojo.mustache fork replaced the stock class-emitting template with
a record-emitting one but only wired the non-enum path into model.mustache. A
top-level enum model (a RESOLVE_INLINE_ENUMS-promoted inline enum property
left unmapped by schemaMappings, or a directly $ref'd enum schema) silently
produced no type at all instead of a compile error.

Restores the stock JavaSpring enumOuterClass partial for isEnum models,
verbatim — an enum has no fields to carry x-klabis-* field-security
extensions, so nothing Klabis-specific needs forking here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nums

Now that model.mustache renders a real enumOuterClass for a top-level enum
schema (previous commit), the generator can synthesize its own DTO-side enum
per module instead of being pointed at the hand-written domain enum via
schemaMappings. Removed the enum-only mapping entries for Gender,
DeactivationReason, DrivingLicenseGroup, TrainerLevel, RefereeLevel (members)
and EventStatus (events), and added explicit DTO<->domain conversions at the
REST boundary (MemberMapper, UpdateMemberRequestMapper, MemberController,
EventController) so the domain enums stay untouched and fully decoupled from
the wire representation.

Authority stays mapped: it's used pervasively outside the DTO layer
(@HasAuthority annotations, security interceptors, JWT claims, OAuth2 scopes),
so redirecting it would force Authority/DTO conversions into core security
code for no benefit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants