`artist:demote-expired-options` artisan command finds every
ArtistEngagement still in Option whose option_expires_at has passed,
transitions it back to Draft via the existing state-machine
(transitionStatus), and writes an `option_expired` activity entry
with the original expiry timestamp captured in properties so the
audit log distinguishes system-driven expiries from manual demotions.
Idempotency: the state-machine bails when the engagement is no longer
in Option, so a second run within the same minute is a no-op for any
given row. The auto-logged `updated` row + the explicit
`status_changed` + the `option_expired` entries are emitted only by
the run that actually performs the transition.
Scheduled in routes/console.php daily at 03:00 Europe/Amsterdam,
matching the existing nightly low-traffic window.
Notification (email project leader on demotion) is deferred to the
notification framework that lands post-Accreditation; tracked under
BACKLOG entry ART-DEMOTE-NOTIFICATION.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-attempt retry history (timestamp, user, outcome, exception detail
if failed) replaces the counter-only retry_count tracking.
Changes:
- New `form_submission_action_failure_retry_attempts` table (cascade on
parent delete, nullOnDelete on user). Explicit short FK names
(`fsafra_failure_fk`, `fsafra_user_fk`) — auto-generated names exceed
MySQL's 64-char identifier limit.
- New FormSubmissionActionFailureRetryAttempt model + factory +
succeeded() state.
- Parent FormSubmissionActionFailure gets retryAttempts() HasMany
relation (latest('attempted_at')).
- New FormFailureRetryService centralises the retry-flow logic. Both
the API controller and the artisan command delegate to it. Service
writes a retry_attempt record per attempt; parent's retry_count
stays as denormalised cache for index-view performance.
- Successful retry: attempt(succeeded) + parent.retry_count++ +
parent.resolved_at + parent.resolved_by_user_id + parent.resolved_note
("Geslaagde retry door {actor.name}" or "Geslaagde retry
(geautomatiseerd)" for command-line invocation without an actor).
- Failed retry: attempt(failed) with NEW exception details +
parent.retry_count++. Parent's exception_class/_message stay
audit-immutable — they represent the FIRST failure.
- canBeRetried() now correctly checks both resolved_at AND
dismissed_at (sessie 2's open question Q2 closure).
- New FailureNotRetriableException (controller → 422) and
ParentSubmissionGoneException (controller → 410) for cleaner
flow control.
12 new tests:
- FormSubmissionActionFailureRetryAttemptTest (5 unit tests)
- RetryFlowProducesRetryAttemptsTest (7 integration tests covering
succeeded path, failed path, resolved/dismissed blocking,
multiple-retries chronological ordering, canBeRetried truth tables)
Pre-existing tests touched:
- FormSubmissionActionFailureTest::test_can_be_retried_only_for_open_state
— updated to reflect Q2 closure (resolved now blocks too).
- Ws6FoundationMigrationTest::test_down_methods_clean_up_columns_and_table
— child table must drop before parent (FK constraint).
- 5 backfill test step-counts bumped +1 (new migration sits at top).
SCHEMA.md → v2.9. Schema dump regenerated.
Refs: RFC-WS-6.md §3 Q5 addendum, sessie 2 Q2
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three CLI commands for ops use, mirroring the API endpoints in Task 9:
- form-failures:retry (id|submission|org filter, --dry-run)
- form-failures:resolve (single id, optional note)
- form-failures:dismiss (single id, DismissalReasonType + note)
Cross-tenant isolation enforced via form_submissions.organisation_id
FK chain (RFC V3). retry_count incremented on retry; failure history
preserved (new row on repeat failure, not in-place mutation).
Refs: RFC-WS-6.md §3 (Q5), §4 (V2, V3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MigrateLegacyFormsData and VerifyFormsDataIntegrity exist to migrate the
pre-form-builder registration_form_fields / registration_field_templates
/ person_field_values tables into the current form_* tables. Those
legacy tables have been dropped from the dev database (verified via the
2026_04_20 drop_remaining_legacy_registration_tables migration), which
means the migrator's top-of-handle() guard always short-circuits the
run. The verify command is only reachable via
MigrateLegacyFormsData::verify() — also dead with its caller gone.
CLAUDE.md delete > adapt. These commands would also break the WS-5d
commit 5 column drop: MigrateLegacyFormsData:225 writes \$rff->options
straight to form_fields.options, which will not exist after commit 5.
Cleaning up before WS-5d starts keeps the dev tree consistent throughout
the refactor.
The two stale comment references in FormBuilderDevSeeder (header docblock
and seedSubmissionsForEvent docblock) plus the migration docblock that
mentions the migrator self-skip behaviour are scrubbed in the same
commit so no orphan references remain.
No production data exists; no migration safety net is being removed.
Tests: 1158 → 1158 green (no test coverage existed for these commands;
they were truly orphaned).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reduces the FormPurpose vocabulary from 22 variants + a `custom` escape
to the seven v1.0 purposes registered in the new PurposeRegistry.
- Purge migration deletes any form_schemas row whose `purpose` is not
in the v1.0 set (cascades through form_fields, form_submissions,
form_values, form_value_options, form_schema_sections,
form_submission_section_statuses, form_submission_delegations,
form_schema_webhooks, form_webhook_deliveries via existing FK).
- Drop migration removes the `custom_purpose_slug` column + its index.
- Both migrations declare their `down()` as a hard failure — we do not
support reversing a purge (pre-launch, no production data).
- `FormPurpose` enum slims to the seven cases; the legacy helpers
(defaultSubmissionMode / defaultSubjectType / allowsPublicAccess)
now delegate to PurposeRegistry so callers keep working.
- FormSchema fillable / FormSchemaResource / StoreFormSchemaRequest /
UpdateFormSchemaRequest / FormSchemaFactory drop every reference to
`custom_purpose_slug` and the `custom` purpose.
- VerifyFormsDataIntegrity drops the custom-slug mismatch check and
sources the subject-type allow-list from PurposeRegistry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FormValueObserver: value_indexed is filter-driven per ARCH §4.4, not
hint-driven. Populating it for every string-hint field produced dead
weight in the partial index and made FilterQueryBuilder logic murkier.
Behaviour after fix:
hint=string, is_filterable=true → populate value_indexed
hint=string, is_filterable=false → leave null
hint=number/date/bool, any filterable → populate typed column (unchanged)
hint=json, any filterable → leave typed columns null (unchanged)
value_number / value_date / value_bool remain hint-driven — they serve
display and sorting beyond filtering. Only value_indexed is gated.
VerifyFormsDataIntegrity: "value_indexed set on non-filterable field"
is now a FAIL (was WARN) — it means the observer didn't run correctly,
which is a real integrity issue.
Observer tests: split the old "string hint populates value_indexed"
case into filterable/non-filterable pair. Full suite 911/911.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
forms:migrate-legacy-data {--dry-run} {--verify-only}
Per-org transaction (outer loop); inside each org, one form_schema per
distinct event_id in registration_form_fields, one form_field per legacy
field (with lowercase→uppercase field_type mapping and PII heuristic),
one form_submission per distinct person_field_values author, one form_value
per legacy row. form_templates derive schema_snapshot in ARCH §4.6.1 shape.
Idempotent via existence checks; skips if registration_form_fields absent.
Wrapped in App\Support\ActivityLog::suppressed() so --dry-run and re-runs
don't storm the activity log.
forms:verify-data-integrity {--strict}
Nine coherence checks: schemas/fields/submissions/values/user_profiles
structure, data migration counts (skipped when legacy tables absent),
orphans, section/schema relation consistency, and strict reachability
(opt-in). Runs all checks to completion; exit 1 on any failure.
Validates binding JSON against config/form_binding.php registry and
field_type against FormFieldType::values() ∪ custom_field_types config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>