feat(form-builder): add PurposeSubjectResolver per purpose (WS-6)

Parallel interface to PurposeGuardProvider for runtime subject
resolution. Seven concrete resolvers, one per v1.0 purpose. Wired
through purposes.php via subject_resolver_class key.

EventRegistration uses PersonProvisioner (may create). Other purposes
resolve from existing context (portal token, production request, auth).
IncidentReport is the only purpose allowed to return null (anonymous-
allowed configurations); the others return concrete model types
(narrowed via PHP covariance) for caller convenience.

Refs: RFC-WS-6.md §3 (Q9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 12:57:21 +02:00
parent 47265e9d4f
commit 16a9265430
13 changed files with 606 additions and 0 deletions

View File

@@ -19,6 +19,12 @@ final class PurposeRegistry
/** @var array<string, PurposeGuardProvider> */
private array $guardProviderCache = [];
/** @var array<string, class-string<PurposeSubjectResolver>>|null */
private ?array $resolverClassCache = null;
/** @var array<string, PurposeSubjectResolver> */
private array $resolverInstanceCache = [];
public function __construct(private readonly ConfigRepository $config) {}
/** @return array<string, PurposeDefinition> keyed by slug */
@@ -33,6 +39,7 @@ final class PurposeRegistry
$definitions = [];
$guardClasses = [];
$resolverClasses = [];
foreach ($raw as $slug => $attrs) {
$mode = $attrs['default_submission_mode'] ?? null;
if (! $mode instanceof FormSubmissionMode) {
@@ -48,6 +55,13 @@ final class PurposeRegistry
);
}
$resolverClass = $attrs['subject_resolver_class'] ?? null;
if (! is_string($resolverClass) || ! is_subclass_of($resolverClass, PurposeSubjectResolver::class)) {
throw new \InvalidArgumentException(
"Purpose '{$slug}' has invalid subject_resolver_class; expected class-string implementing PurposeSubjectResolver."
);
}
$definitions[(string) $slug] = new PurposeDefinition(
slug: (string) $slug,
label: (string) ($attrs['label'] ?? ''),
@@ -57,9 +71,11 @@ final class PurposeRegistry
requiredBindings: array_values((array) ($attrs['required_bindings'] ?? [])),
);
$guardClasses[(string) $slug] = $guardsClass;
$resolverClasses[(string) $slug] = $resolverClass;
}
$this->guardClassCache = $guardClasses;
$this->resolverClassCache = $resolverClasses;
return $this->cache = $definitions;
}
@@ -84,6 +100,26 @@ final class PurposeRegistry
return $this->guardProviderCache[$slug] = $instance;
}
public function subjectResolverFor(string $slug): PurposeSubjectResolver
{
if (! $this->has($slug)) {
throw Exceptions\PurposeNotFoundException::forSlug($slug);
}
if (isset($this->resolverInstanceCache[$slug])) {
return $this->resolverInstanceCache[$slug];
}
/** @var array<string, class-string<PurposeSubjectResolver>> $classes */
$classes = $this->resolverClassCache ?? [];
$class = $classes[$slug];
/** @var PurposeSubjectResolver $instance */
$instance = resolve($class);
return $this->resolverInstanceCache[$slug] = $instance;
}
public function get(string $slug): PurposeDefinition
{
$all = $this->all();