docs(forms): SCHEMA crosswalk, foundation concept page, getting-started + migration playbook, copy catalogue init

SCHEMA.md
- New §3.5.12 "Form Builder" with the legacy-tables-retained note
  placed prominently directly under the section header (per S1 wrap-up
  Path 3 decision: Phase 8 deferred to S2).
- Crosswalk: every legacy volunteer_profiles column → its new home
  (user_profiles columns vs form_fields vs person_tags).
- Summary table for the 13 new tables with one-line purpose + ARCH §
  pointer each.
- Activity log strategy and multi-tenancy discipline noted.
- §3.5.4 marked SUPERSEDED with a pointer to the new section.

/dev-docs/form-builder-migration-playbook.md (new)
- Operator runbook for forms:migrate-legacy-data on real legacy data.
- Pre-flight audit, dry-run, migrate, verify, spot-check, rollback
  paths spelled out. Same legacy-tables-retained note prominently.

/dev-docs/form-builder-getting-started.md (new)
- Developer onboarding. Mental model, code samples for creating a
  schema/field/submission/value, adding a new subject type, registering
  a custom field type, suppressing activity log via
  App\Support\ActivityLog::suppressed.

/dev-docs/COPY_CATALOGUE.md (new)
- Seeded verbatim from ARCH §30 (naming conventions, tooltip catalogue,
  warning catalogue) with a header explaining purpose, growth strategy,
  and the per-PR update workflow.

/docs/organizer/forms/concepts/wat-is-een-formulier.md (new VitePress)
- Dutch, informal je/jij. Follows /docs/.templates/concept-page.md.
- Three example use-cases: vrijwilligersregistratie, artist advance,
  incidentrapportage. Light foundation; depth arrives in S2-S5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 17:06:53 +02:00
parent cd7a804024
commit cfc7610497
5 changed files with 676 additions and 0 deletions

120
dev-docs/COPY_CATALOGUE.md Normal file
View File

