Treble, enforced: what happens when you put one binary in /system

· aosptreblegsibuild-systemcuttlefishvintf


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:

  1. Where does a file live? — partition ownership
  2. What may talk to what? — the interface surface
  3. 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.

Where it lives What talks to what Are they compatible build time require-artifacts-in-path Soong partition attributes product partition interface frozen AIDL interfaces System SDK check sysprop ownership check_vintf_all boot time SELinux split policy linker namespaces VINTF object assembled OTA time manifest ↔ matrix reconcile certification VTS — kernel and HALs sepolicy freeze tests CTS-on-GSI
Three questions, four moments. Most of the enforcement lands at build time, which is why the first Treble mechanism you meet is usually a makefile refusing to cooperate — 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:

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 attributesvendor: 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

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.