Frozen: when an AIDL interface earns a version number

· aospaidlbinderversioningvintftreble


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.

One update unit Two update units client server one build · one artifact · one OTA unstable: true client release train A server release train B frozen: true Seams that make two units system ↔ vendor the vendor image can be older Mainline module ↔ platform the module updates by itself your SDK ↔ their image they rebuild, you don't Frozen regardless of the above stability: "vintf" cannot coexist with unstable AIDL_FROZEN_REL release branches demand a version AIDL_FROZEN_OWNERS when your owner: is listed
The whole decision is the top row: does the interface stay inside one update unit, or does it cross a seam where one side can be replaced without the other? Everything below is detail — the three seams that really produce two units, and the three conditions under which the build takes the choice away from you regardless.

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:

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

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.