@@ -0,0 +1,120 @@
# Crewli — In-app copy catalogue
> Single source of truth for user-facing Dutch copy. Seeded from
> `/dev-docs/ARCH-FORM-BUILDER.md` §30.
## Purpose
Centralises terminology and warnings so every screen, tooltip, and
error message stays consistent. Without this, "Dienst" / "Shift" /
"Taak" drift across pages and confuse organisers; "Privacy-gevoelig"
and "Bevat persoonsgegevens" appear side-by-side for the same flag.
This catalogue is the canonical reference for both backend validation
messages (Form Requests) and frontend rendering (Vue components).
Whenever you write Dutch copy that a user will read, look here first.
## Growth strategy
Living document. Each session that adds user-facing UI:
1. **Check first.** Search for the concept (in Dutch and English) before
coining a new term. If it's here, use the existing wording verbatim.
2. **Propose new terms in PR.** Add to the relevant section. Brief
rationale in the commit message.
3. **Reviewer (Bert) signs off** on terminology before merge. Once a
term is in the catalogue, it's binding for future sessions.
The catalogue stays in `/dev-docs/` (developer-facing) rather than
`/docs/` (end-user-facing). End-user docs are written *using* this
catalogue, not *about* it.
## Update workflow
- Adding a tooltip → update §30.2 here.
- Adding a warning → update §30.3 here.
- Renaming a concept → update §30.1 here AND grep for all current
Dutch occurrences (frontend, validation messages, end-user docs)
and update them in the same PR.
- Wholesale restructure → discuss in design-document.md first; then
rewrite this catalogue in one commit.
## Naming conventions
| Concept | Canonical Dutch term | Never use |
| -------------------- | ---------------------------- | ---------------------------------- |
| `form_schema` | Formulier | Schema, template |
| `form_field` | Veld | Vraag, item |
| `form_template` | Formulier-sjabloon | Template (alleen in dev-docs) |
| `form_field_library` | Veldenbibliotheek | Library, bibliotheek alleen |
| `form_submission` | Inzending | Submission, antwoord |
| `is_filterable` | Filterbaar | Queryable, zoekbaar |
| `is_pii` | Bevat persoonsgegevens | Privacy-gevoelig |
| `freeze_on_submit` | Bevriezen na inzending | Vergrendelen |
| `consent_version` | Toestemmingsversie | Consent-versie |
## Tooltip catalogue (selection)
```
is_filterable:
"Filterbaar — dit veld wordt extra geïndexeerd voor snelle filtering
in overzichten. Alleen aanvinken voor velden die je daadwerkelijk als
filter gebruikt (bijvoorbeeld: shirtmaat wel, motivatie niet)."
is_pii:
"Bevat persoonsgegevens — bij retentie-verwerking worden deze waardes
geanonimiseerd volgens je privacy-instellingen. Vink aan voor velden
zoals telefoon, e-mail, noodcontact, medische info."
is_unique:
"Uniek per formulier — waardes van dit veld moeten uniek zijn over alle
inzendingen heen. Geschikt voor bijvoorbeeld BSN of werknemersnummer.
Dubbele waardes worden afgewezen."
freeze_on_submit:
"Bevriezen na eerste inzending — zodra iemand het formulier indient
kunnen de velden niet meer gewijzigd worden. Gebruik dit voor
contracten, signatures, of formulieren waar de structuur vast moet
staan voor audit-doeleinden."
snapshot_mode:
never: "Geen snapshot — wijzigingen worden alleen in het activity log
bijgehouden."
on_submit: "Snapshot bij inzending — bij elke indiening wordt het
complete formulier gesnapshot voor audit-doeleinden."
always: "Altijd snapshot — elke wijziging (ook drafts) wordt
gesnapshot. Gebruikt meer opslag maar biedt het volledige
audit-spoor."
retention_days:
"Bewaartermijn — na deze periode (vanaf inzendingsdatum) worden
PII-velden automatisch geanonimiseerd. Typische waardes: 1095 dagen
(3 jaar) voor vrijwilligers, 2555 dagen (7 jaar) voor contracten,
null voor onbeperkt bewaren."
```
## Warning catalogue (selection)
```
binding_change_with_submissions:
"Je staat op het punt de koppeling van dit veld te wijzigen terwijl er
al {count} ingediende inzendingen zijn. De historische waardes blijven
bestaan, maar zijn niet meer de bron-van-waarheid. Dit kan niet
ongedaan worden gemaakt."
delete_schema_with_submissions:
"Dit formulier heeft {count} inzendingen. Als je het verwijdert, blijven
de inzendingen bewaard als archief maar zijn niet meer nieuw in te
dienen. Type de naam van het formulier om te bevestigen:"
field_type_change:
"Je wijzigt het veldtype van {old} naar {new}. Bestaande waardes worden
mogelijk niet correct omgezet — sommige kunnen onleesbaar worden.
Aanbevolen: maak een nieuw veld aan in plaats van dit veld te
wijzigen."
public_token_rotation:
"Je roteert de publieke link voor dit formulier. Bestaande gebruikers
kunnen nog 7 dagen inzenden met de oude link; daarna krijgen ze een
410 Gone foutmelding."
```

View File

