Case study

A webshop and its warehouse stock tracker

The MAE shop sells martial arts gear. Its stock is counted with a barcode scanner (or a phone camera) on a handful of web pages that keep everything in private WREN collections, and the counts are copied into the shop's own database by a script. This is how it was built, what we had to work around, and how we'd build it on WREN 0.10.

Written October 2026 from the tracker's repository (the Python scripts that build and publish the pages, its seed and repair scripts, and its tests) and a code review of it, the shop's code and history, the tracker's data in the backup taken before the 0.8.0 upgrade on 2026-10-05, and the working sessions in which both were built. Business figures (units, products, orders) are rounded or left out, and supplier, event and customer names are left out on purpose.

The project

There are two systems, and only one of them runs on WREN.

  • The shop is a storefront in German, English and French, with club stores for B2B customers. Its code is a Cloudflare Worker with its own PostgreSQL database. Work on it started in March 2026. Its catalog has a few hundred products and over a thousand variants from two main suppliers. An older hosted store also still exists.
  • The stock tracker is a set of plain HTML pages. A git repository holds the Python scripts that generate them, a script that publishes them into a private WREN tree, and the scripts that seed, ship and repair data. All data lives in private WREN collections. The tracker was started on 2026-09-08, when the first deliveries were counted, and was built and operated with an AI coding agent working in its own session.
At a glance
Pagestree mae: /stock, /order, /receive, /receipts, /events, /shipments, the supplier's Excel order form, and since October a German phone app (/lager) with its service worker, manifest and icons; the files are documents in the binary collection mae-pages
Codea repository of build_*_page.py generators, publish.py, push_to_wren.py (seed), ship.py, upload_invoice.py, one-off repair scripts, and Node and Playwright tests against fake WREN servers
Stockmae-stock: a few hundred item codes, 960 versions (state on 2026-10-05)
Other datamae-order, mae-receipts, mae-events, mae-shipments, mae-drafts, mae-invoices; binary collections for the pages, invoice PDFs and 20 product illustrations
Accessnothing public; the pages use the owner's WREN browser session; the scripts share one API key, read from a git-ignored file
Link to the shopa CSV snapshot and two Node scripts, apply-stock.js and apply-eans.js, run by hand

Part A

As built

The tracker grew in about three weeks of September 2026, one delivery and one tournament at a time. The shop already existed. The first counts reached it on 2026-09-17. The tracker's code went into git on 2026-10-05, together with camera scanning and the phone app.

Data flow as built A barcode scanner or phone camera and a browser use the tracker pages, which are served from the private WREN tree mae and read and write the private collections with the owner's session. The agent's Python scripts build and publish the pages and seed and repair data with one script key; the agent also hands over a CSV snapshot by message. apply-stock.js and apply-eans.js load the snapshot into the shop's PostgreSQL through admin_update_stock, and the storefront reads from there. Scanner / camera physical count, deliveries, events, shipments owner's WREN session WREN · all private tree mae: pages + order form mae-stock mae-order mae-receipts mae-events mae-shipments mae-drafts upsert by natural key Agent + scripts build, publish, seed, ship, repair: one script key snapshot sent by message CSV snapshot item_code, on_shelf, … item_code, ean by hand, when asked apply-stock.js apply-eans.js match codes to SKUs --dry-run first Shop PostgreSQL admin_update_stock variant stock, aggregates, revision log Storefront Worker routes, stock status manual step
Figure 1. As built: counting happens in WREN; the shop only learns about it when someone exports a snapshot and runs the scripts.

Data model

Every JSON collection has a schema and a natural key from the first push, so all writes are upserts by key (PUT …/by-key/{key}). The stock document is deliberately small:

json · mae-stock schema (abridged)
{
  "naturalKey": "item_code",
  "displayName": "{item_code} — {description}",
  "schema": {
    "required": ["item_code", "quantity", "count_date"],
    "properties": {
      "item_code":  { "type": "string" },
      "quantity":   { "type": "integer", "minimum": 0 },
      "count_date": { "type": "string" },
      "counted_by": { "enum": ["scanned", "stand-in scan", "hand-written line", "size tally",
                               "reported outside the count", "not counted, assumed zero",
                               "received", "unknown"] },
      "ean": {}, "supplier": {}, "category": {}, "count_sheet": {}, "last_received": {}
    }
  }
}

