Skip to content
docsv0.1.5

InMemoryGateService

Non-Doctrine {@see GateServiceInterface} implementation: evaluates gates entirely in process memory, with **no `Doctrine\ORM\EntityManagerInterface` dependency at all** — constructor, properties, everything. It exists for zero-DB / event-sourced consumers (e.g. an `milpa/orchestrator` process replaying its own append-only event log) that cannot construct {@see GatePassageService} because they have no `EntityManagerInterface` to give it, and would otherwise have to hand-roll the D9 self-approval check and the waivability guard themselves (see the orchestrator greenhouse's `HumanGate` for exactly that reimplementation, and its report's "workflow Doctrine-vs-event-sourced finding" section for the full gap writeup). It returns the SAME {@see GatePassage} entity {@see GatePassageService} returns — no parallel VO was needed. `GatePassage`'s constructor and setters are plain PHP (Doctrine's `#[ORM\...]` attributes are metadata read only by an `EntityManager`, never by the entity itself — see `tests/Entities/GatePassageTest.php`'s own docblock for the same observation), so `new GatePassage()` works with zero Doctrine involvement as long as nobody calls `persist()`/ `flush()` on it or reads its Doctrine-generated `getId()` (which stays uninitialized and throws if touched — this class never calls it, using {@see GatePassage::getUuid()} instead wherever a stable identifier is needed, e.g. for {@see AuditLoggerInterface::log()}). **Scope of "in memory":** state (the distinct approvers recorded so far per passage, and the approved-passages-per-entity index) lives only for the lifetime of THIS service instance — there is no cross-process, cross-request, or cross-restart durability, by design (that's what "in memory" means). A caller needs to keep the same instance alive across the `requestPassage()`/`approvePassage()` calls it expects to correlate (e.g. as a request-scoped or long-lived singleton); a consumer that needs durable, replayable approval counts across process boundaries (a true event-sourced system) should record each approval as its own event in its own log and either call this service once per resolved decision, or not delegate multi-approval bookkeeping to it at all. See the package README's "Gate services" section. **`ApprovalPolicy` handling:** {@see GatePassageService} stores a gate's `ApprovalPolicy` but never reads it back — one `approvePassage()` call always finalizes the passage, regardless of policy. This class is the first to actually honor it: {@see \Milpa\Workflow\Enums\ApprovalPolicy::DUAL} (the only case with an unambiguous, non-zero `requiredApprovals()`) requires two DISTINCT approver principals — via two separate {@see self::approvePassage()} calls on the same passage — before the passage transitions out of `REQUESTED`; a single call records the approver and returns the still-pending passage. `SINGLE`, `QUORUM`, and `AUTO` all resolve on the first approval: `QUORUM` and `AUTO` report `requiredApprovals() === 0` (a dynamic quorum size / evidence-driven auto-approval, respectively), and since {@see GateDefinition} carries no quorum-size or evidence-submission-count field for this package to read, both fall back to requiring exactly one approval — the same behavior `GatePassageService` already gives every policy today. **Self-approval / waivability parity with `GatePassageService`:** {@see self::approvePassage()} throws {@see SelfApprovalException} when the approver equals the requester, exactly like {@see GateServiceInterface::approvePassage()}'s own contract requires — matching `GatePassageService`, {@see self::rejectPassage()} does NOT carry this guard (D9's own wording, and `GateServiceInterface`'s docblock, only ever bind self-approval to *approving*). {@see self::waiveGate()} throws {@see NonWaivableGateException} when the gate's `isWaivable` flag is false, identically to `GatePassageService::waiveGate()`.

InMemoryGateService::__construct()

public function __construct(?Milpa\Interfaces\Observability\AuditLoggerInterface $auditLogger = null):

Parameters

Parameters of __construct()
NameTypeDescription
$auditLogger?Milpa\Interfaces\Observability\AuditLoggerInterface

InMemoryGateService::requestPassage()

public function requestPassage(Milpa\Workflow\Entities\GateDefinition $gate, string $entityType, int $entityId, string $requesterId, ?array $fieldValues = null): Milpa\Workflow\Entities\GatePassage

Requests a new gate passage for the given gate and polymorphic entity, held only in this service instance's memory (never persisted) and auditing the request.

Parameters

Parameters of requestPassage()
NameTypeDescription
$gateMilpa\Workflow\Entities\GateDefinition
$entityTypestring
$entityIdint
$requesterIdstring
$fieldValues(array<string, mixed> | null)

InMemoryGateService::approvePassage()

public function approvePassage(Milpa\Workflow\Entities\GatePassage $passage, string $approverId, ?string $notes = null): Milpa\Workflow\Entities\GatePassage

Records an approval for a pending gate passage and audits it. The passage only leaves `REQUESTED` once enough DISTINCT approvers have called this method to satisfy the gate's `ApprovalPolicy` (see class docblock) — until then it is returned unchanged, still pending.

Parameters

Parameters of approvePassage()
NameTypeDescription
$passageMilpa\Workflow\Entities\GatePassage
$approverIdstring
$notes?string

Throws

SelfApprovalException when `$approverId` equals the passage's requester (D9)

InMemoryGateService::rejectPassage()

public function rejectPassage(Milpa\Workflow\Entities\GatePassage $passage, string $rejectorId, string $reason): Milpa\Workflow\Entities\GatePassage

Rejects a pending gate passage with a reason and audits the rejection. Unlike {@see self::approvePassage()}, this is not guarded against self-rejection — neither is {@see GatePassageService::rejectPassage()}, matching `GateServiceInterface`'s own contract, which binds the D9 self-approval rule to approving only.

Parameters

Parameters of rejectPassage()
NameTypeDescription
$passageMilpa\Workflow\Entities\GatePassage
$rejectorIdstring
$reasonstring

InMemoryGateService::getApprovedPassagesForEntity()

public function getApprovedPassagesForEntity(string $entityType, int $entityId): array

Returns every approved gate passage for the given polymorphic entity, most recent first — the in-memory counterpart of {@see GatePassageService::getApprovedPassagesForEntity()}'s `QueryBuilder`. Only passages resolved through THIS service instance are visible.

Parameters

Parameters of getApprovedPassagesForEntity()
NameTypeDescription
$entityTypestring
$entityIdint

InMemoryGateService::waiveGate()

public function waiveGate(Milpa\Workflow\Entities\GateDefinition $gate, string $entityType, int $entityId, string $waiverId, string $justification): Milpa\Workflow\Entities\GatePassage

Approves a gate passage without requiring its normal evidence/field requirements, recording the waiver and its justification, and audits it.

Parameters

Parameters of waiveGate()
NameTypeDescription
$gateMilpa\Workflow\Entities\GateDefinition
$entityTypestring
$entityIdint
$waiverIdstring
$justificationstring

Throws

NonWaivableGateException when the gate's `isWaivable` flag is false

InMemoryGateService::expireIfDue()

public function expireIfDue(Milpa\Workflow\Entities\GatePassage $passage, DateTimeImmutable $now): bool

EL ÚNICO SITIO donde un pase pasa de pendiente a vencido. Ver {@see GateServiceInterface::expireIfDue()} para por qué es un hecho y no una comparación.

Parameters

Parameters of expireIfDue()
NameTypeDescription
$passageMilpa\Workflow\Entities\GatePassage
$nowDateTimeImmutable