@@ -669,6 +669,13 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date;
## 3.5.4 Volunteer Profile & History
> **SUPERSEDED by S1 of the form-builder refactor.** The old
> `volunteer_profiles` table was a planning placeholder that was never
> physically created. It has been split: user-universal columns moved
> to the new `user_profiles` table (§3.5.12), and event-variable / skill
> columns moved to `form_fields` and `person_tags`. See §3.5.12 for the
> new structure and the column-by-column crosswalk.
### `volunteer_profiles`
| Column | Type | Notes |
@@ -1924,3 +1931,59 @@ Immutable audit record of every email sent. No soft deletes.
**Indexes:** `(organisation_id, created_at)`, `(recipient_email, created_at)`, `(template_type, status)`, `(event_id)`, `(person_id)`
**Relations:** `belongsTo` organisation (nullable), event (nullable), person (nullable), user (nullable), triggeredBy → user
**Soft delete:** no — immutable audit table
---
## 3.5.12 Form Builder
> See `/dev-docs/ARCH-FORM-BUILDER.md` v1.2 for the authoritative
> specification. This SCHEMA.md section is a summary only and will be
> fully rewritten at the end of S6.
>
> **Legacy tables retained intentionally.** The tables
> `registration_form_fields`, `person_field_values`, and
> `registration_field_templates` remain in the schema through end of S1.
> They are dropped atomically in the first S2 commit together with the
> removal of legacy controllers, services, requests, resources, policies,
> and routes. DevSeeder and FormBuilderDevSeeder no longer write to them;
> they hold zero rows in dev but the schema is preserved for environments
> with real legacy data that will be migrated via
> `forms:migrate-legacy-data`.
**Crosswalk: legacy `volunteer_profiles` → new locations**
| Legacy column | New location |
| ------------------------------ | ----------------------------------------------- |
| `bio` | `user_profiles.bio` |
| `photo_url` | `user_profiles.photo_url` |
| `tshirt_size` | `form_fields` (Pattern B, per event) |
| `first_aid` | `person_tags` (system-seeded, S3) |
| `driving_licence` | `person_tags` (system-seeded, S3) |
| `allergies` | `form_fields` (Pattern B, per event) |
| `access_requirements` | `form_fields` (Pattern B, per event) |
| `emergency_contact_name` | `user_profiles.emergency_contact_name` |
| `emergency_contact_phone` | `user_profiles.emergency_contact_phone` |
| `reliability_score` | `user_profiles.reliability_score` |
| `is_ambassador` | `user_profiles.is_ambassador` |
### New tables (S1)
| Table | Purpose |
| --- | --- |
| `user_profiles` | User-universal profile (bio, photo, emergency contact, reliability_score, is_ambassador, opaque settings JSON). 1:1 with `users`, auto-created by `UserObserver`. ARCH §4.13. |
| `form_schemas` | Form definition. Polymorphic owner (event / user_profile / artist / company / null). Carries purpose, submission_mode, is_published, public_token, schema_snapshot policy, freeze_on_submit, retention/consent. ARCH §4.1. |
| `form_schema_sections` | Optional sections within a schema (used when `section_level_submit=true`). ARCH §4.8. |
| `form_field_library` | Reusable cross-schema field definitions. ARCH §4.7. |
| `form_fields` | Field within a schema. `field_type` stored as string (custom types via `CustomFieldTypeRegistry`). Carries binding (Pattern A/B/C), is_filterable, is_pii, conditional_logic, value_storage_hint. ARCH §4.2. |
| `form_submissions` | One submission per `(schema, subject)`. Polymorphic subject. Carries status, review_status, schema_snapshot copy, submission lifecycle timestamps, search_index. ARCH §4.3. |
| `form_submission_section_statuses` | Per-section status when `section_level_submit=true`. ARCH §4.9. |
| `form_submission_delegations` | "X fills in this submission on behalf of Y". ARCH §4.10. |
| `form_values` | EAV row per `(submission, field)`. Integer AI PK for fast joins. Typed columns (value_indexed/number/date/bool) populated by `FormValueObserver`. ARCH §4.4. |
| `form_value_options` | Filter pivot for multi-value fields (MULTISELECT/CHECKBOX_LIST/TAG_PICKER). Rebuilt by observer. ARCH §4.5. |
| `form_templates` | Org-scoped reusable schema snapshots. `is_system` for shipped templates. ARCH §4.6. |
| `form_schema_webhooks` | Webhook subscriptions per schema. URL/secret encrypted via Eloquent cast. ARCH §4.11. |
| `form_webhook_deliveries` | Webhook delivery audit + retry queue. ARCH §4.12. |
**Activity log strategy:** explicit calls via `FormSchema::logSchemaChange()` and `FormField::logFieldChange()` — no `LogsActivity` trait (would produce noise). Only impactful events logged (publish toggle, purpose change, binding change, is_pii toggle, etc.). Bulk-fixture suppression via `App\Support\ActivityLog::suppressed(fn() => …)` which flips `config('activitylog.enabled')` for the callback.
**Multi-tenancy:** `OrganisationScope` applied on `FormSchema`, `FormTemplate`, `FormFieldLibrary`. Other form-builder tables inherit isolation through their parent schema; `FormSchemaWebhook` documents this discipline explicitly via a docblock warning to never query directly without an eager constraint.

View File