counted_by records how a figure came about, not just what it is. On 2026-10-05 a little over half of the items had been scanned, about one in eight came from a size tally or a hand-written count sheet, about a fifth had only been received, and about one in ten were "not counted, assumed zero". A zero that nobody checked is not the same as a zero on the shelf, and the schema says so.

CollectionNatural keyHolds
mae-stockitem_codequantity on hand per item, with how and when it was counted
mae-orderline_keysupplier order lines: quantity, received so far, price
mae-receiptsreceipt_keywhat came out of the boxes, and what became of each line
mae-eventsevent_keystock taken to a tournament booth, what came back, what sold
mae-shipmentsshipment_keycustomer orders packed and shipped from the shelf
mae-draftsdraft_keythe scanner's running tally, saved while you scan
mae-invoicesinvoice_keysupplier invoices, with the PDFs in mae-invoice-files

The code

None of the pages is written by hand. Each one is generated by a Python script, and the generated HTML is not committed:

  • Builders. build_receive_page.py (about 2,000 lines) writes the scanner page with the supplier catalog and its barcodes embedded as JavaScript, so the page works with no signal. build_lager_page.py writes the German phone app from the same catalog. The stock, order, receipts, shipments and events pages each have their own builder, and build_pwa.py writes the service worker, manifest and icons that make the phone app installable.
  • Publishing. publish.py reads the tree, compares each built file's SHA-256 with the stored one, and uploads changed files as a new version of the same document (multipart PUT by id), or POSTs a new document and assigns its tree path. Every change is live at once.
  • Seed. push_to_wren.py pushed the 2026-09-08 stocktake and the first orders. It sets each collection's schema on every run, leaves existing stock documents alone unless told otherwise, and upserts order lines and invoices in full.
  • Movements from the command line. ship.py records a shipment from a JSON file, moves stock off the shelf and uploads the invoice PDF. Two one-off scripts settled the tournament booth's held and returned lines, with the live document ids written into them.
  • Tests. A Node harness runs each page's real script against an in-memory fake of WREN. A parity test checks that the phone app writes exactly the documents the desktop scanner page writes. A Playwright test feeds a generated barcode video to Chrome's fake camera and checks that every appearance is counted once, with Chrome's own BarcodeDetector and with the zxing fallback that iPhones get.

A Python client of 25 lines (wren.py) wraps the API. Its comments record two things the agent learned the hard way: the API lives under /api/v1 (at the time the docs showed unprefixed paths), and Cloudflare in front of WREN answers Python's default User-Agent with 403 error code: 1010.

How counting works

The scanner page (/receive) treats a USB or Bluetooth barcode scanner as what it is: a burst of keystrokes ending in Enter. It listens for that at document level, beeps once for a known barcode and twice, lower, for an unknown one. An unknown barcode opens a dialog to attach it to an existing product or create a new one, so the next scan just works. Scans of 18 or 19 digits are read as an EAN-13 plus a batch suffix. The check digit only ranks the possible readings, because one supplier publishes at least one barcode with a wrong check digit. Since October the page can also read barcodes from the phone's camera, and the phone app (/lager) does one job at a time in plain German for someone with a box in one hand.

It has four modes:

  • Delivery scans the boxes against a supplier order and writes a receipt. It does not touch stock. Each line is settled later on the receipts page: onto the shelf, back to the supplier, or held.
  • Inventory counts a shelf and sets the quantity of the scanned products, and only those.
  • Event packs stock for a tournament booth and scans it back afterward. The one event so far, a tournament in mid-September, was still open at the time of the backup, with most of what went out scanned back.
  • Ship packs a customer order. It beeps when one too many is in the box and flags products that aren't on the order.

