POS Addon Spec — the 2012 Unicenta loop, rebuilt as folds¶
Spec for the first op-log-native addon: a browser Point-of-Sale on the migrated tenant, with BOM backflush and replenishment — the same loop RED1 shipped in the 2012 era as the Unicenta POS plugin + AutoBOMOrder for iDempiere, with the middleware deleted. Doctrine: The POS Lens. Roadmap home: item 1 of the Migrate & Compare paper's roadmap.
Status: §P-1..§P-4 BUILT + WITNESSED (2026-06-11,
prompts/POS_LENS_SESSION.md# DONE). Engine side:build/erp/pos_core.js(pure fold glue over the existing verbs, newVerbs=[]) — W-POS-RING · W-POS-WR · W-POS-BACKFLUSH · W-POS-REPLENISH all green (build/erp/poc_pos_*.log). Lens side:erp/pos_lens.js+ thepospill (showWhen:pos-station) on idempiere.html — W-POS-LIVE green on localhost (bim-ootb branchfeat/pos-lens, deploy awaiting explicit GO). §P-5 multi-station stays out of scope (named below). The matrix "POS lens" row stays PENDING: §5's bar is a LIVE ring folded to the cent — the to-the-cent leg is proven headless on the acct-linked db (W-POS-WR), but the live ad_seed.db lacks posting linkage (the same data-gate as Posting-Preview; lighting it =prompts/MIGRATE_POSTING_CONFIG.md). Clean-room rule: Unicenta is defunct and the plugin code private — behaviour is spec'd from the public wiki + RED1's own design; no code is copied.Status update 2026-06-12 (supersedes the PENDING lines above): GO was taken — POS lens LIVE (bim-ootb PR #269, sw v652). The posting data-gate is DISSOLVED: default
ad_seed.dbcarries the acct linkage (PR #271, v653) and the live ring folds to the cent —§POS-CENT maxDiff=0c— so the matrix "POS lens" row is FULLY lit. Wave 3 (PR #274, sw v655) closed the full loop: §L-1 CRUD on POS docs (W-POS-CRUD) · §L-2 void/reverse (W-POS-VOID — postings net 0c, on-hand restored, double-void refused) · §L-3 replenishment ENACTED (W-POS-REPLENISH-LOOP — suggest → PO CO → receipt CO → on-hand +N to the unit → suggestion clears;vendorOf()from realm_product_porows, PO-on-tap wiring live inpos_lens.js). Voids are therefore DONE; returns-with-restock UI remains a next increment. Witnesses:scripts/poc_pos_{crud,void,replenish_loop}.js.
1. What "addon" means here¶
In 2012 the integration needed a fat Java POS, ActiveMQ queues per station, and sync plugins on both ends. The premise was right — the POS is dumb: record the sale, take payment, send the order; the ERP holds the intelligence — but the plumbing was the product. An addon on the browser kernel keeps the premise and deletes the plumbing:
| 2012 (Unicenta ⇄ iDempiere) | Addon (this spec) |
|---|---|
| Fat Java POS client per station | One lens (HTML/JS surface) over the resident tenant db |
| ActiveMQ queue per station, POSSync/OrderSync | The signed op-log — ops are the queue, replay is the sync |
| Station = AD_Org/POS Locator config | Same — c_pos row per station (already in the seed) |
| AutoBOMOrder plugin backflushes on sale | erp_engine.explodeBOM — already witnessed (W-FOLD-BACKFLUSH) |
| ReplenishReport → PO | poc_replenish.js formula fold — already witnessed (W-FOLD-REPLENISH) |
| Products/customers sync ERP→POS | Nothing to sync — the lens reads the same db the ERP renders |
An addon therefore touches four registries and zero engines:
- a pill in the pill registry (the only way UI enters —
pills_idmp.json+IdmpPillActions), - AD windows it may deep-link (already in the dictionary — see §2),
- process handlers in the
ad_process.jsregistry (registerHandler(classname, fn, meta)), - ops through the kernel (
kernel_ops.commitGroup) — handlers never write tables directly.
If an addon needs a fifth thing — its own persistence, a new verb, a forked engine — the rails were
built wrong: stop and report to the engine lane (the FOLD-not-FORK red flag in
prompts/POS_LENS_SESSION.md).
2. Substrate inventory — what already exists (EXTRACT, don't rebuild)¶
Everything below is shipped and witnessed as of 2026-06-11; the addon consumes it.
The POS dictionary ships in the browser seed (full-width ad_seed.db, bim-ootb PR #265 — the
old column-slice had these tables but the windows were unreachable):
| Asset | Rows | Detail |
|---|---|---|
c_pos |
1 | "Garden User - Store": doctype 135, keylayout 100, warehouse 104, pricelist 101, cashbook, cashdrawer |
c_poskey / c_poskeylayout |
163 / 5 | the ring-up key grid, real GardenWorld layouts |
u_posterminal |
1 | extended terminal config (37 cols) |
| AD windows | — | 338 POS Terminal · 339 POS Key Layout · 200008 POS Payment · 200009 POS Tender Type · Sales Order tab 200016 "POS Payment" |
C_DocType 135 |
— | DocBaseType SOO, DocSubTypeSO WR — iDempiere's on-the-fly shipment+invoice POS order |
The fold verbs (scripts/erp_engine.js — pure, host-injected, no DB/clock):
| Verb | Witness | What it proves |
|---|---|---|
buildDoc(spec, parent, lines) |
W-FOLD-BUILDDOC | the one archetype create-verb; createShipment/createInvoice are specs of it, not code |
explodeBOM(bomOf, productId, qty) |
W-FOLD-BACKFLUSH (build/erp/poc_backflush.log) |
recursive recipe explosion on the REAL GardenWorld Patio recipe; multi-path accumulation (cap-screw 8+8=16); flat-explosion falsifier |
movementSign / qtyOnHand(events, opts) |
W-FOLD-QTYONHAND | on-hand is a FOLD of the movement ledger (trailing-char polarity), never a trusted column |
| replenishment formula | W-FOLD-REPLENISH (poc_replenish.js) |
the ReplenishReport:294-327 port — available = onhand − reserved + ordered, type 1/2 → QtyToOrder → PO via buildDoc; oracle-equivalent to iDempiere's own formula SQL, per product, to the unit |
The spine the addon rides: ad_docfsm.js (legalActionsOrder knows docsubtypeso; WR completes
CO→CO), doc_poster.derivePostings + post_resolver (frozen, oracle-anchored postings),
kernel_ops.commitGroup (atomic signed op groups, hash-chained), ad_process.js
registerHandler (the addon's classname entry-point), and the pill registry
(pills_idmp.json + idmp_pills.js, Lucide line icons only).
Conceptual spine: POSLens.md §1 (one clean act in), §5 (scan/QR door), §6 (backflush), §7 (shrinkage), §11 (honest scope at time of writing — this spec supersedes its "to build" list where §2 above says witnessed).
3. The addon, end to end (the 2012 loop as folds)¶
ring items (c_poskey grid / QR scan)
→ C_Order (doctype 135 WR, c_pos defaults: warehouse, pricelist, BP-cash)
→ tender (POS Payment — cash first; tender types from window 200009's dictionary)
→ Complete = ONE signed op group:
CREATE_DOCUMENT C_Order + lines (buildDoc)
CREATE_DOCUMENT M_InOut C- + lines (buildDoc spec createShipment)
CREATE_DOCUMENT C_Invoice + lines (buildDoc spec createInvoice)
CONSUME leaf components (explodeBOM — only when the product is a BOM)
DOC_ACTION CO on each (ad_docfsm dispatch)
→ postings = derivePostings on each doc (preview before, fold after — same engine)
→ on-hand falls (qtyOnHand fold over the new movements)
→ replenishment fold sees available ≤ Level_Min → suggests/creates the PO (W-FOLD-REPLENISH)
No step is new machinery. The sequence is the addon.
§P-1 POS surface (the lens)¶
- Pill
posinpills_idmp.json(icon: Lucideshopping-cartfamily;showWhengated on ac_posrow existing for the logged-in org — data-gated like the Posting-Preview pill). - Surface renders FROM the dictionary: the key grid from
c_poskey/c_poskeylayout(163 real keys), prices from thec_pos.m_pricelist_idlist, BP defaulting toc_bpartnercashtrx_id. Zero per-product code — a new product is a row, not a feature. - Money math via
site/bigdecimal.js(never raw JS Number — standing rule). - Witness W-POS-RING:
§POS ring product=<id> qty=<n> price=<pricelist-row>— every ringed line traces to ac_poskey→m_product→m_productpricerow. §FALSIFIER: ringing a product absent from the price list must refuse (no invented price).
§P-2 Complete = the signed group (the sacred transaction)¶
- One
commitGroupcarrying order+shipment+invoice+backflush+doc-actions. All-or-none; gid idempotent; hash-chained (POSLens §4 unbroken provenance). - The WR semantics come from the dictionary (
docsubtypeso='WR'), not from POS code — the same order completed from the ERP window must produce the same group. - Witness W-POS-WR: group replay == the engine's own
createShipment/createInvoicespecs; postings ==derivePostingsto the cent; §FALSIFIER: tampered op breaksverifyChain.
§P-3 Backflush (AutoBOMOrder reborn)¶
- On Complete, for each line whose product is a BOM (
pp_product_bomlookup — host-injectedbomOf),explodeBOMyields leaf consumption; emit the consumption movements in the SAME group. - Already proven on the real recipe (W-FOLD-BACKFLUSH); the addon's witness W-POS-BACKFLUSH only needs to prove the wiring: ring Patio Set ×1 → the §-log shows the same leaf dict the headless witness derived. §FALSIFIER: a non-BOM product must emit zero consumption ops.
§P-4 Replenishment (the loop closes)¶
- After the group commits, run the W-FOLD-REPLENISH fold over the new ledger state; render the
suggestion (product, warehouse, QtyToOrder) in the POS surface; one tap = the PO group
(
buildDoc, IsSOTrx=N) — suggest by default, auto-create behind ac_pos-level flag. - Witness W-POS-REPLENISH: sell below
Level_Min→ suggestion appears with the formula-derived qty; §FALSIFIER: selling a non-replenish product never suggests.
§P-5 Multi-station ring ("a ring of dumb POS stations around one ERP")¶
- Each station = a
c_posrow (org-scoped), its ops a signed channel — the 2012 ActiveMQ queue-per-station, now just the op-log's own grouping. Offline-first: ops seal locally, sync rides the existing §0.20 sync FSM / relay when present. Out of scope for the first build session; named so it is never re-invented.
3b. DIY single-user UX — the mobile checkout (user direction 2026-06-12)¶
Status 2026-06-12 — the KILLER DEMO is LIVE (bim-ootb PR #276, sw v656). The DIY mobile UX shipped: §P-6/§P-7 layout (now the §LAYOUT floating-panel revision,
prompts/POS_KILLER_DEMO.md) · §P-8 scan · §P-9 register (buildRegisterGroup, W-POS-REGISTER) · §P-10 edit (W-POS-EDIT) · §P-11 receipt + a generic DEMO/SAMPLE payment QR (the payable-QR ⛔ user-fact sidestepped by the explicit DEMO label; real static-DuitNow upload = next increment) · §P-13 hold/recall (W-POS-HOLD). Album = image cards (photo→thumb→Lucide placeholder glyph); payment = a draggable floating panel on its own z-layer (the fixed-bottom-sheet of §P-6 is SUPERSEDED — it moved with the album scroll layer, the named bug). Snap→scan→price→tile→sell→receipt runs end-to-end on a phone in under a minute, signed (§POS-FIRSTSELL). E-5 §P-12 confirmation fold also landed (inout_confirm.js, W-WH-CONFIRM — doctype 148 pick-confirm, on-hand at confirm). Engine bank bim-compiler 9857aefc; the live-witness W-POS-LIVE drives the new surface. Image full-res sync =img_store.jsIDB folder + out-of-band copy job (W-IMG-FOLDER/SYNC).
All of §P-6..§P-11 below is the lens's PRESENTATION + input layer. Hard rule the user repeated
three times: never disturb the underlying engine or flow — every price is still POSCore.ringLine,
every sale is still ONE kernel_ops.commitGroup, every product write rides the SAME signed write-path
as a sale. The dumb-terminal contract (§1) is unchanged; these sections only change what the operator
sees and how input is collected. Target user: a lone DIY operator on a phone, grabbing stuff lying
around to demo a sale right away. A dedicated session resumes this — prompts/POS_LENS_SESSION.md.
§P-6 Mobile layout — items grid + payment as a fixed bottom sheet (pure UI; do FIRST)¶
Observed (user live test, mobile): the lens is a flex ROW — grid (items, flex:2) beside
side (cash panel, min-width:230px). On a ~390 px phone the min-width side panel hogs the right,
squeezing items into ONE left column. Not mobile-fit.
Spec:
- Items in a GRID that fills the width — ~3 across × 4 down = 12 tiles fill the first screen; with
~20 products the rest overflow below, the operator scrolls the item blocks UP.
- Payment = a fixed bottom sheet, floating over the items, anchored low but not flush to the very
bottom (leave the phone's own nav/home-indicator clear — env(safe-area-inset-bottom)); occupies
roughly the lower ~40–46vh. The item grid gets bottom-padding so its last row scrolls clear above it.
- The running Total shows LARGE in the sheet (the operator's primary readout).
- Desktop layout (the current two-column row) must stay intact — drive it from a @media (max-width)
query scoped to the overlay; add ids (pos-wrap/pos-grid/pos-side/pos-total) but touch NO JS
logic (cart, ringLine, buildSaleGroup, handlers all byte-identical).
- Build method (user-dictated): mock it on a MOBILE viewport (puppeteer 390×844, deviceScaleFactor)
and screenshot — tune by eye, not §-log alone. Show the user before deploy (UI-iteration rule).
- Witness W-POS-MOBILE: screenshot at 390×844 with a seeded cart; grid is 3-wide, payment sheet is
fixed at bottom and does not cover the grid's scroll; §POS-MOBILE cols=3 sheet=fixed total=large.
§FALSIFIER: at desktop width the two-column row is unchanged.
§P-7 Pill-icon-only POS mode (pure UI)¶
POS mode presents its actions as pill icons only (the walk-mode "engage a distinct mode" idiom):
a clean DIY surface, Lucide-only icons (the pill-icon-consistency rule), no verbose chrome. The icons
host §P-8..§P-11 (scan, register, edit, receipt). Witness W-POS-PILLS: actions render as icon
pills; §POS-PILLS n=<k> icons-only=Y. §FALSIFIER: non-icon text chrome absent in POS mode.
§P-8 Continuous QR scan mode (input only — reuses ring)¶
Tapping the QR icon enters a continuous scan mode: the camera stays live and each scanned
barcode adds a line and flashes the total, item after item, with no re-tap between scans (cashier
convenience). Each scan resolves the barcode → product (the §P-9 registry / m_product.upc/value),
then calls the EXISTING POSCore.ringLine + cart add — no new pricing/cart logic. The QR module is
the proven W-QR-INPUT pattern (BarcodeDetector + getUserMedia, honest typed fallback), shared with
the warehouse walk's scanner. Witness W-POS-SCAN: N scans → N ring calls, total updates each;
unknown barcode → honest "not found — register it?" (links §P-9), never a fabricated line.
§P-9 Register-a-product (DIY) — scan + snap → M_Product (⚠ ENGINE BOUNDARY — discuss before build)¶
A register icon: the operator scans an item barcode, snaps a photo, and the product is created at Std price 1.00 (a real user-entered default, editable), usable for a sale immediately.
- This is a master-data WRITE — it must ride the SAME signed write path as a sale: a
kernel_ops.commitGroupwhose ops CREATE theM_Productrow (+ them_productpricerow at 1.00, - the barcode as
upc/value, + ac_poskeyso the tile appears). Not a bypass, not invented data. The engine/verbs are unchanged; this just composes existing CREATE ops into a new group, the waybuildSaleGroupcomposes order/ship/invoice ops. - DATA DECISIONS (1 = DECIDED on extracted facts, Fable5 plumbing review 2026-06-12):
- Photo = an
AD_Imagerow in the SAME signed group — ✅ DECIDED. Verified on the origin/main full-width seed:ad_imageEXISTS (15 cols includingbinarydata) andc_poskey.ad_image_idreferences it — fully dictionary-native, no schema change. ⚠ the stale shared~/bim-ootbcheckout shows a slimad_imageWITHOUTbinarydata; always verify againstgit show origin/main:erp/ad_seed.db. Plumbing bound: the op-log is the sync/storage spine (O.c bytes discipline) —binarydatacarries a DOWNSCALED tile-resolution thumbnail (≤ ~32KB JPEG, ~256px), produced client-side before commit; tiles need exactly that. Full-res may additionally sit device-local (IndexedDB), never in the op-log. §FALSIFIER: a register op whose image exceeds the cap is refused (no fat ops in the chain). m_productpricerequires a pricelist version — the DIY product needs a price row on the station'sm_pricelist_version_id(the same one the lens already reads), at 1.00.- PK allocation for the new product/price/poskey ids (the deterministic-id discipline — count
prior CREATE ops, like
nextIds, neverDate.now/random). - Mandatory M_Product columns come from the DICTIONARY, not invention — real iDempiere
requires
m_product_category_id,c_taxcategory_id,c_uom_idon M_Product: default each fromAD_Column.defaultvaluewhere set, else the station's existing GardenWorld rows (e.g. the category/UOM the seed's own POS products use). EXTRACT the defaults; never hardcode. - Witness W-POS-REGISTER: scan+snap → ONE signed group creates product+price+poskey,
chainOk=Y, the new tile rings at 1.00 and sells through §P-2 unchanged. §FALSIFIER: a register with no barcode or no price refuses; the price is the operator's value, never auto-invented beyond the 1.00 default.
§P-10 Edit-a-product (⚠ ENGINE BOUNDARY — same write path)¶
An edit icon: change a DIY product's name / price / photo. Same signed-write discipline — an UPDATE
op group on M_Product/m_productprice through commitGroup, the CRUD-edit-persist path already
proven for documents (crud_overlay lineage). Witness W-POS-EDIT: edit price 1.00→X → signed group
→ the tile and the next ring reflect X; chainOk=Y.
§P-11 On-screen receipt + payment QR (presentation of a completed sale)¶
After Complete (§P-2 commits the signed sale), show an on-screen receipt: the lines + the LARGE
total, the last item flashes but does not obscure the total, and a payment QR button renders a
QR the customer scans to pay by the usual rail (e.g. Maybank2U / DuitNow). The QR encodes a payment
string (amount + reference) — it is a display of the already-committed order total, no new financial
logic (the sale is the truth; the QR is a presentation of its grand total). Witness W-POS-RECEIPT:
after a sale, receipt shows the order's exact total, payment QR encodes that amount + order ref;
§FALSIFIER: the QR amount == the committed GrandTotal to the cent, never a recomputation.
⚠ Rails honesty (Fable5 review 2026-06-12, ⛔ pending user fact): real DuitNow QR is an EMVCo payload bound to a REGISTERED merchant id — a self-composed "amount+ref" QR will not parse in a bank app; composing a synthetic EMVCo string = inventing. The mum-and-pop-honest variant: the owner snaps/uploads their OWN static DuitNow QR once (stored exactly like the §P-9 photo — an
AD_Imagerow) and the receipt displays THAT QR with the LARGE committed total beside it (customer keys the amount — the universal static-QR flow). Order-ref QR generation stays for the receipt-URL use (§POS-RECEIPT link), which any camera can read. Ask the user which payable-QR reality applies before building the "encodes amount" half.
Sequencing (user rhythm — build → show → next): §P-6 layout first (pure UI, the live complaint) → §P-7 pill icons → §P-8 continuous scan → §P-11 receipt+QR → then §P-9/§P-10 product register/edit (after the two open data decisions are settled with the user). The first four touch no engine; the last two write master data through the existing signed path only.
§P-12 POS ⇄ WH walk — a document fold, NOT a live coupling (design agreed 2026-06-12)¶
The realization (user): the two lenses are already connected through the ledger, so there is no
coupling to build. A completed POS sale already commits an M_InOut (§P-2: C_Order + M_InOut +
C_Invoice in one group); the warehouse walk's §S-2 route already sources "open M_Movement /
M_InOut lines." So the honest flow is: finish the sale → its shipment/movement is an open doc →
go to WH walk → the walk routes that doc and you pick it. Another device picking the same list is
just the multi-device version of the same fold (the §P-5 / distributed story — roles on separate
devices, one ledger). The mobile demo "just shows the roles."
- Build cost is small and additive: the walk's
draftPick(wh_walk.js) gains an option to source its route from open POS-generatedM_InOut/M_Movementdocs, alongside its current replenishment draft — a §S-2 selector, not new machinery. Spec the cross-ref indocs/SPATIAL_PICKING_SPEC.md §S-2. - Open-doc honesty (engine note, Fable5 review 2026-06-12): the WR sale (§P-2) completes
M_InOut → COinside the sale group (W-POS-WR:C_Order→CO, M_InOut→CO, C_Invoice→COin ONE group) — its lines are NOT open and on-hand has already moved; routing a walk over them would double-move stock. "Finish the sale → go pick it" therefore rides the deliver-later shape: a sale on the dictionary's plainSOOdoctype (seed has132 Standard Order) whose shipment staysDR— the walk's scan-commit (§S-4) IS the act that completes theM_InOutand moves on-hand. That DR-shipment sale variant is engine glue (pos_core spec choice from the dictionary), not a walk-side patch — engine lane builds it; the walk's selector then honestly sourcesdocstatus IN ('DR','IP')only. WR stays the cash-and-carry default (nothing to pick). - Switching surfaces (POS on idempiere.html → walk on viewer.html): open the walk in a new
tab (
_blank). The POS tab never unloads, so its cart is preserved by definition — no draft, no restore key, no cross-page timing. (Mobile tab-swap is acceptable; the user confirmed it is no longer a big issue.) A deeper in-app coupling stays shelved — not needed for the role demo. - Navigation / "back" = the History timeline is the single source of event navigation. The Z (page) / W (world) history already deep-links a surface+state across pages; POS↔walk hops are entries in that same log. REJECTED (do not build): making the browser BACK button responsible for restoring POS state — that is the cross-page deep-link-restore timing bug class that recurred 3× here (World-card "sometimes gets there", sw v636/v637/v642). The timeline (tap to reach back) is deterministic; back-button-auto-restore is not.
- Granular Z events — spec-to-verify (not now): the Z history should record granular user
"punches" (the buttons pressed), so the timeline can walk an operator's exact session. There is
already a sniffer —
history_tap.js§EVT SNIFF|on — recording every §act, deny-filtered. NEXT SESSION: check whether POS/walk punches already surface through that sniffer (likely cheap), and if so spec a witness that the Z timeline replays a POS session; if not, scope the gap. Do not build blind — verify against the existing§EVTlog first.
§P-13 Hold / recall sale — the in-progress cart as a DR order (the real primitive)¶
Instead of bespoke state-rescue, POS gains the standard hold / recall primitive: park the
in-progress cart as a draft C_Order (docstatus DR) through the existing signed commitGroup,
recall it from a list on tap. This is useful far beyond any walk excursion (phone dies, switch
customer, close the tab) and it sidesteps the timing bug class because recall is user-initiated
on tap, never auto-restore on page load.
- Proof it is folded in, not a private store (user requirement): a held/draft order MUST appear
in the standard Sales Order window and therefore on the Kanban board — because it is a real
DR
C_Orderin the ledger, the same row those surfaces already read. If a held sale does NOT show up in Sales Orders, the parking used a private store and is wrong (fold-not-fork red flag). - Recall completes the HELD order (engine note, Fable5 review 2026-06-12): on recall→Complete,
the group must
DOC_ACTION COthe EXISTING DRC_Orderand build ship/invoice FOR IT (the engine'screateShipment/createInvoicespecs take the order) — never re-runbuildSaleGroupinto a second order. That park/complete split is pos_core glue (engine lane), the list/recall UI rides it. - Witness W-POS-HOLD: hold a cart → DR
C_Ordercommitted (chainOk=Y) → it lists in the Sales Order window + Kanban → recall re-loads the exact lines into the cart → Complete proceeds normally. §FALSIFIER: the held order's lines/total == the cart to the cent; nothing invented on park or recall. §FALSIFIER 2: after recall+Complete exactly ONEC_Orderexists for the sale (no duplicate row).
Findings — webstore/POS-display filter (checked 2026-06-12, per user "check, don't fix")¶
- This seed's
m_productcarries noIsWebStore/IsSold/IsPurchasedcolumn (onlyIsActive). Real iDempiere hasIsWebStore; the slimad_seed.dbm_productdoes not. Not fixed (per instruction). Which items appear on POS is governed byc_poskeymembership (a product tiles because it is in the POS key layout), not a per-product checkbox — so a future "show on POS" filter is ac_poskeyadd/remove, not a product flag, unless the seed gains the column via the install lifecycle. - Incidental for §P-9:
c_poskey.ad_image_idalready exists → the snapped product photo's dictionary-native home is anAD_Imagerow referenced by the new key'sad_image_id(the tile then renders it). DECIDED 2026-06-12 (see §P-9.1): AD_Image row + capped thumbnail inbinarydata— verified present in the origin/main seed.
4. Honest gaps (named, not hidden)¶
- WR completeIt enactment:
ad_docfsmproves the status walk; the order→ship→invoice enactment exists as engine verbs + witnesses, but the live wiring (UI Complete → group) is exactly what §P-2 builds. GardenWorld has no production docs, so backflush stays rule-consistent (two independent implementations agree + falsifier), not fact_acct-diffed. - Tender types beyond cash (card processors) — dictionary rows exist; processors are not in scope (the 2012 plugin was cash-first too).
- Seed scale: 1
c_posrow / 163 keys is a demo shop; a real tenant brings its own rows via the install lifecycle (NEW_CLIENT_MGMT — closed for Odoo + iDempiere). - Gate (unchanged from the work-order): build AFTER the write-path lane is green; a lens can only ride rails that exist.
5. Done-when¶
Each § above is ✅ (witness lines + on-screen verify + deploy) or ⛔ with the one blocking fact; matrix gains a "POS lens" row only when W-POS-WR folds a live ring to the cent.