@@ -0,0 +1,230 @@
# Form Builder — Developer Getting Started
> Onboarding for developers writing code against the new form-builder
> tables. Companion to `/dev-docs/ARCH-FORM-BUILDER.md` v1.2 (the
> authoritative spec) and `/dev-docs/SCHEMA.md` §3.5.12.
---
## Prerequisites
- Laravel 12 / PHP 8.2+.
- Familiarity with Eloquent, ULIDs, polymorphic relationships.
- Read ARCH §0 (TL;DR) and §3 (FormPurpose catalogue).
---
## The mental model in one paragraph
A `FormSchema` is a form definition. `FormFields` belong to it. A
`FormSubmission` is one filled-in instance of a schema, identified by its
polymorphic subject (a Person, User, Company, Organisation, Event, or
nothing for public forms). `FormValues` are the EAV row per
`(submission, field)` pair, JSON payload + typed columns populated by an
observer based on the field's storage hint and filterability. Every
schema declares its purpose (`event_registration`, `incident_report`,
…) which constrains lifecycle and integrations.
---
## Creating a new schema with two fields
```php
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSubmissionMode;
use App\Models\Event;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
$event = Event::find($eventId);
$schema = FormSchema::create([
'organisation_id' => $event->organisation_id,
'owner_type' => 'event',
'owner_id' => $event->id,
'name' => "{$event->name} — registratie",
'slug' => "{$event->slug}-registratie",
'purpose' => FormPurpose::EVENT_REGISTRATION,
'submission_mode' => FormSubmissionMode::DRAFT_SINGLE,
'is_published' => false,
'locale' => 'nl',
]);
FormField::create([
'form_schema_id' => $schema->id,
'field_type' => FormFieldType::SELECT->value,
'slug' => 'shirtmaat',
'label' => 'Shirtmaat',
'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
'is_required' => true,
'is_filterable' => true,
'value_storage_hint' => FormFieldType::SELECT->recommendedValueStorageHint(),
'sort_order' => 1,
]);
FormField::create([
'form_schema_id' => $schema->id,
'field_type' => FormFieldType::TEXTAREA->value,
'slug' => 'opmerkingen',
'label' => 'Opmerkingen',
'value_storage_hint' => FormFieldType::TEXTAREA->recommendedValueStorageHint(),
'sort_order' => 2,
]);
```
Then to log a meaningful change:
```php
$schema->is_published = true;
$schema->save();
$schema->logSchemaChange('schema.published');
```
Trivial label/help_text edits should NOT call `logSchemaChange` — see
ARCH §17.1 for the curated event list.
---
## Creating a submission and its values
```php
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormValue;
$person = \App\Models\Person::find($personId);
$submission = FormSubmission::create([
'form_schema_id' => $schema->id,
'subject_type' => 'person',
'subject_id' => $person->id,
'submitted_by_user_id' => $person->user_id,
'status' => FormSubmissionStatus::SUBMITTED,
'submitted_at' => now(),
'is_test' => false,
]);
// One value per field. The shape of `value` is JSON-wrapped per field type.
FormValue::create([
'form_submission_id' => $submission->id,
'form_field_id' => $shirtmaatField->id,
'value' => ['value' => 'L'],
]);
FormValue::create([
'form_submission_id' => $submission->id,
'form_field_id' => $opmerkingenField->id,
'value' => ['value' => 'Graag een nachtshift'],
]);
```
`FormValueObserver` automatically populates `value_indexed` for filterable
single-value fields, `value_number` for NUMBER hint, `value_date` for
DATE hint, `value_bool` for BOOL hint, and the `form_value_options` pivot
for multi-value filterable fields (MULTISELECT / CHECKBOX_LIST /
TAG_PICKER). You don't write to those columns directly.
---
## Adding a new subject type
Three places to update — keep them in sync:
1. **Morph map** (`app/Providers/AppServiceProvider.php`):
```php
Relation::enforceMorphMap([
// …existing entries…
'artist' => \App\Models\Artist::class,
]);
```
2. **Subject registry** (`config/form_subjects.php`):
```php
'artist' => [
'model' => \App\Models\Artist::class,
'display_attribute' => 'name',
'permission_check' => \App\Policies\ArtistPolicy::class.'@view',
],
```
3. **Reverse relation on the model** itself:
```php
public function formSubmissions(): MorphMany
{
return $this->morphMany(\App\Models\FormBuilder\FormSubmission::class, 'subject');
}
```
The verifier (`forms:verify-data-integrity`) cross-checks subject_type
against the `config/form_subjects.php` keys; an unknown subject_type
will fail the submission-coherence check.
---
## Registering a custom field type
The `field_type` column on `form_fields` is a string, not a DB enum, so
custom types can be added at runtime via configuration. Under the hood
this is the `CustomFieldTypeRegistry` planned for ARCH §17.2 — for now,
register the type's value in `config/form_builder.php`:
```php
'custom_field_types' => [
'COLOR_PICKER' => [
'label' => 'Kleurkeuze',
'storage_hint' => 'string',
'filterable' => true,
],
],
```
The verifier accepts any field_type that's either in `FormFieldType::values()`
or in `config('form_builder.custom_field_types')`. Frontend rendering
and validation handlers come in S6 when the registry interface lands.
---
## Suppressing activity log during bulk operations
`logSchemaChange` and `logFieldChange` produce one activity-log row per
call. In bulk fixture runs (DevSeeder, the data-migration command,
import scripts) this floods `activity_log` with hundreds of rows that
provide no audit value. Wrap the bulk operation:
```php
use App\Support\ActivityLog;
ActivityLog::suppressed(function () use ($schemas): void {
foreach ($schemas as $schemaData) {
$schema = FormSchema::create($schemaData);
// logSchemaChange calls inside this closure are silent no-ops.
}
});
```
The helper flips `config('activitylog.enabled')` for the duration of
the callback and restores it in `finally`. Both our explicit calls AND
the spatie `LogsActivity` trait (used on `Organisation` elsewhere)
respect the flag via `ActivityLogger::log()`.
Do NOT use this in regular request paths — the activity log is the
audit trail; suppressing it silently is an antipattern outside of
fixtures and one-shot commands.
---
## Where to look next
- **ARCH-FORM-BUILDER.md §4** — full column specs for every table.
- **ARCH-FORM-BUILDER.md §6** — binding patterns (entity-owned /
form-owned / mirrored).
- **ARCH-FORM-BUILDER.md §7** — filter architecture and the
`FilterQueryBuilder` interface (S4).
- **`config/form_binding.php`** — Entity Column Registry (Pattern A/C
binding targets).
- **`config/form_subjects.php`** — Subject Type Registry.
- **`config/form_builder.php`** — limits, webhook policy, captcha,
retention, feature flags.
- **`forms:migrate-legacy-data`** + **`forms:verify-data-integrity`** —
see the migration playbook in this directory for the operator's view.

