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
| Name | Type | Description |
|---|---|---|
| $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\GatePassageRequests 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
| Name | Type | Description |
|---|---|---|
| $gate | Milpa\Workflow\Entities\GateDefinition | |
| $entityType | string | |
| $entityId | int | |
| $requesterId | string | |
| $fieldValues | (array<string, mixed> | null) |
InMemoryGateService::approvePassage()
public function approvePassage(Milpa\Workflow\Entities\GatePassage $passage, string $approverId, ?string $notes = null): Milpa\Workflow\Entities\GatePassageRecords 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
| Name | Type | Description |
|---|---|---|
| $passage | Milpa\Workflow\Entities\GatePassage | |
| $approverId | string | |
| $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\GatePassageRejects 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
| Name | Type | Description |
|---|---|---|
| $passage | Milpa\Workflow\Entities\GatePassage | |
| $rejectorId | string | |
| $reason | string |
InMemoryGateService::getApprovedPassagesForEntity()
public function getApprovedPassagesForEntity(string $entityType, int $entityId): arrayReturns 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
| Name | Type | Description |
|---|---|---|
| $entityType | string | |
| $entityId | int |
InMemoryGateService::waiveGate()
public function waiveGate(Milpa\Workflow\Entities\GateDefinition $gate, string $entityType, int $entityId, string $waiverId, string $justification): Milpa\Workflow\Entities\GatePassageApproves a gate passage without requiring its normal evidence/field requirements, recording the waiver and its justification, and audits it.
Parameters
| Name | Type | Description |
|---|---|---|
| $gate | Milpa\Workflow\Entities\GateDefinition | |
| $entityType | string | |
| $entityId | int | |
| $waiverId | string | |
| $justification | string |
Throws
NonWaivableGateException when the gate's `isWaivable` flag is false
InMemoryGateService::expireIfDue()
public function expireIfDue(Milpa\Workflow\Entities\GatePassage $passage, DateTimeImmutable $now): boolEL Ú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
| Name | Type | Description |
|---|---|---|
| $passage | Milpa\Workflow\Entities\GatePassage | |
| $now | DateTimeImmutable |