Nothing is written to stock until you press the button. Until then the tally lives in two places: in the browser at once, and in WREN within a second, as a mae-drafts document. A dead phone, a cleared browser or a second device loses nothing; on load, the newer copy wins.

js · from the scanner page
// Write a quantity, reading the document first so we never clobber a change.
async function writeQuantity(code, {delta, set, seed, countedBy}) {
  const key = encodeURIComponent(code);
  let doc = null;
  try { doc = await api('GET', `/mae-stock/by-key/${key}`); } catch (e) { if (e.status !== 404) throw e; }
  const data = doc ? doc.data : seed;
  const wanted = set !== undefined ? set : (Number(data.quantity) || 0) + (delta || 0);
  const next = {...data, quantity: Math.max(0, wanted), /* counted_by, dates … */};
  return api('PUT', `/mae-stock/by-key/${key}`, next);   // new version, same document
}

The comment promises more than WREN can give: reading first doesn't stop a second device from writing between the read and the write. More on that under warts.

Two labels mark versions worth coming back to. In the page's own words, "Wren already versions every document, so a label is not an audit trail — it is a name for one version worth coming back to." counted on a stock document marks the version that was a real physical count, as opposed to arithmetic from deliveries. as-recorded on a receipt keeps what came out of the box before anyone settled lines.

One item's history

Every movement is a new version of the stock document, and event, shipment and receipt lines record which stock version they produced (stock_version_out, stock_version_back). Here is a white dobok in one size, from its first count to the second delivery:

Versions of one stock document Quantity on hand of one item across seven versions: set from a size tally on September 8, unchanged when the supplier was added, lower when some went to a tournament on September 11, back up when they returned on September 15, empty when an order was shipped the same day, confirmed empty by a scanned recount on September 25, and up again after a delivery was settled onto the shelf on September 30. on hand 0 v1v2v3 v4v5v6v7 Sep 8size tally Sep 8supplier added Sep 11out to event Sep 15back Sep 15shipped Sep 25recount: empty Sep 30delivery scanned
Figure 2. Seven versions of one mae-stock document. Nobody designed a movement log; the version history is one.

From tracker to shop

The shop never reads WREN. On 2026-09-17 the agent working on the tracker sent the agent working on the shop a stock snapshot as text, and the shop side saved it as wren-stock-20260917.csv, with columns item_code, on_shelf, returns_not_yet_shelved, still_out_at_event, supplier, description. A new script, apply-stock.js, takes the first two columns:

bash
node packages/db/scripts/apply-stock.js packages/db/data/wren-stock-YYYYMMDD.csv [--dry-run] [--zero-missing]
  • Matching codes. One supplier's item codes are the shop's variant SKUs. The other supplier's codes are article references, mapped with the same transform the catalog import uses (a prefix, lower case, spaces removed).
  • Through the shop's own function. Every change goes through shop.admin_update_stock, so product totals and the revision log stay as consistent as with the admin UI.
  • First run. The dry run matched all but a handful of codes. One shop SKU had been renamed, and a few codes weren't in the shop yet.
  • Placeholders exposed. About 40 shop variants with stock were not in the snapshot. Every one of them still had its value from the shop's March 2026 seed, with no stock change since: placeholders, not counts. The tracker added them as "not counted, assumed zero", with the shop's figure in a note, and the shop zeroed them with --zero-missing.
  • Barcodes the same way. The shop had barcodes for only a few percent of its variants. The tracker sent the barcodes from mae-stock plus several hundred more from one supplier's catalog, and apply-eans.js loaded them, cutting 18 and 19 digit scanner reads to EAN-13 and skipping placeholder codes.

That was the only sync. On 2026-10-05 the shop still showed the counts of September 17, while the tracker had moved on after a second delivery.

Timeline

