TenantAtlas/apps/platform/app/Support/Providers/Boundary/ProviderBoundarySeam.php
ahmido bd26e209de
Some checks failed
Main Confidence / confidence (push) Failing after 57s
feat: harden provider boundaries (#273)
## Summary
- add the provider boundary catalog, boundary support types, and guardrails for platform-core versus provider-owned seams
- harden provider gateway, identity resolution, operation registry, and start-gate behavior to require explicit provider bindings
- add unit and feature coverage for boundary classification, runtime preservation, unsupported paths, and platform-core leakage guards
- add the full Spec Kit artifact set for spec 237 and update roadmap/spec-candidate tracking

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderBoundaryClassificationTest.php tests/Unit/Providers/ProviderBoundaryGuardrailTest.php tests/Feature/Providers/ProviderBoundaryHardeningTest.php tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderGatewayTest.php tests/Unit/Providers/ProviderIdentityResolverTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- browser smoke: `http://localhost/admin/provider-connections?tenant_id=18000000-0000-4000-8000-000000000180` loaded with the local smoke user, the empty-state CTA reached the canonical create route, and cancel returned to the scoped list

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #273
2026-04-24 21:05:37 +00:00

150 lines
4.8 KiB
PHP

<?php
namespace App\Support\Providers\Boundary;
use InvalidArgumentException;
final class ProviderBoundarySeam
{
public const string FOLLOW_UP_NONE = 'none';
public const string FOLLOW_UP_DOCUMENT_IN_FEATURE = 'document-in-feature';
public const string FOLLOW_UP_SPEC = 'follow-up-spec';
/**
* @param list<string> $implementationPaths
* @param list<string> $neutralTerms
* @param list<string> $retainedProviderSemantics
*/
public function __construct(
public readonly string $key,
public readonly ProviderBoundaryOwner $owner,
public readonly string $description,
public readonly array $implementationPaths,
public readonly array $neutralTerms,
public readonly array $retainedProviderSemantics,
public readonly string $followUpAction,
) {
$this->validate();
}
/**
* @param array{
* owner?: string,
* description?: string,
* implementation_paths?: list<string>,
* neutral_terms?: list<string>,
* retained_provider_semantics?: list<string>,
* follow_up_action?: string
* } $attributes
*/
public static function fromConfig(string $key, array $attributes): self
{
$owner = ProviderBoundaryOwner::tryFrom((string) ($attributes['owner'] ?? ''));
if (! $owner instanceof ProviderBoundaryOwner) {
throw new InvalidArgumentException("Provider boundary seam [{$key}] has an invalid owner.");
}
return new self(
key: $key,
owner: $owner,
description: (string) ($attributes['description'] ?? ''),
implementationPaths: self::stringList($attributes['implementation_paths'] ?? []),
neutralTerms: self::stringList($attributes['neutral_terms'] ?? []),
retainedProviderSemantics: self::stringList($attributes['retained_provider_semantics'] ?? []),
followUpAction: (string) ($attributes['follow_up_action'] ?? self::FOLLOW_UP_NONE),
);
}
public function isProviderOwned(): bool
{
return $this->owner === ProviderBoundaryOwner::ProviderOwned;
}
public function isPlatformCore(): bool
{
return $this->owner === ProviderBoundaryOwner::PlatformCore;
}
public function retainsProviderSemantics(): bool
{
return $this->retainedProviderSemantics !== [];
}
public function documentsProviderSemantic(string $term): bool
{
return in_array($term, $this->retainedProviderSemantics, true);
}
public function coversPath(string $path): bool
{
$normalizedPath = $this->normalizePath($path);
foreach ($this->implementationPaths as $implementationPath) {
if ($normalizedPath === $this->normalizePath($implementationPath)) {
return true;
}
}
return false;
}
/**
* @param array<mixed> $values
* @return list<string>
*/
private static function stringList(array $values): array
{
return array_values(array_filter(
array_map(static fn (mixed $value): string => trim((string) $value), $values),
static fn (string $value): bool => $value !== '',
));
}
private function validate(): void
{
if (trim($this->key) === '') {
throw new InvalidArgumentException('Provider boundary seam key cannot be empty.');
}
if (trim($this->description) === '') {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] must include a description.");
}
if ($this->implementationPaths === []) {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] must include implementation paths.");
}
if ($this->isPlatformCore() && $this->neutralTerms === []) {
throw new InvalidArgumentException("Platform-core provider boundary seam [{$this->key}] must include neutral terms.");
}
if ($this->retainsProviderSemantics() && $this->followUpAction === self::FOLLOW_UP_NONE) {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] retains provider semantics without a follow-up action.");
}
if (! in_array($this->followUpAction, $this->validFollowUpActions(), true)) {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] has an invalid follow-up action.");
}
}
/**
* @return list<string>
*/
private function validFollowUpActions(): array
{
return [
self::FOLLOW_UP_NONE,
self::FOLLOW_UP_DOCUMENT_IN_FEATURE,
self::FOLLOW_UP_SPEC,
];
}
private function normalizePath(string $path): string
{
return trim(str_replace('\\', '/', $path), '/');
}
}