View File

@@ -0,0 +1,186 @@
# Form Builder — Migration Playbook
> Operator runbook for migrating legacy `registration_form_fields` /
> `person_field_values` / `registration_field_templates` data into the new
> `form_*` structure. Companion to `/dev-docs/ARCH-FORM-BUILDER.md` §11.
---
## Status (post-S1)
**Legacy tables retained intentionally.** The tables `registration_form_fields`,
`person_field_values`, and `registration_field_templates` remain in the
schema through end of S1. They are dropped atomically in the first S2
commit together with the removal of legacy controllers, services, requests,
resources, policies, and routes. DevSeeder and FormBuilderDevSeeder no
longer write to them; they hold zero rows in dev but the schema is preserved
for environments with real legacy data that will be migrated via
`forms:migrate-legacy-data`.
Until S2 lands, the legacy registration UI remains functional for any
environment that depends on it.
---
## When to use this playbook
Run this when promoting an environment that holds real legacy
registration data into the new form-builder schema. For development
machines starting from `migrate:fresh --seed`, no migration is needed —
DevSeeder produces new-structure data directly.
---
## Pre-flight audit
Before touching any data, confirm the working state matches expectations.
```bash
# 1. Working tree clean?
git status
# 2. On the expected branch?
git log -1 --oneline
# 3. Database backup exists? (production / staging only — your call for dev)
# 4. Verify the new tables are present and the legacy tables still hold data
php artisan db:show | grep -E "form_|registration_form_fields|person_field_values|registration_field_templates"
```
If the legacy tables are absent, the migration command will detect this
and exit cleanly without doing work — there is nothing to migrate.
---
## Step 1 — Dry run
Always start with a dry run. It writes nothing and prints the same summary
table the real run would produce.
```bash
php artisan forms:migrate-legacy-data --dry-run
```
**What to look for:**
- The per-organisation log lines list every event that will get a new
schema and the number of fields/submissions per event.
- The summary table shows projected counts for `form_schemas`,
`form_fields`, `form_submissions`, `form_values`, `form_templates`.
- Exit code is `0`.
If anything looks surprising — wildly off counts, organisations missing,
templates duplicated — STOP and investigate. Do not proceed to step 2 until
the dry-run output is what you expect.
---
## Step 2 — Run the migration
```bash
php artisan forms:migrate-legacy-data
```
**What happens:**
- Per organisation, inside a transaction (one bad org rolls back only itself).
- Per event in that org: one `form_schemas` row + N `form_fields` rows.
- Per person with `person_field_values` for that event: one `form_submissions`
row (status = `submitted` if person.status ∈ applied/approved/no_show
else `draft`) + one `form_values` row per legacy value.
- `FormValueObserver` populates typed columns and the
`form_value_options` pivot during the inserts (per ARCH §7.2).
- All `registration_field_templates` rows are wrapped in the ARCH §4.6.1
`schema_snapshot` shape and inserted as `form_templates`.
- The whole run is wrapped in `App\Support\ActivityLog::suppressed(...)`
so the activity log isn't flooded by hundreds of `logSchemaChange` /
`logFieldChange` entries from the bulk inserts.
- After migration completes, verification runs automatically. Final
exit code is whatever `forms:verify-data-integrity` returns.
**Idempotent:** if a `form_schemas` row already exists for `(org, event,
event_registration)`, that event is skipped silently. Safe to re-run.
---
## Step 3 — Verify
Verification runs automatically at the end of step 2. To re-run it
standalone (or to verify state without touching data):
```bash
php artisan forms:verify-data-integrity
```
Or, equivalently:
```bash
php artisan forms:migrate-legacy-data --verify-only
```
**Nine checks:**
1. Schema coherence (purpose enum, custom_purpose_slug ⇄ purpose=custom, public_token uniqueness)
2. Field coherence (FK valid, field_type ∈ FormFieldType custom registry, is_filterable matches type, slug unique within schema, binding JSON valid against `config/form_binding.php`)
3. Submission coherence (subject_type/subject_id consistency, subject_type ∈ `config/form_subjects.php`, status ⇄ submitted_at)
4. Value coherence (orphans, unique pair, length, multi-value sanity, value_indexed only set when field.is_filterable)
5. User profile coherence (every user has one profile; reliability_score ∈ [0, 5])
6. Data migration counts (skipped when legacy tables absent)
7. Orphan records (pivot rows, section statuses)
8. Section/schema relation consistency
9. (`--strict`) Strict reachability: value.field.schema must equal value.submission.schema
Exit code `0` means all checks passed. Anything else is a real failure
worth investigating before proceeding.
---
## Step 4 — Spot-check a sample
After verification is green, eyeball a sample submission to confirm the
shape is right.
```bash
php artisan tinker
>>> use App\Models\FormBuilder\FormSubmission;
>>> $sub = FormSubmission::with(['schema', 'values.field'])->latest()->first();
>>> $sub->schema->name;
>>> $sub->values->map(fn($v) => [$v->field->slug => $v->value]);
```
Compare against the legacy view of the same person's data in
`person_field_values` to confirm nothing was silently lost.
---
## Rollback
The data-migration command does not have a `down()` step. If a migration
went wrong, the recovery path is:
1. Restore from backup (production / staging).
2. For dev: `php artisan migrate:fresh --seed` rebuilds everything from scratch.
The legacy tables are preserved during the migration — they are not
modified. So even after running `forms:migrate-legacy-data`, the legacy
data is still there (until S2 drops the tables). This means a production
data-migration error can be recovered by truncating the new form_* tables
and re-running with the underlying legacy data still intact.
---
## Common gotchas
- **`registration_form_fields` table absent:** the command detects this
and exits 0 silently. Expected behaviour for fresh environments.
- **PII heuristic miss:** the heuristic in §11.2.1 of the ARCH covers
obvious patterns (email, phone, geboort, allergie, …) but is not
exhaustive. After migration, organisers can fine-tune `is_pii` per
field via the form-builder UI (S5).
- **`is_published` derived from event status:** the command considers an
event "published" when its status is one of registration_open / buildup
/ showday / teardown / closed. If the legacy registration was open on
events with other statuses, adjust `is_published` manually after migration.
- **Custom field types:** the command maps the 10 legacy field types
(text/textarea/select/multiselect/checkbox/radio/boolean/number/tag_picker/heading)
into the new uppercase enum values. If your legacy data has values
outside this set, the command will set them to `TEXT` and the verifier
will flag them as invalid `field_type`.