Date (2026)What happened
Mar 29 – Apr 4Shop repository started: storefront, MCP server, club stores, an EAN column on products and variants
Sep 8WREN org created; first stock count, the first supplier orders, and the stock, order and receive pages
Sep 11First deliveries scanned; packing for a tournament booth; the scanner page changed 22 times that day
Sep 14 – 16Ship mode and the shipments page; event returns scanned
Sep 17First snapshot applied to the shop; apply-stock.js and apply-eans.js committed; uncounted lines added to the tracker
Sep 25Shelf recount with the new counted label; 20 product illustrations uploaded
Sep 30Second delivery received and settled
Oct 1Last writes before the backup used for this page
Oct 5Tracker code committed to git; camera scanning, installable phone app, German warehouse app; a code review of how it uses WREN

What worked well

  • Upsert by key from day one. Every collection declared its natural key on the first day, so the pages could write PUT /mae-stock/by-key/{item_code} without looking up ids.
  • Versions as the movement log. 960 versions of the stock documents, each one a count, a delivery, an event or a shipment. Event and shipment lines point at the stock version they produced, so any number on the stock page can be traced back.
  • Schemas that say what a number means. counted_by separated real counts from assumptions. That's what showed the shop's seed values for what they were.
  • Drafts that survive. A tally saved to WREN within a second meant a long event return (one draft has 217 versions) never depended on one phone.
  • No server of its own. Static pages and a spreadsheet in a tree, same-origin with the API, using the browser session. There is no backend to deploy, and the pages run on any phone or laptop.
  • Files next to data. The supplier's Excel order form lives in the same tree. The order page fills its quantity cells in the browser and hands back the workbook, with formatting and formulas intact.
  • Tests that run the real page code. The parity test is what lets two front ends (desktop and phone) write the same data safely.

Warts and workarounds

An honest list, from the data and from a review of the code. The status column refers to WREN 0.10.0, the release that publishes this page.

The workarounds from all case studies, with the better way for each, are collected in I did this. Is there a better way?

What happenedWorkaroundRoot causeStatus
Stock reached the shop once, as text passed between two agent sessions, and was 18 days old by October 5CSV plus apply-stock.js, run by handThe shop side had no WREN credentials. When the shop's agent looked for a key in the server's configuration, its session blocked that, correctly.Design: a narrowed read key or a webhook (Part B)
About 40 shop variants showed stock nobody had counted--zero-missing; "not counted, assumed zero" in the trackerThe shop's seed values looked like countsData modeling: record how a figure was obtained
Two devices moving the same item within a second or two: one movement is lost, silentlyNone; "reading first" doesn't prevent itEvery write is read, compute, PUT the whole document; WREN has no conditional writeStill open; detect it after writing (Part B)
An apply that failed halfway, retried, moves the lines that already went through a second timeNone; the phone app asks the warehouse to call the ownerThe tally is cleared only when every line succeeded; ship.py writes the record lastCode fix: settle each line as it's written (Part B)
Re-running the seed would overwrite live stock and order editsTreated as a one-offIt reads existing stock with ?limit=1000, but lists return at most 200, newest first, so the oldest documents look missing; order lines are upserted in fullCode fix: create only, read with a cursor (Part B)
A failed tree assignment, or an invoice uploaded twice, leaves orphaned documentsNonePOST isn't idempotent; files had no name to upsert byFixed: files by name (0.9.0)
Each script run resets schema settings changed in the Admin UINoneScripts send PUT …/_schema on every run, which replaces the whole record, including the natural keyFixed in 0.10: PATCH …/_schema changes only the fields sent
The phone app's service worker can answer a read-before-write from its offline cacheThe page sees an offline header but uses the copyOffline support and arithmetic on stale dataCode fix: never compute a write from a cached read
The tally autosave made 477 versions in mae-drafts, 217 of them on one documentNoneEvery write keeps the previous versionFixed: retention policies (0.8.0)
Pages were changed while in use: the scanner page has 34 versions, 22 from the first delivery day; the pages' history takes 7.3 MBNone; each upload was live at onceNo preview step, and every version kept in fullPreview and promote; retention (stored-once only helps with identical bytes: 70 of 71 page versions differ)
All 2,290 versions in the org are recorded under the same user, whether a scan or the agent wrote themA device id in drafts and via on each scanOne owner account; the scripts' key acts as its creatorGroups, invites, MCP sign-in (Part B)
The scripts' key can do anything the owner can, and never expires; one script put it on curl's command lineNoneNo key: rules on itkey: rules narrow any key, including the owner's (0.7.0)
Product illustrations needed a key, so the shop copied them from the older public store insteadDownload from the public siteEverything stored privately, including images meant to be publicPublic collection rule with a label filter (Part B)
Only about a fifth of the items carry the counted labelNoneThe label arrived halfway through the recount of September 25, and failures to set it are ignored on purposeLesson: decide labels before the first write; wrong labels can now be removed (0.9.0)
A second device only sees new scans after a reloadA Refresh buttonPages read on loadLive events (0.7.0)
One supplier's whole catalog is embedded in the scanner page and the phone appRebuild the pages when the catalog changesReference data kept in code, partly so the app works offlineTrade-off: a catalog collection, cached by the service worker

