ADR 232 · Accepted

An editor's budget status is public API

Context

The AI Tasks module shows an editor their own usage today and this month against the limits of their budget record. One read model produces it: UserBudgetStatusService::forUser() returns a UserBudgetStatus of two BudgetWindowStatus objects with three BudgetMeter each, formatted by LocalizedNumberFormatter in the reader's locale. All five classes were @internal.

Other extensions show the same status to the same editor. The cowriter (netresearch/t3-cowriter) does it in its dialog and calls forUser() and LocalizedNumberFormatter::forBackendUser() directly. Under @internal these may change in any release, including a patch release, and forUser() already changed its signature between 0.41.8 and 0.41.9. A consumer that accepts every patch release of a minor line would then break without a version conflict.

Decision

The five classes are @api (ADR-127):

  • Netresearch\NrLlm\Service\Budget\UserBudgetStatusService, the entry point. Consumers obtain it from the DI container; its constructor is frozen in the snapshot but out of contract, and its parameter types (the repository and the usage-window reader) stay @internal, as api-stability states for every service.

  • Netresearch\NrLlm\Domain\ValueObject\UserBudgetStatus, BudgetWindowStatus and BudgetMeter, the value objects forUser() returns, including their toArray() shapes.

  • Netresearch\NrLlm\Service\Formatting\LocalizedNumberFormatter, which forUser() takes and BudgetMeter carries.

This is the signature-transitive closure of forUser(): no further Netresearch\NrLlm type appears in a method or property signature of the five. The snapshot test asserts that.

Nothing changes in behaviour or signatures. The change is additive: five classes are added to Tests/Unit/Api/api-surface.txt.

Consequences

  • Within a minor line the read model keeps its signatures and its toArray() shapes; during 0.x a break needs a minor release and a BREAKING changelog entry.

  • UserBudgetStatusService has no interface. A consumer that wants to replace it in a test builds a UserBudgetStatus itself, which the value-object constructors allow.

  • BudgetMeter::$formatter and BudgetWindowStatus::fromUsage() are part of the surface. fromUsage() takes the usage array in the shape the internal usage-window reader returns; that shape is now frozen with it.

Alternatives considered

  • A new facade that returns plain arrays. Consumers would get a second read path next to the one the AI Tasks module uses, and the two could drift. Promoting the existing model keeps one.

  • Keeping the classes internal and asking consumers to pin a patch release. That moves the cost to every consumer and still gives no promise.

← All ADRs