ADR-127: A marked, versioned API surface
- Status
-
Accepted
- Date
-
2026-08-05
Context
The roadmap asks for a "public versioned API surface". The extension already
has most of the ingredients: ADR-028/065/101 govern which services are
container-public, the Documentation/Api/ pages describe the consumer
services, and semantic versioning is practised in releases. What is missing is
the identity of the API: nothing in the code says which classes the semver
promise covers. A downstream developer whose autocompletion offers
Agent next to
Completion has no
signal that one is a contract and the other an implementation detail that may
vanish in a minor release.
Decision
Every class-level docblock carries one of three markers, and the marker — not the container visibility, not the documentation — is the authority on what semver covers:
- ``@api`` — the consumer surface. Calling these is covered by semver:
no removal, no signature break, no behavioural contract break within a
major version. Membership is the signature-transitive closure of the
entry points: every type that appears in an
@apimethod signature is itself@api(the response objects, option classes, value objects and typed exceptions a caller necessarily touches). A hand-curated list inevitably drifts; the closure rule is checkable. - ``@api Extension point`` (the marker's literal casing) — interfaces and attributes third parties implement rather than call (tool, guardrail, provider, translator, search-backend, preset, evaluation and middleware contracts). These carry a stricter promise, forced by the direction of implementation: no new abstract member within a major version, because adding one breaks every existing implementor, not just callers.
- ``@internal`` — everything else, explicitly. Controllers, widgets, hooks, upgrade wizards, commands, DI passes, form elements, repositories and the setup wizard may change without notice in any release.
What this deliberately is not
- Not a phpat rule. phpat selects by namespace and inheritance, not by
docblock tag, and cannot assert "signature types of
@apimethods are@api". The closure property is instead asserted by the API snapshot test (follow-up to this ADR): the snapshot renders every@apisignature, so an out-of-closure type surfaces as an unmarked name in a rendered signature. - Not a change to container visibility.
public: trueinServices.yamlremains governed by ADR-028/065; ADR-101 remains the count authority. The two sets overlap but are not equal: a service can be container-public for a TCAitemsProcFunc(Category E) and still@internal, and a value object can be@apiwithout being a service at all. - Not a compatibility promise for protected members. The promise covers
what a consumer calls and what an implementor must provide. Subclassing
internals of
@apiclasses is out of contract.
Consequences
- 128 classes are
@api(85 entry points and hand-verified members plus 43 added by running the closure to its fixpoint), 20 are extension points, and every previously unmarked class in the internal directories carries@internal(82 new markers; the rest existed). IDEs and PHPStan surface@internalusage from outside the package. Documentation/Api/Stability.rststates the promise in consumer terms and is the first page of the API reference.- A follow-up PR adds the snapshot test that freezes the rendered
@apisignatures in-repo, so an unintended break fails CI before review. - New code must pick a marker at creation time; the snapshot test's file list makes an unmarked new public-namespace class visible in review.