Part B

The same system, built today

Same pages, same scanner, same shop database. Stock stays private: it's operational data, not something to publish. What changes is how the counts reach the shop, how the scripts survive failures, who is recorded as writing, and how pages and history are managed. Everything below is checked against WREN 0.10.0.

Data flow, built today Warehouse helpers sign in with their own accounts through a Warehouse group, and agents connect through MCP. Their writes go to private collections with retention policies. publish.py uploads pages by file name with a preview label and promotes them. A webhook on document.updated notifies a small receiver in the shop, which reads the changed items by key with a read-only key and calls admin_update_stock. Product images are public through a label-filtered rule. Pages refresh from live events. Helpers + scanner own accounts, group Warehouse; live events Agents via /mcp sign in as a user publish.py by file name; preview, promote WREN · private mae-stock mae-order receipts, events, shipments, drafts retention: drafts 5, stock all tree mae: "published" for helpers every version names its writer webhook: document.updated WREN · public mae-product-images, "published" Shop receiver checks the signature, GET by-key (read-only key) admin_update_stock Shop + storefront images by public URL
Figure 3. Built today: the shop hears about each change as it happens and reads only what it needs; people and agents write under their own names.

1 · Stock to the shop, without a snapshot

Give the shop its own key and narrow it to reading stock. A key with key: rules uses only those rules:

http · admin, once
POST /api/v1/keys          {"name": "shop-stock-read"}                      → id + key (shown once)
POST /api/v1/permissions   {"principal": "key:<id>", "resource": "collection:mae-stock", "access": "read"}

Hand the key over through a secret store, never a chat. With it, apply-stock.js reads mae-stock directly instead of a CSV: POST /api/v1/mae-stock/_query with select: ["item_code", "quantity", "ean", "counted_by"], up to 1,000 per page, following the cursor. The matching and admin_update_stock stay as they are. Because counted_by comes along, the shop can treat "not counted, assumed zero" differently from a scanned zero instead of losing the difference in a CSV.

To stay current without anyone running a script, subscribe a webhook. Webhooks filter by event type, so the receiver skips other collections. It reads each changed item by key and applies it:

js · shop receiver, sketch
// POST /api/v1/webhooks {"url": "https://<shop>/hooks/wren", "events": ["document.created", "document.updated"]}
async function onWrenWebhook(req) {
  const body = await req.text();
  if (!validSignature(body, req.headers.get("X-Wren-Signature"), WEBHOOK_SECRET)) return new Response(null, { status: 401 });
  for (const { payload } of JSON.parse(body).events) {
    if (payload.collection !== "mae-stock" || !payload.key) continue;
    const r = await fetch(`${WREN}/api/v1/mae-stock/by-key/${encodeURIComponent(payload.key)}`,
                          { headers: { Authorization: `Bearer ${SHOP_STOCK_KEY}` } });
    const { data } = await r.json();
    await applyStock(data.item_code, data.quantity);   // same SKU matching + shop.admin_update_stock
  }
  return new Response(null, { status: 204 });
}

