Findings
- Platform-signature requirement was a documentation artefact, not a real constraint.
- UART ownership survives re-signing under a project key.
- Product behaviour is largely data-driven and reachable without touching vendor logic.
Technical assessment Confidential Day 0 → v1.0
A vending appliance arrived with no source, no documentation, no vendor contact, and a
hardware plane that could not be allowed to move. The engagement recovered the
specification from the running unit — 33 MB of Kotlin compiled to
28,317 smali files — then shipped a bilingual Anytime Fitness front end and a new
creatine product ladder while holding YS_CONFIG.db md5-identical across
every deploy.
The appliance is a Chinese-market coffee/protein dispenser rebadged for a fitness operator. There is no vendor source tree, no API documentation, no upstream security stream, and the recipe/calibration plane is safety-relevant. The only workable path was to treat the running appliance as the specification: acquire everything over ADB, rebuild the APK through apktool, move product behaviour into data wherever possible, and confine new code to an owned extension dex that reaches the vendor app only through reflection.
/sdcard/YSConfig/YS_CONFIG.db.
This was never a rewrite. Recipes, calibration, MDB payment, and the serial framing stay
bit-for-bit intact. Every deploy fingerprints /sdcard/YSConfig/YS_CONFIG.db
before and after, and treats any difference as a hard stop rather than a warning.
Before any change could be justified, the unit had to be identified precisely: which binary is installed, who signed it, under what UID it runs, and which entry point the kiosk boots into.
| Property | Observed value | Source of truth |
|---|---|---|
| Installed APK | com.ys.vending.apk · 37,647,400 B | adb pull of base.apk |
| Version | V1.1.5-20260731.01 · versionCode 6 | manifest / filename |
| Application class | com.ys.coffeenew.CoffeeApp | AndroidManifest |
| Boot entry | ui.activity.SplashActivity | launcher intent filter |
| Active skin | skin.proteindispenser.ProteinActivity | runtime mCurrentFocus |
| Original signer | CN=Jiancheng Song, OU=TCN, O=TCN, L=Changsha, ST=Hunan, C=CN | apksigner verify |
| Original cert SHA-256 | 4d5c6950…09375a7e | apksigner verify |
| Signature scheme | v1 + v2 | apksigner verify |
| Reskin signer | CN=Anytime Fitness Vending, OU=Kiosk Reskin | project JKS |
| Runtime UID | u0_a54 | ps / /proc |
| Install location | /data/app/ | pm path |
Table 1 — Specimen provenance. Certificate digest abbreviated.
The manifest carries android:sharedUserId="android.uid.system" on the
<application> element. Android only honours that attribute on
<manifest>, so it is silently ignored: the app is an ordinary
/data/app install running as u0_a54. That single observation
converted the project from “requires the OEM platform key” to
“re-signable with our own keystore” — and it was confirmed empirically,
because the re-signed build still opens both UART nodes.
Day-zero findings defined what “safe change” could mean for the rest of the engagement. Two of them — the EOL runtime and the frozen hardware plane — constrained every later decision.
Android 7.1.2 with no public security stream since 2020. No modern permission model, no
scoped storage — the latter ironically required, because the entire catalog,
recipe and media tree sits on world-readable /sdcard/coffee.
Any emulation target must stay API 25 at 1080×1920 / 160 dpi, because the
skin is laid out in mdpi where dp and px coincide; a
higher-density AVD silently rescales every asset.
Vendor image rk3288_k518_zhonggu_v15, rooted userdebug build
signed with test-keys, TeamViewer Host preinstalled, brittle USB ADB and TCP
5555 frequently refused.
The BSP is opaque with no upstream patches. Root is what made forensics possible at all, and is simultaneously the largest item on the risk register.
| Risk class | Observation | Operational consequence | Status |
|---|---|---|---|
| OS end-of-life | API 25, no vendor security stream | Assume full-disk and ADB compromise in the threat model | Accepted |
| Root / test-keys | adbd as root, remount possible | Enables forensics; equally expands attacker surface | Inherited |
| Remote support | TeamViewer Host installed by OEM | Third-party channel entirely outside the app sandbox | Not removed |
| World-readable data | Recipes, prices, media on /sdcard | Any app or ADB session can rewrite the price of a drink | Inherited |
| Platform-key myth | sharedUserId on the wrong XML element | Initially appeared to block rebuild entirely; disproved | Resolved |
| Supply chain | TCN certificate, APK Signature Scheme v2 | Re-sign under project JKS viable; OEM trust not replaced | Resolved |
| No source | Kotlin/Java binary only, 4 vendor dex | apktool + jadx + extension-dex hooks as the only route | Mitigated |
| Raw UART | Unauthenticated framing to drink and MDB boards | Physical-trust assumption; deliberately left untouched | Out of scope |
Table 2 — Risk register as assessed on day 0 and its state at v1.0.
No vendor VPN, no Git history, no protocol book. Everything below had to be recovered from a running kiosk over a connection that dropped often enough to make each pull a discrete, verified step.
| Artefact | Device path | Why it was required | Handling |
|---|---|---|---|
| Installed binary | /data/app/…/base.apk | Only existing copy of the program | Pull, archive, never modify in place |
| Hardware config | /sdcard/YSConfig/YS_CONFIG.db | Ports, calibration, recipe physics | Read + md5 only |
| Catalog | /sdcard/coffee/database/coffee_db.db | SKUs, prices, formulas, ad rows, orders | Edited offline, pushed atomically |
| Translations | /sdcard/coffee/database/language_db.db | Every runtime UI string | Pull → SQL → push |
| DataStore protos | /sdcard/coffee/datastore/*.pb | Grid layout, own-cup, serial, calibration | Byte-level patch where unavoidable |
| Media trees | /sdcard/coffee/ads/ · productImage/ | Attract, half banners, product heroes | Replaced wholesale per language |
| Glide cache | /data/data/…/cache/ad_image_cache | Stale art survives file replacement | Cleared on every deploy |
| Runtime proof | /proc/<pid>/fd | Shows which UID actually owns the UART | Captured before and after re-sign |
| App log | /sdcard/coffee/log/coffee-core-<date>.log | Timber log, not visible in logcat | Pulled for failure analysis |
Table 3 — Acquisition ledger. Handling column records the policy, not just the action.
# the question: does an ordinary re-signed app still own the boards? $ adb shell pidof com.ys.vending 4127 $ adb shell ls -l /proc/4127/fd | grep ttyS lrwx------ 1 u0_a54 u0_a54 64 74 -> /dev/ttyS3 # drink board lrwx------ 1 u0_a54 u0_a54 64 75 -> /dev/ttyS2 # MDB payment
Why this is the pivotal observation. The OEM narrative implied the app needed system
privileges to reach the boards. Two file descriptors held by u0_a54 after a
re-signed install disproved it, and that is what made the entire reskin legitimate rather
than speculative.
The app is modern Kotlin MVVM compiled across four dex files. jadx recovers intent well enough to reason about; it does not round-trip, so every rebuild goes through apktool smali. The scale below is what any change had to be located inside.
| Tool | Version | Role in the chain | Round-trips? |
|---|---|---|---|
| apktool | 3.0.3 | Decode and rebuild resources + smali | Yes |
| jadx | 1.5.6 | Java-level reading and control-flow analysis | No |
| d8 | 34.0.0 | Compile owned classes to classes5.dex, --min-api 24 | n/a |
| Temurin JDK | 17.0.20 | Host for apktool, javac, signer | n/a |
| android.jar | android-25 | Sole compile classpath for owned code | n/a |
| uber-apk-signer | 1.3.0 | zipalign + v2/v3 signing | n/a |
| platform-tools | 37.0.1 | All device I/O | n/a |
| sqlite3 | scoop | Offline catalog and translation edits | n/a |
Table 4 — Pinned toolchain. Versions are fixed because apktool output is not stable across releases.
A single process owns the UI, the catalog reads, and both serial links. The reskin operates entirely in the upper two planes; the kernel UART plane is frozen by policy and verified by fingerprint on every deploy.
| Component | Plane | Responsibility | Reskin relationship |
|---|---|---|---|
| SerialPortManager | UART | Opens tty nodes, protocol select (COFFEE / MDB / 240131 / 171111) | Untouched |
| MdbManager | UART | MDB payment state machine over serial client | Untouched |
| ShipAndOrderManager | UART | Order fulfilment; sends product.formula to the board | Untouched |
| MachineStatusManager | UART | Machine state, own-cup wait, fault surfacing | Read only |
| AdPlayManager | Storage | Attract and half-banner rotation from the ads table | 1 hook |
| LanguageSDK | Storage | Runtime strings; exposes current skin language as a Flow | Label edit |
| ProductRepo | Storage | Room access, getProductsByCabinetIndex(int) | Reflected |
| ProductCustomVM | UI | Detail screen state, own-cup and sugar-box selection | 2 hooks |
| SkinActivityVM | UI | Selected product; the actual billing source of truth | Reflected |
| DataStoreManager | Storage | Protobuf settings read/write | Read only |
Table 5 — Vendor component map with the reskin's relationship to each.
The single most valuable finding of the engagement is that most product behaviour is configuration, not code. Recovering the schema turned features that looked like app changes into SQL and protobuf edits.
| Concern | Authoritative store | Consumer path | Change vector |
|---|---|---|---|
| Product grid and prices | coffee_db.product | Shop VM via Room | SQL |
| Hopper recipe | product.formula (JSON) | ShipAndOrderManager → ttyS3 | SQL, derived only |
| Creatine ladder | product.remark | CreatineFlow.loadVariants | SQL |
| Bilingual copy | product.description (EN||FR) | CreatineFlow.descriptionFor | SQL |
| UI strings | language_db.translation | LanguageSDK | SQL |
| Enabled languages | language_config.supportLanguages | LanguageSDK | SQL |
| Attract / banner media | ads table + ads/local/* | AdPlayManager + language filter | Files + hook |
| 2-up grid layout | operational_config.pb #26 | Shop layout selection | Byte patch |
| Own-mug availability | device_setting_config.pb #3 | CreatineFlow + detail VM | Read only |
| Serial path / baud | proto_serial_port_config.pb | SerialPortManager | Frozen |
| Calibration | proto_calibration_value_config.pb | Board calibration | Frozen |
Table 6 — Concern-to-store ownership, with the change vector actually used.
productid, type, slot, layer, isCombinationSlot, slotType, name, capacity, stock, error, error_at, price, discount_price, useDiscount, image, formula, spec, sku, enable, cold, age_can_buy_enable, age_can_buy, sell_time, remark, create_at, update_at, description, detail_image, video, with_hot_water_enable, hot_water_cost, sort, translation_name, cabinet_index, sale_status, testStatus, showFormula, showPrice, showDiscountPrice -- indexes: index_product_sku, index_product_enable_type_sort -- 22 rows total, 7 with enable = 1
price is an integer in thousandths of a currency unit and the UI
appends C$. 6490 renders as 6.49 C$. Assuming cents instead
of mils turns a $6.49 shake into $64.90 on a live payment terminal, which is why every
price edit is asserted against a rendered screenshot rather than the database alone.
| File | Message | Fields of interest | Reskin action |
|---|---|---|---|
| operational_config.pb | OperationalConfig | #26 showType, #1 loginPassword, #4 currencySymbols | Append D0 01 01 if absent |
| device_setting_config.pb | DeviceSettingConfig | #3 selfCupOption, #1 cupCoverOption | Read to gate own-mug UI |
| base_setting_config.pb | BaseSettingConfig | #1 shoppingCart, #5/#6 display rows/columns | None |
| proto_serial_port_config.pb | CoffeeSerialPortConfig | nested address, baudrate, boardId, protocol | Fingerprint only |
| proto_calibration_value_config.pb | CalibrationValueConfig | coffeeBean, water, boxList, perishablesList | Fingerprint only |
| proto_server_config.pb | ServerConfig | ip, port, signKey, machineId, machineSerial | None |
| fault_log.pb | FaultLog | faultCode, faultTime, cabinetNo, isCleared | Read for diagnosis |
| wash_record.pb | WashRecord | brewer / blender / box wash counters | None |
Table 7 — Protobuf configuration files. Field numbers were recovered by decoding live bytes, not from a schema.
showType = true → 2-up grid + category rail
Why bytes and not an API. Jetpack DataStore serialises through generated classes that
the reskin does not have. Appending a well-formed varint field is idempotent, verifiable by
length, and reversible — the deploy script checks for the D0 01 tag first
and leaves the file alone if the operator already enabled the grid.
The operator wanted a real dosing choice at 0, 5 and 10 g, priced independently. An earlier approach that remapped the vendor “half sugar” control was abandoned: it inherited sweetness semantics and could not carry its own price. The shipped design encodes the ladder as ordinary SKUs and stores the relationship in a field the vendor app ignores.
CREA|<sku of 5 g variant>|<sku of 10 g variant> -- parsed by CreatineFlow.loadVariants(): -- remark.startsWith("CREA|") → split on \| → requires length >= 3 -- row 0 is the visible tile (0 g); parts[1] and parts[2] are hidden SKUs -- variant rows stay enable = 0, so they never appear on the grid VANILLA CREA|VANILLA+CREA|VANILLA+CREA+CREA PVL H20 CREA|PVL H20+CREA|PVL H20+CREA+CREA
| # | SKU | Name (EN) | Name (FR) | 0 g | 5 g | 10 g | Hopper |
|---|---|---|---|---|---|---|---|
| 1 | COOKIES + CREAM | Cookies & Cream Protein | Protéine Biscuits & Crème | 6.49 | 6.99 | 7.49 | 2 |
| 2 | CHOCOLATE CAKE | Chocolate Cake Protein | Protéine Gâteau Chocolat | 6.49 | 6.99 | 7.49 | 2 |
| 3 | VANILLA | Vanilla Protein | Protéine Vanille | 6.49 | 6.99 | 7.49 | 2 |
| 4 | ICED CAPPUCINO | Iced Cappuccino Protein | Protéine Cappuccino Glacé | 6.49 | 6.99 | 7.49 | 2 |
| 5 | PINEAPPLE | Pre-Workout Pineapple | Pre-Workout Ananas | 3.99 | 4.49 | 4.99 | 3 |
| 6 | BLUE RASBERRY | Pre-Workout Blue Raspberry | Pre-Workout Framboise Bleue | 3.99 | 4.49 | 4.99 | 5 |
| 7 | PVL H20 | Electrolytes | Électrolytes | 2.99 | 3.49 | 3.99 | 7 |
Table 8 — Seven enabled tiles × three doses = 21 addressable SKUs, of which 14 are hidden rows. Prices in C$; stored as mils.
A 10 g row is generated from its 5 g row rather than authored: copy the row,
then rewrite one field inside the formula JSON —
"materialCost":4 becomes "materialCost":8 — and set the
SKU to TRIM(sku) || '+CREA'.
The creatine step is identifiable because it is the only step with a cost of 4 or 8; the protein step uses 38. Water volume is inherited untouched: 275 ml for proteins, 300 ml for pre-workout and electrolytes.
Creatine always dispenses from hopper index: 1, which the vendor firmware
treats as the adjustable “sugar” box. Enabling it is a JSON flag flip inside
drink_stock.material_box_stock, not a code change.
Field note The 10 g recipe has never been physically dispensed on the board — it exists as a derived recipe and a priced SKU. That is recorded as an open verification item rather than a shipped guarantee.
attract ad_full_attract_{en|fr}.jpg // language-filtered by hook
→ shop 2-up grid, half banner _{en|fr}, 7 tiles
→ CreatineFlow chooser
· 0 / 5 / 10 g // when remark starts with CREA|
· own-mug option // when device_setting.selfCupOption
→ detail hero + creatine badge + nutrition card
→ payment // vendor MDB path, untouched
→ shipment hero + progress arc
All new behaviour lives in one owned class compiled against android.jar alone
— zero vendor classpath. Every integration point is reflection, and every entry point
catches Throwable and falls back to vendor behaviour. A failure in the reskin
degrades to the original app rather than crashing the kiosk.
| Method | What it does | Failure mode |
|---|---|---|
| onProductSelected | Reads sku from the navigation bundle, queries remark, presents the dose chooser, then rewrites sku, price, haveSugarBox and the own-cup keys before calling the vendor navigate. |
Falls through to vendor navigation with the original bundle. |
| applyChosenVariant | Reconciles the chosen variant with SkinActivityVM.getSelectedProduct() so billing follows the dose, loading rows off the UI thread with a 2,000 ms join. |
Detail screen shows the base SKU; price stays consistent with the tile. |
| decorateDetail | Populates the creatine badge and nutrition card, drives own-cup state, hides the vendor cup-choice container. | Vendor detail layout renders unmodified. |
| filterAdsByLanguage | Filters the ad list on _en. / _fr. in the local path or URL, so attract and banner art follow the selected language. |
Full unfiltered playlist plays. |
| onSkinLanguageChanged | Re-submits the cached full ad pool through AdPlayManager when the language Flow emits, so switching language swaps art immediately. |
Art updates on the next natural rotation. |
| decorateShipment | Replaces the vendor coffee animation with a circle-cropped product hero and a rotating progress arc (level 2800/10000, 1,100 ms). | Vendor dispense animation plays. |
| statusCaption | Rewrites empty and INVALID ID machine-status strings to bilingual “Preparing your drink”. |
Raw vendor status string is shown. |
Table 9 — Extension entry points. The failure-mode column is the design contract, not a disclaimer.
The badge does not pattern-match a suffix; it counts occurrences of CREA
within the SKU, so VANILLA+CREA+CREA resolves to 10 g without a second
parsing rule. Small choices like this are what keep 970 lines from needing a parser.
Vendor smali edits are recorded individually because each one is a liability at the next vendor update. The ratio is the point: six files rewritten out of 28,317, and five of those six insert a single static call.
| File | Dex | Edit | Class |
|---|---|---|---|
| …/fragment/ProteinShopFragment$onProductSelected$1.smali | 3 | Route selection through CreatineFlow.onProductSelected instead of navigating directly | Hook |
| …/fragment/ProteinProductCustomFragment.smali | 3 | Two hooks in onCreate / initView, plus a genuine vendor bug fix: read haveSugarBox from the bundle and forward it to the VM | Hook + fix |
| …/manager/AdPlayManager.smali | 3 | Language filter at the head of submitList | Hook |
| …/fragment/ProteinShopFragment$subscribeData$1$1$6$1.smali | 3 | Notify the extension after the language label updates | Hook |
| …/fragment/ProteinShipmentFragment.smali | 3 | Decorate the dispense screen and rewrite the status caption | Hook |
| …/tanslation/LanguageSDK.smali | 2 | Language picker reads English / Français without the vendor gloss | Cosmetic |
Table 10 — Every vendor smali file modified, with intent. Vendor package spelling tanslation is reproduced as found.
Update strategy. New views carry an af_ prefix and are appended rather
than replacing vendor ids, so a future vendor APK can be re-decoded and the same six patches
reapplied mechanically instead of merged by hand.
Two independent chains converge: the owned Java compiles to a dex that is dropped into the apktool tree as an additional multi-dex member, then the whole app is rebuilt, aligned and signed under a project key.
Deployment is a single scripted transaction, not a sequence of ad-hoc adb
commands. It refuses to run against the wrong target, takes a restorable backup first,
fingerprints the hardware plane on both sides of the change, and ends by proving the app is
alive and still holding both serial ports.
Enumerate devices, require exactly one target or an explicit serial, and refuse any emulator-* target outright. Require the staged catalog and the signed APK to exist.
Pull catalog plus its WAL and SHM sidecars, operational_config.pb, and the entire ads tree into backups/v2-<timestamp>/.
md5 of YS_CONFIG.db (and its WAL), catalog, serial config, calibration, operational and device settings. This is the reference the deploy is judged against.
am force-stop com.ys.vending, then wait for the process to release its database handles.
Clear ads/local/full and ads/local/half, then push language-suffixed artwork routed by filename prefix.
Local hero PNGs into /sdcard/coffee/productImage/.
Remove the Glide ad_image_cache directory — without this, replaced artwork keeps rendering from cache.
Copy the staged coffee_db.db over the live file and delete stale WAL/SHM so SQLite cannot resurrect the previous content.
Pull language_db.db, apply the creatine label and language-support SQL locally, push it back, drop sidecars, and archive a copy into the backup folder.
Append D0 01 01 to operational_config.pb only when field 26 is absent; otherwise leave the operator's setting untouched.
adb install -g -r of the aligned, signed APK.
Hand the HOME role back to the device's own Launcher3 so a failed kiosk app cannot lock the unit into an unusable state.
Launch SplashActivity explicitly and allow 30 s for first-run initialisation and board handshake.
Re-hash all six files, classify each as unchanged / expected / unexpected, confirm focus, PID and both tty descriptors, grep logcat for fatals, and capture a screenshot as evidence.
| File | Expected verdict | On mismatch |
|---|---|---|
| /sdcard/YSConfig/YS_CONFIG.db | UNCHANGED | Stop. Investigate before any further action. |
| proto_serial_port_config.pb | UNCHANGED | Stop — serial plane must not move. |
| proto_calibration_value_config.pb | UNCHANGED | Stop — calibration is safety-relevant. |
| device_setting_config.pb | UNCHANGED | Investigate; reskin only reads it. |
| operational_config.pb | MAY CHANGE | Only by exactly three appended bytes. |
| coffee_db.db | CHANGED (expected) | An unchanged hash means the push silently failed. |
Table 11 — Fingerprint verdicts. Both directions are failures: unexpected change and unexpected sameness.
A deploy is only accepted when the appliance proves four things about itself. Anything less is treated as a failed deploy and rolled back from the backup taken minutes earlier.
/dev/ttyS3 and /dev/ttyS2.operational_config.pb and the full ads tree.ClassNotFoundException: org.apache.logging.log4j.spi.ExtendedLoggerWrapper // Netty probing for an optional log4j backend that this build does not ship. // Present on the untouched vendor APK as well - documented so it is never // mistaken for reskin damage during a live deploy.
Control experiment. A rebuild of the untouched vendor APK through the same apktool
version produced no com/ys/ smali differences attributable to the toolchain.
That baseline is what allows the six-file ledger in §09 to be stated as complete rather
than approximate.
The working loop was: pull the current truth, form a hypothesis about which store or class owns a behaviour, patch, deploy to the board, screenshot, compare. Relative effort by workstream:
Full device dump, apktool and jadx trees, first identification pass. Network ADB proved unreliable; USB became the only supported path.
The sharedUserId trap disproved, project keystore established, and UART ownership confirmed on a re-signed build.
Seven-item menu, brand palette, 2-up grid via the protobuf field, product renders and attract art, plus the deploy and rollback tooling.
The sweetness-remap approach abandoned in favour of real SKUs; chooser inserted between grid and detail; detail screen rebuilt around a centred hero.
Language-suffixed media with a filter hook, translation overrides, own-mug gating on the device setting, and a lower call to action.
Installable build, archived original APK, artwork and documentation frozen; HOME handed back to Launcher3; deploy target made explicit so the wrong device is refused.
This engagement did not harden the appliance. The reskin inherits the OEM threat model in full: an end-of-life OS, a rooted test-keys image, a preinstalled third-party remote support agent, world-readable recipe and price data, and unauthenticated UART to both the drink board and the payment peripheral.
What was added is change control, not security control. Treating the kiosk as a high-trust physical object remains a business decision, and it should be made knowingly.
| Control added | What it does | What it explicitly does not do |
|---|---|---|
| Hardware fingerprint gate | Detects any drift in YS_CONFIG.db, serial and calibration config across a deploy | Encrypt, ACL or sign those files |
| Timestamped backup sets | Point-in-time restore of catalog, settings and media | Secure wipe, integrity attestation or off-device escrow |
| Explicit deploy target | Refuses emulators and ambiguous device lists | Authenticate the operator or the workstation |
| Launcher handoff | Prevents a broken kiosk app from locking the unit | Remove the OEM boot receiver |
| Project signing key | Reproducible, attributable installs | Replace OEM supply-chain trust or enable verified boot |
| Fail-open extension code | Reskin failures degrade to vendor behaviour | Protect against a hostile process on the same device |
Table 12 — Controls introduced, and the boundary of each.
The OEM support agent is the shortest path to a compromised kiosk and is not required by any shipped feature.
Prices and recipes on world-readable storage means any local process can retail a drink for zero.
The md5 gate detects drift after the fact; a signature would prevent an unauthorised write in the first place.
| Script | Lines | Target | Effect |
|---|---|---|---|
| set-creatine-variants.sql | 160 | coffee_db | CREA maps, ladder prices, hidden variant rows, derived 10 g recipes |
| set-creatine-menu.sql | 52 | coffee_db | Earlier tile strategy; hopper sugar flag |
| set-menu-copy.sql | 45 | coffee_db | Names, French display names, bilingual descriptions |
| set-creatine-labels.sql | 40 | language_db | Sweetness strings rewritten as creatine doses, EN + FR |
| set-product-descriptions.sql | 25 | coffee_db | Superseded French-only copy |
| set-local-product-images.sql | 18 | coffee_db | Point rows at local hero renders |
| set-languages-en-fr.sql | 6 | language_db | Support list reduced to ["en","fr"] |
| Item | Status | Note |
|---|---|---|
| 10 g creatine physical dispense | Unverified | Recipe derived and priced; never dispensed on hardware |
| Serial framing documentation | Not attempted | Deliberately out of scope; the plane is frozen |
| Vendor update re-application | Designed for | Six patches are mechanical; af_ resources are additive |
| Platform hardening | Not in scope | Requires an OEM image decision, not an app change |