Treble, enforced: what happens when you put one binary in /system
The task was three lines long: write a native daemon, add it to
PRODUCT_PACKAGES, rebuild. I did that, and the build stopped with an error
naming car_generic_system.mk — a file I had never opened, in a project I
wasn’t aware I was part of.
That error is the visible tip of something I’d only ever read about in architecture diagrams. Treble gets described as a design: split the framework from the vendor code, define an interface between them, let the two halves update independently. What the diagrams don’t convey is that the design is backed by a surprising number of tripwires, scattered across the build system, the linker, SELinux and the test suites, all quietly enforcing the same contract. You don’t meet them until you step on one.
Here’s the map I wish I’d had, starting with the one I tripped.
The framing
Every Treble mechanism is answering one of three questions:
- Where does a file live? — partition ownership
- What may talk to what? — the interface surface
- Are the two halves compatible? — version negotiation
And each is checked at some combination of four moments: build time, boot time, OTA time, and certification. The whole point is that you should be able to replace one half without touching the other; booting a Generic System Image is just the empirical test of whether you succeeded.
require-artifacts-in-path, highlighted, is the one this post starts with. Only the compatibility question is asked at all four moments, because it’s the one that can still be wrong long after the build succeeded.1. Where things live: the artifact path requirement
The one I hit. In build/make/core/product.mk:542:
# Specifies a number of path prefixes, relative to PRODUCT_OUT, where the
# product makefile hierarchy rooted in the current node places its artifacts.
# Creating artifacts outside the specified paths will cause a build-time error.
define require-artifacts-in-path
Only three makefiles in the entire tree call it. The one that matters for
an automotive target is car_generic_system.mk:
# System.img should be mounted at /, so we include ROOT here.
_my_paths := \
$(TARGET_COPY_OUT_ROOT)/ \
$(TARGET_COPY_OUT_SYSTEM)/ \
$(call require-artifacts-in-path, $(_my_paths),)
(generic_system.mk does the identical thing for the phone stack.) That is
a claim of ownership: the makefile subtree rooted here owns root/ and
system/.
build/make/core/artifact_path_requirements.mk then runs two checks per
claim, pointing in opposite directions — and this is the part that took me
a while to see, because they use different allow-list variables:
- Inward: files installed by the claiming subtree that land outside its declared paths. “Stay in your lane.”
- Outward: files installed by everything else in the product that land inside those paths. “Nobody else may enter my lane.”
My daemon was the second kind. It installs to system/bin/, which
car_generic_system.mk owns, and it arrives from a makefile that isn’t part
of that subtree. Hence:
<product> produces files inside car_generic_system.mks artifact path requirement.
The escape is a declared exception:
PRODUCT_ARTIFACT_PATH_REQUIREMENT_ALLOWED_LIST += \
system/bin/<my-daemon>
I felt briefly dirty writing that, until I found Cuttlefish doing exactly
the same thing to itself in device/google/cuttlefish/shared/device.mk —
allow-listing system/bin/logcatd and friends because it wants a debug
logger in /system. The mechanism isn’t there to forbid the thing. It’s
there to make each exception written down and reviewable, instead of
accumulating silently until nobody can say what’s device-specific anymore.
One genuinely surprising detail: the strictness knob,
PRODUCT_ENFORCE_ARTIFACT_PATH_REQUIREMENTS, doesn’t do what its relaxed
value suggests. Reading the conditional, relaxed and true both
hard-fail on files in someone else’s lane. The only difference is that
true additionally errors on redundant allow-list entries — so if you
later delete a module and forget to delete its exception, true tells you
and relaxed doesn’t. Only false actually turns the check off. I had
assumed relaxed meant “warn”; it doesn’t, and my automotive target ships
true while the phone target ships relaxed, which is a difference in
tidiness enforcement rather than in permissiveness.
The deeper lesson is that the allow-list is the wrong long-term answer. If
the file is device-specific, it belongs on /product or /vendor — mark it
product_specific: true in Android.bp and it’s outside the generic
system’s lane entirely, no exception needed.
2. Where things live, continued
Two more mechanisms in the same family:
Soong partition attributes — vendor: true, product_specific: true,
system_ext_specific: true, device_specific: true. The declarative
version of the same idea: instead of installing somewhere you shouldn’t and
apologising in a makefile, say up front which partition owns the module.
PRODUCT_ENFORCE_PRODUCT_PARTITION_INTERFACE — makes /product a
first-class Treble citizen rather than a dumping ground. With it on,
modules there may only use stable interfaces and public/system APIs, not
hidden ones. It’s the vendor-interface concept applied to the OEM
customization partition, and it exists because /product was rapidly
becoming the place where partition discipline went to die.
All of this serves the Shared System Image: one system.img reused
across many device SKUs, with the per-device delta confined to
/product and /system_ext.
3. What may talk to what
The largest category, and where Android has changed most recently.
Stable AIDL HALs. Vendor and framework interact only through versioned, frozen interfaces. HIDL was the original vehicle; AIDL took over from Android 10. Freezing is the actual enforcement — once an interface version ships, its signature can’t drift underneath the other half.
Vendor API level. This one caught me out because every tutorial older
than about two years talks about VNDK, and VNDK is gone — deprecated
starting in Android 15, the release my tree is pinned to. No more
VNDK APEX, no more ro.vndk.version; former VNDK libraries just install
into /vendor or /product like any other vendor-available library. The
compatibility clock is now ro.vendor.api_level, which uses a YYYYMM
format rather than an SDK integer, and surfaces to C code as
__ANDROID_VENDOR_API__. The build system is emphatic about owning it —
from board_config.mk:
BOARD_API_LEVEL must not be set manually. The build system automatically sets this value.
Linker namespaces. /system/etc/ld.config.txt decides which shared
libraries a vendor process is even permitted to dlopen. This is the
mechanism that actually stops a vendor blob reaching into a framework-private
.so at runtime, as opposed to merely discouraging it at build time.
System SDK. Vendor Java apps compile against the System SDK, not the full internal surface.
Sysprop ownership. Properties are namespaced by partition, and
sysprop_library makes them typed and stable instead of a stringly-typed
free-for-all across the boundary.
SELinux split policy. plat_ versus vendor_ policy, with neverallow
rules encoding the boundary in a form that fails the build. system/sepolicy/tests/
holds the checkers, including a policy freeze test that refuses changes to
already-frozen public policy.
4. Are the halves compatible: VINTF
The version handshake, and the piece with the widest reach. Four documents, in two pairs:
a manifest describes what is provided, a compatibility matrix describes what is required
The device manifest pairs with the framework compatibility matrix; the framework manifest pairs with the device compatibility matrix. Vendor says what it offers and what it needs; framework says the same; the two are reconciled.
What makes VINTF interesting is that it’s checked at three separate
moments. At build time there’s a check_vintf_all target wired into
build/make/core/Makefile. At boot the VINTF object is assembled and
verified. And at OTA time the manifests and matrices are reconciled before
the update is allowed to apply — which is the mechanism that stops a
framework update landing on hardware that can’t support it. Kernel version
and config requirements ride in the same matrices, which is how GKI gets
enforced.
5. The proof: VTS and CTS-on-GSI
Everything above is a proxy for one question, and the test suites ask it directly. VTS exercises the kernel and the HAL implementations against the interface contract. CTS-on-GSI does the blunt version: replace the framework half with a generic system image and run CTS. If the device still passes, the separation was real. If it doesn’t, some enforcement above got skipped — or allow-listed one time too many.
The shortcut: read the escape hatches
Here’s the trick I’d offer anyone trying to learn this surface. The
BUILD_BROKEN_* variables in build/make/core/board_config.mk are
effectively a catalogue of the rules, because each one exists so that a
device can temporarily violate a specific check:
BUILD_BROKEN_TREBLE_SYSPROP_NEVERALLOW
BUILD_BROKEN_VENDOR_PROPERTY_NAMESPACE
BUILD_BROKEN_ENFORCE_SYSPROP_OWNER
BUILD_BROKEN_DONT_CHECK_SYSTEMSDK
BUILD_BROKEN_INCORRECT_PARTITION_IMAGES
BUILD_BROKEN_VINTF_PRODUCT_COPY_FILES
Read that list top to bottom and you’ve read the rulebook. Same move as
DISABLE_ARTIFACT_PATH_REQUIREMENTS, which switches off the check I started
with: find the off switch, and you’ve found the rule. It’s also a decent
proxy for which rules were painful enough that real devices needed an
exemption.
What I actually took away
Coming from fifteen years of app development, my instinct on hitting that
first error was to make it go away — and the allow-list did make it go away
in one line. The more useful reading is that the error was information: it
told me system/bin isn’t mine, that there’s a whole partition-ownership
model I’d been ignoring, and that the right home for my scratch code is
/product.
Treble isn’t enforced in one place. It’s enforced in the product config, in Soong, in the linker, in sepolicy, in the OTA client and in the test suites, each guarding a different failure mode of the same contract. Which is presumably the only way a boundary like this survives contact with a thousand OEMs.
Reading list
build/make/core/product.mk—require-artifacts-in-pathand friends.build/make/core/artifact_path_requirements.mk— the two checks, ~60 lines, worth reading in full.build/make/core/board_config.mk— theBUILD_BROKEN_*catalogue.- Shared system image — why the system partition is kept generic.
- Vendor API level — the post-VNDK compatibility clock.
- Vendor interface object — VINTF manifests and matrices.
If you want the mechanism underneath all of this, the previous post on
what adb remount actually does
covers the other half: how a supposedly read-only, verity-protected
/system becomes writable at all.