Events within a few seconds arrive as one delivery, failed deliveries are retried, and POST /api/v1/webhooks/{id}/replay re-sends the last seven days, so a shop outage only delays the update. Since 0.9, a write whose data equals the current version creates no version (the response says "unchanged": true), so a recount to the same figure doesn't wake the receiver for nothing.

2 · Writes that survive retries and a second device

Most of the code review's findings are about what happens when a write fails halfway or a second writer acts at the same moment. Three changes cover them:

  • Settle each line as it's written. Remove a row from the tally (and the draft) as soon as its stock write succeeds, and save the shipment, event or receipt after each line. A retry then replays only what failed. ship.py skips lines that already carry a stock_version_out. Create a receipt's key once, when the tally starts, and keep it in the draft, so a retried save upserts the same receipt.
  • A seed that can't overwrite. The seed only creates documents that don't exist (GET …/by-key/{key} answers 404) and never touches a live one. Reading "everything" goes through _query with a cursor, never ?limit=1000 on the list endpoint, which returns at most 200.
  • Notice a lost update. WREN can't refuse a stale write yet, but version numbers go up by exactly one per write, so the writer can tell afterwards that someone else wrote in between, and re-apply its change on top:
python · in the scripts' WREN client
def move(code, delta):
    """GET → PUT on one stock document, and notice when someone else wrote in between."""
    path = f"/mae-stock/by-key/{quote(code)}"
    c, doc = call("GET", path)
    data, v0 = doc["data"], doc["version"]
    c, saved = call("PUT", path, {**data, "quantity": max(0, int(data["quantity"]) + delta)})
    expected = v0 if saved.get("unchanged") else v0 + 1          # unchanged: no new version (0.9)
    if saved["version"] != expected:
        # someone wrote between our GET and PUT, and our PUT replaced it: re-apply on top of theirs
        c, theirs = call("GET", f"/mae-stock/{saved['id']}/versions/{saved['version'] - 1}")
        fixed = max(0, int(theirs["data"]["quantity"]) + delta)
        c, saved = call("PUT", path, {**theirs["data"], "quantity": fixed})
    return saved["version"]

The pages get the same check after their PUT, or at least a warning ("someone else changed this item at the same time"). And a read that comes right before a write must not be answered from the service worker's offline cache.

3 · Snapshots before risky runs, and a way back

The booth repairs ran against live data with hard-coded ids and no way back except rolling documents back one by one. Label the collection first; if the run goes wrong, one call puts it back:

http
# before a recount, a seed with overwrite, or a repair script: label every stock document
POST /api/v1/mae-stock/{id}/labels       {"label": "before-recount-2026-10"}      ← per document

# if it went wrong: one transaction for the whole collection (0.9)
POST /api/v1/mae-stock/_restore          {"label": "before-recount-2026-10"}
→ { "label": "before-recount-2026-10", "restored": …, "undeleted": …, "deleted": 0, "unchanged": …, "collections": ["mae-stock"] }

Restore writes the labeled content as new versions, brings back deleted documents that carry the label, and leaves documents already at the label alone. History moves forward, so the restore itself can be undone. To see what a recount changed on one item, diff the label against the current version: GET /api/v1/mae-stock/{id}/diff?v1=before-recount-2026-10&v2={version}; on receipts, &deep=true names the changed line (/lines/3/quantity) instead of reporting the whole lines array as replaced. A counted label set on the wrong version can be removed: DELETE /api/v1/mae-stock/{id}/labels/counted. And make label failures visible in the page instead of swallowing them.

4 · Publishing pages: by file name, preview, then promote

publish.py is the deploy path this project actually uses, so keep it and let WREN do the bookkeeping. Since 0.9, a file collection whose natural key is filename is addressed by name. Setting the key registers the names of the existing files; if two live documents share a name (the orphans from failed runs), WREN refuses with 409 and lists them, so delete those first:

python · once, then publish.py
call("PUT", "/mae-pages/_schema", {"collectionType": "binary", "naturalKey": "filename",
                                   "displayName": "{filename}"})      # 200, or 409 listing duplicates

