docs(schema,arch-form-builder): reflect purpose registry v1.0

- SCHEMA.md §3.5.12 header rewritten for the 7-purpose vocabulary and
  `PurposeRegistry`. The `custom_purpose_slug` column is dropped from
  the `form_schemas` table and removed from the index list. The
  `form_submissions.subject_type` note cites
  `PurposeRegistry::allSubjectTypes()` instead of the deleted
  `config/form_subjects.php`.
- ARCH-FORM-BUILDER.md TL;DR updated: goal bullet cites 7 purposes
  (v1.0); §3.2 bullet notes the legacy 22-variant vocabulary is
  retired. §17.3 replaced: the "Custom purposes per organisation"
  section is gone; the new "Purpose registry" section documents the
  seven-slug table, PurposeDefinition shape, PurposeRegistry API,
  MorphMapAlignmentTest guard, the pre-publish binding check, and a
  step-by-step "adding a new purpose" checklist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 14:36:18 +02:00
parent 55ba4f24c0
commit 598593b0db
2 changed files with 88 additions and 25 deletions

View File

@@ -24,8 +24,12 @@
Ten bullets every Claude Code session reads before starting:
1. **Goal:** replace the event-scoped `registration_form_fields` with a
polymorphic, universal form builder serving 22 distinct purposes from
event registration to incident reports to contract signatures.
polymorphic, universal form builder serving 7 distinct purposes
(v1.0) from event registration to incident reports to contract
signatures. Purposes are registered in
`config/form_builder/purposes.php` via `PurposeRegistry`; adding a
new purpose requires a code change (config + typically a listener)
— per ARCH-CONSOLIDATION §3 besluit 4.
2. **Four patterns:** entity-bound, submission-bound, event-registration,
public. Every schema matches one.
3. **Three tables are core:** `form_schemas` (definitions), `form_fields`
@@ -64,9 +68,12 @@ Ten bullets every Claude Code session reads before starting:
12. **Integration contracts are explicit.** The form builder's interaction
with identity matching, crowd lists, shifts, and email notifications
is contract-defined (§31). No ad-hoc cross-module coupling.
13. **Per-purpose lifecycles are documented.** Each of the 22 FormPurpose
values has a concrete lifecycle paragraph (§3.2) covering subject
handling, submission flow, integrations, and sample fields.
13. **Per-purpose lifecycles are documented.** Each of the 7 FormPurpose
values (v1.0) has a concrete lifecycle paragraph (§3.2) covering
subject handling, submission flow, integrations, and sample fields.
The wider vocabulary that once counted 22 variants is intentionally
retired in v1.0 — purposes outside the registered seven are not
part of the physical schema nor the behaviour spec.
---
@@ -1976,19 +1983,72 @@ FormSchemaService uses the registry when validating field_type on create/
update. Built-in types are registered by default. Custom types extend the
list without code changes to core.
### 17.3 Custom purposes per organisation
### 17.3 Purpose registry
`FormPurpose::CUSTOM` + `form_schemas.custom_purpose_slug` string.
Organisations define their own purposes (e.g.,
`brandweertraining_certificering`).
The set of purposes served by the form builder is a closed, code-defined
vocabulary. There is no "custom purpose" escape. Organisations cannot
invent purposes at runtime. This is deliberate — purpose drives subject
handling, submission mode, public-access rules, pre-publish required
bindings, and downstream listeners; each of those needs explicit code
support.
Rules:
- `custom_purpose_slug` required when purpose=custom
- Unique(organisation_id, custom_purpose_slug) when purpose=custom
- Subject type and submission_mode must be explicitly set (no purpose-
derived defaults)
- Public token not allowed for custom purposes in v1 (future: per-slug
allowlist)
**v1.0 vocabulary** (seven purposes, defined in
`config/form_builder/purposes.php`):
| Slug | Label (NL) | Subject type | Default submission mode | Public access | Required bindings |
|------|-----------|--------------|-------------------------|---------------|-------------------|
| `event_registration` | Aanmelding vrijwilligers/crew | person | single | yes | `person.email`, `person.first_name`, `person.last_name` |
| `artist_advance` | Artiest advance | artist | draft_single | no | — |
| `supplier_intake` | Leverancier intake | company | single | no | `company.name` |
| `post_event_evaluation` | Evaluatie na afloop | person | single | no | — |
| `incident_report` | Incident-melding | person | multiple | no | — |
| `signature_contract` | Contract-ondertekening | user | single | no | — |
| `user_profile` | Profiel-update | user | single | no | — |
`allows_public_access` is the schema-level public-submission flag.
Portal-token-based flows (artists, suppliers, press) are a different
mechanism and do not consume this flag.
**PurposeDefinition value object** (`app/FormBuilder/Purposes/
PurposeDefinition.php`) holds the five properties above plus the slug.
It is immutable (`final readonly`) and `subjectType` uses the morph
alias, not the FQCN.
**PurposeRegistry service** (`app/FormBuilder/Purposes/
PurposeRegistry.php`) reads the config file, memoises the parsed
definitions per instance, and exposes:
- `all()` — `array<slug, PurposeDefinition>`
- `get(string $slug)` — throws `PurposeNotFoundException` on miss
- `has(string $slug)` — bool
- `allSubjectTypes()` — sorted, unique list of subject-type aliases;
consumed by `AppServiceProvider::registerMorphMap()` (domain-subject
block) and by `StoreFormSubmissionRequest` validation
- `publicAccessibleSlugs()` — slugs whose schemas permit public
submission
`MorphMapAlignmentTest` guards the invariant that every subject_type
returned by `PurposeRegistry::allSubjectTypes()` is registered as a key
in `Relation::morphMap()`.
**Required-bindings pre-publish check.**
`FormSchemaService::publish()` fails with
`PurposeRequirementsNotMetException` (structured; `purposeSlug` +
`missingBindings[]`) if any binding path in the schema's
`PurposeDefinition::requiredBindings` is not present on at least one
`form_fields.binding` JSON of the schema. In WS-5a this check switches
to the relational `form_field_bindings` table.
**Adding a new purpose.** In scope only via an architect-level decision:
1. Add a migration if existing schemas carry the new slug via data.
2. Add the new entry to `config/form_builder/purposes.php`.
3. If new subject type: register the FQCN in
`AppServiceProvider::PURPOSE_SUBJECT_FQCN`; `MorphMapAlignmentTest`
enforces this step.
4. Add listeners wired to `FormSubmissionSubmitted` as needed
(identity-match, tag sync, entity creation, etc.).
5. Add a lifecycle paragraph under §3.2 and a row to the table above.
### 17.4 Custom validation callbacks