Skip to content

0021. State a writer's capabilities in its type

Date: 2026-09-09

Status: Accepted

Context

ADR-0014 put the write side of an integration behind its own contract, and CFX-215 built it: create, update, deactivate, delete and an optional bulk ingest, over the canonical projection the delivery pipeline produces.

Not one of those five is unconditional. A target whose records are created elsewhere and are only enriched by Conflux offers no create. An append-only target — an event sink, an import endpoint, a queue — offers no update. Most targets have no inactive state at all: the first push target has hard deletion and nothing to deactivate against, which ADR-0016 already builds on. And a bulk path that returns no per-record identifier may not be used at all, per ADR-0015, so a writer may have one and still not offer it.

The contract therefore has to say, per writer, which verbs are actually there. The first implementation said it with data: a WriterLimits value object carrying a supportsCreate, supportsUpdate, supportsDeactivate, supportsDelete and supportsBulkIngest flag, plus a throwing default body for each verb on AbstractOutboundWriter, so a writer implemented only what it declared.

That shape has a state nothing prevents: a flag claiming a capability the writer never implemented. It is not hypothetical. Inside CFX-215 alone it produced a failure message naming the flag instead of the missing method, so a reader was sent to inspect the one thing that was correct; two verbs whose default bodies no test reached, found only by reading a coverage report; and a second test double whose entire reason to exist was making that state reachable. Each was repaired individually, which is the signal that the state itself was the defect rather than any one of its symptoms.

The flags cost something else that is easy to miss. if ($limits->supportsDelete) tells the type checker nothing about $writer, so the call inside the branch is unverified. The check and the thing it protects are two separate facts that happen to be written next to each other.

Decision

Which verbs a writer offers is stated in its type, not in a flag. Each verb is a capability interface in App\Contracts — CanCreateRemoteRecords, CanUpdateRemoteRecords, CanDeactivateRemoteRecords, CanDeleteRemoteRecords, CanIngestRemoteRecords — and a writer implements the ones its target has. OutboundWriterContract itself carries no verbs at all; what remains on it is requestsPerMinute().

A caller narrows before it writes:

if ($writer instanceof CanDeleteRemoteRecords) {
    $writer->delete($connection, $reference);
}

A writer cannot claim a verb it has not written, because PHP refuses a class that implements an interface without its methods. The "declared but not implemented" state is gone from the design rather than guarded against, and so is everything that existed to detect, report or test it. PHPStan narrows through the instanceof, so the call inside the branch is checked rather than hoped for.

Each interface is paired with a trait in App\Services\Concerns that supplies the public verb — final, routed through AbstractOutboundWriter::write(), so the circuit breaker (ADR-0009), the span (ADR-0011) and the mapping onto the ledger's single failure type cannot be dropped — and leaves one abstract protected perform*() method to the writer.

The WriterLimits object does not survive the change, and the two facts it held moved to where each of them holds. requestsPerMinute() is on OutboundWriterContract, because the pace a target tolerates is true of it whatever verbs it has. bulkIngestSuppressesSideEffects() is on CanIngestRemoteRecords, because it is a question about a bulk path and a writer without one has no meaningful answer to give.

That second move is the same correction as the first, one size down. A field on a shared object that only means something for a subset of writers is the flag problem again: WriterLimits would have carried a side-effect answer for every writer, including those that can never ingest. Interfaces carry it only where it is true. The side-effect answer also loses its default, which is a gain — a writer offering a bulk path now has to state what using it costs, and "cautiously assume the worst" was too convenient a way to leave that unanswered. The rate keeps one, returning null from the base class, because silence there is cheap: a writer that omits a rate its target does enforce gets rejected and retried, which is loud, where a wrong side-effect assumption is silent and arrives fifty thousand times.

The alternative that was not taken: keep the flags

Keeping supports* on WriterLimits is the smaller change, and it reads well at the call site. It was rejected because it makes the writer the author of two things that have to agree — a declaration and an implementation — with nothing checking that they do. Every mechanism that would check it is a runtime one: a throwing default, a message explaining which of the two is wrong, a double built to reach it, a test per verb to pin it. All of that is the cost of a state the type system can simply refuse to represent.

Consequences

write() and requireMatchingDomain() are protected rather than private. The traits are composed into the subclass and reach the base class from there. That is a real loosening: before, the public verbs were final on the base class and there was no way to reach a target without the breaker, the span and the error mapping. Now a writer could implement CanDeleteRemoteRecords, skip DeletesRemoteRecords, and write its own delete() that does none of it.

An architecture test pairs each interface with its trait, over app/ and tests/Support/ alike, and fails the build when one is used without the other. That is a rule where there used to be a language guarantee, and it is the price this decision pays. It is a deliberate trade: the guarantee that was lost protected against a mistake nobody has made, while the state that was removed produced four separate defects inside one ticket.

Adding a sixth verb costs an interface, a trait and a line in the architecture test, rather than a flag and a default body. Roughly the same work, spread over more files. Ten files now carry what five flags used to, which is the honest cost of the change.

Callers ask a different question. The rule policy in CFX-217 validates a delivery policy against what the target can do; it asks instanceof rather than reading a flag. The runner in CFX-216 does the same before it delivers. Neither can be handed a writer that answers yes and then fails.

Two tests were deleted because PHPStan rejected them as tautologies — that a writer without update has no update, and that a writer implementing an interface is an instance of it. This is the decision working as intended: those guarantees moved out of the test suite and into the checker, where they hold for every writer ever written rather than for the ones a test happened to name.