for path, src in files(pages):
    code, doc = upload("PUT", f"/mae-pages/by-key/{quote(src.name)}", src)   # 201 created / 200 replaced / 200 unchanged
    if code == 201:
        call("PUT", f"/tree/mae{path}", {"documentId": doc["id"]})            # new file: mount it once
    if not doc.get("unchanged"):
        call("POST", f"/mae-pages/{doc['id']}/labels", {"label": "preview"})

No tree listing, no hash comparison in the script, no orphans: an unchanged file returns "unchanged": true and creates no version. Do the same for invoice PDFs (PUT /api/v1/mae-invoice-files/by-key/{invoice_key}.pdf), so uploading an invoice twice is a no-op. And stop the scripts from sending PUT …/_schema on every run, or the next run clears the natural key again.

The scanner page was changed 22 times on the first delivery day, each change live on the phone doing the scanning. Once helpers use the app, try a change at /api/v1/tree/mae/lager?label=preview, then release all pages at once with POST /api/v1/tree/mae/_promote {"label": "published", "from": "preview"}, one transaction. That protects people whose rule on tree:mae has labelFilter: "published"; the owner always sees the current version. If a release was bad, POST /api/v1/tree/mae/_restore {"label": "release-…"} puts every page back to a release label in one transaction.

5 · People and agents under their own names

Today every version says the owner wrote it. Invite the people who scan into a group with write access to the tracker's collections, so each version names who made it:

http · admin
POST /api/v1/groups        {"name": "Warehouse", "description": "scan and count"}
POST /api/v1/permissions   {"principal": "group:<gid>", "resource": "collection:mae-stock", "access": "write"}
   …same for mae-order, mae-receipts, mae-events, mae-shipments, mae-drafts
POST /api/v1/permissions   {"principal": "group:<gid>", "resource": "tree:mae", "access": "read", "labelFilter": "published"}
POST /api/v1/invites       {"email": "…", "role": "member", "groupIds": ["<gid>"]}

Label filters on the tree, not on the data. Under a label-filtered rule, version history and diffs return 403. The receipts page rolls stock back by version number, so the helpers' rules on the data collections must not have a label filter.

Narrow the scripts' key with key: rules to the mae-* collections it writes and tree:mae. Agents can connect to the built-in MCP endpoint instead of holding a long-lived key: signing in through /mcp/login shows a consent page, acts as that user in that org, and issues one-hour tokens that can be revoked under Settings → Connected apps. MCP writes are labeled preview by default, which suits proposals such as a reorder list a person then accepts. For stock writes, pass label: "".

6 · Live pages

Two people scanning at once should see each other's work without a reload, which also makes lost updates rarer. The pages are on the same origin as the API, so the session cookie is enough:

js
const es = new EventSource("/api/v1/_events?collections=mae-stock,mae-drafts");
es.onmessage = e => {
  const ev = JSON.parse(e.data);
  if (ev.type === "reset") return reloadAll();                          // missed events: re-read
  if (ev.collection === "mae-stock" && ev.key) refreshItem(ev.key);     // events carry keys, never data
};

First let the stream past the phone app's service worker, which would otherwise try to cache an endless response. Range filters compare ISO dates since 0.9, so the shipments, receipts and events pages can ask only for recent records (where=shipped_on>=2026-09-01) instead of reading everything. Server-side totals (_query with aggregate) help less than they seem here: the stock page shows every row anyway.

7 · Retention: keep the counts, drop the noise

Stock history is the audit trail and must stay complete: receipts, events and shipments store stock version numbers for rollback. Draft autosaves and superseded page builds can go. Preview first; current and labeled versions are always kept:

http · admin
POST /api/v1/retention/mae-drafts/_preview  {"maxVersions": 5}
PUT  /api/v1/retention/mae-drafts           {"maxVersions": 5}
PUT  /api/v1/retention/mae-pages            {"maxVersions": 10}       # later {"afterLabel": "published"}
PUT  /api/v1/retention/mae-stock            {}                        # exempt from any org default
   …{} also for the receipts, events, shipments, orders and invoices

