Frozen: when an AIDL interface earns a version number
Removing one line from an Android.bp is not usually a design decision.
unstable: true reads like a placeholder — something you write while an
interface is in flux and delete once it settles. I deleted it, and the build
stopped before ninja had started: every consumer in the tree now depended on a
module that no longer existed.
That’s the cheap half. The expensive half is that deleting that line signs a commitment the build system won’t let you cleanly revoke, and that — if the interface ever ships on a device — you genuinely cannot revoke at all.
There’s plenty written about how to freeze an AIDL interface. Much less about when you should, and the answer turns out not to be “when the API is ready.”
The line that renames everything
An unstable interface generates libraries named after itself: foo.bar-ndk,
foo.bar-java. A versioned one generates foo.bar-V1-ndk. The suffix appears
the moment the interface becomes versioned — which is when you delete
unstable: true, not when you later freeze anything. V1 at that point is the
unfrozen next version, and it exists whether or not aidl_api/ does.
So the intuitive order fails. You cannot freeze first and fix consumers afterwards, because Soong evaluates the entire module graph before generating any build rules, and a dangling dependency is a hard analysis error:
error: vendor/…/Android.bp:1:1: "my-daemon" depends on undefined module "foo.bar-ndk".
Or did you mean ["foo.bar-V1-ndk"]?
The freeze target is downstream of the analysis that’s refusing to complete. This shape recurs throughout AIDL’s build integration — the command that would repair the state sits behind the check that’s blocking it — and it’s worth recognising early, because your instinct will be to run the fixer.
For the record, the way through was prosaic: the deletion and the consumer
renames to -V1-ndk travel in one change, m foo.bar-update-api creates the
first dump, and the freeze itself waits for a release. There is a correct
order — it just isn’t the interesting question. The interesting question is
whether to start down this path at all.
What a version number is actually protecting
One thing, and it’s narrow enough to state precisely.
Transaction codes are positional. The first method in the file is code 1, the second is code 2, and the generated proxy and stub agree on that purely by ordering. Insert a method in the middle rather than appending, and every subsequent code shifts by one. Nothing in the source looks wrong. On a device where an old client meets a new server, a call to code 2 arrives at a method that isn’t the one it meant — plausible arguments, different meaning, no error anywhere.
A frozen interface turns that into a build failure with a diff. An unstable one turns it into a field failure with a bug report six months later.
Freezing does not make an interface good, stable, or well designed. It makes one specific class of mistake — silently changing what a wire slot means — loud and early. Everything else it costs you should be weighed against that single benefit.
One dividend does come along with the guarantee: a versioned interface grows
getInterfaceVersion() and getInterfaceHash() on every proxy — a runtime
answer to which contract does the other side actually implement? That’s not
a reason to freeze, but it’s what makes living with several frozen versions
practical, and it returns below, when the other side belongs to someone else.
The criterion: update units, not partitions
The question is not whether the API has settled. It’s: can both sides be rebuilt together, always?
If yes, versioning buys you nothing. The compiler already catches every change, because every client recompiles against the new definition on every build. You pay the freeze ritual and receive a guarantee you already had.
If no — if there’s any seam where one half can be replaced while the other stays put — versioning is the only thing standing between you and a wire mismatch nobody will diagnose quickly.
This is not a marginal case. In the Android 15 tree, 211 Android.bp files
declare an aidl_interface; 77 of them mark one unstable: true. Better
than a third of the interfaces in AOSP are deliberately unversioned, and it
isn’t sloppiness — it’s the correct call whenever both ends ship as one unit.
Test interfaces are the obvious case: cts/tests/tests/binder_ndk/libbinder_ndk_test and
packages/services/Car/tests/android_car_api_test/test_aidl are built from the
same commit as the thing they exercise, forever.
The sharp case is packages/modules/Virtualization/libs/compos_aidl_interface,
which is unstable and lives inside a Mainline module. That looks like a
contradiction until you notice that both ends of that interface ship inside the
same APEX and update together. The boundary that matters is the update unit,
not the partition it happens to sit on.
Where the build decides for you
Three conditions override the judgement call, and each is enforced in
system/tools/aidl/build/.
stability: "vintf". Mutually exclusive with unstable —
aidl_interface.go:974-982 errors with “unstable:true and stability:%q cannot
happen at the same time”. Any HAL crossing the Treble boundary lands here,
because the vendor image genuinely can be older than the system image.
Release branches. checkRequireFrozenAndReason() at
aidl_interface.go:928-950 returns true for every ownerless interface when
AIDL_FROZEN_REL is set, with the reason spelled out in the error: “this is a
release branch (simulated by setting AIDL_FROZEN_REL) - freeze it or set
‘owner:’”. So “it’s internal, unstable is fine” holds right up until the tree
is built as a release.
An owner on the list. Note the escape hatch inside that same message.
Interfaces with owner: set are exempt from the release-branch requirement
unless the owner appears in AIDL_FROZEN_OWNERS (:945-946). That is AOSP
explicitly decoupling a third party’s freeze cadence from the platform’s, and
it’s used in practice — owner: "intel" appears on 20 modules in
external/parameter-framework.
frozen: is an assertion, not a record
Here’s the part that will cost you an afternoon.
After the first freeze, bpmodify writes frozen: true into your
Android.bp. It looks like a note recording what happened. It is a claim,
re-verified on every build, that nothing has changed since. From
aidl_api.go:533:
if m.isFrozen() {
// Throw an error if checkapi returns WITH differences
msg := ... "can not be marked `frozen: true` because there are changes "
hasDevCommand.Text(fmt.Sprintf("2> /dev/null || ( %s && exit -1) && echo 0 >", msg))
}
The rule runs aidl --checkapi=equal between the last frozen dump and the
current source, and demands they be identical. A perfectly legal,
backward-compatible append is still a difference, so the assertion fires — and
because the has_development output feeds the interface’s own build actions,
it fails before the freeze target can run. Same shape as before.
frozen: false is an assertion too, and the opposite one (aidl_api.go:525):
“can not be marked frozen: false if there are no changes or different
imports between the current version and the last frozen version.” Three
meaningful states, then:
frozen: |
Claims | Build fails when |
|---|---|---|
true |
source matches the last frozen version | any change exists |
false |
source differs from it | no change exists |
| absent | nothing | never — it just records the result |
The absent state is why a first freeze goes through quietly and the second one doesn’t: by then the property exists and is making a claim.
One piece of noise worth pre-empting. That generated command ends in
exit -1, which isn’t valid POSIX, so dash reports exit: Illegal number: -1
right underneath the real error. It’s cosmetic — the rule had already failed —
but it looks like the failure when you first meet it.
Append-only, in both directions
Backwards is closed. aidl_interface.go:1032-1040 refuses the combination:
“The interface is configured as unstable, but API dumps exist under %q.
Unstable interface cannot have dumps.” Reverting to unstable means deleting
aidl_api/ outright — an explicit act of destroying the record, not a
configuration change.
Forwards, nothing is ever removed. I scanned every aidl_api directory in
the tree: not one has a gap, not one starts anywhere but 1. Nobody has ever
deleted a frozen version — not once, anywhere.
Soong would let you. Versions must be unique, positive and sorted
(aidl_interface.go:828-848), but there’s no contiguity check, so dropping
{version: "1"} from versions_with_info parses fine. It also drops a
constraint: the compatibility chain verifies X against X-1 across the listed
versions (aidl_api.go:592), so removing V1 leaves V2 verified against
nothing. You’d delete the record and the check that depended on it in one edit.
The tone of the guard rails tells you how the AIDL maintainers feel about
tampering. This is the real error text when a frozen version is missing its
hash (aidl_api.go:609):
DANGER: this should not normally happen. If an interface is changed downstream, it may cause undefined behavior, test failures, unexplained weather conditions, or otherwise broad malfunction of society. DO NOT RUN THIS COMMAND TO BREAK APIS. DO NOT!
You retire the requirement, not the version
So how does a version ever go away? It doesn’t. What goes away is the obligation to implement it, and that lives somewhere else entirely.
For anything VINTF, the lever is the lower bound of the <version> range in
the framework compatibility matrix:
<!-- compatibility_matrix.8.xml --> <version>1-2</version>
<!-- compatibility_matrix.202504.xml --> <version>3-4</version>
That’s android.hardware.health. Versions 1 and 2 are decommissioned: a device
launching at the newer FCM level must implement 3 or 4, and VTS enforces it.
The same diff retires thermal 1 → 3, graphics.composer3 2 → 4, power
4 → 5-6, and every radio.* interface 2 → 3-4.
Now look at what happened to the retired definitions:
hardware/interfaces/health/aidl/aidl_api/android.hardware.health/
1 2 3 current
versions_with_info: [ {version: "1"}, {version: "2"}, {version: "3"} ]
Still there. All of them. Nothing was deleted and the interface was not edited — a new matrix file was added alongside the old ones, and every device continues to be validated against its own launch level.
That’s the model worth internalising: the definition is append-only; the requirement is a moving window over it. Two separate artifacts, changed by two separate acts.
Within a live version, the softer tool is @deprecated — 92 frozen dump files
in the tree carry one (194, once you count the unfrozen current dumps), e.g.
“@deprecated As of android.hardware.graphics.allocator-V2
in combination with AIMAPPER_VERSION_5 this is deprecated & replaced with
allocate2 …” It generates warnings in every backend and communicates intent,
but the wire slot stays occupied permanently, because AIDL never reuses a
transaction code. Deprecation is documentation with compiler support, not
removal.
When the other side is a customer
If you ship an interface into somebody else’s image, the update-unit test is answered structurally: no, you cannot rebuild both sides together, ever. Your release train and theirs are independent by definition. Freezing is mandatory and there’s nothing to deliberate.
The interesting question becomes whether versioning is then a per-customer decision. It isn’t, and letting it drift into one is how a shared interface becomes N forks with a shared build system. Three things get conflated:
- The contract — methods, types, wire order. One linear, append-only history. Never per-customer.
- The support window — which versions you keep implementing and testing. This is where customer production and OTA lifecycles live.
- Feature variation — what a given integration can actually do. Negotiated at runtime as capabilities, never encoded as a version number.
AOSP already separates exactly these three, and the separation is free to copy:
aidl_api/ is the contract, the FCM levels are the support window, the device
manifest is the per-device declaration. Google never edits an interface to
retire a version — they add a matrix.
Concretely: customer A launches on V3 and goes to production; customer B needs
a new method. You append it and freeze V4. B ships it; A’s integration is
untouched — still building against V3, still covered by the V3↔V4
compatibility check. And when a V4 binary meets a V3 peer in the field, the
dividend from earlier is what makes the meeting safe: a client built against
V4 asks the remote what it implements — getInterfaceVersion() returns 3 —
and stays inside the V3 subset instead of throwing transactions at slots that
don’t exist. A long support window is livable because discovering the peer’s
version costs one call.
The failure mode is worth naming out loud, because it arrives gradually: version numbers that encode which customer instead of which wire format. The symptom is that nobody can answer “is V4 compatible with V3” without first asking whose V4. Automotive makes it acute, since a ten-year OTA window means the real constraint isn’t how you number versions but how many you must keep buildable and tested from a single tree at once. That argues for a long support window and strict append-only discipline — not for a contract per customer.
What I actually took away
I’d been reading unstable: true as a maturity signal — a temporary state that
a serious interface eventually graduates from. It isn’t. It’s a factual claim
about topology: everything that speaks this interface is rebuilt together. For
better than a third of AOSP’s interfaces that claim is simply true, and
“graduating” them would be pure cost.
The version number isn’t a badge for the interface. It’s a promise to whoever is on the other side of a seam you can’t rebuild across. Which gives the rule I’d now apply without much further thought:
Freeze when someone you cannot rebuild is on the other end. Stay unstable when you can prove there isn’t.
Reading list
system/tools/aidl/build/aidl_interface.go—checkRequireFrozenAndReason()at:928, the unstable/stability exclusion at:974, version validation at:828.system/tools/aidl/build/aidl_api.go— the twofrozen:assertions around:525-537, the X-against-X-1 chain at:592, the hash guard at:609.system/tools/aidl/build/message_check_compatibility.txt— what the incompatibility failure prints, and worth reading once before you cause one.hardware/interfaces/compatibility_matrices/— diff two matrix files and read the version ranges; it’s the clearest statement of Android’s compatibility policy in the tree.- AIDL interface versioning and the VINTF object on source.android.com.
For where this boundary gets enforced everywhere else — the build system, the linker, sepolicy, the OTA client — the earlier post on Treble as a set of tripwires covers the rest of the same contract.