Drafts always change, because each save writes a fresh timestamp, so unchanged writes don't slow their growth; retention does. Label the versions you want to keep forever before you set a policy: counted on every real count, as-recorded on every receipt, and the before-… snapshots. Labeled versions survive every rule.

8 · Public images, private everything else

The 20 product illustrations are meant for the storefront. A collection rule with a label filter publishes exactly those, and only once they're labeled:

http
POST /api/v1/permissions  {"principal": "*", "resource": "collection:mae-product-images",
                           "access": "read", "labelFilter": "published"}
# the shop links: /api/v1/orgs/{slug}/mae-product-images/{id}/raw/{name}.png

Use a collection rule, not a tree rule. The images are also mounted in the mae tree, next to the stock pages. A tree:mae rule would publish every page in it. Public access is granted per whole tree or collection, so anything public needs its own tree or collection.

Side by side

ConcernAs builtBuilt today
WritesPUT /by-key/{key}same, plus a version check after each write
Failed appliesretry replays every lineeach line settled as it's written
Stock to the shopCSV passed between agent sessions, script run by handwebhook plus by-key read with a narrowed key
Barcodes to the shopone-off CSV, apply-eans.jsthe same webhook, or _query on ean
Page and PDF uploadstree lookup, hash check, PUT by id or POST (orphans on failure)PUT …/by-key/{filename}
Page changeslive on uploadpreview label, atomic promote, tree _restore to a release
Undo a bad runroll back document by documentlabel first, collection _restore
Who wrote itthe owner, alwayseach helper and each agent by name (groups, invites, MCP sign-in)
Agent and script accessowner-equivalent key, no expiryMCP sign-in or a key narrowed with key: rules
Other devicesreloadlive events
History sizeeverything kept, drafts includedretention per collection; stock kept in full
Product imagesprivate; copied from another sitepublic collection rule, label-filtered

Still open

  • Conditional writes need adopting. Since 0.10 a write can say "only if still at version n": the scanner sends the version it read as If-Match and gets 412 with the current version if someone wrote in between, then re-reads and re-applies the count. Imports use ?ifVersion=0 so a re-run can't overwrite live stock. The pages and scripts as built don't send it yet; that is part of the shop's own to-do list.
  • Schema changes: use PATCH in scripts. PUT …/_schema still replaces display name, columns, indexes and natural key together. Since 0.10, PATCH …/_schema changes only the fields sent, so a script that sets its schema on every run no longer drops settings made in the Admin UI.
  • Webhooks can't be filtered by collection. They filter by event type, so a receiver sees every write in the org and has to discard most of them.
  • Public files by name. Since 0.10, a public file collection keyed by filename is also served by name: /orgs/{slug}/{collection}/by-key/{name}/raw. Stock photos stay private here, so this matters only for files the shop chooses to publish.
  • Two systems of record. WREN knows what's on the shelf, and the shop knows what sold. Nothing flows back: goods leave the tracker's stock only when they're scanned in Ship mode. And mapping supplier codes to shop SKUs lives in the shop's scripts, not in either data model.

Lessons

  1. Record how a number was obtained, not just the number. counted_by caught about 40 placeholder quantities that would otherwise have been sold as stock.
  2. Declare natural keys before the first write, for files too. Documents without a name are what failed runs leave behind.
  3. Design every apply for the retry. Settle line by line, so running it again only does what's left.
  4. Label before you repair. A label set before a risky run makes undoing it one call.
  5. Let systems talk to each other, not through agents. A snapshot relayed between two sessions worked once and then went stale. A narrowed key and a webhook keep working when nobody is looking.
  6. Give every writer an identity. One shared account makes a perfect history that can't say who did what.

For a public, read-heavy project on the same ideas, see the tournament tracker case study. The agent sessions that built this tracker logged their work in the task tracker, and the label-and-restore pattern is worked out for test data in repeatable test data. New to these ideas? Start with Concepts.