# Changelog

One entry per released tag, grouped by minor. Latest first.

Pre-1.0 the surface is intentionally evolving — every release may
change something operators depend on. Read each entry before
upgrading across more than a few patches at a time.

## v0.0.x

- v0.0.113 (2026-05-23) — **Catalog mirror — 66 new codebase-patterns detectors ported from vendored blamejs.** The codebase-patterns detector catalog grows from 48 to 114 — 66 new detectors ported verbatim from `lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js`. The full vendored set covers archive-handling traps, framework-helper composition (the `inline-*` family), Node-version-conditional patterns, SBOM derivation traps, mail-server TLS upgrade hygiene, mountinfo bind-detection invariants, and dozens of other bug classes the framework has surfaced through audits. Every new detector scopes `shop` (lib + worker) so reinventions are caught anywhere in the application surface. Vendor allowlist entries referencing internal blamejs paths (e.g. `lib/crypto.js`) are kept verbatim — shop's `_walk` skips the vendor tree, so those entries are harmless no-ops and preserve the operator-readable reason text. Three pre-existing inline reinventions surfaced through the port and are kept with inline `allow:` markers documenting why the framework helper can't compose — the existing `TypeError` message contract requires the literal field name (`/timeoutMs/`, `/opts.version/`) which `validateOpts.*`'s `code`-first error shape doesn't carry. Eight detector blocks (six with regex literals containing literal `"` characters that the splice tool can't roundtrip, plus `release-named-test-file` whose `matchOn: "basename"` directive shop's runner doesn't honor yet, plus `inline-base64url-three-replace` already ported in 0.0.112) defer to a follow-up. **Added:** *Mirror 66 new codebase-patterns detectors from vendored blamejs* — Categories: archive-handling (extract-overwrite-without-refusal, gz-without-safedecompress, read-gz-without-self-authored-budget, tar-walker-without-truncation-check, wrap-recipient-missing-ec-half — except wrap-recipient which deferred to follow-up); the `inline-*` family of framework-helper composition reminders (40+ detectors covering aggregate-issues, assert-no-char-threats, audit-emit-wrapper, audit-shape-validation, bad-input-issue-result, batch-positive-int-validation, buffer-byte-equality-loop, build-guard-gate-forwarder, char-strip-policy-cascade, codepoint-class-table, compliance-posture-lookup, crlf-string-test, default-resolution-cascade, detect-char-threats, emit-event-wrapper, extract-bytes-as-text, flush-timer-scheduler, hex-string-validator, iso8601-millisecond-strip, issue-validator-entry, log-via-or-fallback, migration-filename-regex, numeric-bounds-cascade, object-store-http-request, observability-shape-validation, optional-* validators, profile-builder-forwarder, redis-client-opts-forwarding, require-* validators, resolve-profile-and-posture, rule-pack-loader, sql-identifier-regex, trailing-hspace-strip); SBOM derivation traps (toplevel-ref-by-slash-heuristic, bom-ref enum-rank-without-validation, etc.); compliance/posture coverage (compliance-posture-coverage-drift); cred-store + mailstore + mountinfo invariants; PQC + dot-stuff regex shapes; CONDSTORE + BDAT mail-server invariants. Full list visible by diffing `test/layer-0-primitives/codebase-patterns.test.js` against the v0.0.112 baseline. · *Worker `b` adapter exposes `b.validateOpts`* — `worker/b.js` now re-exports the `validate-opts` leaf module so future Worker primitives can compose `b.validateOpts.optionalPositiveFinite` / `b.validateOpts.optionalFunction` / `b.validateOpts.requireNonEmptyString` etc. against the same one-source-of-truth as lib-side primitives. Adds the leaf to the Worker bundle but doesn't bind a public namespace change. **Changed:** *Three inline-validation sites carry per-line `allow:` markers* — `lib/externaldb-d1.js#_validateOpts` (two lines: `timeoutMs`, `fetch`), `lib/r2-bridge.js#_validateOpts` (one line: `timeoutMs`), and `worker/render/search.js#renderSearch` (one line: `opts.version`) keep the literal `TypeError` shape with the labelled message because each is paired with a test contract that asserts the field name appears in the message (`assert.throws(..., /timeoutMs/)`). The framework's `validateOpts.*` helper produces a `code`-first `TypeError` (`TypeError: validate-opts/bad-positive-finite`) whose message doesn't carry the label; routing through it would silently strip the labelled-field contract from the test. Marker explains the constraint at the source.

- v0.0.112 (2026-05-23) — **Compose `b.crypto.toBase64Url` instead of reinventing — nine primitives + new detector.** Nine framework primitives (`api-keys`, `carrier-accounts`, `customer-surveys`, `stock-alerts`, `stock-receipts`, `subscription-gifts`, `webhook-receiver`, `wishlist-sharing`, `storefront`'s server-side `_b64u`) shipped with hand-rolled base64url encoding — the canonical four-line `.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")` chain. The trailing-padding `.replace(/=+$/, "")` strip is polynomial-ReDoS-shaped per CodeQL js/polynomial-redos. The framework's `b.crypto.toBase64Url(buf)` helper routes through Node's built-in `base64url` encoding which is linear-time and produces the same RFC 4648 §5 output. Every server-side callsite now composes the framework primitive. Two browser-side helpers in `lib/storefront.js`'s WebAuthn UI string-template (no `b.crypto` available in the page runtime) carry inline `allow:inline-base64url-three-replace` markers — `window.btoa` plus the three-replace shim is the browser-built-in equivalent. New `inline-base64url-three-replace` codebase-patterns detector enforces the composition shop-wide. **Added:** *`inline-base64url-three-replace` codebase-patterns detector* — Flags `.replace(/=+$/, ...)` anywhere under `lib/` or `worker/`. Catches the canonical four-line base64url reinvention chain at the trailing-padding-strip line. Ported verbatim (with shop-scope) from blamejs's catalog. Browser-side string-template emissions in `lib/storefront.js` carry inline allow markers documenting that the browser has no `b.crypto` runtime — `window.btoa` plus the three-replace shim is the built-in. **Changed:** *Nine primitives compose `b.crypto.toBase64Url`* — `api-keys` `_generateToken`, `carrier-accounts` `_generateSecret`, `customer-surveys` `_generateToken`, `stock-alerts` `_mintToken` (inlined the redundant `_b64url` helper), `stock-receipts` `_generateToken`, `subscription-gifts` `_generateToken`, `webhook-receiver` `_generateSecret`, `wishlist-sharing` `_generateToken`, and `storefront`'s server-side `_b64u` helper all now route through `_b().crypto.toBase64Url(buf)`. Linear-time output, no polynomial-ReDoS surface, no Node-minor-version Buffer-flag-rename risk.

- v0.0.111 (2026-05-23) — **Restore /feed.xml + /sitemap.xml — `b.safeUrl.parse` returns a URL instance, not a string.** `/feed.xml` and `/sitemap.xml` have been responding 503 "temporarily unavailable" since they shipped. Root cause: three edge handlers (sitemap, feed, scheduled cache-warmer) chained `.replace(/\/$/, "")` directly off `b.safeUrl.parse(...)`. The framework primitive returns a WHATWG `URL` instance, not a string — `URL.prototype.replace` doesn't exist, so every request threw `TypeError: ... .replace is not a function`. The handler's catch swallowed the throw and returned the canonical 503, making the routes appear transiently unavailable when they were in fact permanently broken on that callsite. The bug went undetected because production deploys were frozen at v0.0.101 for a separate reason (now fixed in v0.0.110), so the broken handlers never reached live traffic until today. Fix: chain `.href.replace(/\/$/, "")` so the string method operates on the URL's href string. A new `safeurl-parse-string-method` detector catches the shape repo-wide (47 detectors → adds 1, total 47 → 48). **Added:** *`safeurl-parse-string-method` codebase-patterns detector* — Flags `b.safeUrl.parse(...).<strMethod>(` anywhere under `worker/` — covers `replace` / `startsWith` / `endsWith` / `split` / `includes` / `slice` / `indexOf` / `toLowerCase` / `toUpperCase`. Property access (`.href`) is intentionally NOT flagged — that's the correct shape. The detector exists because three handlers shipped this anti-pattern without anyone noticing while production was frozen; pre-merge detection prevents the next instance from regressing through review the same way. **Fixed:** *Restore `/feed.xml`, `/sitemap.xml`, and the scheduled cache-warmer* — Three callsites in `worker/index.js` shipped `b.safeUrl.parse(env.D1_BRIDGE_URL || "https://blamejs.shop").replace(/\/$/, "")` — `URL.prototype.replace` doesn't exist, so the worker threw TypeError on every request and the edge handler's catch returned the canonical 503. Replaced with `.href.replace(...)` so the trailing-slash strip runs against the URL's serialized form. The semantic contract (operator-configured origin with no trailing slash) is preserved across operator inputs with or without a path prefix.

- v0.0.110 (2026-05-23) — **Unblock Cloudflare production deploys + vendor blamejs v0.12.11 + vendor-drift demoted to warning.** Production Cloudflare Workers deploys have been failing on every push since v0.0.102. Root cause: the container image excludes `worker/` via `.dockerignore` (the worker is deployed separately by `wrangler deploy`, the container only ships the long-running backend), but `test/smoke.js` runs inside the container build's `RUN node test/smoke.js` step and calls the `worker-syntax` gate, which tries to read `worker/index.js` and crashes with `ENOENT`. The Docker build exits 1, `wrangler deploy` aborts, and the production worker is left frozen at the last successful build. The fix: `scripts/check-worker-syntax.js` now skips with a no-op stderr message and exits 0 when its entry file isn't present in the current build context — outside the container (host smoke / CI / npm-publish) the entry is always present and the strict scan runs unchanged. Vendor refresh: blamejs v0.12.11 (carries upstream's matching disciplines). Vendor-drift gate demoted from failure to warning: drift surfaces on stderr (still visible in CI logs + operator terminal) but no longer blocks `npm test` — the committed vendor tree is the source of truth, and operators don't need to refresh on every blamejs release before they can ship an unrelated patch. **Changed:** *`vendor-update.sh --check` warns instead of failing on drift* — Drift between vendored blamejs and the latest upstream tag now surfaces on stderr as `[vendor-check] WARNING — vendored vA.B.C, latest vX.Y.Z` and exits 0. The committed vendor tree is the source of truth — there's no integrity loss from an older-but-still-supported vendor version, and forcing a refresh on every minor blamejs release before any unrelated patch could ship was friction without a corresponding safety win. CI logs and the operator's terminal still display the warning prominently. · *Vendor refresh: blamejs v0.12.10 → v0.12.11* — Standard shallow-clone refresh via `scripts/vendor-update.sh blamejs v0.12.11`. No code changes outside `lib/vendor/blamejs/`. **Fixed:** *Worker-syntax gate skips gracefully when `worker/index.js` is absent* — `scripts/check-worker-syntax.js` now checks for the existence of its entry file before scanning. When the file is missing (Cloudflare container build context, where `worker/` is excluded by `.dockerignore`), the gate logs `[worker-syntax] SKIPPED — <entry> not present in this build context (no worker tree to scan)` to stderr and exits 0. When the file IS present (host smoke runs, CI runners, npm-publish workflow, edge-render checks) the strict balance-walker scan runs unchanged and a missing trailing `}` still fails the build. Unblocks production deploys that have been failing since v0.0.102.

- v0.0.109 (2026-05-23) — **Blog-table-resilient D1 queries + tighter `edge-handler-catch-returns-null` regex.** Live probing surfaced that `/feed.xml` (503), `/sitemap.xml` (503), and `/blog` (500) all fail when the live D1 doesn't have the `blog_articles` table — migration `0189` ships with the blogArticles primitive (v0.0.75) but hadn't been applied to the live deployment yet. The four blog-touching queries in `worker/data/catalog.js` now treat `"no such table"` errors as "no published articles" and return empty rows, so the dependent edge routes degrade gracefully (feed renders no items, sitemap renders only the product + static URLs, /blog shows the empty-state). The `edge-handler-catch-returns-null` detector also tightens — the previous regex spanned 400 chars after the catch's opening `{`, which reached across function boundaries into the next function's guard-clause `return null;` and tripped a false positive. The new regex uses `[^}]` to stay inside the catch body. **Fixed:** *Blog-table-resilient queries in `worker/data/catalog.js`* — `listPublishedBlogSlugs`, `listBlogArticles`, `getBlogArticleBySlug`, and `recentBlogArticles` now catch `"no such table"` errors from D1 and return empty rows / null. Operators who haven't applied migration `0189_blog_articles.sql` (or who deploy the worker before the migrations land) get a clean degradation instead of `/feed.xml` 503 / `/sitemap.xml` 503 / `/blog` 500. Non-table-missing errors still propagate to the caller's `_edgeError` for the canonical 5xx render. · *`edge-handler-catch-returns-null` regex tightened — no more cross-function false positives* — Previous regex: `catch\s*\(\s*[\w$]+\s*\)\s*\{[\s\S]{0,400}?return\s+null\s*;` — the `[\s\S]` matches anything including the catch's closing `}`, so a small catch body followed shortly by a function-guard `return null;` was matching as if the null were inside the catch. New regex: `catch\s*\(\s*[\w$]+\s*\)\s*\{[^}]{0,400}return\s+null\s*;` — `[^}]` keeps the match inside the catch body. Catches still flagged for injection-tested anti-pattern (`catch (e) { return null; }`); the blog-query data layer's guard-clause `return null` outside any catch is no longer flagged. · *Blog-query refactor — null-return moves outside the catch body* — `getBlogArticleBySlug` now uses a `var row = null` outer binding and assigns inside the try; the catch swallows the missing-table case without an inner `return null`. The function's contract ("returns the row or null when not found") is preserved; the tightened detector regex doesn't flag the new shape.

- v0.0.108 (2026-05-23) — **ETag + `If-None-Match` 304 handling on `/privacy` + `/terms` — saves the body bytes on every browser revalidate.** `/privacy` and `/terms` carry `max-age=3600` so a returning visitor's browser revalidates the resource every hour. Previously the revalidate downloaded the full body each time (~5KB); now the response carries an `ETag` derived from a FNV-1a hash of the minified body, and the handler compares incoming `If-None-Match` against the same etag — on a match it returns `304 Not Modified` with no body. The Edge cache (24h s-maxage) holds the 200 response; revalidates hit the Worker, run the cheap hash, and 99% of the time return a few hundred bytes instead of the full page. FNV-1a is chosen for speed + zero allocation — collision irrelevant because operators (not visitors) control the policy text, so the attacker-controlled input space at this surface is empty. **Added:** *ETag + `If-None-Match` 304 handling in `_staticHtml`* — `_staticHtml(body, method, env, request)` minifies the body, computes a 32-bit FNV-1a hash of the minified bytes, formats as a quoted hex ETag (`"<hash>"`), and returns 304 (with the ETag header repeated per RFC 7232 §4.1) when the incoming `If-None-Match` matches. The hash is content-stable — a re-render of the same operator-set policy text produces the same ETag across edge instances. Carries through to `/privacy` and `/terms`; the dynamic edge-rendered routes (`/`, `/search`, `/products/:slug`, `/blog`, `/blog/:slug`) keep their `no-store` posture since their D1-sourced content changes per-request.

- v0.0.107 (2026-05-23) — **`X-Robots-Tag: noindex, nofollow` on `/cart` — crawlers stop indexing the session-bound empty-state.** The guest `/cart` route ships an empty-cart render at the edge for visitors without a session cookie. Crawlers reaching the page were indexing it as if it were content; the empty-state snippet ("Your cart is empty") was appearing in SERPs as a stale search result for unrelated queries. The handler now sets `X-Robots-Tag: noindex, nofollow` on the response so well-behaved crawlers skip indexing. `robots.txt` already `Disallow`s the route — the response header is belt-and-suspenders. **Changed:** *`_edgeCartEmpty` sets `X-Robots-Tag: noindex, nofollow`* — The handler now builds its own Response (instead of routing through `_html`) so the response carries the X-Robots-Tag header alongside the full security-header set + Link preload + minification. Per-route headers compose cleanly without changing `_html`'s signature for one outlier.

- v0.0.106 (2026-05-23) — **RFC 9116 `/.well-known/security.txt` served from the edge.** Vulnerability researchers, automated scanners, and bug-bounty platforms look for `/.well-known/security.txt` (RFC 9116) before opening an unsolicited email to a generic address. The Worker now serves the canonical text with `Contact`, `Expires`, `Encryption`, `Policy`, and `Preferred-Languages` fields. Contact points at `security@blamejs.shop` and the GitHub Security Advisories private-disclosure URL; Policy points at the repo's `SECURITY.md`. Cached `public, max-age=3600, s-maxage=86400`. **Added:** *`/.well-known/security.txt` — RFC 9116 vulnerability-disclosure metadata* — The Worker emits the canonical text with five fields. `Contact: mailto:security@blamejs.shop` + `Contact: https://github.com/blamejs/blamejs.shop/security/advisories/new` (private-disclosure path through GitHub Security Advisories — researchers don't have to email to start a disclosure). `Expires:` is set to one year from response time so crawlers don't refetch constantly. `Policy: https://github.com/blamejs/blamejs.shop/blob/main/SECURITY.md` points at the in-repo disclosure policy. `Preferred-Languages: en`. Every line ends `\r\n` per the RFC. Carries the full security-header set + 1h browser / 24h edge cache.

- v0.0.105 (2026-05-23) — **Per-route Link preloads — PDP carries its hero image in the HTTP/103 Early Hints frame.** `_html(body, method, env, preloads)` now accepts an extra `preloads` array of `{ href, as }` entries — Worker render handlers can append per-route preload hints alongside the always-present theme-stylesheet preload introduced in v0.0.103. `_edgeProduct` uses the new shape to pass the first media row's image URL as `rel=preload; as=image`, so Cloudflare's HTTP/103 Early Hints frame on a PDP carries both the stylesheet AND the hero image. The browser starts both fetches before the HTML body finishes streaming — cuts product-page LCP by another ~100-200ms on a typical render with a large hero. **Added:** *`_html` accepts per-route preloads via a 4th `preloads` array argument* — `_earlyHintsLink(env, extras)` merges the always-on theme-stylesheet preload with any caller-supplied `{ href, as }` entries into a single comma-separated Link header. Cloudflare's edge promotes the full header to multiple HTTP/103 Early Hints frame entries. Per-route preloads cost zero render time (already part of the response headers); the browser-side fetch starts during the round-trip the HTML body is using. · *`_edgeProduct` preloads the first media row's hero image* — The PDP's LCP element is almost always the hero image. The handler now appends `{ href: "/assets/<r2_key>", as: "image" }` to the `_html` preloads when the product has media; products without media render the placeholder letter-mark without a network fetch (no preload needed). Combined with the v0.0.103 theme-stylesheet preload, the HTTP/103 frame on a PDP carries both critical-path assets.

- v0.0.104 (2026-05-23) — **Organization + WebSite + BreadcrumbList Schema.org JSON-LD — three more rich-result types covered.** Home page now emits Organization (Google knowledge-panel + logo + social link) and WebSite (sitelinks search box pointing at `/search?q=`) JSON-LD blocks. Product detail page gains a BreadcrumbList block matching the on-page `<nav class="breadcrumb">` markup. All three compose through the existing `jsonLdScript` helper from `worker/render/_lib.js`. Combined with the Product + Article blocks from v0.0.94, the storefront now ships five distinct Schema.org graph types — Product / Article / Organization / WebSite / BreadcrumbList — across the routes Google's rich-result tester maps. **Added:** *Organization + WebSite JSON-LD on `/`* — `worker/render/home.js#renderHome` appends two `<script type="application/ld+json">` blocks to the body. Organization: `name` + `url` + `logo` + `sameAs` (GitHub mirror) — surfaces in Google's knowledge-panel result. WebSite: `potentialAction` with a `SearchAction` template at `/search?q={search_term_string}` so Google's sitelinks search box hands queries directly to the storefront's search route. · *BreadcrumbList JSON-LD on `/products/:slug`* — `worker/render/product.js#renderProduct` appends a BreadcrumbList block alongside the existing Product block. Two positions: `Shop` (`/`) → `<product title>` (`/products/<slug>`) — matches the on-page breadcrumb nav. Google's product-rich-result panel renders this above the title.

- v0.0.103 (2026-05-23) — **HTTP/103 Early Hints `Link: rel=preload` on every HTML response + vendor v0.12.10.** Every edge-rendered HTML response now carries `Link: </assets/themes/default/css/main.css?v=<version>>; rel=preload; as=style; crossorigin` in the headers. Cloudflare auto-promotes Link headers on 200 responses to HTTP/103 Early Hints frames — repeat visitors see the stylesheet fetch start before the HTML body arrives, accelerating First Contentful Paint and Largest Contentful Paint. The single preload covers the theme bundle every page references; per-route preloads (hero images, etc.) compose by appending to the same header. Bundles vendor refresh from blamejs v0.12.9 to v0.12.10. **Added:** *`Link: rel=preload; as=style` on every HTML response — HTTP/103 Early Hints eligibility* — `worker/index.js` exposes `_earlyHintsLink(env)` returning the canonical preload header for the theme stylesheet (`/assets/themes/default/css/main.css?v=<WORKER_VERSION>`). `_html(body, method, env)` and `_staticHtml(body, method, env)` both inject the header into every 200 / static HTML response. Cloudflare's edge auto-promotes Link headers on 200 responses to HTTP/103 frames — repeat visitors and clients with HTTP/3 see the stylesheet preload start before the HTML body finishes streaming, cutting LCP by ~50-150ms on a typical render. Per-route preloads (e.g. hero image on a product page) compose by appending an additional Link entry. **Changed:** *Vendored blamejs refreshed from v0.12.9 to v0.12.10* — `bash scripts/vendor-update.sh blamejs v0.12.10` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.9 and v0.12.10.

- v0.0.102 (2026-05-23) — **`_redact` composes `b.redact.redact` after the shop-specific patterns — defense-in-depth on log emission.** The Worker's `_redact` helper carried a hand-rolled chain of regex replacements covering shop-specific log shapes (Authorization Bearer / Stripe-Signature / shop_sid / x-d1-bridge-secret / hex digests / opaque tokens). The framework's `b.redact.redact` covers a different set — PEM blocks, AWS access keys, credit-card numbers, SSNs, connection strings, well-known credential field-names — that the shop chain doesn't enumerate. This patch keeps the shop chain (it catches header / cookie shapes b.redact misses) and composes `b.redact.redact` on top so the framework's catalog of sensitive value shapes also runs over the output. Log emissions through `_redact(...)` are now covered by both layers. **Changed:** *`_redact` chains `b.redact.redact` after the shop-specific patterns* — Order: (1) shop chain — Bearer / Stripe-Signature / JWT-tri-segment / shop_sid+shop_auth cookies / d1_bridge_secret + admin_api_key + stripe_*_key headers / 32-char hex / 40-char opaque token; (2) `b.redact.redact` on the result — picks up PEM `-----BEGIN`, AWS `AKIA*`, credit-card Luhn, SSN, JWT (stricter shape), connection strings, and the framework's sensitive-field-name catalog. Both run on every Worker `console.error` / observability emission that flows through `_redact`. `b.redact` joins the Worker-side primitive surface via `worker/b.js` alongside the v0.0.100 `b.cookies` addition.

- v0.0.101 (2026-05-23) — **`worker-syntax` smoke gate (network-free brace/paren/bracket walker) — replaces the broken esbuild gate.** v0.0.99's `worker-esbuild-parse` gate needed `npx esbuild` and the Cloudflare Workers Builds container test stage has no network; v0.0.100 had to remove it. This patch ships `scripts/check-worker-syntax.js` — a self-contained Node walker that reads every reachable file under `worker/` tracking string / template / comment / regex-literal state and asserts balanced braces / parens / brackets at EOF. Catches the node-tolerates / esbuild-refuses class that broke v0.0.95 + v0.0.97 (missing trailing `}` on a function declaration). Runs without an npm-registry hit, runs inside the container build stage without network access, runs in ~50ms. Verified via injection — appending `function _badInject() {` to `worker/index.js` trips the gate; restoration clears it. **Added:** *`scripts/check-worker-syntax.js` + `worker-syntax` smoke gate* — Walks `worker/index.js` and every locally-imported worker module (`./*.js` / `../*.js` references resolved to files under `worker/`) one-deep — at the time of writing that's 10 files. Per-file state machine tracks single-quoted strings, double-quoted strings, template literals (with `\` escape handling), line + block comments, and regex literals (including `[ ]` char classes that contain a literal `/`). At EOF, asserts `{ } ( ) [ ]` are all balanced; an unclosed string / template / comment also fails. On imbalance, the failure message names the file + line of the last-opened token that didn't close. · *Wired into `test/smoke.js` as the fourth static gate* — Runs alongside `release-notes-rollup` / `changelog-in-sync` / `vendor-drift`. Verified by injection — `function _badInject() {` appended to `worker/index.js` produces `[worker-syntax] FAIL — worker/index.js:<line> — imbalance braces=1 parens=0 brackets=0 — opening \`{\` at line <N> not closed` and exits smoke non-zero; restoring the file clears the gate.

- v0.0.100 (2026-05-23) — **Remove the broken `worker-esbuild-parse` gate (v0.0.99 deploy blocker) + compose `b.cookies` for the session-cookie presence check.** v0.0.99's `worker-esbuild-parse` smoke gate ran `npx esbuild` inside the container's Dockerfile test stage. The test stage doesn't carry `node_modules` and the build environment has no network access at that point, so `npx esbuild` couldn't download esbuild and exited non-zero — breaking the smoke gate, breaking the auto-deploy. Gate removed. The original failure mode the gate was guarding against (a missing closing brace that Node tolerates but esbuild refuses) still surfaces in the Cloudflare Workers Builds run itself; a redesigned gate that doesn't need a network-reachable npm registry will land in a follow-up. Same patch composes `b.cookies.parseSafe` for the `_hasSessionCookie` presence check (replaces the inline regex with the framework's parser + grammar enforcement). **Changed:** *`_hasSessionCookie` composes `b.cookies.parseSafe` instead of a regex presence check* — Previous shape: `/\b(shop_sid|shop_auth)=/.test(cookieHeader)` — close enough for presence but skipped the framework's RFC 6265bis token-grammar enforcement (length cap, duplicate-key resolution, CRLF refusal). New shape: `b.cookies.parseSafe(cookieHeader)` returns `{ jar, issues }`; `jar` is a null-prototype object the presence check inspects via `hasOwnProperty` for the session cookie names. `b.cookies` joins the Worker-side primitive surface via `worker/b.js`. **Fixed:** *Remove `worker-esbuild-parse` smoke gate* — The gate ran `npx esbuild worker/index.js --bundle ...` from `test/smoke.js`. Locally + in CI it worked because `node_modules` was already populated. The container's test stage in the Dockerfile doesn't install dependencies — the smoke test runs against the source tree only — so `npx` had to download esbuild fresh, which the Cloudflare Workers Builds environment refuses (network-restricted build stage). Gate removed to unblock the auto-deploy; replacement gate (parser that doesn't need esbuild) tracked for a follow-up.

- v0.0.99 (2026-05-23) — **`worker-esbuild-parse` smoke gate — catches the node-tolerates / esbuild-refuses syntax mismatch that broke v0.0.95 + v0.0.97 deploys.** v0.0.95 and v0.0.97 shipped `worker/index.js` with a missing closing brace on `_warmingHtml`. Node's parser tolerated the unterminated declaration; `node --check` returned OK, smoke passed, the commits merged. Wrangler's esbuild refused with `Unexpected end of file` and blocked the Cloudflare Workers Builds deploy on both versions. v0.0.98 restored the brace. This patch adds a `worker-esbuild-parse` gate to `test/smoke.js` that runs `npx esbuild worker/index.js --bundle --format=esm --platform=neutral` (with the `@cloudflare/containers` + `node:*` imports externalised so the local run doesn't need the polyfills wrangler ships at deploy time) — parse-success-only check that catches the regression class before the commit lands. **Added:** *`worker-esbuild-parse` static gate in `test/smoke.js`* — Runs `npx esbuild worker/index.js --bundle --format=esm --platform=neutral --external:@cloudflare/containers --external:node:* --log-level=error` against the Worker entrypoint. esbuild's parser is stricter than Node's at EOF — it catches unterminated function declarations, unclosed braces / parens / template literals, and similar shapes that node tolerates. The gate runs alongside the existing release-notes-rollup / changelog-in-sync / vendor-drift checks; verified by injection that an appended `function _badInject() {` trips it. The actual bundle resolution (Cloudflare Workers Builds runs `wrangler deploy` which composes the same esbuild + the nodejs_compat polyfills) stays the source of truth for runtime — this gate is parse-only, fast, and runs on every local + CI smoke.

- v0.0.98 (2026-05-23) — **Restore the missing closing brace on `_warmingHtml` — wrangler bundle was failing at EOF.** An earlier patch trimmed too many trailing lines from `worker/index.js` while removing a detector-injection leftover, accidentally taking the closing `}` of `_warmingHtml` along with it. `node --check` parsed the file fine (Node's parser is more forgiving at EOF) but wrangler's esbuild reported `Unexpected end of file` because the function declaration was unterminated. v0.0.95 / v0.0.97 builds both failed on Cloudflare Workers Builds because of this. The brace is back. The smoke + detector gates passed at every prior ship because Node's parser tolerated the missing brace; adding a brace-balance gate to smoke is the next-shipped follow-up. **Fixed:** *`_warmingHtml`'s closing brace restored* — The function body's string-concat return statement ends on the same line as the closing brace in the source template, and an earlier `head -n -N` trim that was meant to undo a detector-injection took the brace along with the injection. Node's `node --check` accepted the unterminated declaration (its parser doesn't require the brace at EOF in the same way esbuild does) but wrangler / esbuild refused with `Unexpected end of file`. v0.0.95 and v0.0.97 Cloudflare Workers Builds deploys both failed on this — they're now unblocked.

- v0.0.97 (2026-05-23) — **Guest `/cart` on the edge + emergency fix for stray detector-injection leftover that broke wrangler bundling on v0.0.95.** v0.0.95 shipped with a one-line leftover at the bottom of `worker/index.js` — a stray `function _testCatchReturnNull() {` from an earlier detector-verification injection that wasn't fully cleaned up. `node --check` accepted the unterminated declaration but wrangler's esbuild refused with `Unexpected end of file`, blocking the v0.0.95 auto-deploy on Cloudflare Workers Builds. This patch removes the leftover and ships v0.0.96's guest-`/cart` edge route at the same time. The release-notes/v0.0.96.json file lands in this ship too (PR #96 was closed in favour of this consolidated release). **Added:** *Guest `/cart` rendered at the edge (carried from the closed PR #96)* — GET `/cart` for visitors without a `shop_sid` / `shop_auth` cookie now renders the empty-cart page directly at the edge — same `worker/render/cart.js#renderCart` that ships in the Worker bundle, called with `lines: []` and zero totals. Cookie-bearing visitors fall through to the container path which has the framework's vault keychain and can decrypt the sealed session to look up the real cart row. Session-cookie detection reuses `_hasSessionCookie` — same predicate that's gated the edge cache since v0.0.78. **Fixed:** *Stray `function _testCatchReturnNull() {` removed from `worker/index.js`* — A detector-verification injection that should have been fully reverted left a single unterminated `function` declaration at EOF. Node's parser tolerates the shape (`node --check` returned OK) but wrangler's esbuild reports `Unexpected end of file` and refuses to build, blocking the v0.0.95 Cloudflare Workers Builds deploy. The leftover is gone.

- v0.0.96 (2026-05-23) — **Guest `/cart` rendered at the edge — no session cookie, no container hop.** GET `/cart` for visitors without a `shop_sid` / `shop_auth` cookie now renders the empty-cart page directly at the edge — the same `worker/render/cart.js#renderCart` that ships in the Worker bundle, called with `lines: []` and zero totals. Visitors with a session cookie fall through to the container path which has the framework's vault keychain and can decrypt the sealed session to look up the real cart row. The session-cookie presence check uses the same `_hasSessionCookie` predicate that's gated the edge cache since v0.0.78, so guest detection is consistent across both paths. **Added:** *Guest `/cart` rendered at the edge via the bundled `renderCart`* — When `_hasSessionCookie(request)` returns false (no `shop_sid` or `shop_auth` in the cookie header), the dispatcher routes `/cart` to `_edgeCartEmpty` instead of `_forwardToContainer`. The handler invokes `renderCart` with `lines: []`, zero totals, an empty `productLookup` — the same `CART_EMPTY_PAGE` branch a freshly-arrived visitor would have seen via the container, served in ~90ms instead of ~700ms cold. Authenticated visitors (cookie present) continue through the container path, where the framework's vault decrypts the session and looks up the real cart row. Security headers + HTML minification + the edge-error fallback all apply uniformly via the existing `_html` + `_edgeError` helpers.

- v0.0.95 (2026-05-23) — **Security headers on every edge response — closes the gap with the container's `b.middleware.securityHeaders` pipeline.** Audit found a real regression introduced when storefront read routes moved to the edge in v0.0.76. The container's `b.middleware.securityHeaders` pipeline attaches `Content-Security-Policy`, `Permissions-Policy`, `Referrer-Policy`, `X-Frame-Options`, `Cross-Origin-{Opener,Resource}-Policy`, `Origin-Agent-Cluster`, `X-DNS-Prefetch-Control`, `X-Content-Type-Options`, and `Strict-Transport-Security` to every response. Edge-rendered routes bypassed that pipeline and carried only the Cloudflare-zone defaults (`HSTS` + `X-Content-Type-Options`) — losing CSP, Permissions-Policy, Referrer-Policy, and X-Frame-Options against the storefront's most-touched surface. This patch attaches the canonical set on every Worker response (HTML / RSS / sitemap.xml / 4xx / 5xx / warming page), restoring the security posture the framework's pipeline documents. **Security:** *Edge responses now carry the canonical security-header set — CSP / Permissions-Policy / Referrer-Policy / X-Frame-Options* — `worker/index.js` defines `_SECURITY_HEADERS` mirroring the literal values the framework's `b.middleware.securityHeaders` pipeline emits (`lib/vendor/blamejs/lib/middleware/security-headers.js#DEFAULT_*`). The shared `_withSecurityHeaders(base)` helper merges the canonical set with per-response headers (content-type, cache-control, retry-after, etc.) without callers having to know each one. Applied to every Worker-constructed Response: storefront read HTML (`_html`), static policy pages (`_staticHtml`), edge 500 (`_edgeError`), edge 404 (product + blog), `/sitemap.xml`, `/feed.xml`, `/newsletter` POST results, the container-warming page, and both XML/text 503 fallbacks. Container responses already carried these headers via the framework pipeline; edge responses now match.

- v0.0.94 (2026-05-23) — **Schema.org JSON-LD on product + blog-article pages — rich-result eligibility for Google / Bing.** Every edge-rendered `/products/:slug` now carries a `<script type="application/ld+json">` block with the Schema.org Product graph: `name`, `description`, `image`, `sku`, and an `AggregateOffer` collapsing the per-variant price range into `lowPrice` / `highPrice` / `offerCount` with the correct currency. Every `/blog/:slug` carries an Article graph: `headline`, `image`, `datePublished` / `dateModified` (ISO 8601), `author`, `description`. Embedded JSON survives the inline-in-HTML XSS surface via the `</` → `<\/` rewrite the new `jsonLdScript` helper applies; standard `JSON.stringify` covers the `"` / `\` escapes. Composes through `worker/render/_lib.js#jsonLdScript`. **Added:** *`jsonLdScript(data)` helper in `worker/render/_lib.js`* — Serialises a JS object as a `<script type="application/ld+json">` block. `JSON.stringify` handles `"` and `\` escapes; an additional `</` → `<\/` rewrite neutralises any literal `</script>` substring inside a value (defense-in-depth — Schema.org fields don't ship HTML in the supported vocabulary, but the rewrite is the canonical XSS escape for inline JSON-in-HTML). Throws `TypeError` on null / non-object input. · *Schema.org `Product` JSON-LD on `/products/:slug`* — Surfaces in Google's product-rich-result panel (price + availability), Bing Shopping, etc. The aggregate-offer block collapses every variant's price into `lowPrice` / `highPrice` / `offerCount` with the correct currency. Decimal precision adjusts for zero-decimal currencies (JPY / KRW divide by 1, format as integer; everything else divides by 100, two decimals). `image` array carries the hero media's full URL when present. · *Schema.org `Article` JSON-LD on `/blog/:slug`* — Surfaces in Google's article-rich-result panel. Fields: `headline`, `image` array (falls back to brand logo when no hero), `datePublished` + `dateModified` as ISO 8601 strings (composed from the article row's `published_at` + `updated_at` epoch-ms columns), `author` as a `Person` graph, `description` from `meta_description` or the first 240 chars of the body.

- v0.0.93 (2026-05-23) — **HTML minification at render time — ~10% transfer-byte reduction on every edge-rendered page.** `worker/render/_lib.js` exports `minifyHtml(html)` — a conservative inter-tag whitespace collapser that strips the indentation the source templates carry for readability. Preserve-tag content (`<pre>`, `<script>`, `<style>`, `<textarea>`, `<code>`) stays untouched because whitespace inside those tags is semantically meaningful. The minifier runs on every `_html()` + `_staticHtml()` response from the Worker; cached responses store the minified bytes so the cache-hit path serves smaller payloads too. Empty-home page goes from 17560 → 15840 bytes (-9.8%); populated pages see proportionally larger reductions because per-product card HTML is indentation-heavy. **Added:** *`minifyHtml(html)` in `worker/render/_lib.js` — conservative inter-tag whitespace collapse* — Stashes every `<pre>` / `<script>` / `<style>` / `<textarea>` / `<code>` block behind sentinel tokens (control-character sequences impossible in operator-rendered HTML), runs a `>\s*\n\s*<` → `><` collapse on the remainder, then reinstates the preserved blocks. Text content untouched (text never carries `>...<` boundaries internally). The minifier is composable from any render module that wants minification per-output, and `_html()` + `_staticHtml()` in `worker/index.js` apply it to every edge-rendered response. · *Edge-rendered + cached responses serve the minified bytes* — `_html(body, method)` now wraps the body in `_minify(body)` before constructing the Response. Cached responses in `caches.default` store the minified bytes, so repeat visitors at the same PoP get the already-shrunk payload. Empty-home page: 17560 → 15840 bytes (-9.8%); populated pages compress proportionally more because per-product card HTML in `worker/render/home.js` is multi-line + indented.

- v0.0.92 (2026-05-23) — **POST `/newsletter` on the edge — every page's footer submit no longer hits the container.** The footer newsletter form lives on every storefront page. The POST submit used to hop through the container's storefront mount; this patch wires it directly into the Worker. Email goes through `b.guardEmail.validateAddress` (RFC 5322 single-address validator with header-injection defense). The normalized address hashes via `b.crypto.namespaceHash("newsletter-signup", ...)` so the raw email never lands on disk. Insert is idempotent via D1's `ON CONFLICT(email_hash) DO NOTHING`; the result thank-you renders `new` vs `existing` based on the row-count delta. Sec-Fetch-Site gates cross-site POSTs (CSRF defense); content-type allowlist is `application/x-www-form-urlencoded` only. **Added:** *Edge `POST /newsletter` handler composed off `b.guardEmail` + `b.crypto.namespaceHash` + `b.uuid.v7`* — `worker/index.js` gains a `_edgeNewsletter(request, env)` handler routed when `pathname === "/newsletter" && method === "POST"` and `env.EDGE_RENDER === "on"`. The handler: (1) refuses Sec-Fetch-Site `cross-site` POSTs; (2) refuses non-`application/x-www-form-urlencoded` content; (3) parses the form body via `URLSearchParams`; (4) validates the email through `b.guardEmail.validateAddress` (refused-shape errors render the operator-facing error page without leaking the validator's internal code); (5) lower-cases the address and hashes it via `b.crypto.namespaceHash("newsletter-signup", normalized)`; (6) generates a row id via `b.uuid.v7`; (7) issues an idempotent insert against `newsletter_signups` with `ON CONFLICT(email_hash) DO NOTHING`; (8) renders `renderNewsletterThanks` differentiating `new` from `existing` based on `r.meta.changes > 0`. All `b.*` primitives compose through the validated `worker/b.js` adapter — no inline reinvention. · *`worker/b.js` exposes `b.guardEmail`* — The framework's email-shape gate ships as `lib/vendor/blamejs/lib/guard-email.js`; this patch adds it to the Worker-side adapter's surface so edge POST handlers compose it the same way server-side code does. The leaf module's transitive requires (`codepoint-class`, `lazy-require`, `gate-contract`, `constants`, `numeric-bounds`, `framework-error`) bundle cleanly under wrangler + nodejs_compat. · *`renderNewsletterThanks` + `renderNewsletterError` rendered through the minimal policy layout* — Both compose `renderTemplate` from `worker/render/_lib.js`. The `existing` path differentiates the copy so a returning subscriber sees "You're already on the list" instead of a generic thanks. The error path runs `b.template.escapeHtml` on the operator-facing message via the b.js adapter.

- v0.0.91 (2026-05-23) — **Ten more codebase-patterns ports — detector count 36 → 46.** Bulk-ports every remaining applicable security / correctness detector from blamejs's catalog so the downstream surface inherits the framework's accumulated discipline in one pass. Covers decompression-bomb defense (`inflate-unzip-without-output-size-cap`, `zlib-decompress-not-via-safedecompress`), auth-path coercion (`buffer-from-string-on-auth-path`), JOSE / JWK confused-deputy (`jwk-import-without-alg-kty-check`, `jose-alg-switch-permissive-default`), SSRF metadata bypass (`ssrf-skip-without-textual-metadata-check`), JWT freshness gating (`optional-iat-age-check-no-required-freshness`), state-machine race conditions (`monotonic-terminal-state-overwrite-without-guard`), external-callback DoS (`external-callback-await-without-timeout`), and OpenMetrics counter exposition (`openmetrics-counter-family-name-mismatch`). All ports preserve their upstream `id` / `primitive` / `regex` / `reason` shape so the lineage stays diff-able against `lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js`. **Detectors:** *`inflate-unzip-without-output-size-cap` — RFC 1951 deflate bomb defense* — Completes the gunzip-cap detector. `zlib.inflateSync` / `inflateRawSync` / `unzipSync` / `createInflate` / `createInflateRaw` / `createUnzip` without a `maxOutputLength` opt is the same amplification class as gzip. Uses the `requires` field to exempt files that name `maxOutputLength` elsewhere. · *`zlib-decompress-not-via-safedecompress` — migration target* — Flags every `zlib.*` decompression in lib/ that hasn't migrated to `b.safeDecompress(buf, { algorithm, maxOutputBytes, maxCompressedBytes })`. The framework primitive composes algorithm allowlist + ratio cap + bomb defense + audit emission in one call. · *`buffer-from-string-on-auth-path` — prototype-pollution coercion defense* — `Buffer.from(String(x))` on an auth-relevant input lets a polluted `toString()` redirect the byte sequence the comparison observes. Validate Buffer-or-string explicitly; refuse other shapes with a typed error. · *`external-callback-await-without-timeout` — operator-callback DoS defense* — Awaiting an operator-supplied callback (`_externalStore`, `_externalSink`, etc.) without a `b.safeAsync.withTimeout` bound means a stalled network call stalls the entire emit chain. Uses the `requires` field to exempt files that compose the timeout helper somewhere. · *`monotonic-terminal-state-overwrite-without-guard` — state-machine race defense* — State machines with a `done` (or terminal) flag whose async handler writes `state.done = true; state.X = ...` must check `if (state.done) return` first; a late-arriving handler otherwise clobbers an earlier terminal state. · *`optional-iat-age-check-no-required-freshness` — token-freshness gate* — `if (typeof X.iat === "number" && now - X.iat > maxAge)` short-circuits to no-check on missing iat. The verifier must refuse missing-iat first OR gate on `(iat is number OR server-nonce present)` before the age comparison. · *`ssrf-skip-without-textual-metadata-check` — SSRF metadata-IP defense* — Any SSRF-skip path (proxy short-circuit, operator-pinned IP, custom dnsLookup) must call `b.ssrfGuard.checkUrlTextual(url)` first; metadata IPs (169.254.169.254 / fd00:ec2::254) are never overridable. · *`jwk-import-without-alg-kty-check` — JWK confused-deputy defense* — `crypto.createPublicKey({ key: jwk, format: "jwk" })` without a sibling `_assertAlgKtyMatch(alg, jwk)` lets an attacker pass an RSA-marked key as EC (or vice versa). Validate alg / kty / use match BEFORE the import. · *`jose-alg-switch-permissive-default` — alg-confusion defense* — A `switch (alg)` with a permissive default (`return` / `break`) accepts unknown algorithm strings, opening HS256-as-RS256 and `none` attacks. The default branch must `throw` with a typed error. · *`openmetrics-counter-family-name-mismatch` — exposition correctness* — OpenMetrics counter `# HELP / TYPE / UNIT` metadata and `<name>_total` sample lines must use the same family identifier. Derive the exposed name once at the top of the per-metric loop.

- v0.0.90 (2026-05-23) — **Two more codebase-patterns ports — `raw-sql-identifier-interpolation` + `timing-safe-equal-utf8-without-shape-guard`.** Two more security detectors ported from blamejs's catalog. `raw-sql-identifier-interpolation` catches `FROM " + variable +` shapes (and similar for INTO / UPDATE / TABLE / INDEX / TRIGGER / VIEW / JOIN) where the SQL identifier composes via string concat instead of `b.safeSql.quoteIdentifier`. The first scan caught eight call sites in `lib/pwa-manifest.js` where the `table` variable is a primitive-controlled ternary literal (`kind === "manifest" ? "pwa_manifests" : "pwa_sw_configs"`) and never traces back to operator input — added a file-level allow marker citing the bounded two-element domain. `timing-safe-equal-utf8-without-shape-guard` flags `nodeCrypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))` shapes that can throw on UTF-8 byte-length divergence — guard with a byte-shape check before encoding, or use `b.crypto.timingSafeEqual` which wraps the validation. **Added:** *`raw-sql-identifier-interpolation` codebase-patterns detector* — Catches SQL identifier interpolation via string concat — `FROM " + table +` and similar shapes for INTO / UPDATE / TABLE / INDEX / TRIGGER / VIEW / JOIN. Bound parameters can only carry values, not identifiers; the proper composition is `b.safeSql.quoteIdentifier(name, dialect)` which runs the framework's `validateIdentifier` (length 1–63, charset gate, reserved-word + sqlite_-prefix refusal) before emitting the dialect-correct quoted form. Detector skips variables prefixed with `q` / `Q` / `quoted` (already-validated identifier convention). Ported from blamejs's catalog. · *`timing-safe-equal-utf8-without-shape-guard` codebase-patterns detector* — Catches `nodeCrypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))` shapes where the two inputs may diverge in UTF-8 byte length even when their string `.length` matches — the underlying nodeCrypto call throws `ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH`. Either restrict to ASCII-only domains (token / hex / base64url shapes), or compare byte lengths explicitly before the call. `b.crypto.timingSafeEqual` wraps this with the boundary validation. Ported from blamejs's catalog. **Changed:** *`lib/pwa-manifest.js` carries a file-level `allow:raw-sql-identifier-interpolation` marker* — First scan caught eight `"<KEYWORD> " + table + " "` interpolations in pwa-manifest.js. The `table` variable is set by a primitive-controlled ternary on the `kind` parameter (`"pwa_manifests"` or `"pwa_sw_configs"` — bounded two-element domain at write time, never operator input). File-level allow marker documents the exception in place; the detector still fires on any real operator-controlled identifier interpolation. Detector count: 34 → 36.

- v0.0.89 (2026-05-23) — **`/blog` list + `/blog/:slug` article-detail rendered at the edge from D1.** The sitemap shipped in v0.0.87 references `/blog/:slug` URLs but neither the list nor the detail page existed at the edge. This patch wires both: `/blog` reads the 12 most recent published articles from D1 and renders a card grid; `/blog/:slug` reads the article row and renders the body as paragraph-broken HTML (markdown rendering deliberately not interpreted at the edge — operators who want markdown formatting store pre-rendered HTML in the `body` column). Missing slugs serve the same edge 404 the products route uses. The blog layout adds a `<link rel="alternate" type="application/rss+xml">` so feed-readers auto-discover `/feed.xml` from any blog page. **Added:** *`/blog` list page served from the edge* — `worker/data/catalog.js` exposes `listBlogArticles(DB, { limit, offset })` returning slug + title + author + meta + hero + published_at for published articles, newest first. The list renderer composes a card grid; each card links to `/blog/<slug>`. An empty result set falls back to a clean empty-state with a link to the RSS feed. Empty + populated states both render through `renderTemplate` (the strict templater) so XSS-relevant fields (title, author, lede) compose `b.template.escapeHtml` on substitution. · *`/blog/:slug` article-detail page served from the edge* — `getBlogArticleBySlug(DB, slug)` returns the full row (body + tags + meta) for `status = 'published'` articles. `renderBlogArticle` composes the article's body via `_paragraphsFromPlainText` — splits on blank lines, `b.template.escapeHtml`s each paragraph, wraps in `<p>` — so the body column is plain-text-safe even when operators write raw markup. Missing slugs serve the same edge 404 as `/products/:not-found`. OpenGraph `og:type` is `article`; `og:image` falls back to the brand logo if no `hero_image_url`. · *Blog layout adds RSS auto-discovery + a Blog nav entry* — Every page rendered through `worker/render/blog.js` carries `<link rel="alternate" type="application/rss+xml" href="/feed.xml">` so RSS-aware browsers and feed readers auto-discover `/feed.xml` from `/blog`. The header nav gains a `Blog` entry alongside Shop / Cart.

- v0.0.88 (2026-05-23) — **Four more codebase-patterns ports + scanner `requires` field support + vendor v0.12.9.** The codebase-patterns scanner gains a `requires` field that exempts a primary regex hit when a paired bounding opt appears anywhere in the same file (e.g. `zlib.gunzipSync(...)` paired with `maxOutputLength: <const>`). Four more detector ports follow from blamejs's catalog: `gunzip-without-output-size-cap` (zip-bomb decompression cap), `audit-action-with-hyphen` (validator-refused hyphen segments), `non-canonical-audit-outcome` (only `success` / `failure` / `denied` pass the strict validator), `wildcard-suffix-match-without-single-label-check` (DNS wildcard `*.example.com` must refuse single-label matches per RFC 6125 §6.4.3). Vendor refresh to v0.12.9. **Added:** *Scanner `requires` field — secondary whole-file check exempts a primary hit when paired with a documented opt* — `_check` now reads an optional `requires` regex on each detector entry. When set, a primary hit is exempted only if the same file's contents match `requires` somewhere. The companion check covers patterns where the call shape is correct ONLY when paired with a bounding option elsewhere (e.g. `zlib.gunzipSync(buf)` paired with `maxOutputLength: <byte-constant>`). Ported from blamejs's catalog convention. · *Four new ported detectors* — `gunzip-without-output-size-cap` (DoS via decompression-bomb amplification; require `maxOutputLength` on every `zlib.gunzipSync` / `createGunzip` / brotli-decompress); `audit-action-with-hyphen` (action segments with `-` fail the `[a-z][a-z0-9_]*` validator — use snake_case); `non-canonical-audit-outcome` (only `success` / `failure` / `denied` pass `audit.record()`; `ok` / `fail` / `error` / etc. are silently normalized by `safeEmit` but the canonical strings belong at the call site); `wildcard-suffix-match-without-single-label-check` (RFC 6125 §6.4.3 — a `*.example.com` wildcard covers exactly one label, never zero / single-label). Detector count goes 30 → 34. **Changed:** *Vendored blamejs refreshed from v0.12.8 to v0.12.9* — `bash scripts/vendor-update.sh blamejs v0.12.9` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.8 and v0.12.9. · *`lib/customer-import.js` per-row error outcome string carries an inline allow marker* — The detector flagged `outcome: "error"` on a per-row import-result object in `lib/customer-import.js:271`. Inspection confirmed it's a per-row operator-facing result, NOT an `audit.emit` payload — different vocabulary. Added an inline `// allow:non-canonical-audit-outcome — per-row operator-facing import result; not an audit.emit outcome` marker documenting the exception in place. The non-canonical-audit-outcome detector still fires on any real `audit.emit({outcome: "error"})` call.

- v0.0.87 (2026-05-23) — **`/sitemap.xml` rendered at the edge from D1 — fourth and final broken footer link fixed.** `/sitemap.xml` was the last footer link still returning 403 via the container's bot-guard. The Worker now generates the urlset directly from D1 — every active product slug and every published blog article slug joins the home + `/privacy` + `/terms` static-page roster. Output matches sitemaps.org/schemas/sitemap/0.9; XML escape composes `b.template.escapeHtml` through the strict `renderTemplate`, and slugs URL-encode at the path segment so a hypothetical slug with spaces or ampersands produces a crawler-valid URL. Cached `public, max-age=600, s-maxage=3600` (matches `/feed.xml`). **Added:** *`/sitemap.xml` served from the edge — generated from D1 in two queries* — `worker/data/catalog.js` exposes `listActiveProductSlugs(DB)` and `listPublishedBlogSlugs(DB)` — each pulls slug + updated_at with a 50,000-row cap (the sitemap protocol's per-file URL limit). The two queries fan out in parallel via `Promise.all`. `worker/render/sitemap.js` carries the urlset template + per-entry composition; `renderTemplate` from `worker/render/_lib.js` provides the per-value HTML / XML escape (same primitive everything else in the Worker uses). Slugs URL-encode via `encodeURIComponent` before insertion so a hypothetical `mug & co` slug renders as `mug%20%26%20co` in the `<loc>`. Each entry carries `<lastmod>` (ISO date from the row's updated_at), `<changefreq>` (daily for products, weekly for blog, monthly for legal), and `<priority>` (1.0 home, 0.8 products, 0.6 blog, 0.3 legal). Cached `public, max-age=600, s-maxage=3600`. The handler falls back to a `text/plain; 503` with `retry-after: 60` on D1 failure — never escalates to the container.

- v0.0.86 (2026-05-23) — **Edge 404 / 500 / `/feed.xml` rendered through the worker — exceptions no longer escalate to the container, plus two new codebase-patterns detectors.** Edge handlers now own their own failure modes. The previous `_edgeHome` / `_edgeSearch` / `_edgeProduct` shape caught render exceptions and returned `null`, which routed back through the dispatcher to `_forwardToContainer` — the container then either bot-guard-403'd the visitor or ran the same primitive surface that just threw on the edge. Each handler's `catch` now serves a 5xx via the new `renderInternalError` helper (designed page, no container hop) and the failure logs to the Worker's observability sink. `/products/:slug` for a missing slug renders an edge 404 instead of escalating. `/feed.xml` generates an RSS 2.0 channel from `blog_articles` via D1 + the same templater the rest of the worker uses. Two new codebase-patterns detectors catch the regressions: `edge-handler-catch-returns-null` flags any worker catch that returns `null` (routing-dispatch returns are still legitimate; only catches are flagged); `unvalidated-env-url-as-origin` flags `fetch(env.X_URL)` / `origin: env.X_URL` / `origin: "https://..." + env.X` shapes that bypass `b.safeUrl.parse`. Both verified via injection. **Added:** *Edge 404 page for missing product slugs — no container hop* — `worker/render/policy.js` gains a `renderNotFound({ what, shopName, version })` helper composed off the same minimal layout as `/privacy` and `/terms`. `_edgeProduct` now returns the rendered 404 with HTTP 404 + `cache-control: public, max-age=60, s-maxage=300, must-revalidate` when `getProductBySlug` resolves to null — previously the handler returned null and the request fell through to the container's bot-guard 403 (which crawlers and visitors landed on alike when a slug had been archived or never existed). · *`/feed.xml` RSS 2.0 feed served from the edge* — `worker/render/feed.js` renders a complete `<rss version="2.0">` channel from a `blog_articles` row set; `worker/data/catalog.js` exposes `recentBlogArticles(DB, { limit })` (default 20). Each article surfaces as an `<item>` with `<title>`, `<link>`, `<guid isPermaLink="true">`, `<pubDate>` (RFC 822-formatted), `<description>`, and `<author>`. XML escape composes `b.template.escapeHtml` — XML 1.0 §4.6 accepts the same `&#x27;` numeric reference the HTML primitive emits, so the framework primitive covers both surfaces. The handler returns `cache-control: public, max-age=600, s-maxage=3600` so readers re-fetch every 10 minutes while the edge holds for an hour. The footer's RSS link is no longer dead. **Changed:** *Edge handlers no longer escalate render exceptions to the container* — `_edgeHome`, `_edgeSearch`, and `_edgeProduct` previously returned `null` on catch — which the dispatcher then routed to `_forwardToContainer`. The container couldn't fix a render-side bug (it runs the same primitive surface that just threw), the visitor experience of fallback-to-different-backend is worse than a clean error page, and the escalation hid the bug from observability. Each handler now serves a 5xx via the new `renderInternalError(opts)` helper (designed page, composed off the same minimal layout as `/privacy` and `/terms`) with `cache-control: no-store` so the failure isn't cached. `console.error` to the Worker's observability sink stays explicit; only the silent-escalation path is removed. `/feed.xml`'s 503 path stays plain-text (RSS readers expect that on failure) but no longer falls through to the container. · *Cache-control on static pages now splits `max-age` from `s-maxage`* — `/privacy` and `/terms` previously carried `cache-control: public, max-age=86400, must-revalidate` — browsers held the page for 24h, which delayed any operator-shipped policy update. The new shape is `public, max-age=3600, s-maxage=86400, must-revalidate`: browsers revalidate every hour, Cloudflare's zone cache amortizes the render across the whole zone for 24h. Visitors get the same TTFB; operator-shipped updates propagate in under an hour to every browser. · *Origin URLs in `/feed.xml` + the cache-warmer cron now pass through `b.safeUrl.parse`* — Both call sites previously built the origin via string concat from `env.D1_BRIDGE_URL`. `b.safeUrl.parse` validates the value against the framework's HTTPS-only allowlist (`ALLOW_HTTP_TLS` — refuses `javascript:`, `file:`, `data:` schemes) and returns the normalized URL. The trailing-slash strip leaves a clean origin the per-route path concatenates against. An attacker who somehow set `D1_BRIDGE_URL` to a non-HTTPS scheme would now fail at validation instead of letting the cron warm a hostile origin. · *`/feed.xml` XML rendering now composes through `renderTemplate` (the strict templater)* — Feed XML was built via string concat with inline `b.template.escapeHtml` calls. The refactor routes both the channel envelope and each `<item>` block through the same `renderTemplate` from `worker/render/_lib.js` that the rest of the Worker uses — same `{{name}}` substitution shape, same unknown / unused placeholder refusal at composition time, same per-value escape. XML 1.0 §4.6 accepts the numeric `&#x27;` reference `b.template.escapeHtml` emits, so the HTML primitive covers the XML output too. · *`lib/admin.js` switches from `try { audit.emit(...) } catch {}` to `audit.safeEmit(...)`* — The framework ships `safeEmit` as the drop-silent variant of `audit.emit` — handles sink failure, invalid namespace, and shape errors internally without throwing. The previous local `try/catch` wrapper duplicated that contract; composing the framework primitive removes the duplication and lets the new `raw-audit-emit-without-drop-silent-wrap` detector enforce the discipline globally. **Detectors:** *Eight new `codebase-patterns` detectors — two project-specific + six ported from blamejs's catalog* — `edge-handler-catch-returns-null` and `unvalidated-env-url-as-origin` are project-specific; both verified via injection test. The remaining six are direct ports from the vendored framework's own catalog (`lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js`), with `id` / `primitive` / `regex` / `reason` shapes preserved so the lineage stays diff-able against upstream: `number-coerce-or-zero-on-json-source` (validate finite non-negative integer instead of `Number(x) || 0`), `slice1-optional-parseint-silent-default` (refuse empty-digit segment with an explicit throw before parseInt), `utf16-length-as-byte-cap` (`Buffer.byteLength(s, "utf8")` instead of `s.length` for byte caps), `raw-audit-emit-without-drop-silent-wrap` (`b.audit.safeEmit` instead of bare `audit.emit`), `non-ct-iss-compare` (`b.crypto.timingSafeEqual` for issuer compare), `fs-path-from-operator-identifier-without-traversal-refusal` (validate name + explicit `..` refusal before `path.join`). All eight scan the `shop` scope (lib/ + worker/). Detector count goes from 22 → 30.

- v0.0.85 (2026-05-23) — **FSM-name shape detector + email-campaigns rename + /SECURITY.md redirect (auto-deploy fix) + vendor v0.12.8 (audit drop-silent contract restored).** The `email-campaigns` primitive named its FSM `emailCampaign` — camelCase. The framework's audit-action validator at `lib/vendor/blamejs/lib/audit.js:401` enforces `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$` per segment, so every transition fired `flush dropped event: audit action must be 'namespace.verb[.qualifier...]' — got: fsm.emailCampaign.transition` and the event was dropped silently. Renamed to `email_campaign`. A new `codebase-patterns` detector `fsm-name-not-audit-action-safe` catches this class of bug going forward — it scans `fsm.define({ ... name: "..." ... })` calls (using a new multi-line scan mode) and refuses any name that doesn't match the per-segment shape. Auto-deploy is also unblocked: the `/SECURITY.md` route now redirects to the GitHub mirror (same shape as `/CHANGELOG.md`) — bundling the file via wrangler's text rule was failing under `wrangler versions upload`. Bundled vendor refresh to blamejs v0.12.8 — upstream restored the `safeEmit` drop-silent contract in the flush handler, eliminating the `flush dropped event: db.init() must be awaited` log spam that layer-1 tests were producing whenever a primitive composed `b.fsm.transition` without the framework's storage layer being initialized. The npm `homepage` field also moves from the GitHub mirror to `https://blamejs.shop` so the registry page links at the live deployment rather than the source repo. **Added:** *`fsm-name-not-audit-action-safe` codebase-patterns detector* — Scans `fsm.define({ ... name: "..." ... })` calls in `lib/` and flags any name that doesn't match the per-segment audit-action shape (`[a-z][a-z0-9_]*`). The pattern spans lines so multi-line `fsm.define({ \n name: "..." \n })` shapes are caught; a new `multiline: true` flag on the detector entry routes the scan through a whole-file regex pass instead of the per-line one. Description points at the canonical audit-action validator + the framework's PQC-first naming convention. · *Multi-line scan mode in `codebase-patterns.test.js`* — The scanner now accepts `{ multiline: true }` per detector. When set, the whole-file content is scanned with the global regex via `matchAll`; line numbers are derived from the byte offset by counting preceding newlines. Per-line scans remain the default — only detectors that need cross-line context (FSM `name:` field validation, future shape checks) opt in. **Changed:** *Vendored blamejs refreshed from v0.12.6 to v0.12.8* — Upstream restored the `b.audit.safeEmit` drop-silent contract in the flush handler at `lib/vendor/blamejs/lib/audit.js:1129` — events queued before `b.db.init()` is awaited (or where storage rejected the write) now drop without the `log.error("flush dropped event: ...")` spam that prior versions emitted. The framework's chain integrity guarantee is unchanged; only the flush-side log behaviour. Layer-1 tests in this codebase wire `query` directly via `node:sqlite` (the framework's storage layer is intentionally not initialized in unit tests), so every primitive that composes `b.fsm.transition` previously caused dozens of dropped-event logs per smoke run. Local smoke goes from ~100 dropped-event logs to zero. · *`package.json` homepage now points at the live deployment* — The `homepage` field was `https://github.com/blamejs/blamejs.shop` so the npm registry page linked at the source repo. Operators landing on `npmjs.com/package/@blamejs/blamejs-shop` were one click away from the reference deployment but the link sent them to the README. Field now reads `https://blamejs.shop` so the registry entry points at the live storefront; the `repository` field still references the GitHub mirror for source. **Fixed:** *`email-campaigns` FSM renamed from `emailCampaign` to `email_campaign`* — The FSM name composes into audit actions as `fsm.<name>.transition`. The framework's audit-action validator (`lib/vendor/blamejs/lib/audit.js:401`) enforces `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$` per segment — `emailCampaign` failed validation because of the uppercase `C`, and the audit module dropped every campaign-state transition silently with an error log. Snake_case rename closes the gap. No data shape change — the FSM `name` is an identifier in the framework, not a column value. · *`/SECURITY.md` redirects to the GitHub mirror — unblocks Cloudflare auto-deploy* — 0.0.84's `wrangler [[rules]] type = "Text"` bundling of `SECURITY.md` worked under local `wrangler deploy` but failed under `wrangler versions upload` (the command Cloudflare Workers Builds runs): `✘ [ERROR] No loader is configured for ".md" files`. The route now serves a 302 to `https://github.com/blamejs/blamejs.shop/blob/main/SECURITY.md` — same pattern as `/CHANGELOG.md`. Both files stay readable to operators; neither bloats the Worker bundle.

- v0.0.84 (2026-05-23) — **`/privacy`, `/terms`, `/SECURITY.md`, `/CHANGELOG.md` served from the edge — footer links no longer 404.** The storefront footer has always linked to `/privacy`, `/terms`, `/SECURITY.md`, and `/CHANGELOG.md`, but the container never owned those routes — every footer click landed on a 403 / 404. This patch wires each route into the Worker: `/privacy` and `/terms` render minimal policy pages composed off a stripped-down layout; `/SECURITY.md` serves the in-tree file as `text/markdown` (bundled via a new wrangler `[[rules]]` Text rule); `/CHANGELOG.md` redirects (302) to the GitHub mirror since the file at 303 KB would inflate the gzipped Worker by ~60 KB. All four pages cache 24h `must-revalidate` so the browser + Cloudflare's zone cache hold them across visits. **Added:** *Edge handlers for `/privacy`, `/terms`, `/SECURITY.md`, `/CHANGELOG.md`* — `worker/render/policy.js` carries `renderPrivacy(opts)` and `renderTerms(opts)` — minimal layout (no marquee / hero / catalog chrome) with the policy text inlined. Both compose `renderTemplate` from `worker/render/_lib.js` so the substitution + HTML escape semantics match the rest of the Worker. `worker/index.js` routes `/privacy` and `/terms` to those renderers, `/SECURITY.md` to the wrangler-text-bundled in-tree SECURITY.md, and `/CHANGELOG.md` to a 302 redirect at `https://github.com/blamejs/blamejs.shop/blob/main/CHANGELOG.md`. All four respond with `cache-control: public, max-age=86400, must-revalidate` so the browser and Cloudflare's zone cache hold them between visits. · *`wrangler.toml` text-rule for `SECURITY.md` bundling* — `[[rules]] type = "Text" globs = ["SECURITY.md"] fallthrough = true` — wrangler's esbuild loader now treats `SECURITY.md` as a text-imported string. `import SECURITY_MD from "../SECURITY.md"` in `worker/index.js` resolves to the file's content. `CHANGELOG.md` is excluded by glob (303 KB → 60+ KB gzipped Worker bloat); that route uses an HTTP redirect to the GitHub mirror instead.

- v0.0.83 (2026-05-23) — **Cache-warmer cron + cache-key normalization for referral traffic.** A once-a-minute scheduled handler now warms the edge cache for `/` and `/search` so the 60-second cache TTL never lapses at PoPs that have seen at least one real visitor — repeat visitors get the cached response even after a brief idle gap. Cache-key normalization strips common tracking parameters (`utm_*`, `fbclid`, `gclid`, `gbraid`, `wbraid`, `msclkid`, `mc_eid`, `mc_cid`, `_ga`, `igshid`, `ref`, `fb_action_*`, `trk_*`, `yclid`) before computing the `caches.default` key, so two visitors arriving with different referral params share the same cache entry. The original request URL is preserved end-to-end — the strip only affects which cache bucket the response lives in. **Added:** *Once-a-minute cache-warmer cron — keeps every active PoP's `/` and `/search` entries fresh* — `wrangler.toml` gains `[triggers] crons = ["* * * * *"]` and the Worker now exports a `scheduled(event, env, ctx)` handler that GETs each storefront read route with a browser-shaped header set. The cache TTL is 60s — the once-a-minute warm keeps the cache populated across the gap so a visitor arriving 30 seconds after the previous hit still sees a cache hit instead of a renderer miss. Gated by `env.EDGE_RENDER === "on"`. Auth-cookie-bearing visitors continue to bypass the cache (per-session content stays correct); only the unauthenticated default fetches get warmed. · *Cache-key normalization — referral traffic shares cache entries with direct traffic* — The cache lookup now uses a derived key that strips `utm_*`, `fbclid`, `gclid`, `gbraid`, `wbraid`, `msclkid`, `mc_eid`, `mc_cid`, `_ga`, `igshid`, `ref`, `fb_action_*`, `trk_*`, and `yclid` query parameters before computing the `caches.default` lookup. The original request URL stays intact end-to-end so analytics and the rendered output still see the unmodified params; the strip only affects which cache bucket the response is stored / retrieved from. A visitor arriving at `/?utm_source=newsletter` now hits the same cache entry as one arriving at `/` directly.

- v0.0.82 (2026-05-23) — **`vendor-update.sh --check` skips gracefully when upstream is unreachable (Cloudflare Workers Builds fix).** The vendor-drift gate inside the container smoke test was failing in build environments that can't reach `api.github.com` — `_latest_tag()` returned empty and the script reported a phantom drift against an empty version string. The committed `lib/vendor/blamejs/` tree is already the source of truth at build time; freshness can only meaningfully be checked when the upstream tag is reachable. The gate now skips with a warning to stderr when the upstream lookup returns empty, instead of failing the build. **Fixed:** *`scripts/vendor-update.sh --check` no longer fails the build when upstream is unreachable* — When `_latest_tag()` returns an empty string (sandboxed CI runner, rate-limited anonymous GitHub API request, air-gapped image), the gate emits `[vendor-check] SKIPPED — could not resolve upstream tag (offline / rate-limited); committed v<X> is the source of truth` to stderr and exits 0. The next operator-run smoke against a network-reachable environment re-verifies freshness. Online behavior is unchanged — when the upstream tag resolves, the gate compares as before and fails on actual drift.

- v0.0.81 (2026-05-23) — **Comprehensive `codebase-patterns` detector catalog for primitive composition + shape alignment with the vendored framework's catalog.** Extends the `codebase-patterns` detector with five additional reinvention catchers (`manual-random-uuid`, `manual-random-bytes`, `weak-hash-sha2`, `manual-createhmac`, `worker-direct-vendor-import`) and aligns every entry's data shape with the vendored framework's canonical catalog at `lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js` — `id` / `primitive` (one-line replacement) / `regex` / `allowlist` / `reason`. Existing `console-direct`, `math-random`, `todo-fixme-hack-xxx`, `empty-catch-swallow` detectors expand from the `lib`-only scope to a new `shop` scope (lib/ + worker/) so the Worker substrate gets the same hygiene gates. The runner prints both the canonical primitive and the deeper reason on failure so the operator-facing fail message points directly at the b.* call that should have been composed. **Added:** *Five additional `codebase-patterns` detectors for blamejs primitive composition* — `manual-random-uuid` (`crypto.randomUUID()` → `b.uuid.v7()` or `b.uuid.v4()`), `manual-random-bytes` (`crypto.randomBytes(n)` → `b.crypto.generateBytes(n)`), `weak-hash-sha2` (`createHash("sha256"|"sha384"|"sha512")` → `b.crypto.sha3Hash(data)` outside explicit protocol exceptions), `manual-createhmac` (`createHmac(...)` → `b.crypto.hmacSha3` / `b.crypto.hmacSha256`), `worker-direct-vendor-import` (Worker code reaching for `lib/vendor/blamejs/lib/*.js` leaf modules outside `worker/b.js` → use the adapter). The four detectors that already existed for `lib`-only enforcement (`console-direct`, `math-random`, `todo-fixme-hack-xxx`, `empty-catch-swallow`) expand to the new `shop` scope covering both `lib/` and `worker/`. **Changed:** *Detector entry shape aligned with the vendored framework's catalog* — Every entry now carries `id`, `primitive` (the canonical one-line replacement), `regex`, `allowlist`, and `reason` — matching the shape blamejs's own `codebase-patterns.test.js` uses for its 95 internal detectors. The runner prints both the primitive line and the deeper reason on failure so operators see what to compose AND why, not just the regex match. · *Allow markers on the documented exceptions (`worker/index.js` console.*, `lib/pixel-events.js` SHA-256)* — `worker/index.js` carries per-line `allow:console-direct` markers on every `console.log/error` call — Workers have no framework observability sink; `console.*` IS the structured log emission point auto-routed to wrangler tail / Logpush. `lib/pixel-events.js#_sha256Hex` carries inline `allow:weak-hash-sha2` (and the existing `allow:non-shop-require`) markers — Meta CAPI / Google EC / TikTok / Pinterest / Snap CAPI mandate SHA-256 of the normalised identifier on the wire and `b.crypto.sha3Hash` is not a valid substitute.

- v0.0.80 (2026-05-23) — **Worker code composes blamejs primitives through a single `worker/b.js` adapter + four new codebase-patterns detectors catch reinvention.** The Worker now imports the framework primitives it needs through a single validated adapter at `worker/b.js`. The adapter pulls leaf modules from `lib/vendor/blamejs/lib/` directly (the framework entry's `node:tls.DEFAULT_MIN_VERSION` write has no Worker analogue) and re-exports them under the canonical `b.<namespace>` shape. Worker code now reads `b.template.escapeHtml(s)`, `b.money.of(n, c).format("en-US")`, `b.crypto.timingSafeEqual(a, b)`, and `b.crypto.hmacSha256(secret, message)` — same call shape as server-side code. Four new `codebase-patterns` detectors catch the reinvention shapes (per-character HTML-escape regex, `Intl.NumberFormat({style:"currency"})`, hand-rolled timing-safe comparison loop, inline `crypto.subtle.sign("HMAC")`) so future Worker / lib code can't reach for these without being flagged at smoke time. Eight pre-existing reinvention sites in `lib/storefront.js` (the 5-char `_esc`, `_escAttr`, `_orderEsc`, `_xmlEsc` helpers), `lib/wishlist-digest.js`, and `lib/barcodes.js` were refactored to compose `b.template.escapeHtml` — bringing the apostrophe escape (`&#x27;`) defense the previous 4-char shape was missing. **Added:** *`worker/b.js` — single validated blamejs adapter for the Worker substrate* — Imports `crypto`, `template`, `money`, `uuid`, `safe-url`, `safe-sql`, `fsm` leaf modules from `lib/vendor/blamejs/lib/` and re-exports them under the canonical `b.<namespace>` shape. Adds one Worker-side `b.crypto.hmacSha256` extension (the framework's PQC-first policy ships only `b.crypto.hmacSha3` publicly; Stripe webhook verify mandates SHA-256 per their published protocol). The extension composes `node:crypto.createHmac` — the same primitive the framework's internal `hmac()` helper uses. · *Four new `codebase-patterns` detectors — flags blamejs-primitive reinvention* — Adds `worker-render-reinvented-primitive` (per-char HTML escape regex → `b.template.escapeHtml`), `intl-numberformat-currency-reinvented` (`new Intl.NumberFormat({style:"currency"})` → `b.money.of(amount, currency).format(locale)`), `manual-timing-safe-equal` (hand-rolled `diff |= a.charCodeAt(i) ^ b.charCodeAt(i)` loop → `b.crypto.timingSafeEqual`), and `inline-hmac-subtle-crypto` (`crypto.subtle.sign("HMAC", ...)` → `b.crypto.hmacSha256` or the framework's webhook-verify primitive). The detectors scan a new `shop` scope (lib/ + worker/) so they catch reinvention in both surfaces. **Changed:** *Worker render + Stripe verify compose blamejs primitives through `worker/b.js`* — `worker/render/_lib.js` now delegates `escapeHtml`, `escapeAttr`, and the per-value substitution in `renderTemplate` to `b.template.escapeHtml`, and routes `formatPrice` through `b.money.of(BigInt(minor), currency).format("en-US")`. `worker/index.js`'s `_timingSafeEqual` delegates to `b.crypto.timingSafeEqual`. `_verifyStripeSignature` swaps its inline `crypto.subtle.sign("HMAC", ...)` block for `b.crypto.hmacSha256(secret, message)`. The warming-page canonical-URL escape now uses `b.template.escapeHtml`. · *Eight pre-existing inline HTML-escape helpers refactored to `b.template.escapeHtml`* — `lib/storefront.js` had six per-function helpers (`_esc`, `_escAttr` × 3, `_orderEsc`, `_xmlEsc`) that hand-rolled the 4- or 5-char escape. `lib/wishlist-digest.js#_htmlEscape` and `lib/barcodes.js`'s inline barcode-label escape used the same shape. All eight now compose `b.template.escapeHtml` — the four-character helpers gain the apostrophe (`&#x27;`) escape they were missing, closing a small defense-in-depth gap on attribute interpolation against single-quoted attributes.

- v0.0.79 (2026-05-23) — **Container Dockerfile uses the committed vendored tree instead of re-fetching from GitHub at build time.** The container image's `vendor` stage previously ran `bash scripts/vendor-update.sh blamejs latest` to re-fetch the vendored blamejs at image-build time. That stage fails in build environments without outbound `api.github.com` access (rate-limited anonymous requests, sandboxed CI runners, etc.) — most visibly on Cloudflare Workers Builds. The committed `lib/vendor/blamejs/` tree is already the source of truth, and the smoke gate's `vendor-update.sh --check` already enforces it matches the latest upstream release tag, so the in-build re-fetch is redundant. The vendor stage now just `COPY`s the committed tree. **Fixed:** *`Dockerfile` vendor stage no longer requires `api.github.com` access at image-build time* — Removed the `RUN bash scripts/vendor-update.sh blamejs "${BLAMEJS_TAG}"` step and the `BLAMEJS_TAG` build arg. The vendor stage now copies `lib/vendor/` from the build context directly. Image builds in network-restricted environments (Cloudflare Workers Builds, air-gapped CI runners) now succeed where they previously errored on `could not resolve latest blamejs release tag`. The committed vendor stays the source of truth; freshness is enforced by the smoke gate's `vendor-update.sh --check`, not the image build.

- v0.0.78 (2026-05-23) — **Edge HTML cache + SmartPlacement — unauthenticated storefront reads drop to ~10-30ms TTFB.** Two complementary perf wins layer on top of the v0.0.77 edge render. The Worker now wraps `/`, `/search`, and `/products/:slug` with a `caches.default` lookup keyed on the full request URL; only unauthenticated requests (no `shop_sid` / `shop_auth` cookie) are eligible. Cached responses carry `cache-control: public, max-age=60, stale-while-revalidate=300` so the edge keeps serving HTML while a background revalidate refreshes the data. Authenticated requests skip the cache entirely so per-session content stays correct. Separately, `wrangler.toml` gains `[placement] mode = "smart"` so the Worker compute runs close to D1 rather than close to the user — eliminates the cross-region D1 round-trip on cache miss. **Added:** *Edge HTML cache for unauthenticated storefront reads — 60s TTL + 300s stale-while-revalidate* — `worker/index.js` wraps `_edgeRender` with a `caches.default` lookup. Requests carrying any `shop_sid` / `shop_auth` cookie bypass the cache and hit the renderer directly. Cache misses store the rendered HTML via `ctx.waitUntil(cache.put(...))` so subsequent visitors get an edge-cache hit at the same PoP. Stale-while-revalidate lets the edge serve last-known-good HTML during the 5-minute background-revalidate window. Cached responses carry an `x-edge-cache: hit` header for observability; misses carry `x-edge-cache: miss`. · *SmartPlacement enabled — Worker runs close to D1, not close to the user* — `[placement] mode = "smart"` in `wrangler.toml`. Cloudflare's placement engine routes the Worker compute to a region close to the bound D1 database instead of the user-nearest PoP. The edge still serves the eventual response to the user via standard routing — only Worker execution moves. Cuts the D1 round-trip on cache miss from ~30-50ms (cross-region) to sub-ms (intra-region).

- v0.0.77 (2026-05-23) — **Edge-render catalog queries collapsed to JOINs — home / search / PDP render in ~160ms TTFB.** Home + search now serve from a single window-functioned JOIN that returns every active product with its first variant's price and first media row pre-joined. PDP serves from two parallel queries — one variants × prices LEFT JOIN, one media list. Worker compute time dropped from 2.8s (serial N+1) to 81ms (single JOIN). End-to-end TTFB on the live storefront is now ~160ms, down from the original ~3.4s container cold-start. EDGE_RENDER default flips to "on" so the perf win is the new baseline. Also fixes split-shipments ORDER BY id (UUIDv7 within-ms collision) and refreshes vendored blamejs to v0.12.6. **Changed:** *Edge catalog reads collapsed to JOINs — single round trip for home / search* — `worker/data/catalog.js` now exposes `listActiveProducts(DB, {limit, offset, currency})` and `searchProducts(DB, {q, limit, currency})` returning pre-decorated rows: each product carries `starting_price_minor` + `starting_price_currency` + `hero_media` joined inline. Two `ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY position ASC, created_at ASC)` window subqueries pick the first variant + first media per product; `LEFT JOIN prices` off the hero variant carries the active price for the requested currency. PDP now uses `listVariantsWithPrices(DB, productId, currency)` — one `variants LEFT JOIN prices` query returns every variant with its current price column. The previous N+1 helpers (`listVariantsForProduct`, `currentPrice`) are removed from the catalog module. · *`EDGE_RENDER` default flipped from `"off"` to `"on"` in `wrangler.toml`* — After the JOIN refactor put live-storefront TTFB at ~160ms, the edge-render path is the new baseline. Operators who want the legacy container-render path for the storefront read routes can set `EDGE_RENDER = "off"` in their `wrangler.toml` override. · *Vendored blamejs refreshed from v0.12.5 to v0.12.6* — `bash scripts/vendor-update.sh blamejs v0.12.6` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.5 and v0.12.6. **Fixed:** *`split-shipments` — `splitsForOrder` was ordering by `id DESC` (UUIDv7) instead of `proposed_at DESC`* — Two plans created in the same wall-clock millisecond shared a UUIDv7 timestamp prefix; the suffix is random, so `ORDER BY id DESC` returned them in undefined order. The v0.0.71 ship added a monotonic `_now()` to the `proposed_at` column, but the query still sorted on `id`. Changed to `ORDER BY proposed_at DESC, id DESC` so the monotonic timestamp is the primary sort key and the random ID suffix only breaks ties when timestamps truly match.

- v0.0.76 (2026-05-23) — **Edge-render path for storefront read routes — `/`, `/search`, `/products/:slug` served from the Worker without a container hop + vendor refresh to blamejs v0.12.5.** The storefront read-side render now runs at the edge. `/`, `/search`, and `/products/:slug` are rendered by the Cloudflare Worker reading D1 directly through the bound binding, then returning HTML — no container hop, no Durable Object handoff. Public visitors see TTFB drop from ~3.4s (container wake-up) to ~50ms. Gated by the `EDGE_RENDER` env var so operators can roll back per-deploy without a re-push. Write-side routes (POST /cart/lines, /checkout, /admin, webhooks) continue to land on the container. Bundles the vendored blamejs refresh from v0.12.4 to v0.12.5 and three CI-tight 50ms `waitUntil` timeouts bumped to 5s for runner-contention resilience. **Added:** *Worker edge-render — `/` home, `/search`, and `/products/:slug` rendered without a container hop* — New Worker-side modules under `worker/render/` (`_lib.js`, `home.js`, `product.js`, `search.js`, `cart.js`) and `worker/data/catalog.js` carry the storefront templates + D1 read-side queries. When `env.EDGE_RENDER` is `"on"`, the Worker queries D1 directly via the bound `env.DB` binding and returns HTML inline. The container's storefront mount continues to own the same routes when the flag is off, so the toggle is operator-controlled and reversible. Cart-count display in the header is fixed at zero on edge-rendered pages until the sealed-session decrypt path lands in a follow-up — visitors landing on `/cart` still see the live count from the container path. · *`EDGE_RENDER` and `SHOP_NAME` wrangler vars* — `wrangler.toml` gains two new `[vars]` entries: `EDGE_RENDER` (default `"off"` — flip to `"on"` to enable the edge-render path) and `SHOP_NAME` (default `"blamejs.shop"` — surfaces in the rendered `<title>` and OpenGraph metadata). **Changed:** *Vendored blamejs refreshed from v0.12.4 to v0.12.5* — `bash scripts/vendor-update.sh blamejs v0.12.5` ran cleanly; `lib/vendor/blamejs/MANIFEST.json` updated. See `lib/vendor/blamejs/CHANGELOG.md` for the upstream surface changes between v0.12.4 and v0.12.5. **Fixed:** *Three 50ms `waitUntil` timeouts bumped to 5s — `live-chat`, `fraud-screen`, `support-tickets` tests* — Nine call sites across `test/layer-1-state/live-chat.test.js`, `test/layer-1-state/fraud-screen.test.js`, and `test/layer-1-state/support-tickets.test.js` were polling for `Date.now()` to advance past a captured timestamp on a 50ms budget. Under CI runner contention that budget sometimes exhausts before the monotonic-clock tick lands. Same fix the v0.0.69 ship applied to `return-labels.test.js`; same rule §11 root cause.

- v0.0.75 (2026-05-22) — **Sixteen new primitives across operator tooling, vendor / inventory ops, marketing automation, and storefront content.** Sixteen primitives ship together. Operator + admin: customerImpersonation, carrierAccounts, operatorApprovals, customerMerge, operatorHelpCenter, eventLog. Vendor + inventory: vendorInvoices, inventoryAudits, smartRestocking, taxRemittance. Marketing + retention: winbackCampaigns, wishlistDigest. Storefront: blogArticles, categoryNavigation, productCompare, lineGiftWrap. **Added:** *`customerImpersonation` primitive — operator login-as-customer with audit* — 32-byte plaintext token returned once; namespaceHash at rest. 60-min default TTL. Capability gate via operatorRoles.hasPermission. notifyCustomer fan-out via injected notifications. actionsRecord builds the audit trail. Migration `0190`. · *`carrierAccounts` primitive — per-operator carrier API account management* — Carrier enum: ups / fedex / usps / dhl / canada_post / royal_mail / australia_post. All secrets hashed via namespaceHash under per-field namespaces. rotateCredentials with 24h grace. verifyCredentials timing-safe. Migration `0191`. · *`operatorApprovals` primitive — multi-step approval workflows* — Defines workflows with required_approvers + required_capability + escalation_after_hours. castVote dedup via UNIQUE(request_id, approver_id). Status FSM pending / approved / rejected / executed / cancelled / escalated. Migration `0192`. · *`vendorInvoices` primitive — vendor-issued invoices with PO reconciliation* — FSM received → approved → paid with disputed + voided side branches. Partial-pay first-class. UNIQUE(vendor_slug, invoice_number) for dedup. reconcileAgainstPOs returns variance summary. agingReport bucketing. Migration `0193`. · *`customerMerge` primitive — operator merge of duplicate customer accounts* — Reparents orders + subscriptions + loyalty + reviews + addresses + paymentMethods atomically (pre-flight all, then write all). 7-day rollback window. Jaro-Winkler-scored findDuplicateCandidates. customer_merge_redirects table for forwarding. Migration `0194`. · *`productCompare` primitive — storefront 2-4 product comparison* — Cap of 4 per session. compareTable returns per-attribute rows for the table render. Default attributes: price / sku / brand / vendor / weight / dimensions / inventory_status. defineCompareAttribute adds custom rows. popularCompares for analytics. Migration `0195`. · *`winbackCampaigns` primitive — multi-step re-engagement for lapsed customers* — Operator defines lapse_days_min + lapse_days_max + steps (delay + template + optional coupon). scanForLapsedCustomers finds candidates; enrollCustomer schedules; dispatchTick advances. markRecovered halts further steps. metricsForCampaign returns recovery rate + avg time-to-purchase. Migration `0196`. · *`inventoryAudits` primitive — periodic full-inventory audits* — Kinds: full / quarterly / spot. Scopes: all / category / vendor / location. recordScanLine + markRecount + finalizeAudit. Variance write-through composes inventoryLocations.adjustStock with `inventory-audit:<slug>` reason. compareToPriorAudit returns per-(sku, location) deltas. Migration `0197`. · *`wishlistDigest` primitive — scheduled weekly / monthly wishlist email digests* — Per-customer enrollment + cadence. next_dispatch_at computed via Intl.DateTimeFormat with two-pass DST-fold refinement. composeDigest returns HTML + text. Suppressed customers keep cadence ETA on rails but no email fires. Migration `0198`. · *`eventLog` primitive — universal append-only application event stream* — Drop-silent on bad input. Severity: debug / info / warning / critical. query with HMAC cursor. tail + topKinds + metricsForKind + purgeOlderThan (exclude_critical opt-in). Distinct from analytics (customer events) and operatorAuditLog (operator mutations). Migration `0199`. · *`operatorHelpCenter` primitive — in-admin operator help articles* — audience_roles closed allow-list against operatorRoles.PERMISSIONS. searchSuggest title 3 / section 2 / body 1 weighted ranker. recordHelpfulVote dedup at UNIQUE(slug, operator_id). Migration `0200`. · *`categoryNavigation` primitive — hierarchical storefront category tree* — self-FK parent_slug + CHECK(slug != parent_slug). tree returns nested shape. breadcrumbsFor returns ancestor chain. move walks ancestor chain to refuse cycles (bounded MAX_TREE_DEPTH=16). reorderSiblings requires the complete member set. Migration `0201`. · *`lineGiftWrap` primitive — per-line gift wrap selection* — Distinct from giftOptions (one wrap for the whole order). UNIQUE(order_id, line_id) UPSERT. gift_message ≤ 500 chars + control-byte refusal; recipient_name ≤ 120. feeForOrder sums per-line wrap fees via injected giftOptions. renderPackingSlipLines HTML-escapes every operator field. Migration `0202`. · *`smartRestocking` primitive — EOQ + safety-stock recommendations* — Composes demandForecast + reorderThresholds + costLayers + vendors. recommendOrderQty returns EOQ formula `sqrt(2DS/H)` + service-level safety-stock + reorder point + cost estimate. Service levels: 0.90 / 0.95 / 0.99. definePolicy + applyPolicy assigns SKUs to policies. Migration `0203`. · *`taxRemittance` primitive — per-jurisdiction tax payment tracking* — Records remittances with payment_method enum (bank_transfer / credit_card / ach / wire / check). reconcileWithFiling returns variance. lateRemittances threshold + recordPenalty + metricsForJurisdiction on-time rate. Migration `0204`. · *`blogArticles` primitive — operator-published blog posts* — Author + tags + featured_product_ids + SEO meta + publish FSM (draft → published → archived → draft). Markdown render through b.template.escapeHtml + b.safeUrl. relatedArticles tag-overlap ranking. byAuthor + popularArticles + recordView. Migration `0189`.

- v0.0.74 (2026-05-22) — **`emailEngagementScore` primitive + republish 0.0.73.** 0.0.73 source landed on `main` but the npm publish workflow stopped on a smoke check — the email-engagement-score test was committed without its lib counterpart. 0.0.74 adds the missing `emailEngagementScore` primitive and republishes the full v0.0.73 surface so `npm install @blamejs/blamejs-shop@0.0.74` pulls every primitive cleanly. **Added:** *`emailEngagementScore` primitive — per-customer 0-100 email engagement grade* — `bShop.emailEngagementScore.create({ query?, emailCampaigns?, emailSuppressions? })` returns `{ recordEngagementEvent, getScore, recompute, recomputeAll, unengagedCustomers, metricsForBand, historyForCustomer }`. Six event types with calibrated weights (opened +5, clicked +15, unsubscribed -50, spam_reported -75, bounced -10, not_opened_in_window -3). Score starts at 50 baseline, clamped to [0, 100]. Four bands: unengaged (0-19) / lapsed (20-49) / engaged (50-74) / highly_engaged (75-100). Click implies open in the open_rate denominator (compensates for image-blocking clients). Monotonic per-customer `_resolveOccurredAt` to break same-millisecond ties. Migration `0187_email_engagement_score.sql`. **Fixed:** *Republish to npm — 0.0.73 source on `main` reached operators via `git clone` but not via `npm install`* — `npm install @blamejs/blamejs-shop@0.0.73` resolves to a missing version because the publish workflow exited early when the CI smoke gate couldn't find `lib/email-engagement-score.js` (its test file was committed alongside v0.0.73 but the lib file itself was untracked at commit time). 0.0.74 ships the full surface so operators upgrading by version number get every primitive.

- v0.0.73 (2026-05-22) — **Twenty new primitives across operator ops, commerce, storefront, and compliance.** Twenty primitives ship together. Operator surfaces: marketingBudget, operatorSessions, operatorInbox, operatorActivityFeed, disputeResolution, shrinkageReport, demandForecast, fulfillmentSLA, salesTaxFilings, binLocations. Commerce: orderExchanges, searchRanking, shippingInsurance, cartRecovery, emailABTests, bannerABTests, referralLeaderboard. Storefront: pwaManifest, sidebarWidgets, announcementBar, stockReceipts, suggestionBox. Subscriptions: subscriptionAnalytics. Compliance: consentLedger. **Added:** *`marketingBudget` primitive — per-channel spend + ROAS* — Eleven channel kinds with per-channel currency lock. Integer-bps ROAS. UTC month budget reconciliation. Migration `0172`. · *`orderExchanges` primitive — different-item exchange FSM* — Six-state FSM with two-arm convergence. closeExchange requires both delivered_at and returned_at non-null. approveExchange opens an inventory hold pre-commit. Migration `0164`. · *`disputeResolution` primitive — chargeback lifecycle* — Four dispute kinds, seven evidence kinds, four outcomes. FSM open / submitted / lost / written_off. Migration `0173`. · *`operatorSessions` primitive — staff login with MFA + lockout* — 32-byte base64url bearer, hashed via namespaceHash. 8-hour TTL. Per-IP binding. Lockout after 5 failures in 15 minutes. Migration `0165`. · *`shippingInsurance` primitive — per-shipment insurance + claims* — Basis-point premium math. Insurance FSM active / cancelled / expired. Claim FSM filed / approved / denied. Four claim types. Migration `0166`. · *`pwaManifest` primitive — PWA manifest + service-worker config* — Display + orientation enums. Byte-stable JSON output via sorted-key serialise. URLs gated via b.safeUrl. Migration `0168`. · *`searchRanking` primitive — operator-tunable search reranker* — Boost/demote weights + manual pins per query. CTR + conversion math. Session ids hashed via namespaceHash. Migration `0167`. · *`emailABTests` primitive — subject + body A/B with Wilson CI* — Deterministic SHA3-512 assignment with 100k-bucket weight mapping. Sticky via the existing sent ledger row. Migration `0169`. · *`operatorInbox` primitive — in-admin notification feed* — Severity enum info / warning / urgent / critical. Exactly-one-of operator_id / role addressing. HMAC cursor pagination. Migration `0175`. · *`demandForecast` primitive — per-SKU demand prediction* — Four model kinds (SMA / WMA / exponential_smoothing / linear_regression). Band-coherence CHECKs. Migration `0179`. · *`stockReceipts` primitive — customer QR-scan delivery confirmation* — 32-byte token hashed at rest. Receipt FSM issued / scanned / completed / expired. Line FSM pending / received / damaged / partial. Migration `0177`. · *`bannerABTests` primitive — promo banner A/B with Wilson CI* — Deterministic per-session assignment. FSM running / paused / concluded / archived. Migration `0174`. · *`cartRecovery` primitive — abandon-cart email sequences* — Operator-defined step schedule. Composes cartAbandonment + email + emailSuppressions. markRecovered halts further steps. Migration `0171`. · *`sidebarWidgets` primitive — placement-targeted storefront blocks* — Nine widget kinds. Audience filter with optional customerSegments handle. Schedule-window gating + drop-silent counters. Migration `0176`. · *`consentLedger` primitive — append-only GDPR consent audit trail* — Nine consent kinds across cookies / marketing / data-sharing / data-processing. Two states (granted / withdrawn). Six sources. CSV / JSON audit export. Migration `0185`. · *`announcementBar` primitive — top-strip storefront message* — Four themes ranked urgency / promo / info / success. Schedule window + audience filter. Session-based dismissal dedup via UNIQUE(slug, session_id_hash). Migration `0180`. · *`shrinkageReport` primitive — loss-prevention dashboard* — Aggregates inventoryWriteoffs by reason / period / location / sku. Sample-stddev anomaly detection. Mixed-currency aggregation refusal. Migration `0170`. · *`subscriptionAnalytics` primitive — MRR / churn / cohort retention* — MRR cadence normalization across plan frequencies. Churn rate voluntary / involuntary / total. Cohort retention by month-zero. Migration `0178`. · *`operatorActivityFeed` primitive — staff-side timeline* — Aggregates operatorAuditLog + supportTickets + operatorInbox + operatorSessions. Cross-operator teamFeed. currentlyOnline 5-minute gate. Migration `0188`. · *`referralLeaderboard` primitive — top-referrer rankings + tier bonuses* — Reads from referrals primitive for ranking. Composes loyalty.earn for tier bonus payouts. UNIQUE(customer_id, period_label, tier) is the re-award gate. Migration `0182`. · *`fulfillmentSLA` primitive — order ship + deliver SLA tracking* — Per-priority policies (standard / expedited / overnight / same_day) with cutoff_local_time + timezone via Intl.DateTimeFormat. Severity bands minor / major / critical. Migration `0183`. · *`binLocations` primitive — warehouse bin / aisle / shelf assignments* — Auto-primary policy + pickPathSort by (aisle, shelf, level). Audit log with variance computation. Bin condition states clean / needs_audit / damaged / unusable. Migration `0186`. · *`salesTaxFilings` primitive — quarterly / monthly filings prep* — Aggregates orders + tax + taxRates + taxExempt into per-jurisdiction filings. FSM draft / computed / submitted / paid / amended. Banker's-rounded integer math on re-derive. Migration `0184`. · *`suggestionBox` primitive — customer feature-request box with up/downvote* — Categories: product_idea / feature_request / improvement / complaint / general. Status FSM open / under_consideration / planned / shipped / declined / duplicate. Vote dedup via UNIQUE(suggestion_id, session_id_hash). Migration `0181`.

- v0.0.72 (2026-05-22) — **`loyaltyEarnRules` primitive — per-action point earning rules.** `loyaltyEarnRules` defines HOW points are earned across eight trigger events: `per_dollar_spent`, `per_purchase`, `per_review`, `per_referral_redeemed`, `birthday`, `signup_bonus`, `first_purchase`, `abandoned_cart_recovered`. Distinct from the existing `loyalty` (ledger) and `tierBenefits` (perks) primitives. **Added:** *`loyaltyEarnRules` primitive — per-action point earning rules with optional per-event caps* — `bShop.loyaltyEarnRules.create({ query?, loyalty? })` returns `{ defineRule, getRule, listRules, updateRule, archiveRule, evaluateForEvent, awardForEvent, metricsForRule, applyBatch }`. Triggers: `per_dollar_spent / per_purchase / per_review / per_referral_redeemed / birthday / signup_bonus / first_purchase / abandoned_cart_recovered`. `evaluateForEvent` returns the points calculation (multiply + floor + zero-floor); `awardForEvent` composes the injected `loyalty.earn` handle and rolls back the audit row on ledger failure. Per-event cap (`max_per_event`) clips runaway awards. `customer_status_in` allowlist filters which customer tiers a rule applies to. UNIQUE(`rule_slug`, `customer_id`, `trigger_event_ref`) dedups re-submitted events. `applyBatch` runs multiple awards atomically with partial-failure reporting. Migration `0163_loyalty_earn_rules.sql`.

- v0.0.71 (2026-05-22) — **`splitShipments` — monotonic `proposed_at` so back-to-back plans sort newest-first.** Single-fix release. `splitShipments.planSplit` calls landing in the same wall-clock millisecond now get strictly-increasing `proposed_at` stamps via a process-local monotonic clock. Without the fix, `splitsForOrder(order_id)` returned the rows in nondeterministic order under burst-write conditions — CI runners hit the collision more reliably than local clocks. **Fixed:** *`splitShipments` — process-local monotonic `_now` so consecutive `planSplit` calls sort deterministically* — Two `planSplit` calls inside the same millisecond shared a `proposed_at` value, and the secondary tiebreaker (UUID v7 id) was scrambled by the intra-ms random suffix. The primitive now bumps `_now()` by 1ms when `Date.now()` returns a stale value, so `splitsForOrder` reliably returns newest-first under any insert burstiness. Same fix pattern as `orderNotes` / `productBulkOps` / `cookieConsent` / `liveChat` / `sms-dispatcher` / others — applies cleanly to any primitive where `Date.now()` precision drives sort order.

- v0.0.70 (2026-05-22) — **Six new primitives: click and collect, customer surveys, email templates, knowledge base, pixel events, sitemap generator.** Six primitives ship in one release covering store pickup (`clickAndCollect`), customer feedback (`customerSurveys`), operator-editable transactional email templates (`emailTemplates`), self-serve help center (`knowledgeBase`), server-side conversion pixel tracking (`pixelEvents`), and storefront sitemap generation (`sitemapGenerator`). **Added:** *`clickAndCollect` primitive — buy-online-pickup-in-store workflow* — `bShop.clickAndCollect.create({ query?, order?, inventoryLocations?, notifications? })` returns `{ definePickupLocation, availableLocations, scheduleAtLocation, markReadyForPickup, markPickedUp, markNoShow, pickupsForLocation, customerSchedules }`. Five-state FSM: scheduled → ready → picked_up + no_show + cancelled terminals. Capacity gated on one-hour buckets; lead-time gate per location. `markReadyForPickup` enqueues a pickup-ready notification through the injected handle (drop-silent on failure so a notifications outage can't roll back the hold shelf). `markPickedUp` drives the parent order to delivered via `order.transition(id, 'mark_delivered')` (swallows fsm/illegal-transition for idempotency). Signature hashed via `namespaceHash('click-and-collect-signature', raw)`. Migration `0126_click_and_collect.sql`. · *`customerSurveys` primitive — post-purchase NPS / CSAT / CES surveys* — `bShop.customerSurveys.create({ query? })` returns `{ defineSurvey, getSurvey, archiveSurvey, issueInvitation, getInvitation, invitationsForCustomer, submitResponse, responsesForSurvey, rollup, closeInvitation, cleanupExpired }`. Kinds: nps / csat / ces / custom. Trigger events: after_delivery / after_support_close / after_refund / manual. NPS / CSAT / CES enforce primary-question shape (`max === 10 / 5 / 7` respectively). NPS rollup = `round(%promoters - %detractors)`, CSAT = top-2-box positive_pct + mean, CES = mean + agree_pct. Invitation tokens 32-byte base64url plaintext returned exactly once, stored only as SHA3-512 namespaceHash. Migration `0128_customer_surveys.sql`. · *`emailTemplates` primitive — operator-editable transactional email templates* — `bShop.emailTemplates.create({ query? })` returns `{ defineTemplate, getTemplate, listTemplates, updateTemplate, publishVersion, archiveTemplate, renderTemplate, versionsFor, validateVariables }`. Closed 11-kind enum (order_confirmation / order_shipped / order_delivered / order_refunded / password_reset / account_verification / abandoned_cart / wishlist_discount / review_request / welcome / generic). `{{var_name}}` substitution composes `b.template.escapeHtml`; `{{var_name|raw}}` slots require schema vouching at definition time. Locale fallback to `en`. Migration `0125_email_templates.sql`. · *`knowledgeBase` primitive — self-serve customer help center with search ranking* — `bShop.knowledgeBase.create({ query?, defaultLocale?, cursorSecret? })` returns `{ defineArticle, getArticle, listArticles, updateArticle, publishArticle, unpublishArticle, archiveArticle, recordView, recordVote, voteAggregateForArticle, popularArticles, searchSuggest }`. Locale fallback chain. `searchSuggest` ranks by title 3 + tag 2 + body 1 weighted score; archived + unpublished excluded. Vote dedup at the `UNIQUE(slug, session_id_hash)` index. In-process markdown subset composes `b.template.escapeHtml` + `b.safeUrl.parse` (https-only + /-rooted internal). Session ids hashed via `namespaceHash` under `kb-view-session` / `kb-vote-session`. Migration `0162_knowledge_base.sql`. · *`pixelEvents` primitive — server-side conversion-tracking pixel / event API* — `bShop.pixelEvents.create({ query? })` returns `{ registerProvider, recordEvent, dispatchTick, markDispatched, markFailed, eventsForOrder, dispatchedInPeriod, failedEvents, metricsForProvider }`. Provider enum: meta_capi / google_ec / tiktok_events / pinterest_capi / snap_capi. Event names: purchase / add_to_cart / view_content / lead / complete_registration / search. Customer email + phone SHA-256-hashed (per provider spec — NOT namespaceHash; ad platforms accept SHA-256 of the normalised value). Five-step retry back-off on transient failures. Migration `0123_pixel_events.sql`. · *`sitemapGenerator` primitive — storefront sitemap.xml + sitemap-index.xml* — `bShop.sitemapGenerator.create({ query?, catalog?, collections?, storefrontPages?, custom? })` returns `{ defineSection, sections, archiveSection, validateOriginUrl, generate, recordGeneration, lastGeneration }`. Splits at 50,000 URLs or 50 MB serialized per chunk (whichever hits first). Path percent-encoding + XML escape on every emitted `<loc>`. Per-section sources: product / collection / storefront_page / custom. `validateOriginUrl` gates via `b.safeUrl` (https-only). Migration `0130_sitemap_generator.sql`.

- v0.0.69 (2026-05-22) — **Twelve new primitives + return-labels CI smoke fix.** Twelve primitives ship in one release plus a CI smoke fix that unblocks the npm publish workflow. The smoke fix bumps a too-tight 50ms `waitUntil` in `return-labels.test.js` to 5s so it survives the GitHub Actions runner's slower scheduling. New primitives cover orders (order ratings, packing slips, print queue), customers (wishlist sharing, customer activity feed, push notifications, order escalation), inventory (allocations, drop-ship forwarding, auto-replenishment), operator tooling (operator roles, clickstream events, damage photos). **Added:** *`orderRatings` primitive — per-order rating for shipping / packaging / recommend* — `bShop.orderRatings.create({ query? })` returns `{ submitRating, getRating, ratingsForCustomer, aggregateForPeriod, flagComment, responseToCustomer, topPositiveRatings, topNegativeRatings }`. Ratings 1..5 across three dimensions. UNIQUE(order_id) so one rating per order. Comment + operator response HTML-escaped on render. flagComment moderates abuse; responseToCustomer is the operator's public reply. Migration `0151_order_ratings.sql`. · *`wishlistSharing` primitive — share-a-wishlist links + group wishlists* — `bShop.wishlistSharing.create({ query?, wishlist? })` returns `{ createShareLink, revokeShareLink, viewShared, recordView, createGroupWishlist, joinGroupWishlist, leaveGroupWishlist, groupWishlistsForCustomer, listSharesForOwner, listGroupMembers }`. Tokens are 32-byte base64url plaintext returned once, hashed at rest via `namespaceHash` under per-domain namespaces. Privacy enum: public / unlisted / friends_only. Group wishlists let multiple customers co-author a list. Migration `0150_wishlist_sharing.sql`. · *`inventoryAllocations` primitive — soft-reserve holds for in-progress carts* — `bShop.inventoryAllocations.create({ query?, inventoryLocations? })` returns `{ holdForCart, releaseHold, releaseAllForCart, commitHold, extendHold, availableForSku, holdsForCart, cleanupExpiredHolds, metricsForSku }`. Holds prevent overselling during checkout. `availableForSku` returns committed - on_hold. `commitHold` composes `inventoryLocations.adjustStock` to commit the reservation as a real stock movement. TTL-bound; `cleanupExpiredHolds` walks expired rows and frees the inventory. Migration `0152_inventory_allocations.sql`. · *`dropshipForwarding` primitive — drop-ship vendor order-forwarding flow* — `bShop.dropshipForwarding.create({ query?, vendors?, orderTracking? })` returns `{ bindSkuToVendor, forwardOrder, markVendorAccepted, markVendorShipped, markVendorDelivered, markVendorFailed, markVendorReturned, getForwarding, forwardingsForOrder, pendingForwardings, metricsForVendor }`. Six-state FSM: queued / accepted / shipped / delivered / failed / returned. Composes `b.fsm` for transition validation. `forwardOrder` writes per-vendor forwarding rows. Migration `0154_dropship_forwarding.sql`. · *`damagePhotos` primitive — image attachments for damage / quality-control events* — `bShop.damagePhotos.create({ query? })` returns `{ recordPhoto, getPhoto, photosForSubject, archivePhoto, replacePhoto, metricsForKind, findDuplicatesBySha }`. Subject kinds: writeoff / return / quality_check / damaged_receipt / customer_complaint. SHA3-512 + size + content_type per upload. content_type allowlist: image/jpeg, image/png, image/webp, image/heic. `findDuplicatesBySha` returns prior uploads with the same digest (fraud detection signal). Migration `0159_damage_photos.sql`. · *`pushNotifications` primitive — APNs / FCM / Web Push outbound* — `bShop.pushNotifications.create({ query? })` returns `{ registerProvider, registerDevice, revokeDevice, recordOptIn, recordOptOut, isOptedIn, enqueueNotification, markDelivered, markFailed, dispatchTick, notificationsForCustomer, devicesForCustomer, metricsForProvider }`. Marketing channel uses opt-IN; transactional + alert use opt-OUT. Device class / provider kind compatibility enforced (ios↔apns, android↔fcm, web↔web_push, desktop↔fcm|web_push). Retry budget 5 with [1m, 5m, 30m, 2h, 12h] back-off. Migration `0148_push_notifications.sql`. · *`autoReplenish` primitive — auto-PO when reorder thresholds fire* — `bShop.autoReplenish.create({ query?, reorderThresholds?, purchaseOrders?, vendors? })` returns `{ definePolicy, tickReplenishment, getPolicy, listPolicies, archivePolicy, updatePolicy, replenishmentHistory, markPolicyTriggered }`. `tickReplenishment` finds reorder candidates via `reorderThresholds.scanAll` + groups by vendor + submits PO via `purchaseOrders.createDraft + submitToVendor`. Per-policy gates: min/max PO value, max_concurrent_open_pos cap, vendor-archived skip. Schedule: hourly / daily / weekly. Migration `0155_auto_replenish.sql`. · *`operatorRoles` primitive — staff-side RBAC* — `bShop.operatorRoles.create({ query?, operatorAuditLog? })` returns `{ defineRole, assignRoleToOperator, revokeRoleFromOperator, rolesForOperator, operatorsWithRole, hasPermission, listRoles, updateRole, archiveRole, listPermissions, permissionUsageLog, recordPermissionUse }`. 15-permission closed allow-list (orders.* / customers.* / catalog.* / inventory.write / vendors.manage / settings.write / billing.view / reports.read / users.* / support.handle). Multi-role union; expires_at honored. Migration `0157_operator_roles.sql`. · *`clickstream` primitive — server-side storefront analytics events* — `bShop.clickstream.create({ query? })` returns `{ recordPageView, recordEvent, sessionPath, topPages, topClicks, funnelAnalysis, bouncerate, dwellByPage, cleanupOlderThan }`. Tracks page views + clicks + form submits + scroll depth + dwell without third-party scripts. Event kinds: click / form_submit / form_abandon / video_play / video_complete / scroll_depth / dwell. Query strings + URL fragments stripped at write so PII in query params never reaches the table. Drop-silent on bad input. Migration `0161_clickstream.sql`. · *`customerActivity` primitive — per-customer aggregated activity timeline* — `bShop.customerActivity.create({ query?, cursorSecret?, order?, wishlist?, loyalty?, supportTickets?, reviews? })` returns `{ forCustomer, recentActivity, summarize, lastActivityAt, inactiveCustomers, purgeStaleCache }`. Aggregates events from order / wishlist / loyalty / support / reviews into one feed for the operator's customer-detail page. Missing peers skipped silently. `summarize` returns 30/90/365-day kind counts with cache hit/miss. Migration `0153_customer_activity_cache.sql`. · *`packingSlips` primitive — warehouse packing-slip rendering + print queue* — `bShop.packingSlips.create({ query?, order, giftOptions? })` returns `{ renderHtml, renderPdfPayload, recordPrint, printsForOrder, enqueueForLocation, dequeueForLocation, bulkRenderForLocation }`. Inlined Code-128 barcode SVG renderer (no external library dependency). HTML-escapes every operator + customer field. Gift options: hide_prices strips price/total columns; gift_message + recipient_name rendered inert against hostile input. Composite PK on the queue table as idempotency guard. Migration `0149_packing_slips.sql`. · *`printQueue` primitive — warehouse print job queue scheduler* — `bShop.printQueue.create({ query? })` returns `{ enqueueJob, claimJob, markComplete, markFailed, cancelJob, getJob, jobsForStation, pendingByKind, cleanupCompleted, stationActivity, dailyMetrics }`. Job kinds: packing_slip / shipping_label / invoice / packing_label / return_label / pickup_slip. `claimJob` returns next queued job (FIFO with priority tiebreak), flips `in_progress`. station_filter restricts which stations can claim a job. Retry budget on markFailed. Migration `0158_print_queue.sql`. **Fixed:** *`return-labels.test.js` — bumped a 50ms `waitUntil` timeout to 5s for CI runner scheduling* — GitHub Actions Ubuntu / macOS runners under contention sometimes need more than 50ms between `Date.now()` ticks to clear a monotonic-clock guard. The two `waitUntil` calls in `_listQueries` now use a 5000ms budget so the CI smoke gate doesn't drop the npm publish workflow. Local runs were always fast enough; only CI saw the failure.

- v0.0.68 (2026-05-22) — **Republish 0.0.67 + three new primitives: seller signup, tax cert renewals, order escalation.** 0.0.67 source landed on `main` but the npm publish workflow didn't reach the registry. 0.0.68 republishes the twelve primitives from 0.0.67 plus three new ones: `sellerSignup` (marketplace seller onboarding flow with KYC-style document workflow), `taxCertRenewals` (companion scheduler for `taxExempt` certificates expiring within a window), and `orderEscalation` (rule-driven operator routing for orders that need manual review). **Added:** *`sellerSignup` primitive — marketplace seller onboarding flow* — `bShop.sellerSignup.create({ query?, vendors? })` returns `{ submitApplication, requestDocument, recordDocumentUploaded, approveApplication, rejectApplication, requestRevisions, getApplication, listApplications, documentsForApplication }`. Document kinds: `w9 / w8ben / id / business_license / utility_bill / insurance / bank_statement / other`. Status FSM: `submitted → docs_pending → in_review → revisions_requested → approved | rejected | withdrawn`. `approveApplication` composes injected `vendors.registerVendor` to flip the application into a live vendor row. Email + phone + tax_id all hashed via `namespaceHash` under separate namespaces; raw values never persist. Migration `0146_seller_signup.sql`. · *`taxCertRenewals` primitive — companion scheduler for tax exemption certificate renewals* — `bShop.taxCertRenewals.create({ query?, taxExempt?, notifications? })` returns `{ defineSchedule, scanAndEnqueue, recordReminderSent, markEscalated, markRenewed, markExpired, certificationsDueSoon, metricsForSchedule, listSchedules, archiveSchedule, listRemindersForCert }`. Per-jurisdiction `lead_time_days` window. `scanAndEnqueue({ now })` walks `taxExempt` certs expiring in `[now, now + lead_time_days]` and queues reminders via the injected notifications handle. `markEscalated` fans out to operator after `escalate_after_days` if the customer hasn't acknowledged. Migration `0145_tax_cert_renewals.sql`. · *`orderEscalation` primitive — rule-driven operator routing for manual-review orders* — `bShop.orderEscalation.create({ query?, notifications?, orderTags? })` returns `{ defineRule, evaluateOrder, recordEscalation, resolveEscalation, markAsFalsePositive, openEscalations, escalationsForOrder, metricsForRule, listRules, updateRule, archiveRule }`. Rule conditions: `total_minor_min / country_in / prior_orders_max / fraud_score_min / refund_pending / payment_method_kind_in`. A rule fires only when every named condition matches the snapshot. `recordEscalation` fans out tag application via injected `orderTags` and notification dispatch via injected `notifications`. FSM: open → resolved | false_positive. Migration `0144_order_escalation.sql`. **Fixed:** *Republish to npm — 0.0.67 source on `main` reached operators via `git clone` but not via `npm install`* — `npm install @blamejs/blamejs-shop@0.0.67` resolves to a missing version because the publish workflow exited early on a content-quality check in the release-notes file. 0.0.68 ships the identical surface as 0.0.67 plus the three new primitives above, so operators upgrading by version number get the full set.

- v0.0.67 (2026-05-22) — **Twelve new primitives: customer notes, order tags, customer risk profile, tier benefits, promo bundles, subscription gifts, reorder reminders, product Q&A, locale router, inventory writeoffs, robots config, refund automation.** Twelve primitives across CRM (customer notes, customer risk profile, tier benefits), commerce (order tags, promo bundles, subscription gifts, reorder reminders, product Q&A, refund automation), operations (inventory writeoffs), and storefront infrastructure (locale router, robots config). **Added:** *`customerNotes` primitive — operator-side CRM annotations attached to customer records* — `bShop.customerNotes.create({ query?, cursorSecret? })` returns `{ addNote, getNote, notesForCustomer, updateNote, archiveNote, unarchiveNote, pinNote, unpinNote, searchByTag, popularTags }`. Author enum: operator / system (customer-facing content belongs in portal). Kinds: general / preference / escalation / warning / billing. Body cap 8000, control-byte + zero-width refusals. HMAC cursors with orderKey separation prevent cross-listing replay attacks. Migration `0134_customer_notes.sql`. · *`orderTags` primitive — free-form operator tagging with rules engine* — `bShop.orderTags.create({ query?, cursorSecret? })` returns `{ defineTag, applyTag, removeTag, tagsForOrder, ordersWithTag, popularTags, defineRule, applyRulesToOrder, listTags, archiveTag, updateTag, historyForOrder }`. Soft-delete via partial UNIQUE index on `(order_id, tag_slug) WHERE removed_at IS NULL`. Rules engine matches conditions (total_minor / country / line_count / customer_segment) and applies tags automatically. Color CHECK gates malformed hex. Migration `0137_order_tags.sql`. · *`customerRiskProfile` primitive — long-running per-customer risk aggregation* — `bShop.customerRiskProfile.create({ query?, fraudScreen?, returns?, dunning?, creditLimits? })` returns `{ recordSignal, getProfile, tier, historyForCustomer, recompute, recomputeAll, topRiskCustomers, clearSignal }`. Signal kinds: chargeback / refund_request / fraud_score_high / chargeback_dispute_lost / late_payment / address_mismatch / device_diversity_high / refund_to_credit. Severity 1..10. Bands: low (0..9) / moderate (10..24) / high (25..49) / critical (50+). 90-day recent window; older signals count toward lifetime only. `clearSignal` is the operator override for false positives. Migration `0142_customer_risk_profile.sql`. · *`tierBenefits` primitive — per-loyalty-tier perks engine* — `bShop.tierBenefits.create({ query?, loyalty? })` returns `{ defineBenefit, updateBenefit, archiveBenefit, listBenefits, benefitsForTier, benefitsForCustomer, recordUsage, usageForBenefit, metricsForBenefit }`. Kinds: free_shipping / percent_off / early_access / priority_support / exclusive_access / birthday_bonus. Per-kind value validation. Conditions envelope (min_order / currency / region / time window) filters at resolution. `benefitsForCustomer` composes loyalty.tierForCustomer with `.balance(id).tier` fallback. Migration `0141_tier_benefits.sql`. · *`promoBundles` primitive — time-limited promotional bundles distinct from regular bundles* — `bShop.promoBundles.create({ query?, catalog? })` returns `{ defineBundle, getBundle, activeBundles, detectInCart, applyBundleToCart, recordRedemption, updateBundle, archiveBundle, metricsForBundle }`. Components canonicalized sorted-by-sku. `applyBundleToCart` is a pure transform. `recordRedemption` uses guarded UPDATE for cap-race + UNIQUE(slug, order_id) for idempotency. Mutable surface restricted to title/window/caps post-define; components/price/currency immutable. Migration `0140_promo_bundles.sql`. · *`subscriptionGifts` primitive — gift a subscription + owner-to-owner transfers* — `bShop.subscriptionGifts.create({ query?, subscriptions?, email? })` returns `{ purchaseGift, redeemGift, cancelGift, transferOwnership, getGift, giftsForGiver, transfersForSubscription, expiringGifts, cleanupExpired }`. Token: 32-byte uniform draw, base64url-rendered (43 chars), SHA3-512 hashed at rest under `subscription-gift-token`, plaintext returned exactly once. Recipient email hashed via guardEmail + namespaceHash. FSM pending → delivered → redeemed | expired | cancelled with race-loser detection on UPDATE rowCount. Migration `0135_subscription_gifts.sql`. · *`reorderReminders` primitive — "time to reorder" nudges for consumable products* — `bShop.reorderReminders.create({ query?, order?, notifications?, email? })` returns `{ defineReminderProfile, enrollCustomerSku, dispatchTick, recordSent, markFailed, remindersForCustomer, unsubscribeFromSku, metricsForProfile }`. Operator defines per-SKU reorder intervals; primitive enqueues at `last_order_at + interval_days`. Channels: email / sms / in_app via injected handles. `unsubscribeFromSku` stops future dispatches. Migration `0136_reorder_reminders.sql`. · *`productQA` primitive — customer Q&A on PDP with moderation queue* — `bShop.productQA.create({ query?, customers? })` returns `{ submitQuestion, submitAnswer, approveQuestion, rejectQuestion, approveAnswer, rejectAnswer, pinAnswer, voteUpAnswer, questionsForProduct, getQuestion, topAnswerForQuestion, metricsForProduct, cleanupRejected }`. Questions + answers moderated separately. Vote-up dedup via UNIQUE(answer_id, session_id_hash). Top-answer pinning floats one answer above sort. Email + session id namespace-hashed before write. Migration `0133_product_qa.sql`. · *`localeRouter` primitive — storefront locale resolution from request hints* — `bShop.localeRouter.create({ query?, customers?, geolocation? })` returns `{ defineLocale, definePolicy, resolveLocale, setCustomerLocale, clearCustomerLocale, getPolicy, listPolicies, archivePolicy, setActivePolicy, activePolicy, localePopularity, getLocale, listLocales, getCustomerLocale }`. Strategies: url_prefix / subdomain / cookie / accept_language_only / customer_preference. Customer pref wins across every strategy; URL/subdomain/cookie/Accept-Language fallback per active strategy. Partial-unique index enforces at-most-one active policy. Migration `0139_locale_router.sql`. · *`inventoryWriteoffs` primitive — damage / lost / shrinkage stock write-offs* — `bShop.inventoryWriteoffs.create({ query?, inventoryLocations?, costLayers? })` returns `{ recordWriteoff, getWriteoff, listWriteoffs, costImpactForPeriod, reverseWriteoff }`. Reasons: damaged / lost / shrinkage / expired / recall / sample / quality_control / theft. Debits stock via inventoryLocations.adjustStock; composes costLayers.recordReversal when injected. Mixed-currency aggregation refusal in `costImpactForPeriod`. Compensating rollback on costLayers refusal so the shelf never disagrees with the writeoff ledger. Migration `0138_inventory_writeoffs.sql`. · *`robotsConfig` primitive — operator-editable robots.txt rules* — `bShop.robotsConfig.create({ query? })` returns `{ defineRule, listRules, archiveRule, updateRule, addSitemap, removeSitemap, listSitemaps, setHostDirective, getHostDirective, render, predefinedTemplates, applyTemplate }`. Predefined templates: `block_ai_crawlers` (GPTBot, ClaudeBot, CCBot, Google-Extended, Bytespider, Amazonbot, FacebookBot, and other major AI training crawlers), `block_all`, `open_all`, `standard_with_admin_disallow`. Sitemap URLs validated via b.safeUrl (https-only). `host_directive` table locked to single row via PK = 1. Migration `0147_robots_config.sql`. · *`refundAutomation` primitive — auto-refund eligibility + execution rules* — `bShop.refundAutomation.create({ query?, refundPolicy?, returns?, payment?, customerRiskProfile? })` returns `{ defineAutoRule, evaluateForRefundRequest, executeAutoRefund, markManualOverride, metricsForRule, listRules, updateRule, archiveRule }`. Rules: max_amount_minor, max_refunds_per_customer_year, requires_low_risk (via customerRiskProfile.bandFor), eligible_reasons, currency_in_set. `executeAutoRefund` composes payment.refund. `markManualOverride` flips a manual_review row to auto_approved / declined. Per-factory monotonic clock guarantees strictly-increasing decided_at. Migration `0143_refund_automation.sql`.

- v0.0.66 (2026-05-22) — **Ten new primitives: site redirects, cost layers, payment retries, pick lists, preorder, discount allocation, currency rounding, credit limits, theme assets, business hours.** Ten primitives ship in one release covering warehouse operations (pick lists, FIFO/LIFO inventory cost layers), commerce (preorder, currency rounding rules, discount allocation breakdown), B2B (credit limits with net-30 terms + aging), payment recovery (payment retries with failure-code-aware policy), and storefront infrastructure (site redirects, theme assets registry, business-hours calculator). **Added:** *`siteRedirects` primitive — operator-defined 301/302/307/308 redirects* — `bShop.siteRedirects.create({ query? })` returns `{ defineRedirect, resolveForPath, recordHit, listRedirects, getRedirect, updateRedirect, archiveRedirect, unarchiveRedirect, topHits, cleanupExpired }`. Match kinds: exact / prefix / regex. Regex patterns refused if they carry backreferences (`\1`-`\9`) or lookahead/lookbehind to keep catastrophic-backtracking out of the live table. `resolveForPath` walks exact > prefix (longest source_path wins, slug ASC tiebreak) > regex (slug ASC). `target_url` accepts /-rooted internal paths OR full https:// via `b.safeUrl`. Migration `0119_site_redirects.sql`. · *`costLayers` primitive — FIFO / LIFO / weighted-average inventory cost tracking* — `bShop.costLayers.create({ query?, catalog? })` returns `{ setMethod, getMethod, recordReceipt, consumeForSale, recordReversal, currentLayers, cogsForOrder, cogsForPeriod }`. Method enum: fifo / lifo / weighted_average. FIFO consumes oldest layers first; LIFO newest; weighted_average computes the mean of remaining layers. Each receipt adds a cost layer with `quantity_remaining` + `unit_cost_minor`. `consumeForSale` returns `{ consumed_layers: [...], total_cogs_minor, currency }` and refuses on insufficient on-hand. `recordReversal` restores stock on returns by adding back to a layer. Per-SKU strict-monotonic `occurred_at` clock so same-millisecond receipts can't tie on the FIFO/LIFO ordering key. Migration `0121_cost_layers.sql`. · *`paymentRetries` primitive — failure-code-aware retry orchestration for one-time payments* — `bShop.paymentRetries.create({ query?, payment? })` returns `{ defineRetryPolicy, enrollFailure, tickRetries, recordRetryOutcome, unenrollPayment, statusForPayment, historyForPayment, policiesForFailureCode, metricsForPolicy }`. Distinct from `dunning` (subscriptions only). Failure-code-aware: `insufficient_funds` → wait 24h + retry; `card_declined` → wait 7d + retry once; `invalid_card` → terminal-at-enrollment. `tickRetries` walks `next_retry_at <= now` rows and dispatches via composed payment.retry. `terminal_after_attempts` exhausts an enrollment + flips `abandoned` status. Migration `0120_payment_retries.sql`. · *`pickLists` primitive — warehouse pick-list workflow* — `bShop.pickLists.create({ query?, order, orderTracking?, inventoryLocations? })` returns `{ generateList, getList, listLists, confirmLine, markListComplete, cancelList, discrepanciesFor }`. Generated from N open orders for a given warehouse, sorted by aisle/sku/priority/order_id (materialized into a `sequence_number` column so picker walk order is durable across reads). Optional `inventoryLocations.binForSku` probe for aisle position. `markListComplete` fans out shipment creation via composed `orderTracking.createShipment` per-order. Migration `0118_pick_lists.sql`. · *`preorder` primitive — pre-launch reservations distinct from backorder* — `bShop.preorder.create({ query?, catalog?, order? })` returns `{ defineCampaign, reserve, getReservation, reservationsForCustomer, cancelReservation, convertReservationToOrder, launchCampaign, closeCampaign, availability, getCampaign }`. Backorder = sku exists but out-of-stock; preorder = sku not yet released. `defineCampaign({ slug, sku, launch_at, charge_at?, max_units_available?, deposit_minor?, full_price_minor })`. `launchCampaign` triggers auto-conversion of all active reservations via composed `order.createFromCart`. Cancelled reservations free capacity for re-reservation. Migration `0124_preorder.sql`. · *`discountAllocation` primitive — per-line allocation of order-level discounts* — `bShop.discountAllocation.create({ query })` returns `{ allocate, recordAllocation, allocationsForOrder, reverseAllocation, metricsForKind }`. Order has $20 off the total — primitive distributes across lines for accounting + refund precision. Kinds: proportional / equal / by_subtotal / by_quantity. Half-even rounding via `b.money.fromMinorUnits(...).multiply([num, den])`; remainder lands deterministically on highest-subtotal line. `reverseAllocation` produces per-line refund amounts that re-sum to the original total. Migration `0129_discount_allocation.sql`. · *`currencyRounding` primitive — per-currency display rounding rules* — `bShop.currencyRounding.create({ query? })` returns `{ defineRule, getRule, listRules, roundFor, updateRule, archiveRule, historyForCurrency, round }`. Distinct from `currencyDisplay` (FX rate cache) — this is the rounding behavior applied at checkout. CHF rounds to nearest 0.05; SEK to nearest 0.10; JPY whole units. Modes: half_up / half_even / half_down / ceiling / floor. `applies_to` enum: display_only / cart_total / line_total / all. Pure `_round(amount, step, mode)` exported as `currencyRounding.round` for direct composition without the factory. Migration `0132_currency_rounding.sql`. · *`creditLimits` primitive — B2B credit accounts with net-30 / net-60 terms + aging report* — `bShop.creditLimits.create({ query?, customers? })` returns `{ defineAccount, getAccount, listAccounts, updateAccount, suspendAccount, reinstateAccount, chargeOrder, releaseHold, recordPayment, availableCredit, outstandingBalance, agingReport }`. `chargeOrder` checks available credit, refuses on insufficient (no row written). `releaseHold` restores credit on cancel. `agingReport({ customer_id })` returns balance by days-past-due bucket (current / 30d / 60d / 90d+). Payments + releases FIFO-settle against oldest charges. FSM: active / suspended / closed. Migration `0122_credit_limits.sql`. · *`themeAssets` primitive — operator-uploaded theme asset registry* — `bShop.themeAssets.create({ query? })` returns `{ registerAsset, getAsset, assetsForTheme, assetsByKind, updateAsset, archiveAsset, assignToTheme, recordImpression, metricsForAsset, cleanupOrphans }`. Asset kinds: font / logo / hero_image / favicon / banner_image / og_image / icon / custom. Content-addressed via `sha3_512` digest (validated as lowercase 128-char hex). `source_url` gated via `b.safeUrl` (https-only + /-rooted internal). `cleanupOrphans({ days_idle })` archives assets not assigned to any active theme. Migration `0131_theme_assets.sql`. · *`businessHours` primitive — operator-defined business-hour windows with DST-correct math* — `bShop.businessHours.create({ query? })` returns `{ defineSchedule, isOpenAt, nextOpenAt, nextCloseAt, weekSummary, addException, removeException, addHoliday, listSchedules, archiveSchedule }`. Per-day open/close times in IANA timezone, plus per-date exceptions + holidays. `Intl.DateTimeFormat` for tz-correct calendar math (DST-correct via Intl, no manual offset arithmetic). Two-pass `_wallClockToEpochMs` algorithm converges across DST folds. `nextOpenAt` skips holidays + closed days. Migration `0127_business_hours.sql`.

- v0.0.65 (2026-05-22) — **Twenty new primitives: address validation, auto discount, captcha gate, catalog drafts, cookie consent, customer roles, cycle counting, delivery estimate, email warmup, metered usage, price display, product bulk ops, purchase orders, quotes, recommendations, reorder thresholds, shipping zones, split shipments, trust badges, webhook receiver.** Twenty primitives ship in one release. Covers per-customer self-serve flows (cookie consent, captcha gate, address validation), operator-side catalog tooling (drafts, bulk ops, reorder thresholds, purchase orders, cycle counting, delivery estimate, shipping zones), commerce primitives (auto discount, quotes, recommendations, price display, split shipments, metered usage, trust badges), and integrations (webhook receiver, email warmup, customer roles). **Added:** *`addressValidation` primitive — cache + lookup for address-validation API results* — `bShop.addressValidation.create({ query? })` returns `{ recordValidation, lookupCached, signatureFor, recordSuggestion, lookupSuggestions, cleanupExpired, metricsForSource, validationsForOrder }`. Input signature derived via `namespaceHash('address-validation-input', canonical(input))` so the same address normalizes to one cache row. Source enum: `usps / smarty / lob / google / melissa / manual`. Classification enum: `residential / commercial / po_box / military / unknown`. Migration `0115_address_validation.sql`. · *`autoDiscount` primitive — automatic discounts without coupon codes* — `bShop.autoDiscount.create({ query?, catalog?, customerSegments? })` returns `{ defineRule, getRule, listRules, updateRule, archiveRule, evaluate, recordApplication, metricsForRule }`. Trigger kinds: `cart_total_min / item_count_min / sku_purchase`. Value kinds: `percent_off / amount_off_total / amount_off_each / free_shipping / bogo`. Priority + max_redemptions + customer segment gating. Migration `0107_auto_discount.sql`. · *`captchaGate` primitive — CAPTCHA verification at high-risk entrypoints* — `bShop.captchaGate.create({ query? })` returns `{ registerProvider, verifyToken, recordOutcome, metricsForProvider, ... }`. Provider enum: `turnstile / hcaptcha / recaptcha_v2 / recaptcha_v3`. reCAPTCHA v3 gets a threshold-score floor (basis-points). Secret hashed via `namespaceHash('captcha-secret', ...)`; session id hashed before write. Provider-callback-driven verify; operator's worker calls the actual provider endpoint. Migration `0114_captcha_gate.sql`. · *`catalogDrafts` primitive — staging workflow for catalog mutations* — `bShop.catalogDrafts.create({ query?, catalog })` returns `{ openDraft, stageChange, listChanges, removeChange, previewMerged, publishDraft, cancelDraft, rollbackDraft, listDrafts, historyForSku }`. Stage N changes against catalog (create / update / archive products + variants + prices + tags + inventory); publish atomically. 7-day rollback window. Pre-flight validates every change against the live catalog before writing anything. Migration `0112_catalog_drafts.sql`. · *`cookieConsent` primitive — GDPR / ePrivacy consent management* — `bShop.cookieConsent.create({ query? })` returns `{ recordConsent, getConsentFor, withdrawConsent, categoryAllowed, metricsForBanner, cleanupOlderThan, registerPolicyVersion }`. Categories: strictly-necessary (always on) + functional + analytics + marketing + preferences. DNT / Sec-GPC headers force implicit deny on marketing + analytics regardless of recorded consent. Policy-version bump flags older session consents for re-prompt. Session id hashed before write. Migration `0103_cookie_consent.sql`. · *`customerRoles` primitive — B2B company-account roles + capabilities* — `bShop.customerRoles.create({ query?, customers? })` returns `{ defineRole, assignRole, unassignRole, rolesForEmployee, employeesForCompany, hasCapability, recordOrderApproval, listRoles, updateRole, archiveRole }`. Capabilities: `can_view_orders / can_place_order / can_approve_order / can_manage_users / can_view_pricing / can_apply_payment_terms / can_request_quote / can_view_invoices`. UNIQUE(company, employee) so an employee carries one role per company. Migration `0101_customer_roles.sql`. · *`cycleCounting` primitive — physical inventory cycle counts* — `bShop.cycleCounting.create({ query?, catalog, inventoryLocations? })` returns `{ defineCount, worksheetFor, recordCount, finalizeCount, cancelCount, discrepanciesFor, listCounts, historyForSku, getCount }`. Kinds: `rotating / abc / full`. `finalizeCount({ apply_adjustments })` writes adjustments through `inventoryLocations.adjustStock` with `reason='cycle-count:<slug>'` so the audit row carries the count slug. Migration `0108_cycle_counting.sql`. · *`deliveryEstimate` primitive — PDP + cart delivery-window calculator* — `bShop.deliveryEstimate.create({ query?, inventoryLocations?, shippingZones? })` returns `{ defineCarrierTransit, defineCutoff, defineHoliday, definePostalZone, estimate, estimateForCart, listTransits, listCutoffs, listHolidays, archiveTransit, archiveHoliday }`. Calendar math is Intl-DateTimeFormat-driven (DST-correct); weekend + region-holiday skips applied separately to origin ship-by + destination delivery-by. Migration `0117_delivery_estimate.sql`. · *`emailWarmup` primitive — gradual SMTP IP/domain warmup* — `bShop.emailWarmup.create({ query?, now? })` returns `{ defineSchedule, canSend, recordSends, recordEngagement, currentDay, pauseSchedule, resumeSchedule, archiveSchedule, listSchedules, getSchedule, metricsForSchedule }`. Daily targets array (Day 1: 50, Day 2: 100, etc.) cap outbound volume to build sender reputation. Day-index math computed against UTC midnight of `start_date`. Migration `0116_email_warmup.sql`. · *`meteredUsage` primitive — usage-based subscription billing* — `bShop.meteredUsage.create({ query?, subscriptions?, subscriptionBilling? })` returns `{ defineMeter, recordUsage, usageForPeriod, periodSummary, recordPeriodInvoice, listMeters, updateMeter, archiveMeter }`. Tier schedule for tiered pricing (first 1000 free, next 10000 at $0.001, ...). `recordUsage` idempotency_key dedups re-submitted events. `recordPeriodInvoice` queues invoice line via subscriptionBilling. Migration `0095_metered_usage.sql`. · *`priceDisplay` primitive — per-region tax-included / tax-excluded display modes* — `bShop.priceDisplay.create({ query?, tax?, taxRates?, geolocation? })` returns `{ defineRule, getModeFor, formatPrice, defineCustomerOverride, clearCustomerOverride, customerOverride, listRules, updateRule, archiveRule }`. Resolution priority: customer-override > customer_status_in > country default. Compose `tax.calculateInclusive / calculateExclusive` for the breakdown. Migration `0097_price_display.sql`. · *`productBulkOps` primitive — bulk product mutations with pre-flight + audit* — `bShop.productBulkOps.create({ query?, catalog, maxBulkRows? })` returns `{ bulkSetPrice, bulkAdjustPrice, bulkArchive, bulkUnarchive, bulkAddTag, bulkRemoveTag, bulkSetInventory, previewFilter, auditTrail, categories, tags }`. Filter `{ skus?, vendor_slug?, category?, tag_any?, tag_all? }`. Pre-flight cap default 1000, ceiling 10000. Half-up rounding on percent adjustments; floored at 0. Process-local monotonic `_now` so back-to-back audit rows sort newest-first reliably. Migration `0104_product_bulk_ops.sql`. · *`purchaseOrders` primitive — operator-facing POs to vendors* — `bShop.purchaseOrders.create({ query?, vendors?, inventoryReceive? })` returns `{ createDraft, submitToVendor, confirmByVendor, recordPartialReceipt, closePO, cancelPO, getPO, listPOs, linesForPO, update }`. FSM `draft → submitted → confirmed → partially_received → received → closed`. `recordPartialReceipt` composes `inventoryReceive.draft + apply` for restock. `closePO` refuses unless all lines fully received. Migration `0099_purchase_orders.sql`. · *`quotes` primitive — B2B RFQ / quote flow* — `bShop.quotes.create({ query?, cart?, order? })` returns `{ requestQuote, respondToQuote, customerAccept, customerReject, cancelQuote, convertToOrder, getQuote, quotesForCustomer, pendingResponse, listExpired, markExpired }`. FSM `requested → responded → accepted → converted` with `rejected / expired / cancelled` terminal branches. `convertToOrder` composes `order.createFromCart` using the quoted prices. Accepts either `lines` or `cart_id` (via cart aggregator). Migration `0102_quotes.sql`. · *`recommendations` primitive — storefront product recommendation engine* — `bShop.recommendations.create({ query?, catalog, analytics?, recentlyViewed? })` returns `{ recommendForProduct, recommendForCart, recommendForCustomer, recommendForCategory, setOverride, removeOverride, listOverrides, recordImpression, recordClick, recordConversion, metricsForKind }`. Override layer reads first then falls through co-purchase → category-popular → random-in-stock. Session id namespace-hashed before write. Migration `0105_recommendations.sql`. · *`reorderThresholds` primitive — per-SKU + per-location reorder thresholds + PO suggestions* — `bShop.reorderThresholds.create({ query?, catalog, inventoryLocations?, vendors? })` returns `{ defineThreshold, evaluate, scanAll, proposePurchaseOrder, recordVelocity, updateThreshold, archiveThreshold, listThresholds }`. Velocity-driven suggested_qty = `gap + ceil(units_per_day * lead_time_days)`. Partial-UNIQUE on `(sku, location_code)` for active rows. Migration `0098_reorder_thresholds.sql`. · *`shippingZones` primitive — operator-defined shipping zones + rate tables* — `bShop.shippingZones.create({ query? })` returns `{ defineZone, getZone, listZones, updateZone, archiveZone, rateFor, zoneForDestination }`. Region match: country-only or country + region. `rateFor` half-open `[min_weight, max_weight)` + `[min_order, max_order)` bucketing across rate rows; returns all matches sorted by rate ascending. Migration `0106_shipping_zones.sql`. · *`splitShipments` primitive — one-order N-shipment planning* — `bShop.splitShipments.create({ query?, order, orderTracking, backorder?, inventoryLocations? })` returns `{ planSplit, executeSplit, mergeShipments, splitsForOrder, recommendStrategy }`. Strategies: `availability / location / vendor / manual`. `executeSplit` writes shipment rows via `orderTracking.createShipment`. `mergeShipments` combines source shipments into a target. Migration `0096_split_shipments.sql`. · *`trustBadges` primitive — storefront trust seals + certifications* — `bShop.trustBadges.create({ query? })` returns `{ defineBadge, activeForPlacement, listAll, getBadge, updateBadge, archiveBadge, renderHtml, recordImpression, recordClick, impressionCount, clickCount }`. Placements: header / footer / pdp / cart_review / checkout / order_confirmation. SVG payload sanitized via `b.guardSvg`; link_url through `b.safeUrl`. Multiple badges can stack at one placement, sorted priority-DESC. Migration `0111_trust_badges.sql`. · *`webhookReceiver` primitive — inbound HMAC-signed webhook acceptor* — `bShop.webhookReceiver.create({ query?, now? })` returns `{ defineSource, verifyAndPersist, markProcessed, markFailed, unprocessedEvents, eventsForSource, getEvent, purgeOlderThan, rotateSecret, archiveSource, getSource, listSources }`. HMAC-SHA-256 verification + replay-window timestamps + idempotency_key dedup. Rotation carries a 24h grace window where deliveries signed under the old secret still verify. Plaintext secret returned once at `defineSource` / `rotateSecret`; storage holds only the namespaceHash digest. Migration `0110_webhook_receiver.sql`. **Fixed:** *`productBulkOps` — monotonic `_now` so back-to-back audit rows sort newest-first reliably* — Same Date.now() resolution issue that affected several earlier primitives — replaced with a process-local monotonic clock that bumps by 1ms on collision. The audit-trail `ORDER BY occurred_at DESC, id DESC` listing now returns rows in the correct insertion order even under same-millisecond bulk operations.

- v0.0.64 (2026-05-22) — **Two new primitives: compliance export + live chat.** `complianceExport` is the GDPR / CCPA / LGPD subject-access-request export + deletion lifecycle. `liveChat` is the real-time customer-service queue with operator assignment and per-session messages. **Added:** *`complianceExport` primitive — GDPR / CCPA / LGPD subject-access-request export + deletion lifecycle* — `bShop.complianceExport.create({ query?, customers, order?, orderNotes?, subscriptions?, addresses?, paymentMethods?, supportTickets?, loyalty? })` returns `{ requestExport, requestDeletion, getRequest, listRequests, fulfillRequest, dispatchExport, processDeletion, dismissRequest, auditForCustomer }`. Composes injected per-domain readers via a `forCustomerExport(customer_id)` / `forCustomerDeletion(customer_id, { dry_run })` contract; the primitive owns the request row, the scope/section filtering, and the dry-run-vs-wet posture. Scope enum: `full / orders_only / identity_only`. Jurisdiction enum: `gdpr / ccpa / lgpd / other`. Status FSM: `received / processing / fulfilled / delivered / dismissed`. Migration `0109_compliance_export.sql`. · *`liveChat` primitive — real-time customer-service queue* — `bShop.liveChat.create({ query? })` returns `{ openSession, enqueueSession, assignToOperator, recordMessage, closeSession, getSession, messagesForSession, operatorQueue, waitingQueue, operatorWorkload, markOperatorAvailable, markOperatorAway, cleanupAbandoned }`. Six-state FSM: queued / assigned / active / waiting / closed / abandoned. Visitor session id + email hashed via `namespaceHash` under `live-chat-visitor` / `live-chat-email` namespaces; raw values never persist. Process-local monotonic `_now` so back-to-back operator assignments and message inserts paginate stably. `cleanupAbandoned({ idle_minutes })` reaps idle sessions. Migration `0113_live_chat.sql`.

- v0.0.63 (2026-05-22) — **Three new primitives: invoice renderer, store credit, error log.** Three primitives ship in this release. `invoiceRenderer` produces operator + customer-facing formal invoices (HTML for PDF conversion + plain text + sequential invoice numbers). `storeCredit` is the per-customer account-bound credit wallet, distinct from gift cards (no code). `errorLog` is the operator-side HTTP error event log with top-404 / top-5xx / slow-render dashboards. **Added:** *`invoiceRenderer` primitive — formal accounting invoices with sequential numbers* — `bShop.invoiceRenderer.create({ query?, order })` returns `{ nextInvoiceNumber, renderHtml, recordInvoice, invoicesForOrder, getInvoiceByNumber }`. `nextInvoiceNumber({ series? })` returns sequential `INV-YYYY-NNNNNN`-shaped id with per-series counter. `renderHtml({ order_id, locale?, series?, due_days? })` returns the full HTML invoice with operator + customer addresses, line items, tax breakdown, totals, payment terms, due date. All operator + customer input fields HTML-escaped via `b.template.escapeHtml`. `recordInvoice` writes the audit row carrying `html_size` + `sha3_512` digest. Migration `0093_invoice_renderer.sql` (`invoice_renderings` + `invoice_sequence` tables). · *`storeCredit` primitive — per-customer account-bound credit wallet* — `bShop.storeCredit.create({ query? })` returns `{ credit, debit, expire, balance, history, transactionsForOrder, expiringWithin, bulkBalance, cleanupExpired }`. Distinct from `giftCardLedger` — store credit is account-bound, no code. `credit` source enum: `refund / goodwill / promotional / manual / loyalty_redemption`. `debit` refuses overdraft. `balance_after_minor` denormalized per row so current balance is O(1). `cleanupExpired({ now })` scheduler-callable: walks expired rows + adds offsetting expire entries. `expiringWithin({ customer_id, days })` lists balance set to expire so the storefront can prompt the customer. Migration `0094_store_credit.sql`. · *`errorLog` primitive — HTTP error event log with operator dashboards* — `bShop.errorLog.create({ query? })` returns `{ recordError, top404Paths, top5xxErrors, slowRenders, errorById, byStatus, metrics }`. Drop-silent on bad input — hot-path observability sink; throwing here would crash the request that triggered the log entry. Session id always hashed via `namespaceHash('error-log-session', ...)` before write. `top404Paths({ from, to, limit })` and `top5xxErrors({ from, to, limit })` power operator dead-link cleanup + upstream-health dashboards. `slowRenders({ from, to, limit, p99_threshold_ms? })` surfaces requests over the operator-configured p99 budget. `metrics` returns counts + p50 / p95 / p99 response time percentiles. Migration `0100_error_log.sql`.

- v0.0.62 (2026-05-22) — **Ten new primitives: plan changes, vendors, loyalty redemption, gift registry, geolocation, stock transfers, SMS dispatcher, email campaigns, storefront dashboards, refund policy.** Ten primitives ship in one release covering subscription proration, multi-vendor marketplace foundation, loyalty redemption catalog, wedding/baby/housewarming registries, country resolution from request hints, audited multi-step stock movements, SMS opt-in / dispatch / STOP-keyword handling, bulk email campaign scheduling, operator dashboard composition, and refund eligibility rules. **Added:** *`planChanges` primitive — subscription plan upgrade / downgrade with proration* — `bShop.planChanges.create({ query?, subscriptions, subscriptionBilling? })` returns `{ proposeChange, executeChange, cancelPendingChange, pendingChangeFor, historyForSubscription, applyScheduledChanges, prorate }`. `proposeChange({ subscription_id, new_plan_id, change_at? })` returns `{ proration_credit_minor, first_charge_minor, currency, effective_at, change_kind, from_plan_id, to_plan_id }`. `change_kind` derives off the clock: `change_at >= current_period_end` → `next_billing_cycle` with zero proration; otherwise → `immediate` with floored `(amount * remainingMs / periodMs)` math on both sides. `executeChange` writes the ledger row, flips `subscriptions.plan_id` when the effective clock is now-or-past or queues for the scheduler. Queues a net proration invoice through `subscriptionBilling.recordInvoice` when injected. Module-level `planChanges.prorate(fromAmount, toAmount, periodStart, periodEnd, effectiveAt)` is exposed for callers that want the pure math without instantiating the factory. Migration `0083_plan_changes.sql`. · *`vendors` primitive — multi-vendor marketplace foundation* — `bShop.vendors.create({ query? })` returns `{ registerVendor, getVendor, vendorBySlug, listVendors, updateVendor, pauseVendor, reinstateVendor, archiveVendor, assignSku, unassignSku, vendorForSku, skusForVendor, recordCommission, payoutsDue }`. Slug is the public PK, immutable post-registration so `vendor_skus` + `vendor_commissions` rows never orphan. `contact_email` hashed via `namespaceHash('vendor-contact-email', lowercased)` — raw email never persists. FSM: `active ↔ paused`, both → `archived` (terminal). `assignSku` enforces single-vendor SKU ownership via `vendor_skus.sku UNIQUE`. `recordCommission` is idempotent on `(vendor_slug, order_id)` so the same multi-vendor `order_id` across different vendors is legal. Commission math is `floor(gross_minor * split_bps / 10000)`. Migration `0084_vendors.sql`. · *`loyaltyRedemption` primitive — customer-facing reward redemption layer* — `bShop.loyaltyRedemption.create({ query?, loyalty, coupons? })` returns `{ defineReward, getReward, listRewards, updateReward, archiveReward, redeemForCustomer, getRedemption, redemptionsForCustomer, markConsumed, cancelRedemption }`. Reward kinds: `discount_percent / discount_amount / free_product / free_shipping`. `redeemForCustomer` debits points via `loyalty.redeem`, mints a single-use coupon via `coupons` if injected. `max_per_customer` cap enforced across active+consumed; cancelled redemptions free a slot. `cancelRedemption` refunds points only when status is `active` at cancel time — points consumed past the FSM state are operator-only via `loyalty.adjust`. Migration `0085_loyalty_redemptions.sql`. · *`giftRegistry` primitive — wedding / baby / housewarming registries* — `bShop.giftRegistry.create({ query?, catalog })` returns `{ createRegistry, addItem, removeItem, getRegistry, getBySlug, listForOwner, searchPublic, purchaseItem, progressFor, closeRegistry, update }`. Privacy enum: `private / unlisted / public`. Owner-facing purchase reads redact `buyer_customer_id` when `reveal_buyer=false`; storage still carries the id for refund/audit. `active → closed` FSM gates all mutation paths. Over-purchase past `quantity_desired` throws `GIFT_REGISTRY_OVER_PURCHASE`. `progressFor(slug)` returns per-item + overall progress percentages. Migration `0086_gift_registry.sql`. · *`geolocation` primitive — country / locale resolution from request hints* — `bShop.geolocation.create({ query? })` returns `{ resolve, defineCountry, getCountry, listCountries, updateCountry, setGeoBlock, blockedRoster }`. `resolve({ cf_country, cf_region?, accept_language?, timezone?, customer_supplied_country? })` returns `{ country, region?, currency, locale, timezone?, geo_blocked, allowed_payment_kinds, allowed_shipping_kinds }`. Pure validators (ISO 3166-1 alpha-2, ISO 4217, BCP-47, IANA timezone shape). RFC 7231 `Accept-Language` q-sorted parser picks the highest-q tag whose primary subtag matches the row default's primary subtag. Customer-supplied country wins only when a row exists; stale values fall through to `cf_country`. Migration `0092_geolocation.sql`. · *`stockTransfers` primitive — audited multi-step stock movement between locations* — `bShop.stockTransfers.create({ query?, inventoryLocations })` returns `{ openTransfer, markShipped, markInTransit, markReceived, reconcile, markException, getTransfer, listOpen, transfersForLocation, discrepanciesFor }`. Six-state FSM: `open → shipped → in_transit → received → reconciled` (happy) or `→ exception` (terminal sad). Composes `inventoryLocations.adjustStock` as the sole owner of `inventory_stock` writes — origin debited at open, destination credited at reconcile (only by the received qty, so short-ships preserve the audit trail of loss). Discrepancy flag computed at reconcile (`quantity_shipped - quantity_received`), surfaces both short-ships (positive) and over-counts (negative). Three tables: `stock_transfers` + `stock_transfer_lines` + append-only `stock_transfer_events` audit log. Migration `0089_stock_transfers.sql`. · *`smsDispatcher` primitive — outbound SMS with opt-in / opt-out / STOP-keyword auto-handling* — `bShop.smsDispatcher.create({ query? })` returns `{ registerProvider, providerForRegion, recordOptIn, recordOptOut, isOptedIn, handleInboundKeyword, enqueue, markDelivered, markFailed, dispatchTick, messagesForCustomer, messageByProviderRef, hashPhone }`. Phone hashed via `namespaceHash('sms-phone', E.164)`. STOP-keyword auto opt-out: any incoming `STOP` / `UNSUBSCRIBE` / `CANCEL` / `END` / `QUIT` / `STOPALL` registers opt-out + sends a single confirmation. Transient-failure retry on `1m / 5m / 30m / 2h / 12h` back-off; terminal after budget. Non-https endpoints refused. Body refusal for emoji-only / control bytes / zero-width / bidi-override. Migration `0088_sms_dispatcher.sql`. · *`emailCampaigns` primitive — operator-scheduled bulk email campaigns* — `bShop.emailCampaigns.create({ query, mailingAudiences, email, emailSuppressions? })` returns `{ defineCampaign, scheduleCampaign, pauseCampaign, resumeCampaign, cancelCampaign, sendNow, dispatchTick, recordEvent, getCampaign, listCampaigns, metricsForCampaign }`. FSM: draft → scheduled → sending → sent, with paused / cancelled off-ramps. Dispatcher drains the audience in pages via `audiences.resolve`, stamps a `delivered` event per successful `mailer.send`, then transitions sending → sent with `recipients_resolved_count` + `sent_count` + `sent_at` populated. Per-recipient send failures are drop-silent so one bad ESP refusal can't stall the campaign — ESP webhook backfill via `recordEvent` records `bounced` / `unsubscribed`. Migration `0087_email_campaigns.sql`. · *`storefrontDashboards` primitive — operator dashboard composition* — `bShop.storefrontDashboards.create({ query?, salesReports?, analytics?, cartAbandonment?, inventoryAlerts? })` returns `{ defineDashboard, getDashboard, listDashboards, updateDashboard, archiveDashboard, cloneDashboard, renderDashboard }`. Layout enum: `grid_2col / grid_3col / single_column / freeform`. Widget kinds: `revenue_chart / top_products / top_customers / inventory_low_stock / cart_abandonment / order_status_funnel / recent_orders / aov_trend / refund_rate`. `renderDashboard({ slug, from, to, locale? })` dispatches each widget to its injected data source; missing sources yield `data: null`; locale falls back via BCP-47 right-trim through the bundled en / es / fr catalogue. `cloneDashboard({ source_slug, new_slug, new_title?, new_owner? })` — clone starts un-archived even if source is archived. Migration `0091_storefront_dashboards.sql`. · *`refundPolicy` primitive — operator-managed refund eligibility rules* — `bShop.refundPolicy.create({ query? })` returns `{ definePolicy, evaluate, getPolicy, listPolicies, updatePolicy, archivePolicy, auditEvaluation, listAudit }`. Walks active policies in `(priority DESC, created_at ASC, slug ASC)`; the first in-scope policy whose preconditions all pass governs. Refund-kind math: `full` and `store_credit_only` cap at `order_total_minor`; `partial` floors `total*bps/10000`; `no_refund` returns `eligible:false`. Restocking fee subtracts from the cap, clamped at zero with `restocking_fee_exceeds_refund` reason. Calendar-day window computed against UTC-midnight boundaries. `auditEvaluation` records every decision for compliance export. Migration `0090_refund_policy.sql`. **Fixed:** *`smsDispatcher` — monotonic `created_at` so back-to-back enqueues paginate stably* — Same-millisecond `enqueue` calls shared a `created_at` value, and the cursor-based pagination on `messagesForCustomer` couldn't tell consecutive rows apart. The primitive now stamps `created_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value.

- v0.0.61 (2026-05-22) — **Eleven new primitives: customer import, code minter, storefront forms, cart bulk ops, carrier rates, operator audit log, CMS blocks, gift-card ledger, discount analytics, search facets, dunning.** Eleven primitives in one release. `customerImport` bulk-loads customers from CSV / NDJSON with dedupe by email hash + on_conflict mode. `codeMinter` generates single-use discount codes in bulk over a confusion-resistant alphabet with collision retry. `storefrontForms` is the operator-defined contact / lead-capture / wholesale-application form layer with per-session throttle. `cartBulkOps` is B2B-style cart operations (bulk add, replace, price-list apply, reorder, split). `carrierRates` caches shipping rate quotes per (carrier, service, origin, dest, weight) with TTL. `operatorAuditLog` is the cryptographically-chained tamper-evident audit log for operator-side mutations. `cmsBlocks` is the slot-content CMS for headers, hero copy, footer columns, etc. with markdown render + BCP-47 locale fallback. `giftCardLedger` is the append-only credit/debit/expire ledger on top of `giftcards` with denormalized balance-after column for O(1) balance queries. `discountAnalytics` rolls up coupon impressions + redemptions for operator dashboards. `searchFacets` is the operator-registered facet engine (categorical / numeric_range / boolean) for storefront filter sidebars. `dunning` is the payment-recovery workflow for failed subscription charges. **Added:** *`customerImport` primitive — bulk customer loader with email-hash dedup* — `bShop.customerImport.create({ query?, customers })` returns `{ dryRun, importRows, importFromCsv, importFromNdjson, lastReport, cancelInflight }`. Dedup is by the same `namespaceHash('customer-email', canonical)` digest the `customers` primitive computes — two emails differing only in domain casing collide on a single hash (local-part stays RFC-5321-sensitive). on_conflict modes: `update / skip / error`. CSV-injection refusal happens before the run row opens, so a rejected upload leaves zero rows in both `customers` and `customer_imports`. `cancelInflight` flips status → cancelled and the row-processing loop checks the flag between rows; partial writes persist so the operator re-uploads to fill the gap. Migration `0061_customer_imports.sql`. · *`codeMinter` primitive — bulk single-use discount-code generator* — `bShop.codeMinter.create({ query?, coupons })` returns `{ mintBatch, getBatch, listBatches, codesForBatch, voidBatch, exportBatchCsv, DEFAULT_ALPHABET, STATUSES }`. Default alphabet `23456789ABCDEFGHJKLMNPQRSTUVWXYZ` (32 glyphs skipping confusion-resistant 0 / 1 / I / O). Custom alphabets use rejection sampling against `Math.floor(256/len)*len` to keep the draw uniform. Per-code collision retry budget of 16; bust raises `CODE_MINTER_COLLISION_BUDGET_EXHAUSTED`. The `code_batch_members.coupon_code UNIQUE` constraint is the authoritative cross-batch gate. `voidBatch` archives every member's coupon via `coupons.archive` and flips status → voided. `exportBatchCsv` yields header `coupon_code,minted_at\r\n` then one CRLF row per code, paginated 500 at a time, ordered for byte-stable re-exports. Migration `0075_code_batches.sql`. · *`storefrontForms` primitive — operator-defined contact / lead-capture forms* — `bShop.storefrontForms.create({ query?, notifications?, webhooks? })` returns `{ defineForm, getForm, listForms, updateForm, archiveForm, submit, submissionsForForm, throttleCheck, hashSession }`. Field kinds: `text / email / phone / textarea / select / checkbox / number`. submit_to kinds: `email / webhook`. Per-session throttle bucketed on the SHA3-512 namespace hash of the session id; `throttle=0` bypasses. Persist-then-dispatch flow with `dispatched_at` / `dispatch_error` stamped on the row. Email-field validation routes through `b.guardEmail`. Migration `0076_storefront_forms.sql`. · *`cartBulkOps` primitive — B2B-style bulk cart operations* — `bShop.cartBulkOps.create({ query?, cart, catalog, customers?, lineGrouper? })` returns `{ addLines, replaceLines, clearLines, priceListApply, reorder, splitCart, quoteForCart, priceLists }`. Atomicity via pre-flight validation: every SKU resolves + price-snapshots before any write; bad row throws `lines[N].sku — …` with zero side effects. `priceListApply` resolves slug from explicit input OR `price_list_assignments[customer_id]`; refuses currency mismatch / archived list / unknown slug. `reorder` skip categories surface as `{ sku, qty, reason }` so the storefront renders "couldn't add N items" affordances. `splitCart` requires an operator-supplied `lineGrouper(line, splitBy)` (no built-in vendor/category guesswork); source cart marked `abandoned` on split. Sub-module `priceLists` exposes `{ create, setMember, assign, listMembers }`. Migration `0078_price_lists.sql`. · *`carrierRates` primitive — at-checkout shipping rate cache* — `bShop.carrierRates.create({ query?, cursorSecret? })` returns `{ registerCarrier, recordRateQuote, ratesForShipment, cleanupExpiredQuotes, recentQuotes, metricsForCarrier }`. Carrier enum: `ups / fedex / usps / dhl / flat_rate`. `recordRateQuote` dedup-upserts on `UNIQUE(carrier_slug, service_code, origin_postal, dest_postal, weight_grams)`. `ratesForShipment` returns sorted-by-cost rates from the cache + a synthetic flat-rate fallback when no live rates apply; expired rows + paused carriers excluded. `rate_endpoint` gated via `b.safeUrl` (https-only). `metricsForCarrier({ slug, from, to })` aggregates by (service_code, currency). Migration `0080_carrier_rates.sql`. · *`operatorAuditLog` primitive — cryptographically-chained tamper-evident operator audit log* — `bShop.operatorAuditLog.create({ query? })` returns `{ record, listByActor, listByResource, searchAction, chainHead, verifyChain }`. Each row carries `row_hash = SHA3-512(prev_hash || canonical-json(row-without-hashes))` so the chain is replay-verifiable. `verifyChain()` recomputes hashes left-to-right and flags any tampered row. `occurred_at` is clamped to be strictly monotonic against the chain head so same-millisecond appends still chain deterministically. Actor enum: `operator / system / app`. UA-class enum: `browser / mobile_app / api_client / cli / bot / unknown`. Opaque base64url `(occurred_at, id)` keyset cursor for the list surfaces. Migration `0074_operator_audit_log.sql`. · *`cmsBlocks` primitive — slot-content CMS with BCP-47 locale fallback* — `bShop.cmsBlocks.create({ query?, translations? })` returns `{ defineBlock, setLocalized, getRendered, listBlocks, archiveBlock, update, versionsForBlock }`. Two-table layout (`cms_blocks` PK + `cms_block_localizations` FK CASCADE) so version history per (key, locale) is queryable and `defineBlock` is idempotent without disturbing localizations. `getRendered` resolution order: archived → short-circuit empty; otherwise walk BCP-47 fallback chain (right-to-left subtag strip), pick highest-version row whose publish window covers `now`; finally fall back to `default_body`. Markdown render composes `b.template.escapeHtml` + `b.safeUrl.parse` (https-only allowlist plus /-rooted absolute paths). Migration `0079_cms_blocks.sql`. · *`giftCardLedger` primitive — append-only credit/debit/expire ledger with O(1) balance* — `bShop.giftCardLedger.create({ query?, giftcards? })` returns `{ credit, debit, expire, balance, history, transactionsForOrder, bulkBalance, expiringBalance }`. `balance_after_minor` denormalized per row so current balance is O(1) (ORDER BY occurred_at DESC LIMIT 1). Replay-derivability preserved — SUM(credit) - SUM(debit) - SUM(expire) reconstructs balance and is asserted in the test. Per-card `occurred_at` made strictly monotonic via internal `_resolveOccurredAt` (bumps to `prior + 1` on tie or out-of-order operator-supplied timestamp). `expire` caps at current balance (returns `noop: true` when already drained) so scheduled sweeps degrade gracefully against interim debits. `bulkBalance` returns one row per requested id with `balance_minor: 0` for cards with no ledger rows. `expiringBalance` JOINs against `giftcards.expires_at` (canonical expiry source). Migration `0081_gift_card_ledger.sql`. · *`discountAnalytics` primitive — per-coupon + per-tier-set redemption + revenue-impact aggregates* — `bShop.discountAnalytics.create({ query?, coupons?, quantityDiscounts? })` returns `{ recordImpression, recordRedemption, topCoupons, couponPerformance, tierPerformance, revenueImpact, redemptionFunnel }`. Sessions hashed via `namespaceHash('discount-analytics-session', session_id)` at the boundary. Tier-set redemptions tagged with the `coupon_code = 'tier:<tier_set_id>'` convention so the same table backs both surfaces. Process-local monotonic `_nowMs` so back-to-back impressions in the same wall-clock millisecond chain deterministically. Migration `0073_discount_analytics.sql`. · *`searchFacets` primitive — operator-registered facets for storefront filter sidebars* — `bShop.searchFacets.create({ query?, catalog })` returns `{ defineFacet, getFacets, previewQuery, recordFacetUse, listFacets, updateFacet, archiveFacet }`. Kinds: `categorical / numeric_range / boolean`. `getFacets` computes per-facet `{ key, label, kind, options: [{ value, label, count, selected }] }` using the standard "leave focal facet unconstrained" rule so a selected vendor still shows the other vendors' counts. `previewQuery({ filters, sample? })` returns `{ total, sample }` of products matching the candidate filter set. Refuses bad key shape, bad kind, missing/illegal buckets, display_limit on non-categorical, duplicate bucket labels, inverted bucket bounds, key collisions. Session id namespace-hashed before write on `recordFacetUse`. Migration `0082_search_facets.sql`. · *`dunning` primitive — payment recovery workflow for failed subscription charges* — `bShop.dunning.create({ query, subscriptionBilling, email, notifications })` returns `{ defineDunningPolicy, enrollInvoice, tickDunning, statusForInvoice, historyForInvoice, unsetEnrollment, metricsForPolicy }`. Actions: `retry_charge / send_reminder / pause_subscription / cancel_subscription`. Enrollment FSM: `active / recovered / cancelled / abandoned`. `tickDunning({ now })` advances state with composition into email / notifications / billing dependencies — handle methods are surface-optional so a partial wiring still ticks. `metricsForPolicy` aggregates recovery rate + cancellation rate per policy. Migration `0077_dunning.sql`. **Fixed:** *`discountAnalytics` — monotonic `occurred_at` so impressions in the same millisecond sort deterministically* — `Date.now()` on Windows resolves at ~15ms; three impressions inserted in the same tick shared an `occurred_at` and UUID v7's intra-ms randomness scrambled the secondary `id` ordering. The primitive now stamps `occurred_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value.

- v0.0.60 (2026-05-22) — **Fourteen new primitives: tax rates, storefront pages, webhook subscriptions, tenants, API keys, barcodes, customer portal, subscription billing, translations, coupon stacking, A/B experiments, print receipts, inventory snapshots, product import.** Fourteen primitives land together. `taxRates` is the operator-managed per-jurisdiction rate table with scheduled effective dates. `storefrontPages` is the markdown-driven CMS for legal / about / shipping copy. `webhookSubscriptions` separates subscription management from delivery. `tenants` runs N branded shops from one deployment with per-tenant domain + currency + theme. `apiKeys` issues hashed-at-rest tokens with scope allowlists + per-key rate limits. `barcodes` maps SKUs to UPC-A / EAN-13 / Code-128 / GTIN-14 values with checksum validation + inline-SVG rendering. `customerPortal` mints single-use scoped portal sessions. `subscriptionBilling` is the invoice + payment-attempt + dunning ledger. `translations` is the per-resource multi-locale lookup with locale-fallback chains. `couponStacking` is the rules engine for which discount codes may be combined. `experiments` is the deterministic-assignment A/B test framework with Wilson confidence intervals. `printReceipts` renders ESC/POS thermal + HTML/PDF + plain-text email receipts. `inventorySnapshots` captures point-in-time stock counts with SHA3-512 tamper-evidence + delta reports. `productImport` is the bulk loader for flat-CSV / Shopify-JSON / blamejs-native catalog data. **Added:** *`taxRates` primitive — per-jurisdiction rate table with scheduled effective dates* — `bShop.taxRates.create({ query? })` returns `{ defineRate, rateFor, listForJurisdiction, updateRate, archiveRate, bulkImport, scheduledChanges }`. Jurisdiction shape is `XX` or `XX-YYY` per ISO 3166. `defineRate` refuses overlapping windows within the same (jurisdiction, category). `rateFor({ jurisdiction, category?, on_date })` resolves category-specific first, then NULL-category fallback. `effective_from` inclusive, `effective_until` exclusive. `scheduledChanges({ from, to })` lists rate transitions in the window for the operator scheduler. Migration `0058_tax_rates.sql` (CHECK enum source: manual / vies / state_dept / avalara). · *`storefrontPages` primitive — markdown CMS for About / Shipping / Privacy / Terms* — `bShop.storefrontPages.create({ query? })` returns `{ defineDraft, publish, unpublish, archive, restore, update, get, getPublished, listPublished, listDrafts, listArchived, renderHtml }`. FSM `draft → published → archived` with `restore` lifting archived → draft. Built-in markdown subset: headings, paragraphs, ul/ol, blockquote, hr, inline code, bold, italic, links. Every text run through `b.template.escapeHtml`; every inline link URL through `b.safeUrl.parse` (https-only + /-rooted internal paths). Unsafe URLs drop the `<a>` entirely so anchor text falls back to inert escaped text. Migration `0059_storefront_pages.sql` (layout enum: default / wide / landing / legal). · *`webhookSubscriptions` primitive — subscription management with 24h secret rotation grace* — `bShop.webhookSubscriptions.create({ query?, now? })` returns `{ subscribe, get, listForOwner, subscriptionsForEvent, pauseSubscription, resumeSubscription, update, unsubscribe, rotateSecret, expireRotationGrace }`. Plaintext signing secret returned ONCE on `subscribe` + `rotate`; at rest only the SHA3-512 namespace hash + a previous-hash for a 24h rotation grace window. `subscriptionsForEvent(event_type)` returns active subscriptions matching the event type for delivery fan-out. `endpoint_url` gated via `b.safeUrl` (https-only). Migration `0060_webhook_subscriptions.sql` (owner_type enum: operator / app / customer). · *`tenants` primitive — multi-store deployment with per-tenant domains + currency + theme* — `bShop.tenants.create({ query? })` returns `{ defineTenant, addDomain, removeDomain, setPrimaryDomain, pauseTenant, resumeTenant, archiveTenant, get, getById, resolveByHost, listTenants, update, stats }`. Slug regex `[a-z0-9]([a-z0-9_-]*[a-z0-9])?` ≤ 64. Domain regex `[a-z0-9][a-z0-9.-]*\.[a-z]{2,}` ≤ 255. `resolveByHost(host)` strips port + lowercases + nearest-match against the registered domain set; paused tenants resolve, archived do not. FSM: `active ↔ paused`, both → `archived` (terminal). Migration `0063_tenants.sql` (`tenants` + `tenant_domains` tables with FK CASCADE). · *`apiKeys` primitive — hashed-at-rest tokens with scope + rate limit + 24h rotation grace* — `bShop.apiKeys.create({ query? })` returns `{ issueKey, verifyToken, revoke, rotate, listForOwner, getKey, update, recordUse, usageForKey, cleanupExpired }`. Plaintext is 32 bytes via `b.crypto.generateBytes` rendered as 43-char URL-safe base64; storage holds only the `namespaceHash('api-key-token', plaintext)` digest. `verifyToken` routes hex compare through `b.crypto.timingSafeEqual`. `rotate` slides the old hash into `token_hash_previous` with a 24h grace + issues a new row. Owner type enum: operator / app / affiliate / tenant. Migration `0064_api_keys.sql` (`api_keys` + append-only `api_key_usage` log). · *`barcodes` primitive — SKU → UPC-A / EAN-13 / Code-128 / GTIN-14 with checksum validation + SVG render* — `bShop.barcodes.create({ query?, catalog })` returns `{ assign, lookup, bySkuList, lookupByValue, unassign, assignAuto, defineRange, listRanges, renderSvg, validateValue }`. Checksum gate per kind (GS1 mod-10 reused for UPC-A / EAN-13 / GTIN-14; Code-128 mod-103 at render). `assignAuto` uses CAS on `next_value` to make concurrent mints race-safe (lost race surfaces as `BARCODE_RANGE_RACE` so the caller can retry). `renderSvg` ships its own EAN/UPC L/G/R tables + ITF-14 widths + Code-128 Set B inline — no external barcode library, no `<script>` / `<foreignObject>` in the output. `validateValue` is a pure-function exported on the module so callers can validate without instantiating the factory. Migration `0068_barcodes.sql`. · *`customerPortal` primitive — single-use scoped customer self-serve sessions* — `bShop.customerPortal.create({ query? })` returns `{ createSession, verifyToken, revokeSession, listForCustomer, expireOlderThan }`. Token plaintext returned once on `createSession`; storage holds only `namespaceHash('customer-portal-token', plaintext)`. Scopes: `full / billing_only / address_only / subscriptions_only / order_history_only`. `verifyToken` flips the row to `consumed` (single-use); constant-time hit/miss equalisation via `b.crypto.timingSafeEqual` on the miss path. Single-use enforced by an `UPDATE … WHERE status='issued'` SQL guard so racing verifies don't both succeed. Default TTL 15 min. Migration `0072_customer_portal_sessions.sql`. · *`subscriptionBilling` primitive — invoice + payment-attempt + dunning ledger* — `bShop.subscriptionBilling.create({ query?, subscriptions, payment? })` returns `{ recordInvoice, recordPaymentAttempt, markPaid, markFailed, enterDunning, exitDunning, invoicesForSubscription, failedInvoices, dunningRoster, arpu }`. Webhook-replay idempotency on `processor_invoice_id` + `(invoice_id, attempt_number)`. Invoice FSM: pending → paid / failed / voided. Dunning states: active / dunning / recovered / cancelled / written_off. `arpu({ from, to })` returns average revenue per user bucketed by currency. `invoice_url` gated via `b.safeUrl`. Migration `0066_subscription_billing.sql` (3 tables with FK CASCADE). · *`translations` primitive — per-resource multi-locale lookup with locale-fallback chain* — `bShop.translations.create({ query? })` returns `{ setTranslation, getTranslation, getForResource, removeTranslation, localesForResource, bulkSet, missingTranslations }`. Locale normalised to BCP-47 canonical form; fallback chain walks right-to-left stripping subtags + appends `en` baseline. `bulkSet` validates every row before writing any (pre-flight-then-write for D1 portability). Values pass through `b.template.escapeHtml` at write so HTML metacharacters land inert while operator-intent newlines survive. `missingTranslations({ resource_kind, locale, sample_size })` reports resources lacking translations in that locale's fallback chain. Migration `0070_translations.sql` (UNIQUE(resource_kind, resource_id, locale, field) backing the upsert). · *`couponStacking` primitive — rules engine for which discount codes may be combined* — `bShop.couponStacking.create({ query?, customerSegments? })` returns `{ definePolicy, getPolicy, listPolicies, updatePolicy, archivePolicy, evaluate, activePoliciesForCart }`. Governing policy = highest-priority active policy whose `order_min_minor` + `customer_segment_in` preconditions match the cart. Conservative default when no policy governs: "first code allowed, rest refused with `no_policy_allows_stacking`". `exclusive_codes` short-circuits + additionally refuses the quantity-discount stack regardless of `with_quantity_discounts`. Refusal reasons: `no_policy_allows_stacking` / `other_codes_not_allowed_to_stack` / `max_codes_per_order_exceeded` / `exclusive_code_present` / `quantity_discount_stack_refused`. Migration `0067_coupon_stacking.sql`. · *`experiments` primitive — A/B testing with deterministic assignment + Wilson confidence intervals* — `bShop.experiments.create({ query? })` returns `{ defineExperiment, getVariant, recordConversion, metricsForExperiment, listExperiments, pauseExperiment, resumeExperiment, archiveExperiment, update }`. Variant assignment is deterministic per session: `namespaceHash('experiments-assign', slug + ':' + sessionHash)` → first 64 bits mod cumulative weight (computed in two 32-bit halves to stay inside JS's 53-bit safe-integer territory). Sessions assigned 100x return the same variant 100x. `variants_json` is read-only after `defineExperiment` — changing variants would corrupt prior assignments. `recordConversion` is drop-silent on unknown experiment / variant / archived / bad-shape (hot-path). `metricsForExperiment` returns per-variant counts + Wilson 95% CI when `assigned_sessions` is supplied. Migration `0071_experiments.sql` (FSM: draft / running / paused / archived terminal). · *`printReceipts` primitive — ESC/POS thermal + HTML/PDF + plain-text receipt rendering* — `bShop.printReceipts.create({ query?, order })` returns `{ thermal, htmlPdf, plainText, previewBuffer, recordPrint, printsForOrder }`. `thermal({ paper_width_mm? })` defaults to 80mm cols; 58mm option supported. `htmlPdf` returns an HTML string suitable for headless-Chrome PDF conversion. `plainText` scrubs C0 control bytes + DEL on output. Every operator-input ship-to field passes through `b.template.escapeHtml` for HTML output. Each print writes a `receipt_prints` audit row with byte size + SHA3-512 hash so an operator can prove what came off the printer. `previewBuffer` returns the rendered string WITHOUT writing the audit row (operator preview path). Migration `0062_print_receipts.sql`. · *`inventorySnapshots` primitive — point-in-time stock captures with SHA3-512 tamper-evidence + delta reports* — `bShop.inventorySnapshots.create({ query?, catalog, inventoryLocations? })` returns `{ takeSnapshot, getSnapshot, listSnapshots, deltaBetween, summary, purgeOlderThan }`. Single-bucket capture reads from `inventory` (location_code NULL); multi-location capture reads from `inventory_stock` when `inventoryLocations` is wired. `deltaBetween({ from_snapshot_id, to_snapshot_id, sku? })` returns per-(sku, location) deltas tagged `added / removed / changed / unchanged`. `summary(snapshot_id)` returns counts + total units + flagged stockout outliers + tamper-evidence check (recomputes the SHA3-512 hash + flags mismatch). `purgeOlderThan(days)` walks expired snapshots — FK CASCADE removes the detail rows. Migration `0065_inventory_snapshots.sql`. · *`productImport` primitive — bulk loader for flat-CSV / Shopify-JSON / blamejs-native catalogs* — `bShop.productImport.create({ query?, catalog })` returns `{ dryRun, importRows, importFromCsv, importFromJson, lastReport, cancelInflight, listImports, errorsForImport }`. Formats: `flat_csv / shopify_json / blamejs_native`. on_conflict modes: `update / skip / error`. Within-import SKU dedupe surfaces as `duplicate_sku_in_import` row-error. Shopify JSON shape converts decimal prices to minor units. RFC-4180 CSV with quoted-comma + quote-doubling. Per-row errors persist to `product_import_errors` for operator triage. Migration `0069_product_imports.sql`.

- v0.0.59 (2026-05-22) — **Eleven new primitives: collections, customer segments, recently-viewed, stock alerts, shipping + return labels, promo banners, search synonyms, affiliates, mailing audiences, order timeline.** Eleven primitives ship in one release. `collections` registers manual + smart product groupings. `customerSegments` evaluates RFM-style rules over order history. `recentlyViewed` tracks per-customer / per-session product browse history. `stockAlerts` issues back-in-stock subscriptions with confirmation tokens. `shippingLabels` + `returnLabels` are the carrier-agnostic label-management primitives. `promoBanners` is the storefront CMS for placement-targeted marketing. `searchSynonyms` rewrites and corrects customer search queries. `affiliates` runs the partner program with single-use codes + per-affiliate commission ledgers. `mailingAudiences` is the newsletter segmentation primitive. `orderTimeline` aggregates events from every order-touching primitive into a single chronologically-sorted feed with operator-friendly summary memoization. **Added:** *`collections` primitive — manual + smart product collections* — `bShop.collections.create({ query?, catalog, cursorSecret? })` returns `{ defineManual, defineSmart, get, list, update, archive, addProduct, removeProduct, reorderProducts, productsIn, collectionsForProduct, evaluateRules }`. Manual collections store an explicit ordered membership; smart collections evaluate rules against the catalog at read time. Rules support `eq / neq / contains / gt / gte / lt / lte / in / not_in / between` with field-class guards. `collectionsForProduct(product_id)` unions manual membership with active-smart-rule matches. `update` uses `b.safeSql.assertOneOf` against an `ALLOWED_COLUMNS` allowlist. Migration `0043_collections.sql`. · *`customerSegments` primitive — RFM-style segmentation with cached membership* — `bShop.customerSegments.create({ query?, cursorSecret? })` returns `{ defineSegment, evaluate, recompute, segmentsForCustomer, listSegments, update, archive, unarchive, stats }`. Rules ANDed across `recency_days_max/min`, `frequency_orders_min`, `lifetime_orders_min/max`, `monetary_minor_min/max`, `aov_minor_min`, `refund_rate_bps_min/max`, `last_order_status_in`, `country_in`, `currency_in`. Cancelled orders excluded from aggregates. `recompute()` refreshes the membership cache; subsequent `evaluate(slug)` reads off the cache for paginated iteration. Migration `0049_customer_segments.sql`. · *`recentlyViewed` primitive — per-customer / per-session browse history* — `bShop.recentlyViewed.create({ query?, catalog })` returns `{ recordView, forCustomer, forSession, merge, recommend, cleanupOlderThan, purgeCustomer }`. Session ids are namespace-hashed (`recently-viewed-session`) before write — the log never holds a recoverable identifier. Re-viewing a product within 5 minutes updates the existing row's `last_viewed_at` + increments `view_count` rather than writing a new one. `forCustomer({ exclude_purchased: true })` filters out products the customer has already bought. `merge({ session_id, customer_id })` rolls a guest session into the customer record at login. Sliding-window cap default 20. Migration `0050_recently_viewed.sql`. · *`stockAlerts` primitive — back-in-stock notifications with confirmation tokens* — `bShop.stockAlerts.create({ query?, catalog, notifications? })` returns `{ subscribe, unsubscribe, confirm, isSubscribed, listForSku, listForCustomer, scanAndNotify, cleanupExpired, stats, hashEmail }`. `subscribe` mints a 32-char base64url confirmation token (hash stored, plaintext returned once). `scanAndNotify({ now })` walks pending alerts where `stock_on_hand - stock_held > 0` and (when `notifications` is wired) enqueues an in-app message under event-type `stock.back-in-stock`. Email + token always hashed via `namespaceHash` before write. Migration `0048_stock_alerts.sql`. · *`shippingLabels` primitive — carrier-agnostic shipping label management* — `bShop.shippingLabels.create({ query?, cursorSecret? })` returns `{ requestLabel, markPurchased, voidLabel, markUsed, getLabel, labelsForShipment, labelsForOrder, pendingLabels, voidedInWindow, costsByPeriod, customsForLabel }`. `purchased_via` enum: `easypost / shippo / shipstation / stamps_com / manual / custom`. `label_url` gated through `b.safeUrl.parse` (https-only). `voidLabel` refuses past the carrier's 30-day window with a typed error code. `costsByPeriod({ from, to, carrier? })` groups by purchased_via + currency for cost-allocation dashboards. The actual label-generation HTTP call is the operator's worker. Migration `0051_shipping_labels.sql`. · *`returnLabels` primitive — operator-funded return-shipping flow* — `bShop.returnLabels.create({ query?, returns? })` returns `{ issueLabel, markShipped, markInTransit, markDelivered, markException, getLabel, labelForReturn, pendingPickup, inTransit, eventsForLabel }`. FSM: `issued → shipped → in_transit → delivered`, with an `exception` terminal branch. `markInTransit` accepts re-entry so a multi-hop journey produces a row-per-scan timeline. `markDelivered` calls the injected `returns.markReceived` callback so the RMA flips to `received` status automatically. `issueLabel` refuses unless the underlying return is in `approved` status. Migration `0052_return_labels.sql`. · *`promoBanners` primitive — operator-controlled marketing banners across storefront placements* — `bShop.promoBanners.create({ query?, customerSegments? })` returns `{ defineBanner, activeForPlacement, listAll, getBanner, updateBanner, archive, unarchive, renderHtml, impressionCount, clickCount, recordImpression, recordClick }`. Placements: `top_strip / homepage_hero / pdp_side / cart_side / search_empty / footer`. Audiences: `all / logged_in / guest / segment`. Themes: `info / promo / urgency / success`. `cta_url` gated through `b.safeUrl.parse` (https-only + /-rooted internal paths). `renderHtml` escapes every operator-input field via `b.template.escapeHtml` so script / img-onerror payloads render inert. `recordImpression` / `recordClick` drop-silent on bad slug (hot-path). Migration `0053_promo_banners.sql`. · *`searchSynonyms` primitive — query rewriting + typo correction + stopword removal* — `bShop.searchSynonyms.create({ query? })` returns `{ addGroup, addTypo, addStopword, removeStopword, getGroup, listGroups, updateGroup, deleteGroup, listTypos, listStopwords, rewrite, learnFromQueries }`. Group kinds: `bidirectional` (any term matches any other) + `directional` (terms[0..N-1] all map to terms[N], canonical is terms[N]). `rewrite(query, { include_corrections?, max_expansions? })` returns `{ canonical, expansions, corrections }`. Tokenise → strip control bytes (don't refuse — denial-of-search would block legitimate shoppers) → stopword drop → typo correction → conservative English stemmer (`-ies / -ing / -ed / -es / -s`, min length 4). `learnFromQueries({ from, to, min_count })` scans `search_query_log` for term co-occurrences not yet covered by an existing group. Migration `0055_search_synonyms.sql`. · *`affiliates` primitive — partner program with single-use codes + per-affiliate commission ledger* — `bShop.affiliates.create({ query?, cursorSecret? })` returns `{ registerAffiliate, getAffiliate, affiliateByCode, listAffiliates, updateAffiliate, pauseAffiliate, reinstateAffiliate, recordVisit, attributionForSession, recordCommissionEvent, commissionsForAffiliate, markCommissionPaid, markCommissionVoided, payoutsDue, topAffiliates }`. Codes are 8 chars over a confusion-resistant 32-glyph alphabet. Commission kinds: `percent_bps / amount_per_order_minor / amount_per_signup_minor`. `commission_minor` is computed at write time and stored — historical payouts don't shift when the operator later edits the affiliate's rate. `recordCommissionEvent` is idempotent on `(order_id, affiliate_id)`. Email + visitor session id always hashed via `namespaceHash` before write. Migration `0057_affiliates.sql`. · *`mailingAudiences` primitive — newsletter audience segmentation with suppression filtering* — `bShop.mailingAudiences.create({ query?, newsletter?, emailSuppressions?, cursorSecret? })` returns `{ defineAudience, resolve, count, getAudience, listAudiences, update, archive, recompute, auditDelivery, listDeliveries }`. Audience rules over `newsletter_signups`: `subscribed_after/before`, `source_in`, `tag_any/all`, `country_in`, `double_opt_in`, `language_in`, `customer_status_in`. `resolve({ slug, skip_suppressed: true })` filters out suppressions via the injected `emailSuppressions` peer (default true). Defaults: emails return hashed only; plaintext requires `include_plaintext: true` for verifier-role-gated routes. `recompute()` refreshes the per-slug membership cache. `auditDelivery` writes append-only rows for compliance export. Unsubscribed signups never match any rule. Migration `0056_mailing_audiences.sql` (+ additive columns on `newsletter_signups` for `tags_csv` / `country` / `language` / `customer_status` / `double_opt_in_at`). · *`orderTimeline` primitive — unified per-order event feed across every order-touching primitive* — `bShop.orderTimeline.create({ query?, order?, orderTracking?, orderNotes?, returns?, payment?, fraudScreen?, shippingLabels?, notifications? })` returns `{ forOrder, summarize, customerVisibleFor, compareOrders, recentActivity }`. Aggregates events from `order_transitions`, `shipment_events`, `order_notes`, `return_authorizations`, `fraud_screenings`, `shipping_labels`, and `notifications` into a chronologically-sorted feed newest-first. `customerVisibleFor({ order_id, locale })` filters to events the customer should see (en / es / de built-in, unknown locales fall back to English). `summarize(order_id)` returns `{ status, first_event_at, last_event_at, event_count, milestones: { paid_at?, fulfilled_at?, shipped_at?, delivered_at?, refunded_at? }, cache_hit }`. Cache invalidation tracks both `last_event_at` AND a per-source row count so a same-millisecond event still flushes the cache. Every injected peer is optional. Migration `0054_order_timeline_cache.sql`. **Fixed:** *`returnLabels.issueLabel` — accepts an optional `issued_at` epoch-ms for deterministic test ordering* — Synthetic-timestamp tests that mix `markShipped({ shipped_at: ... })` with the issuer's wall-clock `Date.now()` could see the issued event sort AFTER the synthetic ones. `issueLabel` now accepts `issued_at` so the full timeline can be authored from a single clock source. Production callers omit the field; `Date.now()` is the default. · *`orderTimeline.summarize` — cache freshness check covers every source the collector walks* — `_sourceStats` now joins `shipping_labels` (via `shipments.order_id`) and `notifications` (via `payload_json` `LIKE '%"order_id":"<uuid>"%'`) so the per-source row count matches `_collectAll`. Previously, an order with any shipping-label or notification rows had a mismatched count between cache-write and cache-check, and the cache never registered as fresh. The second `summarize(order_id)` call now correctly hits the cache.

- v0.0.58 (2026-05-22) — **Five new primitives: sales reports, quantity discounts, subscription controls, gift options, support tickets.** Five primitives ship in one release. `salesReports` aggregates orders for operator dashboards with cursor-paginated detail views + opt-in memoization. `quantityDiscounts` adds automatic tier pricing (buy N for X% off) scoped per SKU / product / collection / vendor / category / global, distinct from coupon codes. `subscriptionControls` layers pause / resume / skip-next / change-quantity / change-frequency / cancel / reactivate on top of the existing `subscriptions` surface with a full audit ledger. `giftOptions` adds gift wrapping (operator-configured wrap SKUs with fees) + gift message + recipient-name + gift-receipt toggle, with HTML-escape at every rendered field. `supportTickets` is a broader customer-service surface than `orderNotes` — pre-sale questions, account problems, complaints — with threaded messages, priority FSM, SLA timer, and per-operator assignment. **Added:** *`salesReports` primitive — order aggregations for operator dashboards* — `bShop.salesReports.create({ query?, cursorSecret? })` returns `{ revenueByDay, revenueByWeek, revenueByMonth, topProducts, topCustomers, revenueByCountry, revenueByPaymentMethod, customerCohort, aov, refundRate, funnel, purgeExpired }`. Each surface accepts `{ from, to, currency? }`; top-N surfaces add `{ limit, cursor? }` for HMAC-paginated detail. Opt-in memoization (`cache: true`) writes to a `sales_report_cache` row keyed on `(report_key, params_hash)` with operator-tuned TTL; default is fresh recompute so first-load dashboards never serve stale data. `customerCohort(cohort_month)` returns per-month retention for the cohort that first ordered in `cohort_month`. `funnel({ from, to })` counts checkout-started / payment-intent-created / paid / fulfilled / refunded. Migration `0042_sales_report_cache.sql` (id, report_key, params_hash, result_json, computed_at, expires_at + indexes on (report_key, expires_at), (params_hash)). · *`quantityDiscounts` primitive — automatic tier pricing per SKU / product / collection / vendor / category / global* — `bShop.quantityDiscounts.create({ query?, catalog })` returns `{ defineTier, getTiersForLine, applyToLine, applyToCart, update, archive, unarchive, list, tierBreakdown }`. Discount kinds: `percent_off (bps)`, `amount_off_each (minor)`, `amount_off_total (minor)`, `fixed_each_price (minor)`. `applyToLine` returns `{ original_unit_minor, discounted_unit_minor, line_subtotal_minor, line_discount_minor, applied_tier_id }` after walking applicable rules ordered by scope-specificity (sku > product > collection > vendor > category > global) and picking the best one. `exclusive: true` on a tier set short-circuits stacking. `defineTier` refuses overlapping `min_quantity` values within a single tier set so the schedule is unambiguous. Per-unit math composes `b.money.fromMinorUnits(...).multiply([num, den])` for half-even rounding consistent with the framework Money class. Migration `0044_quantity_discounts.sql` (`qd_tier_sets` + `qd_tiers` tables). · *`subscriptionControls` primitive — pause / resume / skip / change-quantity / change-frequency / cancel / reactivate* — `bShop.subscriptionControls.create({ query?, subscriptions })` returns `{ pause, resume, skipNext, changeQuantity, changeFrequency, cancel, reactivate, historyForSubscription, actorReport, scanAutoResume }`. `pause({ until?, reason })` flips active → paused; an `until` value schedules auto-resume via `scanAutoResume(now)`. `skipNext({ count, reason })` bumps `next_billing_at` by N periods without changing status; `changeFrequency({ new_frequency, reason })` recomputes `next_billing_at` on the new schedule (weekly / biweekly / monthly / quarterly / semiannual / annual). `cancel({ immediate? })` defaults to end-of-period (customer keeps access through the paid period); `immediate: true` ends now. `reactivate` allows cancelled → active within a 90-day grace window; refuses past. Every state change writes a `subscription_control_events` row with `actor_type`, `actor_id`, `before_json`, `after_json`, and a 280-char operator-prose `reason` so customer service can replay the full state arc. Migration `0045_subscription_controls.sql` (new event table + ALTER TABLE additions to `subscriptions` for `paused_at` / `paused_until` / `cancelled_at` / `quantity` / `frequency` / `next_billing_at`). · *`giftOptions` primitive — gift wrapping, gift message, gift receipt* — `bShop.giftOptions.create({ query?, catalog })` returns `{ defineWrap, listWraps, getWrap, updateWrap, archiveWrap, setForOrder, getForOrder, clearForOrder, feeForOrder, renderPackingSlipLine, analytics }`. `wrap_sku` references a real catalog SKU so wrap inventory + cost flow through normal channels. `setForOrder({ order_id, wrap_sku?, gift_message?, recipient_name?, hide_prices? })` validates `gift_message` (≤ 500 chars, control-byte-free, zero-width-char-free) + `recipient_name` (≤ 120 chars) + refuses unknown / archived wrap_sku. `renderPackingSlipLine({ order_id, locale })` returns `{ message_lines, recipient_name, hide_prices }` with every operator-input field HTML-escaped via `b.template.escapeHtml` (script-in-message / img-onerror payloads are rendered inert). `analytics({ from, to })` powers the operator dashboard (gift-option order count, top wrap_skus, gift-message rate). Migration `0046_gift_options.sql` (`gift_wraps` + per-order `gift_options` rows). · *`supportTickets` primitive — threaded customer-service surface with SLA timers* — `bShop.supportTickets.create({ query?, cursorSecret? })` returns `{ open, reply, transition, assign, unassign, addTag, removeTag, get, listForCustomer, listOpenAssignedTo, listUnassigned, thread, slaCheck, metrics }`. Distinct from `orderNotes` — a ticket isn't necessarily tied to an order. Categories: `pre_sale / order_issue / shipping / billing / refund / account / complaint / feature_request / other`. Priority: `low / normal / high / urgent`. Status FSM: `new → in_progress → waiting_customer → in_progress → resolved → closed`, with `reopened` re-entering `in_progress` and `closed` reachable from any state. Operator reply auto-flips `new → in_progress`. Email always hashed via `namespaceHash('support-ticket-email', ...)` before write. `slaCheck()` returns tickets breaching SLA (urgent 1h / high 4h / normal 24h / low 72h since last operator action). `metrics({ from, to })` covers per-category counts, average first-response time, resolution rate, escalation rate. Migration `0047_support_tickets.sql` (`support_tickets` + `support_messages` + `support_status_history` tables).

- v0.0.57 (2026-05-22) — **Ten new primitives: bundles, variants, multi-warehouse inventory, backorder, payment methods, order notes, fraud screening, order export, print-on-demand, save-for-later.** Ten primitives ship in one release. `bundles` registers kit-products that expand at cart resolution time. `variants` is the option-axis layer that lets a single product carry the cartesian product of color × size × style (etc.) with per-variant overrides. `inventoryLocations` splits stock across N warehouses + retail stores + drop-ship suppliers with a routing strategy. `backorder` lets out-of-stock SKUs still be ordered with an expected-ship date carried to the PDP. `paymentMethods` stores per-customer processor tokens (Stripe payment-method ids, PayPal billing-agreement ids, etc.) with strict refuses on PAN / CVV-shaped fields. `orderNotes` adds a threaded customer-service surface attached to each order with `internal` and `customer_visible` visibility. `fraudScreen` returns a `{score, decision, signals}` triage at checkout from a 13-signal heuristic. `orderExport` streams CSV / NDJSON / scheduled-export rows with CSV-injection refusal at every cell. `printOnDemand` is the supplier-agnostic POD passthrough (Printful / Printify / Gelato / Lulu / Gooten / Cloudprinter + `custom`). `saveForLater` parks a cart line on the customer's account without losing the snapshot price. **Added:** *`bundles` primitive — kit / multi-pack products that expand at cart resolution* — `bShop.bundles.create({ query?, catalog })` returns `{ defineBundle, getBundle, listBundles, expand, priceBundle, updateBundle, deleteBundle }`. Bundle SKUs reference real catalog SKUs (or nested bundle SKUs up to 2 levels). `expand({ bundle_sku, quantity })` returns the flattened component list with multipliers applied. `priceBundle` sums child priceMinor and applies an optional `bundle_discount_bps` override. Cycle detection refuses bundles that eventually point back to themselves. `deleteBundle` refuses while any active cart line references the bundle. Migration `0032_bundles.sql` (`bundles` + `bundle_components` tables with UNIQUE(bundle_sku, sku) + FK CASCADE). · *`variants` primitive — product option matrix with per-variant overrides* — `bShop.variants.create({ query?, catalog })` returns `{ defineAxis, generateMatrix, materializeMatrix, getVariant, variantsForProduct, findVariant, updateVariant, archiveVariant, unarchiveVariant, archiveAxisOption }`. Operators register axes (`color`, `size`, etc.) with positions and option labels. `generateMatrix` previews the cartesian product without writing — the operator validates before `materializeMatrix` commits. SKU shape: `<prefix>-<axis1-opt>-<axis2-opt>-...` lowercase ASCII slug. `findVariant({ product_id, axis_values: { color: 'red', size: 'L' } })` resolves a specific cell. `image_url` validates via `b.safeUrl` so `javascript:` / `data:` schemes are refused at write. Migration `0033_product_variants.sql` (`product_variant_axes` + `product_variant_axis_options` + `product_variants` tables). · *`inventoryLocations` primitive — multi-warehouse stock + order routing* — `bShop.inventoryLocations.create({ query?, catalog })` returns `{ defineLocation, listLocations, getLocation, updateLocation, deactivateLocation, setStock, adjustStock, transferStock, stockForSku, totalForSku, availableLocations, routeOrder }`. Each location has a type (`warehouse` / `retail` / `dropship`) + priority + active flag. `transferStock` is an atomic two-row write so no stock is created or lost. `routeOrder` strategies: `priority-fill` (default — walks active locations in priority order), `single-location-cheapest`, `nearest-by-postal-prefix`. Returns `{ allocation: [{ location_code, lines }], unfulfillable }`. `inventory_adjustments` ledger is append-only — every `adjustStock` writes a reason-tagged row for audit replay. Migration `0034_inventory_locations.sql`. · *`backorder` primitive — orderable when out-of-stock with expected-ship dates* — `bShop.backorder.create({ query?, catalog })` returns `{ markBackorderable, markNotBackorderable, getStatus, listBackorderable, recordBackorder, fulfillBackorder, cancelBackorder, availabilityFor, customerBackorders, pendingForSku, arrivalsThisWeek }`. `availabilityFor(sku)` returns `{ status: 'in_stock' | 'backorderable' | 'out_of_stock', expected_ship_date?, message? }` for the PDP. `recordBackorder` enforces the per-sku `max_backorder_quantity` cap (NULL = unlimited). `arrivalsThisWeek` returns backorders with `expected_ship_date <= now + 7d` for operator-facing dashboards. Migration `0035_backorder.sql` (`backorder_skus` config table + `backorder_lines` per-order rows with status FSM (pending / fulfilled / cancelled)). · *`paymentMethods` primitive — saved processor tokens with PAN / CVV refusal* — `bShop.paymentMethods.create({ query? })` returns `{ add, get, listForCustomer, setDefault, archive, markExpired, defaultForCustomer, byProcessorToken, audit }`. Add refuses any field that looks like a raw PAN (regex: 13-19 digits in a row, including hyphen / space-collapsed forms), refuses CVV-shaped fields, validates processor enum (stripe / paypal / square / braintree / authorize_net), validates last4 (exactly 4 digits), validates expiry not in the past. `setDefault` enforces write-side default-uniqueness — promoting clears sibling default in the same transaction. `markExpired` is operator-scheduler-callable and writes an audit row per archived method. `audit(payment_method_id)` returns the row's full state-change history. Migration `0036_payment_methods.sql` (`payment_methods` + `payment_method_audit` tables with partial UNIQUE index on `(customer_id) WHERE is_default = 1 AND archived_at IS NULL`). · *`orderNotes` primitive — threaded customer-service notes per order* — `bShop.orderNotes.create({ query?, cursorSecret? })` returns `{ add, get, listForOrder, thread, markRead, pin, unpin, customerVisibleForOrder, internalForOrder, resolve, reopen }`. Visibility enum: `internal` (operator-only) / `customer_visible` (rendered on the customer's order page + emailed). Body cap 8000 chars, refuses control bytes + zero-width chars + empty-after-trim. `thread` returns a tree-shaped reply graph. `pin` floats a note to the top of the listing; resolve / reopen runs an internal-thread workflow with a 280-char resolution summary. Migration `0037_order_notes.sql` (`order_notes` + `order_note_reads` tables with indexes on (order_id, visibility, created_at desc) and (order_id, pinned desc, created_at desc)). · *`fraudScreen` primitive — heuristic risk score + decision at checkout* — `bShop.fraudScreen.create({ query?, customers?, addresses?, paymentMethods?, emailSuppressions? })` returns `{ screen, recordOutcome, recordChargeback, flagEmail, unflagEmail, customerRiskHistory, recentScreenings, hashEmail }`. `screen({ order_draft })` returns `{ score: 0..100, decision: 'approve' | 'review' | 'step_up' | 'refuse', signals }`. Decision thresholds: 0-39 approve, 40-69 review, 70-89 step_up, 90+ refuse. 13 signals fire independently and stack with operator-tunable weights: velocity (>N orders/24h from same email hash), high-value-new-customer, address-mismatch, free-email-domain, disposable-email-domain, session-too-fast (< 15s), session-too-old (> 24h), ua-curl-class, large-line-count, mismatched-bin-country (operator-supplied BIN → country map), prior-chargeback (looks up chargebacks ledger by email hash), suppressed-email, manually-flagged. Emails always hashed via `namespaceHash('fraud-email', ...)` before write. Migration `0038_fraud_screenings.sql` (`fraud_screenings` + `fraud_chargebacks` + `fraud_email_flags` tables). · *`orderExport` primitive — CSV / NDJSON streaming export with CSV-injection refusal* — `bShop.orderExport.create({ query?, order, cursorSecret? })` returns `{ csvForRange, ndjsonForRange, summaryForRange, scheduleExport, cancelExport, getExport, listExports, markExportRunning, markExportComplete, markExportFailed }`. Built-in 24-column schema covering order id / status / customer hash / addresses / line counts / monetary totals / payment method / fulfillment status / refund / return / chargeback flags. RFC-4180 quoting with CRLF; cell content starting with `=`, `+`, `-`, `@` gets prefixed with `'` per OWASP guidance (signed numerics like `+15.00` are exempt). Email is hashed via `namespaceHash('order-export-email', ...)` — raw email never reaches the export. `scheduleExport` writes a queued row + the operator's worker walks the `pod_fulfillments`-style FSM (queued → running → complete | failed | cancelled). Migration `0039_scheduled_exports.sql`. · *`printOnDemand` primitive — supplier-agnostic POD passthrough* — `bShop.printOnDemand.create({ query?, catalog })` returns `{ bindSku, unbindSku, getBinding, listBindings, updateBinding, costForOrder, forwardOrder, markFulfillmentSubmitted, markFulfillmentShipped, markFulfillmentFailed, markFulfillmentCancelled, getFulfillment, fulfillmentsForOrder, pendingFulfillments }`. Supplier enum: `printful` / `printify` / `gelato` / `lulu` / `gooten` / `cloudprinter` / `custom`. The primitive stores the SKU → supplier-product binding (artwork URL + variant id + position config + colorway + operator's wholesale `cost_minor`) and the per-order pending-fulfillment row. The actual HTTP push to each supplier is the operator's worker (each has its own auth + endpoint). `costForOrder(order_lines)` returns `{ total_cost_minor, by_supplier, unbound_skus }` for margin reporting. Migration `0040_print_on_demand.sql` (`pod_bindings` + `pod_fulfillments` tables with FSM-driven status column). · *`saveForLater` primitive — park a cart line on the customer account* — `bShop.saveForLater.create({ query?, catalog, currency?, cursorSecret? })` returns `{ moveFromCart, moveToCart, add, remove, clear, listForCustomer, countForCustomer, staleCheck, repriceAll, expireOlderThan }`. Distinct from `wishlist` — `wishlist` is "I'm interested", `saveForLater` is "I was about to buy this but not now". Lines carry their original cart context (price-at-save, variant, quantity, notes) so the customer can re-add with the snapshot price or the current price (`moveToCart` `use_price` parameter). `staleCheck` flags `is_stale` (price changed), `is_unavailable` (SKU gone), `is_low_stock` (catalog quantity < quantity-saved) per row. Refuses re-add to cart when the SKU is now out-of-stock and not backorderable. `expireOlderThan(days)` is the operator-scheduler sweep. Migration `0041_save_for_later.sql` with UNIQUE(customer_id, sku, COALESCE(variant_id, '')). **Fixed:** *`orderNotes` — monotonic `created_at` so back-to-back inserts always sort newest-first* — `Date.now()` on Windows resolves at ~15ms — three notes inserted in the same millisecond shared a `created_at` value, and the secondary `id DESC` tiebreaker fell back to UUID v7's random suffix (unstable since the time prefix collapsed). The primitive now stamps `created_at` with a process-local monotonic clock that increments by 1ms when `Date.now()` returns a stale value, so the listing's `pinned DESC, created_at DESC, id DESC` ordering reliably places the most recent note first regardless of insert burstiness.

- v0.0.56 (2026-05-22) — **Republish of the eleven-primitive batch to npm.** 0.0.55 source landed on `main` but the npm publish workflow ran against the prior commit and didn't reach the registry. 0.0.56 republishes the same eleven primitives (`orderTracking`, `returns`, `loyalty`, `referrals`, `notifications`, `addresses`, `taxExempt`, `emailSuppressions`, `currencyDisplay`, `searchSuggestions`, `cartAbandonment`) plus migrations `0021`-`0031` so `npm install @blamejs/blamejs-shop@0.0.56` pulls the full surface. No code changes from 0.0.55 — only the version bump + this changelog entry. **Fixed:** *Republish to npm — 0.0.55 source on `main` reached operators via `git clone` but not via `npm install`* — `npm install @blamejs/blamejs-shop@0.0.55` would resolve to a missing version. 0.0.56 ships the identical surface so operators upgrading by version number get the full eleven-primitive batch + the eleven new migrations. Tag `v0.0.55` remains on the repo; operators referring to either version see equivalent code.

- v0.0.55 (2026-05-22) — **Eleven new primitives: order tracking, returns, loyalty, referrals, notifications, addresses, tax exemptions, email suppressions, multi-currency display, search suggestions, cart abandonment.** Eleven primitives land in one release, each composed on the vendored blamejs surface (`b.crypto.namespaceHash`, `b.guardEmail`, `b.guardUuid`, `b.uuid.v7`, `b.pagination`, `b.safeSql`, `b.money`). Order tracking + shipments now drive carrier deep-links (UPS / FedEx / USPS / DHL / Royal Mail / Canada Post / Australia Post). Returns ship a full RMA FSM (pending → approved → received → refunded). Loyalty awards points + tier bands. Referrals issue two-sided rewards. Notifications queue with retry + preferences. Saved addresses give customers a per-account book with default-shipping/billing uniqueness. Tax exemption certificates carry an approve/reject/revoke pipeline + a region-aware `isExempt` check. Email suppressions gate outbound mail per scope (transactional / marketing / all). Multi-currency display caches FX rates as integer basis-points with a staleness flag. Search suggestions blend operator-featured rows with popular queries. Cart abandonment scans idle carts and emits per-detection rows for downstream reminder fan-out. **Added:** *`orderTracking` primitive — shipments + per-event log + carrier deep-links* — `bShop.orderTracking.create({ query?, cursorSecret? })` returns `{ createShipment, addEvent, getShipment, listForOrder, byTrackingNumber, carrierTrackingUrl, statusFromCarrier, latestStatus }`. Shipment status flows `label_created → in_transit → out_for_delivery → delivered`, with `exception` + `returned` terminal branches. `carrierTrackingUrl(carrier, tracking_number)` returns the carrier deep-link from a built-in URL template table for UPS / FedEx / USPS / DHL / Royal Mail / Canada Post / Australia Post; unknown carriers return `null` rather than guessing. `addEvent` appends a `shipment_events` row with the carrier-reported status string + raw timestamp + free-form description. Migration `0021_shipments.sql` (shipments + shipment_events tables, indexes on (order_id, created_at), (tracking_number), (status, created_at)). · *`returns` primitive — RMA workflow with per-line reason codes* — `bShop.returns.create({ query?, cursorSecret? })` returns `{ open, approve, reject, markReceived, refund, get, listForCustomer, listForOrder, byStatus }`. The RMA FSM is `pending → approved → received → refunded` with a `rejected` terminal branch from `pending`. Each line carries a reason code from the operator-config'd enum (default: `defective`, `wrong_item`, `wrong_size`, `changed_mind`, `damaged_in_transit`, `not_as_described`, `arrived_late`, `other`) + an optional 280-char note. `refund` writes the refund amount in minor units + the operator's payment-processor refund id. Migration `0023_returns.sql` (returns + return_lines tables, FK CASCADE, indexes on (customer_id, created_at desc), (order_id), (status, created_at desc)). · *`loyalty` primitive — tier bands + point ledger with earn / redeem / expire* — `bShop.loyalty.create({ query?, cursorSecret? })` returns `{ award, redeem, expire, balance, tierForCustomer, history, customersInTier, recomputeTier }`. Default tiers are `bronze (0)`, `silver (250)`, `gold (1000)`, `platinum (5000)` with operator-overridable thresholds. Point ledger is append-only (`earn` / `redeem` / `expire` / `adjust` row types) so totals are always derivable + audit-replayable. `expire` walks rows whose `expires_at <= now` and emits an offsetting `expire` row equal to the unconsumed remainder. Migration `0022_loyalty.sql` (loyalty_ledger + customer_loyalty_summary cached-aggregate tables, indexes on (customer_id, occurred_at desc), (expires_at) where expires_at IS NOT NULL). · *`referrals` primitive — two-sided reward with single-use codes* — `bShop.referrals.create({ query?, cursorSecret? })` returns `{ issueCode, redeem, getCode, listForReferrer, statusForCode, expireScan, statsForReferrer }`. `issueCode` mints a per-referrer code (8-char crockford-base32) and stores it lowercase-normalised; one referrer can hold N codes. `redeem(code, referee_customer_id)` is single-use, refuses self-referral, refuses re-redemption, and emits both the referrer reward row + the referee reward row in one transaction. Reward shape is operator-config'd (`{ kind: 'percent_off' | 'amount_off' | 'points', value }`). Migration `0025_referrals.sql` (referral_codes + referral_redemptions tables, UNIQUE(referee_customer_id) so a customer can only be referred once, indexes on (referrer_customer_id), (status, created_at desc)). · *`notifications` primitive — queue with retry + per-customer channel preferences* — `bShop.notifications.create({ query?, cursorSecret? })` returns `{ enqueue, dispatch, markDelivered, markFailed, setPreference, getPreferences, listForCustomer, listPending }`. Channels: `email`, `sms`, `webhook`. A `notifications_preferences` row stores per-(customer_id, channel, category) opt-in state — `enqueue` consults the matrix and refuses with `{ skipped: true, reason: 'opted-out' }` when the matching row is opt-out. `dispatch` reads `next_retry_at <= now` rows in `pending` and walks the operator-supplied dispatcher callback. Failure schedules an exponential-backoff retry on `[60s, 5m, 30m, 4h]` before terminal `failed`. Migration `0024_notifications.sql` (notifications + notifications_preferences tables, indexes on (status, next_retry_at), (customer_id, created_at desc), (status, created_at desc)). · *`addresses` primitive — per-customer address book with default flags* — `bShop.addresses.create({ query? })` returns `{ add, update, get, listForCustomer, defaultShipping, defaultBilling, archive, unarchive, setDefaultShipping, setDefaultBilling, matchByContent }`. Default-flag uniqueness is enforced write-side — promoting an address to default clears the flag on its sibling rows in the same transaction. Archive drops both default flags so a stale archived row can't accidentally remain default. `matchByContent` collapses casing + whitespace on the address fields so dedup catches near-duplicates at submit time. Migration `0026_customer_addresses.sql` (id, customer_id, full_name, line1/line2, city, region, postal_code, country (ISO-3166-1 alpha-2), phone, is_default_shipping, is_default_billing, archived_at, created_at, updated_at). · *`taxExempt` primitive — exemption certificates with approve / reject / revoke pipeline* — `bShop.taxExempt.create({ query? })` returns `{ submit, approve, reject, revoke, expireScan, get, activeForCustomer, listPendingReview, isExempt }`. Cert numbers persist plaintext for operator display + dedup-hashed via `b.crypto.namespaceHash('tax-cert-number', uppercase(trim(...)))`. Submit is idempotent on `(customer_id, jurisdiction, hash)` — re-submitting an approved cert returns the existing row's status. `isExempt(customer_id, jurisdiction)` honours region-aware ancestry — an `US` certificate covers `US-CA` subdivision lookups. Read-path filters `expires_at <= now` so a slow `expireScan` scheduler can't honor a stale row at checkout. Migration `0027_tax_exempt_certs.sql` (certs + the FSM status column, indexes on (customer_id, status, expires_at), (status, submitted_at), (jurisdiction, status)). · *`emailSuppressions` primitive — bounce / complaint / unsubscribe / opt-out gate* — `bShop.emailSuppressions.create({ query?, cursorSecret? })` returns `{ add, isSuppressed, remove, byHash, list, cleanupExpired, stats }`. Suppression types: `bounce` (hard / soft), `complaint`, `unsubscribe`, `operator_manual`, `rate_limit_block`. Default scope is operator-friendly per type — bounce/complaint suppress `transactional`, unsubscribe suppresses `marketing`, operator-manual + rate-limit suppress `all`. `isSuppressed(email, scope)` honours scope hierarchy — an `all` row blocks every requested scope. Email never persists raw; only the lowercased + trimmed form + the `namespaceHash('email-suppression', ...)` digest. Migration `0028_email_suppressions.sql` (id, email_hash UNIQUE, email_normalised, type, scope, reason, occurrences, expires_at, created_at, updated_at). · *`currencyDisplay` primitive — FX rate cache + safe conversion + locale-aware format* — `bShop.currencyDisplay.create({ query? })` returns `{ setRate, getRate, convert, format, convertAndFormat, cleanupExpired, bulkSetRates }`. Rates encode as integer basis-points (`rate * 10000`) so storage stays integer-clean. Conversion composes `b.money.convert` — rounding is half-to-even via the framework's BigInt path, so zero-decimal targets (JPY, KRW) work without per-currency casing. Stale rows surface `{ stale: true, converted_minor: null }` instead of returning an out-of-date number — the storefront falls back to native currency. `bulkSetRates` validates every row before writing any (pre-flight, since D1 has no client transactions). Migration `0029_fx_rates.sql` (id, base_currency + quote_currency UNIQUE pair, rate_bps, source, fetched_at, expires_at). · *`searchSuggestions` primitive — operator-featured rows + popular-query log + product matches* — `bShop.searchSuggestions.create({ query?, catalog })` returns `{ recordQuery, suggest, addFeatured, updateFeatured, deleteFeatured, popularQueries, cleanupOldQueries }`. `suggest(prefix)` returns a three-array shape (`featured`, `popular`, `products`) so the storefront renders sectioned dropdowns without re-querying. Featured rows carry an FSM (`active` / `paused` / `expired`) + a `(starts_at, expires_at)` window so an operator can time a promotion. `link_url` on featured rows refuses `javascript:` / `data:` / `vbscript:` schemes. Session ids on `recordQuery` are hashed via `namespaceHash('search-suggestions-session', ...)` before write — the popular-query log never holds a recoverable identifier. Migrations `0030_search_suggestions.sql` (featured_search_suggestions + search_query_log tables, indexes on (active + window), (query_normalised, recorded_at desc), (priority desc)). · *`cartAbandonment` primitive — scheduled scanner over idle carts + reminder fan-out feed* — `bShop.cartAbandonment.create({ query?, cursorSecret? })` returns `{ scan, markReminderSent, markReminderSkipped, markReminderFailed, recentDetections, statsForRun, cleanupOld }`. Each `scan` writes a `cart_abandonment_runs` row + N `cart_abandonment_detections` rows for carts whose last update falls in the idle window. Idempotent — a cart already detected within the current idle window skips via a `last detection > idleCutoff` guard + a `UNIQUE(cart_id, detected_at)` collision catch. Session ids are namespace-hashed (`cart-abandonment-session`) before write. Reminder skip reasons route to `skipped-suppressed` (consult `emailSuppressions`) vs `skipped-no-email` (anonymous cart). Migration `0031_cart_abandonment_runs.sql` (detections + runs tables, indexes on (status, detected_at desc), (cart_id), (run_id)).

- v0.0.54 (2026-05-22) — **Reviews, wishlist, inventory-receive, webhooks DLQ, payment idempotency, newsletter unsubscribe, email templates, VAT/GST, midnight theme, analytics events.** Ten new or extended primitives land together. `reviews`, `wishlist`, and `inventoryReceive` are brand-new with their own migrations and surface APIs. `webhooks` gains a dead-letter queue + exponential-backoff retry + sliding-window rate-limit + signed-incoming verification. `payment` gains idempotency-key tracking so a re-issued Stripe call replays the stored response instead of double-charging. `newsletter` gains an unsubscribe token flow + resubscribe path. `email` gains three new transactional templates (wishlist-discount, abandoned-cart, review-request). `tax` gains VAT/GST extraction, reverse-charge for EU B2B, and format-only VAT-ID validation for 29 country codes. `analytics` gains an event-stream surface with hashed identifiers + top-N aggregations + funnel math. `themes/midnight` ships as an alternate dark-mode theme that inherits the default theme's component CSS and overrides only the design tokens. **Added:** *`reviews` primitive — moderated customer reviews per product* — `bShop.reviews.create({ query?, cursorSecret? })` returns `{ submit, get, publish, reject, listForProduct, summaryForProduct, byCustomer, hashCustomerId, hashCustomerEmail }`. Email is stored hash-only via `b.crypto.namespaceHash` namespace `"reviews-customer"`; raw addresses never persist. Either `customer_id` or `customer_email` is required at submit; the hash collapses casing for dedup. Status pipeline: `pending → published → rejected`. `summaryForProduct` returns count + average + distribution histogram. Migration `0011_reviews.sql` (id, product_id, customer_id, customer_id_hash, rating CHECK 1..5, title cap 120, body cap 4000, verified_purchase, status CHECK enum, created_at, updated_at) with indexes on (product, status, created_at desc), (customer_hash, created_at), and (product, rating). · *`wishlist` primitive — saved-for-later with operator analytics* — `bShop.wishlist.create({ query?, cursorSecret? })` returns `{ add, remove, listForCustomer, isWishlisted, countForProduct, popularProducts }`. Idempotent dedup via `UNIQUE (customer_id, product_id, COALESCE(variant_id, ''))`. `popularProducts` returns the top-N most-wishlisted products for operator merchandising. HMAC-tagged pagination cursor on `created_at` desc + `id` desc. Migration `0012_wishlist.sql` (id, customer_id, product_id, variant_id nullable, notes cap 280, created_at). 38 layer-1 check assertions covering add/dedup, scoped remove, pagination, tamper-refused cursor, distinct-customer count. · *`inventoryReceive` primitive — bulk stock receipts with audit trail* — `bShop.inventoryReceive.create({ query?, catalog, cursorSecret? })` returns `{ draft, apply, reverse, get, byReference, list }`. Operators draft a `pending` receipt with N lines in one transaction, then `apply` walks each line + calls `catalog.inventory.restock(sku, qty)` — atomic, so a single restock failure aborts the whole apply and leaves the receipt pending for retry. `reverse` rolls back an applied receipt by issuing the inverse stock adjustment. Idempotent re-apply on `applied` status is a no-op. Migration `0018_inventory_receipts.sql` (receipts + receipt_lines tables, FK CASCADE, indexes on received_at + status + receipt_id + sku). · *`webhooks` — dead-letter queue + exponential backoff + sliding-window rate-limit + signed incoming verification* — Failed deliveries now retry on a `[60s, 5m, 30m, 4h, 24h]` exponential schedule. After 5 failures, the row moves to `webhook_dlq` for operator investigation; `replayFromDlq(dlq_id)` re-queues with `attempts=0`. Per-endpoint `rate_limit_per_minute` (default 60) is enforced via a sliding-window count over the deliveries table. New `verifyIncoming(payload, signature_header, secret, tolerance_seconds?)` matches the Stripe `t=<unix>,v1=<hmac>` shape via `b.webhook.verify`; `signOutgoing(payload, secret, ts?)` is the companion emitter so the framework can both produce and verify the standard signature shape. Migration `0017_webhook_dlq.sql` adds the DLQ table + the next_retry_at + last_attempted_at columns on deliveries. · *`payment` — idempotency-key tracking on mutating Stripe calls* — `createPaymentIntent`, `refund`, and the `subscriptions.*` mutating methods now accept an `idempotency_key` parameter. When the primitive is created with a `query` opt, the same key + canonicalised body hash returns the stored response without re-calling Stripe; the same key with a *different* body throws `TypeError("payment: idempotency_key collision (different inputs)")` — silently returning would let an attacker replay a different amount. Migration `0015_payment_idempotency.sql` stores (idempotency_key PK, operation, request_hash, response_status, response_body, expires_at) with a 24-hour TTL matching Stripe's. `cleanupExpired()` purges expired rows on operator schedule. · *`newsletter` — single-use unsubscribe token + resubscribe flow* — `issueUnsubscribeToken(signup_id)` mints a 24-byte base64url plaintext bearer (32 chars), stores only the `namespaceHash("newsletter-unsubscribe", plaintext)` with a one-year expiry; the plaintext is returned once, never re-issuable. `consumeUnsubscribeToken(plaintext)` is the single-use redeem — structured-result return distinguishes `not-found`, `already-consumed`, `expired`, `ok`. Uses `b.crypto.timingSafeEqual` to equalize hit-vs-miss CPU work. `resubscribe({ email })` clears `unsubscribed_at` for a previously-opted-out signup. Migration `0014_newsletter_unsubscribe_tokens.sql` (token_hash PK, signup_id, created_at, consumed_at, expires_at). · *`email` — three new transactional templates* — `sendWishlistDiscount({ customer_email, product_title, product_url, old_price, new_price, discount_pct, expires_at? })` — price-drop alert with the orange CTA back to the product. `sendAbandonedCartReminder({ customer_email, customer_name?, cart_url, lines, total, notes? })` — loops cart lines through the strict renderer once per row so every cell stays HTML-escaped. `sendReviewRequest({ customer_email, customer_name?, order_id, products, review_base_url })` — per-product review links built as `review_base_url + "/" + slug + "/review"`. All three emit both html + text via `b.mail.compose`, validate addresses via `b.guardEmail`, and refuse unknown / unused placeholders at the strict renderer. · *`tax` — VAT / GST extraction + reverse charge + VAT-ID format validation* — `calculateInclusive({ amount_minor, rate_bps, currency })` extracts VAT from a gross price; `calculateExclusive` adds VAT to a net price; both use banker's rounding to even and guarantee `net + tax === gross`. `applyReverseCharge({ ctx, seller_vat_id?, buyer_vat_id?, buyer_country })` returns `rate_bps: 0` when both VAT IDs format-validate, both countries are EU, and the buyer VAT-ID country matches. `validateVatId(vat_id, country_code)` regex-checks against 29 country formats (EU-27 + GB + CH, with GR aliasing to EL). `format({ rate_bps, locale?, reverse_charge? })` renders via `Intl.NumberFormat` with optional reverse-charge annotation. Operators wire VIES live-validation out of band — the primitive's JSDoc documents the boundary. · *`analytics` — event-stream surface with hashed identifiers + top-N + funnel* — `recordEvent({ event_type, session_id?, customer_id?, product_id?, search_q?, page_url?, user_agent_class?, payload?, occurred_at? })` hashes `session_id` and `customer_id` via `b.crypto.namespaceHash` before write — raw identifiers never persist. Refuses raw email or IP shapes on every string field. `topSearchTerms`, `topViewedProducts`, `funnel`, `sessionFlow(session_id, …)`, `dropAfter(ts)` cover the operator surfaces. Migration `0019_analytics_events.sql` (id, event_type CHECK enum, session_id_hash NOT NULL, customer_id_hash, payload_json bounded 4 KiB, product_id, search_q, page_url, user_agent_class CHECK enum, occurred_at) + 4 indexes covering (event_type, time), (session, time), (product, event_type, time), and (search_q, time). · *`themes/midnight` — alternate dark-mode theme via design-token override* — 2.59 KiB stylesheet at `themes/midnight/assets/css/main.css` that `@import`s the default theme's component CSS, then overrides ~26 design tokens (palette flipped, shadow alpha deepened, accent warmed to `#ff8a3a` for dark contrast). Two per-surface fixes for `.site-header` and `.site-footer` which hard-code colours outside the token system. Operators activate by setting `SHOP_THEME=midnight` in wrangler vars. The midnight theme demonstrates the inheritance pattern for operators authoring their own theme.

- v0.0.53 (2026-05-22) — **Docs refresh, sample catalog tripled, designed empty-cart card, worker hardening.** Four surfaces refresh in one release. `SECURITY.md` + `CONTRIBUTING.md` + `docs/deploy-cloudflare.md` cover the scoped package name + custom domain + the full 0001-0010 migration table. The sample catalog grows from 4 products to 12 (Operator Hoodie, Vault Stick, Signing Cable, Build Pass, Audit Log Kit, Self-Hosted Plan, Operator Mug, Sticker Pack — each with its own brand-coloured SVG hero). The empty-cart row gains a designed `cart-empty` card with the brand 🛒 icon, eyebrow + title + lede + dual CTAs (`Browse products` primary, `Find a specific product` ghost linking the header search). The Worker gets a `/robots.txt` edge fallback (so crawlers get a clean answer even during container cold-start), the warming-up page picks up `noindex`/`canonical`/`aria-live`/refresh tuned to `Sec-Fetch-Mode`, and a new `/_/version` probe surfaces deploy state. **Added:** *Sample catalog grows from 4 to 12 products with brand-coloured SVG heroes* — `scripts/sample-product-images/{operator-hoodie,vault-stick,signing-cable,build-pass,audit-log-kit,self-hosted-plan,operator-mug,sticker-pack}.svg` ship as the next round of reference imagery. Apparel SVGs use dark-ink gradients with accent-orange illustrations; hardware uses navy-to-blue gradients with grey/black device silhouettes + accent-orange chip highlights; digital uses purple-to-navy with credential-artifact motifs carrying a visible PQC / ML-DSA seal; bundles use deep-orange or green with stacked-object compositions. All 800×800 viewBox, system font stack, monospace SKU at bottom-right. `scripts/seed-sample-products.sql` + `scripts/seed-sample-product-media.sql` extend with the matching catalog + media rows (UUIDv7 ids continue the existing numbering 0005-000c). · *Designed empty-cart card* — `renderCart` emits a `.cart-empty` section instead of a bare `<td colspan>` row when there are no lines. The card carries the brand emoji in a dashed-border circle, `Cart` eyebrow, `Your cart is empty` headline, a lede that explains how the cart holds the add-time price, and dual CTAs — `Browse products →` (primary, → `/`) + `Find a specific product` (ghost, anchors `#site-search-q` for header-search focus without inline JS). Populated cart gains a `Shop / Cart` breadcrumb above the section head, matching the PDP's pattern. · *Worker `/robots.txt` edge fallback* — Even during container cold-start, the Worker now serves a minimal `User-agent: *\nAllow: /\nSitemap: https://blamejs.shop/sitemap.xml\n` directly at the edge with a 1h cache. Crawlers never see the warming page for robots probes. R2-uploaded `/assets/robots.txt` overrides still win for operators with a custom policy. · *Worker `/_/version` deploy probe* — Operator-friendly diagnostic endpoint returning `{ worker, container_image, time }`. No auth required (pure read-only probe); use it to verify a deploy reached the edge before sending traffic. · *`CONTRIBUTING.md`* — 210 lines covering: dev environment setup (clone → vendor-update → smoke), the release workflow (release-notes JSON → CHANGELOG rebuild → branch → PR → admin merge → tag → publish), code conventions (CommonJS, `var`, zero npm runtime deps, compose `b.*` primitives, vendored tree read-only, security defaults non-opt-in, PQC-first), how to run + write tests (smoke + layer-0/1/2, `waitUntil` over `setTimeout`), the explicit list of artifacts each publish produces, and a pointer to `SECURITY.md` for vuln reporting. **Changed:** *Warming-up page — `noindex`, canonical, ARIA-live, refresh tuned to navigation mode* — The cold-start fallback page picks up `<meta name="robots" content="noindex, nofollow">` + a canonical link so crawlers don't index the placeholder. The auto-refresh interval shifts based on `Sec-Fetch-Mode` — 5 seconds for `navigate` requests (real visitor in a browser tab), 8 seconds for non-navigation fetches (XHR/fetch/Stripe.js probes that shouldn't spam the container). A new `aria-live="polite"` region announces the warming state to screen readers. · *`SECURITY.md` — Stripe webhook clause updated to match shipped code* — The container's defense-in-depth verification clause used a placeholder phrase from before `lib/payment.js` shipped. Now describes the actual `b.webhook.verify` call (alg `hmac-sha256-stripe`) running inside `lib/payment.js` before any FSM transition. Every other audit point matched the workflow output as-is — SLSA L3 provenance, Sigstore-keyless SBOM signatures, SHA-256 + SHA3-512 digests, ML-DSA-65 PQC sidecar. · *`docs/deploy-cloudflare.md` — custom-domain section + 0001-0010 migration table + demo-seed step* — Adds a `Wire a custom domain` section (zone-add → Worker custom-domain bind → `wrangler.toml` route alternative → TLS verify). Replaces the bare-paragraph migration mention with the explicit 0001-0010 table (calling out the intentional `0007` gap from the abandoned discounts work). Adds a `Seed demo content` section that runs both seed SQL files via `wrangler d1 execute --remote`. Switches the existing migration-apply step to `--remote` for clarity.

- v0.0.52 (2026-05-22) — **Live deploy moves to the custom domain — README + wrangler config point at https://blamejs.shop.** The reference deploy now serves at `https://blamejs.shop` instead of the `*.workers.dev` subdomain. README header now reads 'Homepage: **https://blamejs.shop**' and the admin-API curl example uses a placeholder `your-shop.example.com` host since operators replace it with their own. `wrangler.toml#D1_BRIDGE_URL` switches to `https://blamejs.shop` so the container's externalDb adapter calls back through the canonical origin. **Changed:** *README header — 'Homepage' instead of 'Live demo', custom domain* — `Homepage: **https://blamejs.shop**` replaces the previous `Live demo: **https://blamejs-shop.coocoo.workers.dev/**`. Operators evaluating the framework now see the canonical address. The admin-API curl example in the operator quick-start drops the placeholder `<your-worker>.workers.dev` host in favour of a `your-shop.example.com` placeholder with a comment pointing at the reference deploy. · *`wrangler.toml#D1_BRIDGE_URL` → `https://blamejs.shop`* — The container's externalDb D1 adapter posts SQL to `<D1_BRIDGE_URL><D1_BRIDGE_PATH>` (the Worker's service-binding bridge endpoint). Updating the base URL routes those internal calls through the custom domain. Cloudflare resolves custom domains to the same Worker that serves `*.workers.dev`, so the bridge keeps working with no router change.

- v0.0.51 (2026-05-22) — **SEO crawl surfaces — `/robots.txt` + `/sitemap.xml` listing every active product.** Crawlers landing on the live shop had no discovery surface — every product page was reachable only by being linked from the home grid, and there was no robots.txt to direct the crawl. This release wires `/robots.txt` (allow-everything-except-session-scoped, pointing at the sitemap) and `/sitemap.xml` (lists the home + /admin + every `status='active'` product with its `updated_at` lastmod, capped at 1000 rows). The XML is hand-rolled — no node:xml dep — with attribute-escaped values so a slug containing `&` or `<` can't break out of the document. **Added:** *`GET /robots.txt`* — `User-agent: *` + `Allow: /` for crawlable surfaces. Disallow paths cover the session-scoped + operator-only surfaces (`/admin`, `/cart`, `/checkout`, `/pay/`, `/orders/`, `/account`) — none of those have crawl value and exposing them risks crawlers tripping rate limits on the cart-bound DO. `Sitemap:` directive resolves from the request `Host` header so the same handler serves the right absolute URL for `localhost:8080` (dev), `blamejs-shop.coocoo.workers.dev` (live), or any custom domain. `Cache-Control: public, max-age=3600`. Operators with stricter requirements override by uploading a `robots.txt` key to R2 — the Worker's static-asset bridge serves R2 keys ahead of the storefront router, so the bucket file wins. · *`GET /sitemap.xml`* — Lists the home page (priority 1.0, daily changefreq), the `/admin` landing (priority 0.3, monthly changefreq), and every `status='active'` product (priority 0.8, weekly changefreq) with the product's `updated_at` or `created_at` as `lastmod` (ISO date). Capped at 1000 rows — the sitemap spec allows 50,000 but a shop at that scale should pre-segment into a sitemap index. The XML is hand-rolled, attribute-escape applies `&`, `<`, `>`, `"`, `'` so an operator-supplied slug can't break the document. `Cache-Control: public, max-age=300` so a catalog update propagates inside five minutes without an operator action.

- v0.0.50 (2026-05-22) — **OpenGraph + Twitter Card meta tags — shared links unfurl with proper previews.** Pages rendered without `og:*` or `twitter:*` tags. Sharing the live URL in Slack / iMessage / Twitter / Discord landed a bare URL with no preview, no image, no description. This release wires OpenGraph + Twitter Card meta tags into the LAYOUT head with sensible site-level defaults (brand logo + shop-level lede). The PDP overrides them with product-specific values — `og:title` is the product title, `og:description` is the catalog `description`, `og:image` is the first attached media URL — so a product share renders with the actual hero image + product copy. **Added:** *OpenGraph + Twitter Card tags in `LAYOUT`* — `<meta property="og:type">`, `og:site_name`, `og:title`, `og:description`, `og:image`, `og:url` + the Twitter Card analogues (`twitter:card=summary_large_image`, `twitter:title`, `twitter:description`, `twitter:image`). All templated through `{{og_*}}` placeholders so the `_render` HTML-escape protects against operator-supplied content breaking out of the attribute boundary. `<meta name="description">` also lands so non-OG crawlers see the same description. · *Default OG values on `_wrap(opts)`* — Every page renders with sensible defaults: `og:type=website`, `og:title=<page title> — <shop name>`, `og:description=Open-source ecommerce framework built on blamejs. Server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.`, `og:image=/assets/brand/logo.png`. Per-renderer overrides via `opts.og_*` work surgically — only the field passed in overrides; the rest stay on defaults. · *Product-specific OG on the PDP* — `renderProduct` overrides four OG fields: `og_type=product`, `og_title=<product.title> — <shop_name>`, `og_description=<product.description>` (or a generated fallback when the description is empty), `og_image=<assetPrefix><heroMedia.r2_key>` (the first attached media row). A PDP share now renders the SVG hero + the product copy, not the brand logo + a generic shop description.

- v0.0.49 (2026-05-22) — **README refresh — scoped install command, live-demo URL, refreshed primitive table + migration list.** The README hadn't been touched since v0.0.43; the install snippet still pointed at the unscoped `blamejs-shop` name and the primitives table listed only the original commerce surface. Operators landing on the GitHub repo now see the correct `npm install @blamejs/blamejs-shop`, the live demo URL, badges for npm + license + SLSA L3, and a primitives table that includes `customers`, `subscriptions`, `newsletter`, and `theme` alongside the original commerce stack. **Changed:** *README — install snippet uses `@blamejs/blamejs-shop`* — Replaces the unscoped clone-and-smoke recipe with `npm install @blamejs/blamejs-shop` + the `require()` shape that resolves the framework + adapters. The local clone path stays as the secondary recipe for operators who want to develop against the repo. Three badges land in the header — npm version, Apache-2.0 license, SLSA Level 3. · *README — primitive table extended* — Adds rows for `customers` (passkey-only, email hash-only), `subscriptions` (Stripe-backed recurring), `newsletter` (signup primitive shipped in v0.0.41), and `theme` (file-backed templates). The `storefront` row gains the full surface description — utility bar, sticky header, dark hero with code-preview card, marquee, featured-product callout, collections grid, framework feature band, designed catalog grid, newsletter band, and the four-column footer. `admin` row mentions the subscription-plan routes. · *README — migration list updated* — Lists migrations 0001-0010 (catalog, cart, order, shop_config, webhooks, customers, inventory_thresholds, subscriptions, newsletter_signups) so operators don't have to grep the directory to know what schema lands. Adds the demo-seed recipe (`scripts/seed-sample-products.sql` + `scripts/seed-sample-product-media.sql`) for one-shot population of the live demo's four products + their SVG hero images. · *Live-demo URL pinned* — `https://blamejs-shop.coocoo.workers.dev/` lands in the README header so operators evaluating the framework can see a real deployed instance rendering the storefront design system end-to-end before committing to wiring their own Cloudflare account.

- v0.0.48 (2026-05-22) — **Home page — featured-product callout between the primitives marquee and the collections grid.** The home page rhythm went marquee → 6-tile collections grid → framework band, so a single product never got more than the four-tile catalog card's surface area to introduce itself. This release inserts a split callout (square hero image on the left, copy column with `Featured` eyebrow + title + description + accent-coloured price + `View product →` CTA on the right) between the marquee and the collections grid. The render picks the first product with attached media; operators wrapping `renderHome` can override the selection via `opts.featured`. **Added:** *`.featured-product` section* — Sits between the primitives marquee and the collections grid. Two-column grid at desktop (square hero + copy column), stacks to image-above-copy at <64rem with a 16/10 image aspect ratio. Title is a `clamp(1.75rem, 3vw, 2.5rem)` headline; price renders in the display font at `--text-2xl` with the accent colour + tabular-nums. Image is wrapped in an anchor to the PDP and has a hover-scale of `1.03` driven through the existing `--duration-slow` ease curve. The card itself has a hairline border on `var(--paper)` so it reads as a content tile, not a hero band — keeps the dark hero above and the framework band below as the page's main rhythm changes. · *`storefront.renderHome({ ..., featured? })` selection override* — When `opts.featured` is supplied the renderer uses it verbatim (shape: `{ title, description, price, slug, image_url, image_alt }`). When absent, the renderer scans the active-products list for the first row with `image_url` and uses that as the featured pick. Products without media are skipped; when no product has media the callout renders as an empty string and the page rhythm falls back to marquee → collections directly. **Changed:** *Product data shape — `description` carried through* — The renderHome `products.map(...)` projection now includes `description` (from the catalog products row) so the featured callout's lede can pull product-specific copy. Existing callers that didn't pass a description continue to render — the callout falls back to a stock 'Server-rendered, PQC-secured, shipped from origin' line.

- v0.0.47 (2026-05-22) — **Account dashboard — 4-stat row + recent-orders table with per-order product thumbnails.** The dashboard surfaced a bare three-column orders table (order id, status, total). This release adds a 4-stat row above the body (orders / lifetime spend / member since / passkeys) and a new 'Items' column on the orders table that renders the first four product thumbnails per order with a `+N` overflow chip when there are more. Visitors land on a richer surface that summarises their relationship with the shop instead of just listing opaque order ids. **Added:** *Stats row — orders, lifetime spend, member since, passkeys* — Four-up grid above the orders table. `Orders` is the count of returned orders (capped at 10 by the listForCustomer page; future patch could surface the true count via a `count()` shape on the order primitive). `Lifetime spend` sums `grand_total_minor` across orders in the dominant currency (falls back to '—' if multiple currencies appear, since silently mis-summing them is worse than declining to compute). `Member since` reads `customer.created_at` when present, otherwise the earliest order's `created_at` — both as an ISO date. `Passkeys` calls `deps.customers.listPasskeys(customer.id)` (drop-silent on `TypeError` if the primitive build doesn't expose the listing surface yet). · *`Items` column — per-order product thumbnails* — Per-order, the route walks `order.lines`, collapses to unique variant ids, and resolves each through cached `catalog.variants.get` → `catalog.products.get` + `catalog.media.listForProduct`. The variant cache is held across orders so a customer with multiple orders containing the same SKU only hits the catalog once. The table cell surfaces the first four thumbs (`.account-order__thumb`, 2.5rem rounded tiles); orders with more than four show a `+N` overflow chip. Status renders as the same `.pdp__badge`-style pill the order page uses — green-bordered `--ok` variant for `completed` / `shipped` / `delivered`. **Changed:** *`storefront.renderAccount({ customer, orders, order_product_lookup?, passkey_count?, asset_prefix? })`* — New optional opts. `order_product_lookup` is `{ order_id: [{ product, hero_media }, ...] }` — the route bundles it in. `passkey_count` is the integer count of enrolled passkeys (0 when the customers primitive can't enumerate them on this build). `asset_prefix` defaults to `/assets/` for image URL composition. All three are optional — calling with just `customer` + `orders` continues to render cleanly, the stat row just shows `0` / `—` for the missing fields.

- v0.0.46 (2026-05-22) — **Order confirmation page — line items render thumbnails + product titles (same pattern as the cart).** The post-checkout `/orders/:order_id` page used the read-only `CART_LINE` template that emitted just SKU + qty + unit + total. The order confirmation now reuses the same product-cell treatment v0.0.45 shipped on the live cart — small thumbnail (from `catalog.media.listForProduct`) + product title + SKU chip in a slug-linked anchor — so the page a customer lands on after a successful purchase shows what they bought visually, not just a column of opaque SKU codes. **Changed:** *`CART_LINE` (read-only, used by the order page) — same product cell as the editable cart line* — Markup mirrors `CART_LINE_EDITABLE` minus the qty / update / remove forms. First cell is `<a class="cart-line__product-link" href="/products/<slug>">` wrapping the thumbnail + title + SKU chip; remaining cells (qty / unit / total) are unchanged. Reuses the existing `.cart-line__thumb` + `.cart-line__product-*` CSS — no new theme rules. · *`storefront.renderOrder({ ..., product_lookup })` accepts the variant→product map* — Mirrors `renderCart`'s contract. The route handler bundles a `{ variant_id: { product, hero_media } }` cache built per-unique-variant; lines whose variant is missing fall through to SKU-as-title + the placeholder tile. The order page table header is now 'Product' instead of 'SKU'. · *`GET /orders/:order_id` walks the order's frozen lines once* — After loading the order, the route walks `o.lines` and for each unique `variant_id` pulls `catalog.variants.get` → `catalog.products.get` + `catalog.media.listForProduct`. The cache prevents a multi-quantity-same-variant order from hitting the catalog more than once per variant. The lookup is passed to `renderOrder` so the line rendering matches the cart's pattern byte-for-byte.

- v0.0.45 (2026-05-22) — **Cart line items — thumbnail + product title + slug-linked anchor alongside the SKU chip.** Cart rows previously rendered only the SKU code, qty, unit, and total — the visitor had to remember what they actually added. Each line now leads with a 3.5rem rounded thumbnail (from the product's first `catalog.media` row), the product title in a bold inline label, and the SKU as a small monospace chip below. The whole product cell is an anchor back to the PDP so a visitor can re-enter the product page directly from the cart. Lines whose variant is missing media render the placeholder tile (dashed-border + the diagonal-stripe pattern the catalog empty-state uses). **Changed:** *`CART_LINE_EDITABLE` template — product cell replaces SKU-only cell* — First cell is now `<a class="cart-line__product-link" href="/products/<slug>">` wrapping a thumbnail + a `<span class="cart-line__product-meta">` that stacks the product title (bold) and SKU chip (monospace, on a `--bg-2` background). Hover on the link tints both the title and the link affordance to the accent color. The qty / unit / total / action cells are unchanged. · *`storefront.renderCart({ ..., product_lookup })` accepts a variant→product lookup* — Routes pass a `{ variant_id: { product, hero_media } }` map; lines render with the matching product's title + slug-linked URL + hero-media thumbnail. Lines without a lookup match (variant deleted, media not attached yet) fall through to a SKU-as-title display with the placeholder tile. The cart-table column header is now 'Product' instead of 'SKU'. · *`GET /cart` builds the product lookup* — After listing the cart's lines, the route walks each unique `variant_id` (cached by id so a cart with the same variant twice only hits the catalog once), pulls `catalog.variants.get` → `catalog.products.get` + `catalog.media.listForProduct`, and bundles the result for the renderer.

- v0.0.44 (2026-05-22) — **Package rename — `@blamejs/blamejs-shop` (scoped under the org alongside `@blamejs/core` + `@blamejs/exceptd-skills`).** The npm package is now published under the `blamejs` organization scope as `@blamejs/blamejs-shop`. Installation switches from `npm install blamejs-shop` to `npm install @blamejs/blamejs-shop`; the import surface is unchanged — `require("@blamejs/blamejs-shop")` returns the same shop primitive set the unscoped name did. The legacy unscoped package will be deprecated on the registry with a pointer to the new name so existing operators see the upgrade path on their next install. **Changed:** *`package.json#name` → `@blamejs/blamejs-shop`* — Matches the existing org-scoped packages (`@blamejs/core`, `@blamejs/exceptd-skills`). The publish workflow already passes `--access public` to `npm publish`, which is the access mode scoped packages need to land as publicly-installable. · *Install command* — `npm install @blamejs/blamejs-shop` replaces `npm install blamejs-shop`. The `require` path follows: `var bShop = require("@blamejs/blamejs-shop");`. The entry point + export shape are byte-for-byte identical to v0.0.43 — only the resolution path changes. **Migration:** *Operator upgrade path* — Operators on the unscoped `blamejs-shop` change one line in their `package.json` dependencies: `"blamejs-shop": "^0.0.43"` → `"@blamejs/blamejs-shop": "^0.0.44"`. Every `require("blamejs-shop")` call site needs the matching update (`require("@blamejs/blamejs-shop")`). The unscoped name will be deprecated on the registry with a pointer to the new scoped path so a fresh `npm install` on the old name surfaces the migration message in npm's stderr.

- v0.0.43 (2026-05-22) — **Search results — image-bearing product cards (consistent with the home grid).** The search results page rendered text-only `PRODUCT_CARD` tiles — title + price + 'View product →' link — while the home catalog grid (v0.0.42) shifted to clickable image-bearing `product-card` tiles. This release unifies the two surfaces: search results now go through the same `_buildProductCard(p)` picker so a hit lands a real hero-image tile when the product carries media, and the text-only fallback when it doesn't. The same `asset_prefix` override the home renderer accepts also works on the search renderer. **Changed:** *`storefront.renderSearch({ products, asset_prefix? })` uses `_buildProductCard`* — Same render-time picker the home grid uses — when a result product carries `hero_media`, the card renders as the image-bearing tile; without media, the text-only fallback. `asset_prefix` defaults to `/assets/` and can be overridden for operators on a different R2 binding or CDN remap. · *Search route fetches `catalog.media.listForProduct` per hit* — `GET /search` now calls the media lookup alongside the existing variants + prices loop. The first attached media row becomes `product.hero_media` on the data shape `renderSearch` consumes — same shape the home route v0.0.42 emits.

- v0.0.42 (2026-05-22) — **Home catalog grid — product cards now render the hero image alongside title + price.** The home page catalog grid used text-only cards (`<h2>` + price + 'View product →' link). When a product carries at least one `catalog.media` row, the card now renders as a clickable image-bearing tile — square hero image at the top, then a single-row meta strip with title + accent-colored price. Products without media keep the existing text-only card so a freshly-listed product with no images yet renders cleanly. **Added:** *`PRODUCT_CARD_IMAGE` template + `_buildProductCard(p)` picker* — When a product carries an `image_url`, the renderer emits an `<a class="product-card">` wrapper with a square `<figure class="product-card__media">` hero (`object-fit: cover`) above a meta strip holding the title and price. Hover lifts the card and darkens the border. When no media exists the existing `PRODUCT_CARD` text-only tile is the fallback. The picker keeps the template engine's HTML-escape behavior — `image_url` + `alt_text` flow through `_render`, never bypassing the escape. · *Home route fetches media per product* — `GET /` now calls `deps.catalog.media.listForProduct(product.id)` alongside the existing `variants.listForProduct` + `prices.current` lookups. The first media row becomes `product.hero_media` on the data shape `renderHome` consumes; products with no attached media stay at `hero_media = null` and the renderer drops back to the text-only card. **Changed:** *`storefront.renderHome({ products, asset_prefix? })` — `asset_prefix` defaults to `/assets/`* — Image URLs are built as `assetPrefix + p.hero_media.r2_key` so operators on a custom asset prefix (different R2 binding, CDN remap) can override without forking the renderer. Backward-compatible — when `asset_prefix` is omitted the default lands at the Worker's `/assets/*` static-asset bridge.

- v0.0.41 (2026-05-22) — **Newsletter signup primitive — POST `/newsletter` enrolls a visitor, designed thank-you page on either branch.** The storefront footer + newsletter band already rendered an email form that posted to `/newsletter`, but no handler was mounted — submitting the form 404'd. This release ships a `newsletter` primitive (`lib/newsletter.js`) that composes `b.guardEmail` for RFC-shape validation, `b.crypto.namespaceHash` for dedup, and stores rows in `newsletter_signups` (new migration `0010_newsletter_signups.sql`). The storefront's POST `/newsletter` route hands the form payload to the primitive and renders a designed thank-you card on success — with separate copy for the `new` (just enrolled) and `dedup` (already on the list) branches — and a designed error card on validation refusal. **Added:** *`newsletter` primitive (`lib/newsletter.js`)* — `bShop.newsletter.create({ query? })` returns `{ signup, byEmailHash, count, EMAIL_NAMESPACE }`. `signup({ email, source })` normalises the email (trim + lowercase), validates via `b.guardEmail(addr, { profile: 'strict' })`, hashes through `b.crypto.namespaceHash('newsletter-email', normalised)` for the dedup key, and runs an `INSERT OR IGNORE` so a repeat signup is an idempotent no-op. Returns `{ id, status: 'new' | 'dedup', email_normalized }`. `byEmailHash(hash)` is the operator lookup for an unsubscribe flow. `count()` returns the active signups for an optional live newsletter-band stat. · *Migration `0010_newsletter_signups.sql`* — New table — `id` UUID PRIMARY KEY, `email_hash` UNIQUE (the namespaceHash dedup key), `email_normalized` (the canonical address an operator needs to actually send a message; D1's encrypted-at-rest default + the operator's access controls cover the PII posture), `source` TEXT (defaults to `storefront-footer`, validated against `/[a-z0-9][a-z0-9._-]*[a-z0-9]/`), `created_at` epoch ms, `unsubscribed_at` NULL until the user opts out (the opt-out flow ships in a follow-up; the column lands now so existing rows don't need a schema rewrite). Two indexes — `created_at` for time-range broadcasts, `source` for channel-attribution filters. · *Storefront route — POST `/newsletter`* — Mounts only when `deps.newsletter` is wired (operators that don't construct the primitive get a clean 404 instead of a misleading 200). Catches `TypeError` (input refusals — bad email format, oversized source string, control bytes) and renders the designed error card with HTTP 400; everything else (D1 unreachable, vault hiccup) renders the same card with HTTP 500. Success renders the thank-you card with HTTP 200 and adapts the copy to the `new` vs `dedup` branch. · *Designed thank-you + error cards* — `renderNewsletterThanks(opts)` and `renderNewsletterError(opts)` both wrap the layout shell with an `eyebrow` + centred title + lede + dual-CTA card (`Back to the shop →` primary, `Star on GitHub` ghost). The `dedup` branch reads 'You're already on the list.' so a double-submit isn't a silent failure. **Changed:** *`server.js` wires `bShop.newsletter.create({})` into `sfDeps`* — Constructed alongside `customers` inside the `if (catalog && cart)` block; the primitive only needs `b.externalDb.query` which the deploy already provisions via the Worker's `D1_BRIDGE_URL`. · *`lib/index.js` exports `newsletter`* — Same shape as the rest of the shop primitives — `var n = bShop.newsletter.create({});`. **Detectors:** *Layer 1 coverage continues to gate input refusal at every entry point* — The newsletter primitive defers shape checks to `b.guardEmail` (already covered by the framework's own test suite) and `b.crypto.namespaceHash` (ditto). The shop layer's contribution is `_normalizeSource` (regex + length cap) and the idempotent dedup flow; the layer-1 smoke surface continues to refuse missing-input + wrong-type + over-length cases on every public entry point in the shop primitives.

- v0.0.40 (2026-05-22) — **Product detail page — gallery wired to real `media` rows with a branded SVG placeholder fallback.** The PDP gallery used to render a giant initial-letter mark as a placeholder regardless of what the product looked like. The storefront now reads `catalog.media.listForProduct(product.id)` on every PDP request — when at least one media row exists, the first row drives the hero `<figure>` (with `alt_text` for a11y) and up to three more rows feed the thumbnail strip. The letter-mark falls through as a clean default when no media has been attached yet. A demo-seed pair (`scripts/seed-sample-product-media.sql` + four brand-colored SVG images under `scripts/sample-product-images/`) ships alongside the existing sample-products seed. **Added:** *`storefront._buildPdpGallery(product, media, assetPrefix)`* — Composes the PDP hero figure + thumbnail strip from `catalog.media` rows. When `media` is non-empty, the first row becomes `<figure class="pdp__media pdp__media--image"><img src="<asset_prefix><r2_key>" alt="<alt_text>"></figure>` (with `loading="eager"` since it's above-the-fold), the next three drive thumbs, and missing thumb slots render as dashed-border placeholders so a single-image product still occupies the four-slot grid. When `media` is empty the gallery falls back to the letter-mark placeholder so a freshly-seeded product never renders an empty square. All template substitutions go through `_render`'s HTML-escape; URLs and alt text are attribute-escaped via a local `_escAttr` helper so an operator-supplied `r2_key` containing `"` or `<` lands as escaped text inside the attribute. · *`scripts/seed-sample-product-media.sql`* — Idempotent media seed: one `media` row per seeded product pointing at `products/<slug>.svg` with `content_type = image/svg+xml`, 800×800, `position = 0`, and human-readable `alt_text`. Run alongside the existing `scripts/seed-sample-products.sql` via `wrangler d1 execute blamejs-shop --remote --file=scripts/seed-sample-product-media.sql`. Drop the rows with `DELETE FROM media WHERE id LIKE '00000000-0000-7000-8000-0000000000%';` when replacing with operator-supplied media. · *`scripts/sample-product-images/` — reference SVGs* — Four 800×800 SVG product placeholders matching the seeded catalog: `operator-tee.svg` (charcoal + accent shield silhouette + tee mark), `edge-reader.svg` (navy + hardware silhouette with status LEDs + monospace label), `operator-license.svg` (purple + ML-DSA-signed card mockup), `starter-bundle.svg` (orange gradient + stacked-box composition representing the three component SKUs). Each uses only the design-system palette + system font stack — no external font fetches, no raster assets. Operators copy these into their own R2 prefix as a starting point or replace them entirely; the demo seed expects them at R2 key `products/<slug>.svg`. **Changed:** *`storefront.renderProduct({ product, variants, prices, media, ... })`* — Accepts an optional `media` array of media rows. When supplied, the gallery renders the real image; when omitted (the current contract for callers that don't read media — including the layer-1 test fixtures), the gallery renders the letter-mark placeholder. Backward-compatible — existing callers that don't pass `media` continue to render the placeholder, exactly the v0.0.39 behavior. The PDP storefront route (`GET /products/:slug`) now calls `deps.catalog.media.listForProduct(product.id)` and threads the result through. · *Theme CSS — `.pdp__media--image` variant* — When the gallery hero is a real image, the `--image` modifier swaps the gradient placeholder backdrop for `var(--paper)`, removes the orange radial-glow `::after` decoration, and lets the `<img>` fill the square via `object-fit: cover`. Thumbnail `<img>` elements get the same `object-fit: cover` treatment so the strip stays uniform across portrait and landscape source images.

- v0.0.39 (2026-05-22) — **Publish workflow — retry the post-publish `npm view` so CDN propagation delay doesn't surface as a red X.** The post-publish `Verify the version landed on the registry` step ran `npm view <pkg> version` immediately after `npm publish`. The npm registry CDN takes several seconds to publish a fresh manifest, so the immediate read returned a 404 even on a successful publish — leaving the otherwise-green release run with one red X. Replaced with a six-attempt retry loop (6 second backoff, ~36s total budget) that exits cleanly the moment the registry serves the target version. **Fixed:** *Post-publish registry-verify retry loop* — The single-shot `npm view` after `npm publish` raced the npm registry's CDN — the publish call returned 200, the package landed, but the manifest GET endpoint still returned 404 for several seconds. The verify step now polls `npm view <pkg> version` up to six times with a six-second backoff (~36 seconds total budget). It exits success the moment the registry returns the just-published version, and only flags a real mismatch (`exit 1`) when the propagation never lands inside the budget.

- v0.0.38 (2026-05-22) — **Publish workflow — install devDeps before `npm sbom` so the runtime-dep tree resolves.** `npm sbom` walks the installed dep tree; without `node_modules` it errors `ESBOMPROBLEMS missing: <devDep>` even when `--omit=dev` would have excluded the devDep from the output. The validate job now runs `npm install --no-audit --no-fund --ignore-scripts` before the SBOM step so the tree resolves; the `--omit=dev` filter on `npm sbom` continues to keep the SBOM clean. **Fixed:** *`npm install` before `npm sbom` in the publish workflow* — The SBOM step was running against a missing dep tree. Added `npm install --no-audit --no-fund --ignore-scripts` so the local install resolves before `npm sbom --omit=dev` walks it. The zero-runtime-deps invariant check downstream is unchanged — it counts components in the emitted SBOM, which `--omit=dev` keeps at zero.

- v0.0.37 (2026-05-22) — **ESLint configuration — flat-config + bug-class rules so the publish workflow's lint gate runs.** ESLint 9+ requires a flat-config file at the repo root. The publish workflow's `npx eslint@latest --max-warnings 0 .` step couldn't find one and exited with `ESLint couldn't find an eslint.config.(js|mjs|cjs) file`. Added `eslint.config.mjs` configured for the project's posture (Node 24 LTS, CommonJS, ES2024) and pruned four pre-existing warnings the new config surfaced — three unused-variable hygiene fixes, one `var config` redeclaration in `server.js`, and one `_cartCountForReq` hoisting inside `lib/storefront.js` so the `/admin` landing + catch-all `onNotFound` handler can reach it regardless of `deps.customers` wiring. **Added:** *`eslint.config.mjs` — flat-config* — Bug-class rule set mirroring the vendored framework's posture: `no-undef`, `no-redeclare`, `no-const-assign`, `eqeqeq` (with the `== null` idiom carve-out), `no-this-before-super`, `no-throw-literal`, `no-unused-vars` (underscore-prefix exception). Stylistic rules (`no-useless-escape`, `no-promise-executor-return`, `no-prototype-builtins`, `no-control-regex`) are intentionally `off` — they're idiomatic patterns the codebase relies on, not bug signals. Three config blocks: CJS `.js` (default), ESM `.mjs`, and Cloudflare Workers `worker/**/*.js` with the Workers runtime global set. Ignores cover the usual `node_modules`, `lib/vendor`, `.test-output`, `.scratch`, `.template`, `.wrangler`, `.playwright-mcp`, and `.claude` worktree paths. **Fixed:** *`_cartCountForReq` hoisted to mount-scope* — The helper that reads `shop_sid` and returns the cart-line count for the header pill was nested inside the `if (deps.customers)` block at the bottom of `storefront.mount`. The `/admin` landing handler and the `onNotFound` catch-all 404 (both added in v0.0.35, mounted outside that block) reached for `_cartCountForReq` and got a `ReferenceError` at runtime when `deps.customers` was absent — in production both deps are wired together so the path never fired, but the path was real. The helper now lives at the top of `mount()` so every handler can reach it. · *`server.js` — duplicate `var config` removed* — `var config = catalog && cart ? bShop.config.create({}) : null;` at the top of the routes function already binds `config` for the whole scope. A second `var config = bShop.config.create({});` inside the admin-mount block was a JavaScript no-op (var declarations don't shadow inside nested blocks) but a real lint signal. Removed; the admin block reuses the outer handle. · *Test-scope unused locals + admin checkout placeholder* — Three values — `var now`, `var bad`, and `var checkout` — were assigned but never read in `test/layer-1-state/analytics.test.js`, `test/layer-1-state/catalog-import.test.js`, and `lib/admin.js` respectively. Renamed with the underscore prefix the project uses for intentional unused locals (`_now`, `_bad`, `_checkout`) so the `no-unused-vars` rule's allowlist matches them.

- v0.0.36 (2026-05-22) — **Publish workflow YAML — refactor the SLSA download-artifact step out of flow mapping so the parser accepts it.** The release `npm-publish.yml` workflow couldn't start. `download-artifact` for the SLSA provenance file used YAML flow-mapping form (`with: { name: blamejs-shop-${{ … }}.intoto.jsonl }`) and the parser read the `}}` inside the GitHub expression as the flow-collection close. Rewritten as block form, the workflow parses and the publish pipeline runs. **Fixed:** *`npm-publish.yml` — `download-artifact` step parses correctly* — The SLSA provenance download step at the publish job mixed YAML flow-mapping `{ … }` with a GitHub `${{ … }}` expression. The parser hit the `}}` inside the expression as a flow-mapping close, rejecting the whole document at parse time (no jobs ever materialised — the run surfaced as `startup_failure`). Rewriting the step as block form (`with:` / `  name: …` on its own line) fixes the parse error. No semantic change to the workflow.

- v0.0.35 (2026-05-22) — **Storefront redesign — multi-section landing, designed cart / PDP / checkout, theme CSS extracted to an external stylesheet.** Every storefront page now ships through the same design system — utility bar, sticky header with search + cart pill, dark hero with code-preview card, primitives marquee, collections grid, framework feature band, designed catalog empty state, newsletter band, and a four-column footer. Cart, product, checkout, pay, order, account login / register / dashboard, search, /admin, and 404 each got their own designed treatment. The theme's design tokens moved out of inline `<style>` blocks (which `style-src 'self'` blocks) into `themes/default/assets/css/main.css`, served same-origin from R2 with a build-time version cache-buster. Operators override the look by uploading a replacement CSS at the same R2 key or by passing `theme_css` to the storefront renderers. **Added:** *`/admin` landing page* — Operators (and the footer link) now land on a designed HTML page at `/admin` that explains the API-only posture and shows four curl examples (create a product, search products, restock inventory, issue a refund) as syntax-highlighted code cards, plus a sidebar note linking to `lib/admin.js` on GitHub. Every other `/admin/*` route remains a bearer-token-gated JSON endpoint. The HTML route only mounts inside the storefront's standard mount; no admin API behavior changed. · *Designed 404 page via `router.onNotFound`* — Every unmatched route — `GET`, `POST`, anything — now renders a designed not-found page (gradient `404` numeral + 'That page slipped the catalog.' copy + Back-to-shop + View-your-cart CTAs) inside the standard layout, instead of the framework's default `<h1>404 Not Found</h1>` text body. Wired through `router.onNotFound`, so it covers paths and verbs uniformly. · *Live hero stat — `Products live` count* — The first stat in the hero now reflects the actual catalog size (the count of `status='active'` products returned to the home renderer). Empty catalogs show an em-dash. The remaining three stats stay hardcoded brand-facing values (`npm runtime deps: 0`, `Default crypto: PQC`, `License: Apache 2.0`). · *Customer accounts wired into storefront mount* — `server.js` constructs `bShop.customers.create({})` and threads it into `sfDeps.customers`, opting the `/account/login`, `/account/register`, `/account` dashboard, and the passkey ceremony JSON endpoints into the deploy. The customers primitive itself shipped in `v0.0.28` but its routes were never mounted in the storefront. · *`scripts/seed-sample-products.sql` — demo catalog seed* — One-shot SQL seed for the demo storefront: four products (Operator Tee, Edge Reader v1, Operator License, Starter Bundle), four variants, four current-price rows, and an inventory row each. Run with `wrangler d1 execute blamejs-shop --remote --file=scripts/seed-sample-products.sql`. Uses `INSERT OR IGNORE` so re-running is idempotent. Operators replace this with their own catalog through the admin API once they have one. **Changed:** *Storefront layout — utility bar + designed header + multi-column footer* — `LAYOUT` in `lib/storefront.js` now wraps every page in a thin top utility bar (live status pill, primitive strapline, GitHub call-to-action), a sticky header with the wordmark logo + pill search input + Shop / Framework nav + circular account icon + cart pill with count badge, and a four-column footer (brand + GitHub / npm / RSS social, Shop, Framework, Operators) plus a bottom strip with copyright + Security / Privacy / Terms links. The same shell wraps every page renderer. · *Home page composition* — Six sections, all server-rendered. Dark hero with eyebrow + version pill, headline with accent on `nothing`, lede, primary + ghost CTAs, a four-stat row, and a tilted glass code-preview card showing a `bShop.checkout.finalize(…)` snippet with syntax highlighting. A CSS-only infinite-scroll marquee lists twelve framework primitives (ML-KEM-1024, ML-DSA-65, XChaCha20-Poly1305, SHAKE256, Argon2id, SLSA L3 provenance, Sigstore-keyless SBOM, Trusted Types, Stripe-first payments, WebAuthn passkeys, server-rendered HTML, zero npm runtime deps). A six-tile collections grid (Apparel, Hardware, Digital, Subscriptions, Bundles, Gift cards) with per-tile gradient art and hover-arrow circles. A dark framework feature band with split copy + four numbered feature cards. A catalog section that renders product cards when seeded and a designed empty state otherwise — dashed-placeholder tile grid + 'Add the first product' copy + a literal `curl POST /admin/products` snippet. · *Product detail page* — Two-column layout with breadcrumb (Shop / Product), a square gallery placeholder showing the product initial as a large semi-transparent mark with an orange radial glow + four thumbnail squares, and an info column with eyebrow + title + description + three status badges (In stock, Ships from origin, Stripe-secured checkout) + a variant table with title / SKU code chip / accent-colored price / qty input + Add to cart button. The mobile breakpoint stacks the gallery above the info column and hides the SKU + Action header columns on narrow viewports. · *Cart / checkout / pay / order page redesigns* — Cart page is a two-column grid: line table on the left (SKU code chip, qty input with Update button, unit + line price, Remove button), sticky Order Summary card on the right (totals list with grand-total row, full-width Continue to checkout CTA, tax + shipping note). Checkout form is a vertical form-stack with grouped uppercase labels, inline country / state / postal row, and a designed Order Summary panel. Pay page hosts the Stripe Payment Element inside a designed card with a full-width Pay button + payment message line. Order confirmation shows a status badge (`In stock`-style chip) + a two-column items / totals layout. Every form input + button + table now uses the shared design tokens. · *Account login / register / dashboard* — Login + register are centered single-column auth cards with the design tokens — eyebrow + title + lede + form-stack + full-width submit + auth-message line + alt link (`New here?` / `Already have one?`). The passkey ceremony JS is unchanged. Dashboard shows a head row (eyebrow + name + lede on the left, Sign-out button on the right) followed by a recent-orders table in a designed body card. · *Search page* — Search now opens with a left-aligned section head (eyebrow + dynamic title + summary + `All products →` shortcut) followed by either the product grid or a dashed-border empty card with a magnifier icon + `We don't carry that yet` + `Browse the full catalog` CTA. The empty state copy adapts to a blank query (`Use the search box in the header to look for a product…`) vs a no-match query (`Nothing in the catalog matched …`). · *Default theme stylesheet — extracted to `themes/default/assets/css/main.css`* — The 346-line inline `<style>` block in the layout is gone. Every design token (palette, type scale, spacing, radius, shadow, motion, container width), reset, atom (eyebrow, link-arrow, btn-primary, btn-ghost, btn-secondary, dot), and component (header, utility bar, cart pill, hero, marquee, collections, framework band, value cards, PDP, cart, checkout, pay, order, auth, account dash, search, 404, admin landing, newsletter, footer) ships from `themes/default/assets/css/main.css` (~12 KB). The layout references it as `<link rel="stylesheet" href="/assets/themes/default/css/main.css?v=<package-version>">`. The Worker's static-asset bridge passes the request straight to R2 with `Cache-Control: public, max-age=300`. Operators override by uploading their own stylesheet to the same R2 key, or by passing `opts.theme_css` to any storefront renderer. · *Worker cold-start envelope + warming-up fallback page* — `worker/index.js` bumps `sleepAfter` from `30s` to `15m` so normal browsing keeps the container warm. The cold-start retry budget grows to roughly 60 seconds (`[0, 2000, 4000, 8000, 16000, 30000]` ms backoff slots) to cover Cloudflare's container provisioning + the framework's boot-time SLH-DSA-SHAKE-256f audit-signing keypair generation. If every retry still hits a closed port, the visitor lands on a designed `Warming up — blamejs.shop` page that auto-refreshes every eight seconds, instead of the SDK's raw `Failed to start container` text. · *Container instance type → `standard-2`* — `wrangler.toml` sets `instance_type = "standard-2"`. The `standard-1` profile (0.5 vCPU) couldn't reliably finish the boot-time SLH-DSA-SHAKE-256f keypair generation inside the worker's retry budget on cold starts; `standard-2` reduces the cold-start window comfortably under the new 60-second budget. **Removed:** *Inline `<style>` block + Google Fonts dependency* — The framework's default `Content-Security-Policy: style-src 'self'` blocks both inline `<style>` blocks and external-host stylesheets. The 346-line layout style block + the `fonts.googleapis.com` + `fonts.gstatic.com` `<link>` tags are gone; the layout now uses a system font stack (`ui-sans-serif, system-ui, -apple-system, …`) so no third-party host needs to be allow-listed in CSP. The theme stylesheet is the single source of truth for typography. · *`style=""` attributes on every storefront renderer* — Twenty-six inline `style=""` attributes across the cart, checkout, pay, order, and account renderers were the other half of the inline-style CSP problem. They're replaced by semantic class hooks (`cart-line__update`, `cart-line__btn`, `form-stack`, `form-row`, `form-row--inline`, `form-field__input--xs`, `pay-card`, `pay-card__element`, `pay-card__submit`, `pay-card__message`, `checkout-summary`, etc.) defined in the theme stylesheet. **Fixed:** *License copy — three places said `MIT` when the framework ships under Apache 2.0* — The utility bar pill, the hero stats row, and the footer copyright line each labelled the project `MIT`. The actual `LICENSE` is Apache 2.0 and `package.json#license` has always been `Apache-2.0`. All three render the correct text now. · *Collection-tile gradients* — The six home-page collection tiles ship per-tile gradient art (orange, charcoal, purple, green, coral, navy) plus a corner-highlight overlay. A later `.collection-card__art` rule was clobbering the per-tile `--N` modifiers via `background-image` shorthand specificity, leaving every tile gray. Tiles now store the gradient as a `--card-gradient` custom property per modifier so both the highlight overlay and the base gradient compose correctly. · *Hover specificity on the on-dark secondary CTA* — The hero's `VIEW ON GITHUB` ghost button rendered with the default dark text (`a.btn-secondary` selector outranked `.btn-secondary--on-dark`) so the text was invisible on the dark hero. The on-dark modifier now uses `a.btn-secondary.btn-secondary--on-dark` to bump specificity, restoring the white text + translucent border. **Migration:** *Theme override path* — Operators replace the default look in one of three ways: (1) upload a replacement `main.css` to R2 at `themes/default/css/main.css` — same key, same `<link>` URL, no code change required; (2) pass `opts.theme_css` to any storefront renderer (`renderHome`, `renderProduct`, `renderCart`, …) to point a request at a different stylesheet URL; (3) register a named theme via the `theme` primitive and let the storefront's `assetUrl("css/main.css")` resolve to `/assets/themes/<name>/css/main.css`. The page-level `?v=<package-version>` cache-buster appends automatically when no explicit `theme_css` is supplied. · *Demo seed* — `wrangler d1 execute blamejs-shop --remote --file=scripts/seed-sample-products.sql` lands four demo products with one active USD price each + initial inventory. Idempotent. Drop the rows with `DELETE FROM products WHERE id LIKE '00000000-0000-7000-8000-%';` when replacing with a real catalog. · *Vendor refresh — blamejs `v0.11.44`* — `scripts/vendor-update.sh blamejs latest` refreshed the vendored framework to `v0.11.44`. Carries the framework's own storefront-test, security-header, csp middleware, and router refinements through to this deploy.

- v0.0.33 (2026-05-21) — **Subscriptions primitive — Stripe-backed recurring billing.** New `subscriptions` primitive composes the Stripe Subscriptions API for recurring billing. Operators model recurring offers as `subscription_plans` (mirroring a pre-created Stripe Price), bind customers to plans via `subscriptions.create`, and let the webhook handler replay `customer.subscription.*` events into the local row. The one-time-order PaymentIntent path is unchanged. **Added:** *Migration `0009_subscriptions.sql`* — Two new tables. `subscription_plans` mirrors a Stripe recurring Price: `stripe_price_id`, `interval` (`day|week|month|year`), `interval_count` (1..12), `currency`, `amount_minor`, `trial_days` (0..730), optional `variant_id` FK (NULL allowed — standalone plans without a catalog variant are valid). `subscriptions` mirrors Stripe's subscription object byte-for-byte: `stripe_subscription_id UNIQUE`, `status` matching Stripe's enum (`trialing|active|past_due|canceled|incomplete|incomplete_expired|unpaid`), `current_period_start/_end`, `cancel_at_period_end`. Indexed by variant, active, customer, status, and stripe id. · *`subscriptions` primitive* — `lib/subscriptions.js` ships `create({ query, payment })` returning `{ plans, subscriptions }`. `plans.{create, get, list, update, archive}` covers CRUD over the catalog-side recurring offer. `subscriptions.create({ customer_id, plan_id, payment_method_id })` POSTs to Stripe via the payment dep, then persists the returned object locally so the shop has an authoritative view without round-tripping to Stripe on every read. `subscriptions.cancel(id, { at_period_end })` routes at-period-end through Stripe's UPDATE (`cancel_at_period_end: true`) while immediate cancel uses DELETE. `subscriptions.byStripeId(stripeId)` is the webhook lookup. `subscriptions.handleStripeEvent(event)` dispatches `customer.subscription.created/updated/deleted` into the local row. · *`payment.subscriptions` extension* — `lib/payment.js` gains a `subscriptions` namespace alongside `createPaymentIntent` / `refund`: `create(input, idempotencyKey)` → `POST /v1/subscriptions`, `retrieve(id)` → `GET`, `update(id, input, idempotencyKey)` → `POST /v1/subscriptions/:id`, `cancel(id, { at_period_end }, idempotencyKey)` → `DELETE` or `POST` with `cancel_at_period_end`. Input validation refuses missing customer / empty items / negative trial period at the entry point so the Stripe round-trip never starts with bad arguments. Existing one-time-PaymentIntent API is unchanged. · *Checkout webhook routing* — `checkout.handleStripeEvent` recognises `customer.subscription.created/updated/deleted` and routes them to `deps.subscriptions.handleStripeEvent` (when wired). The one-time-order `payment_intent.*` + `charge.refunded` event map is unchanged — both paths coexist on the same webhook endpoint, gated by `event.type`. With no `subscriptions` dep wired, subscription events return `{ handled: false, reason: "no-subscriptions-handler" }`. · *Admin routes* — `POST /admin/subscription-plans` creates a plan. `GET /admin/subscription-plans?variant_id=&active=` lists with optional filters. `GET /admin/subscription-plans/:id`, `PATCH /admin/subscription-plans/:id`, `POST /admin/subscription-plans/:id/archive`. `GET /admin/subscriptions?customer_id=&status=` lists subscriptions filtered by customer or Stripe status. `GET /admin/subscriptions/:id`. `POST /admin/subscriptions/:id/cancel` accepts `{ at_period_end }`. Every mutating route writes the standard audit row alongside the existing admin write events. **Detectors:** *Layer 1 — `subscriptions`* — `test/layer-1-state/subscriptions.test.js` exercises plans CRUD (create with + without variant, get, list with variant/active filters, update, archive), subscriptions.create with a stubbed Stripe surface (asserts idempotency key + price + customer flow through), refusal when the plan is archived, cancel at-period-end vs immediate, byStripeId lookup, handleStripeEvent upsert (updated + created replay), handleStripeEvent delete, unknown event types, and checkout routing for `customer.subscription.*` events (with + without subscriptions dep wired). Validation covers every entry point — bad interval, interval_count out of [1,12], trial_days out of [0,730], non-lowercase currency, non-positive amount_minor, malformed variant_id, missing customer_id / plan_id / payment_method_id at subscriptions.create, malformed status filter on list, missing event on handleStripeEvent.

- v0.0.32 (2026-05-21) — **Theme primitive — file-backed templates with fallback resolution.** Operators ship a custom storefront look without forking `lib/storefront.js`. A theme is a directory under `themes/<name>/` holding `layout.html` plus per-page views (`home.html`, `product.html`, `cart.html`, `checkout.html`, `pay.html`, `order.html`, `notfound.html`). When set, the storefront dispatches every render through `b.template`; any view missing from the active theme falls back to the bundled `themes/default/` baseline. The inline-string templates stay in force when no theme is configured. **Added:** *`theme` primitive* — `lib/theme.js` ships `create({ themesDir, name, fallback? })` returning `{ render, exists, assetUrl, name, fallback }`. Each theme owns a `b.template` engine bound to its own directory; render walks the active engine first, falls back to the default engine when the file is missing, and throws `ThemeError` when both miss. Slug-shaped (`^[a-z0-9][a-z0-9-]{0,63}$`) view names and theme names defend against path-traversal via request-derived input. · *Bundled `themes/default/`* — Reference theme matching the prior inline look: `layout.html` (header + nav + `{% block body %}` insertion point), per-page views for home / product / cart / checkout / pay / order / notfound, and `assets/css/main.css` (lifted out of the inline `<style>` block). Operators copy the directory and edit in place to fork the visual identity. · *Storefront integration* — `storefront.mount(router, deps)` accepts `deps.theme`; when set, every route handler renders via the theme's file-backed views. The `renderHome` / `renderProduct` / `renderCart` / `renderCheckoutForm` / `renderPayPage` / `renderOrder` / `renderNotFound` functions all accept an optional `opts.theme` so callers outside the mount path get the same dispatch shape. Absence of `opts.theme` preserves the inline-string templates byte-for-byte — no breaking change for current deploys. · *`SHOP_THEME` env wiring* — `server.js` builds a theme via `bShop.theme.create({ themesDir: process.env.SHOP_THEMES_DIR || "./themes", name: process.env.SHOP_THEME })` whenever `SHOP_THEME` is set. `SHOP_THEME_FALLBACK` overrides the chained fallback name (default `default`). Theme assets ship via R2 at `/assets/themes/<name>/...`; upload once via `wrangler r2 object put` so the Worker's static pass-through serves them with the existing cache headers. **Detectors:** *Layer 1 — `theme`* — `test/layer-1-state/theme.test.js` builds a temp themes directory, exercises active-then-fallback resolution, verifies `assetUrl` shape + traversal refusal, asserts `exists` reports active-OR-fallback hits, and covers the validation matrix (missing themesDir, bad slug, missing active-theme dir, bad viewName at render time).

- v0.0.31 (2026-05-21) — **Inventory low-stock alerts.** Per-SKU low-stock thresholds with optional alert fan-out. When a SKU's available stock (`stock_on_hand - stock_held`) drops below its configured threshold, the framework writes an `inventory_alerts` row, emits a `b.log.warn` line, and (when an outbound-webhooks dep is wired) fans out an `inventory.low_stock` event. The hot-path decrement DO triggers the alert; admin routes set thresholds and list recent fires. **Added:** *`inventoryAlerts` primitive* — `lib/inventory-alerts.js` ships `create({ query, webhooks?, logger? })` returning `{ check, fire, checkAndFire, list }`. `check(sku)` is a pure SELECT that returns `{ alert, available, threshold }`. `fire(sku, available, threshold)` persists a row in `inventory_alerts`, calls `webhooks.send("inventory.low_stock", payload)` when the webhooks dep is wired, and always calls `logger.warn(...)`. `checkAndFire(sku)` is the common-case one-call wrapper. The logger defaults to `b.log` bound with `component: "inventory-alerts"` so emitted lines are filterable in the aggregator. Webhooks and logger sinks are drop-silent on throw — the persisted alert row is the source of truth, observability failures never roll it back. · *`catalog.inventory.setThreshold` + `checkLowStock`* — `catalog.inventory.setThreshold(sku, threshold)` writes the per-SKU threshold (non-negative integer or `null` to disable). `catalog.inventory.checkLowStock(sku)` returns the same `{ alert, available, threshold }` shape as the alerts primitive's `check`. The catalog factory accepts an `onStockChange(sku)` callback that fires after every `inventory.release` / `inventory.restock` so the alerts primitive can hook in without the catalog needing to know about it. · *Migration `0008_inventory_thresholds.sql`* — Adds `low_stock_threshold INTEGER` (nullable) to the existing `inventory` table via `ALTER TABLE`. Creates `inventory_alerts` (`id` TEXT PK uuid7, `sku` TEXT, `available_at_alert` INTEGER, `threshold` INTEGER, `fired_at` INTEGER) with composite indexes on `(sku, fired_at)` and `(fired_at)` for admin listing. The `sku` column is a loose FK — alert rows survive inventory deletion so the audit trail outlives the SKU. · *Worker InventoryLock decrement → alert callback* — After every decrement that succeeds, the DO reads `low_stock_threshold` alongside the stock columns. If post-decrement available is strictly below the threshold, the DO POSTs to a new `/_/low-stock-alert` Worker endpoint (gated by the same `D1_BRIDGE_SECRET` as the SQL bridge). The Worker forwards to the container, which runs the alerts primitive — writes the row, fans out webhooks, emits the log line. Fire-and-forget at every hop so alert delivery never gates the stock hold. · *Admin routes* — `PATCH /admin/inventory/:sku/threshold` (body `{ threshold }`) sets or clears the threshold; the mutation is audited under `shop_admin.inventory.set_threshold`. `GET /admin/inventory/alerts` returns the most recent alerts (defaults to the 100 newest by `fired_at` DESC); optional `?sku=` narrows to a single SKU's history, `?limit=` + `?offset=` page through older alerts. **Detectors:** *Layer 1 — `inventory-alerts`* — `test/layer-1-state/inventory-alerts.test.js` exercises setThreshold + checkLowStock round-trip (no-threshold default, above-threshold, below-threshold, cleared, unknown SKU), below-threshold fire writes the row + logger line + webhook send with the full payload, above-threshold returns null without touching any sink, threshold-null disables the alert path, the webhooks dep is genuinely optional (logger still fires), list pagination with limit + offset and SKU narrowing, validation refusals at every entry point (bad sku / threshold / available / limit / offset / mis-shaped deps), and the catalog `onStockChange` callback firing the alert path on `inventory.release` end-to-end.

- v0.0.30 (2026-05-21) — **Bulk catalog CSV import via admin.** Operators upload a CSV to bulk-create products + variants + prices + inventory in a single request. The admin API gains `POST /admin/catalog/import` (Content-Type: `text/csv`, optional `?dry_run=true`) — rows sharing a `product_slug` collapse to one parent product (first row wins for title / status / description), each row attaches a variant + price + inventory entry, and per-row errors are collected and returned alongside the success counts so the operator can re-upload with fixes. **Added:** *`catalogImport` primitive* — `lib/catalog-import.js` ships `create({ catalog })` returning `{ importCsv }`. Parses through `b.csv` (RFC 4180), content-safety-filters every cell through `b.guardCsv` strict profile (refuses formula-injection cells starting with `=` / `+` / `-` / `@`, bidi overrides, C0 control bytes, null bytes, dangerous-function denylist including `WEBSERVICE` / `HYPERLINK` / `IMPORTXML`), then validates the exact 10-column header before any row is processed. Numeric columns (`variant_weight_grams`, `price_amount_minor`, `inventory_qty`) accept only `/^\d+$/` — trailing garbage like `"100abc"` refuses outright rather than coercing through `parseInt`. Slug + SKU pass through the catalog primitive's existing validators. · *Admin route `POST /admin/catalog/import`* — Accepts `Content-Type: text/csv` body + `?dry_run=true|false` query param. Returns `{ created: { products, variants, prices, inventory_rows }, errors: [{ row_index, message }], rows, dry_run }`. The audit row is written to the `shop_admin.catalog.import` action with the per-run summary (row count, error count, created counts). Mounts only when `catalogImport` is wired into `admin.mount()` deps — operators opt in implicitly by passing the dep. · *`text/csv` body parser* — `server.js` extends the body parser's `text` sub-parser content-type list to include `text/csv` alongside `text/plain` and bumps the limit to 2 MiB so the importer's own 1 MiB cap binds first. Existing JSON / urlencoded / multipart routes are unaffected. **Security:** *Size caps default 1 MiB / 10000 rows* — Configurable per call via `opts.maxBytes` / `opts.maxRows`. Catches accidental wrong-file uploads (an OS image, a database dump) before any parse cycle is spent. Refuses with a `TypeError` rather than a row-error — the whole upload is rejected. · *Not atomic — partial failure leaves partial state* — D1 doesn't expose multi-row transactions cleanly across the HTTP bridge. Inserts run sequentially and rely on the catalog primitive's existing `slug` + `sku` UNIQUE constraints for idempotency. A row that errors midway leaves the rows before it persisted; the operator re-uploads the same CSV to fill in the gaps, and rows that already landed surface as duplicate-slug / duplicate-SKU row-errors on the retry. **Detectors:** *Layer 1 — `catalog-import`* — `test/layer-1-state/catalog-import.test.js` exercises the 3-product / 5-variant happy path against an in-memory SQLite loaded from `migrations-d1/0001_catalog.sql`, multi-variant-per-product de-dupe (first row's title / status / description win), `dry_run: true` returning the same counts without writing, header validation refusing wrong-column-order / short / empty CSV, per-row errors collected without aborting (bad slug / bad SKU / non-numeric cell), duplicate-slug-across-runs surfacing as a row-error without corrupting the first upload, `maxBytes` / `maxRows` refusing outright, and `b.guardCsv` refusing formula-injection cells (`=cmd|/c calc!A1`, `+SUM(...)`, `=WEBSERVICE(...)`).

- v0.0.28 (2026-05-21) — **Customer accounts primitive (passkey-only).** Shoppers register an account with an email + display name, enroll a WebAuthn passkey on their device, and sign in passwordlessly thereafter. The customer row stores the SHA3-512 namespaced hash of the address (never the address itself); the storefront route layer drives the registration + assertion ceremonies, persists per-credential rows with monotonic counter tracking, and seals the authenticated session in an AEAD-encrypted cookie keyed off the vault. **Added:** *Migration `0006_customers.sql`* — Two tables. `customers` carries `id` (TEXT PK uuid7), `email_hash` (TEXT, UNIQUE — `b.crypto.namespaceHash("customer-email", email)`, hex SHA3-512), `display_name` (TEXT, 128-char cap), `created_at`, `updated_at`. `customer_passkeys` carries `id` / `customer_id` (FK CASCADE) / `credential_id` (TEXT UNIQUE, base64url WebAuthn handle) / `public_key` (TEXT, COSE bytes base64) / `counter` (INTEGER, monotonic) / `transports` (comma list) / `created_at`. Indexed by `email_hash` for login lookups and `customer_id` for the per-account credential listing. · *`customers` primitive* — `lib/customers.js` ships `create({ query })` returning `{ register, get, byEmailHash, hashEmail, listPasskeys, addPasskey, getPasskeyByCredentialId, updatePasskeyCounter, removePasskey }`. Every email input runs through `b.guardEmail.validate` at the strict profile (refuses smuggling / header-injection / multi-@ / mixed-script) before the domain is case-folded and hashed; duplicate registrations refuse with `CUSTOMER_DUPLICATE`. `addPasskey` refuses a duplicate `credential_id` with `PASSKEY_DUPLICATE`; `updatePasskeyCounter` refuses a non-monotonic counter with `PASSKEY_COUNTER_REGRESSION` (the WebAuthn L3 clone-detection signal). Counter == 0 is the authenticator-doesn't-track marker and skips the write. · *Order pagination by customer* — `order.listForCustomer(customerId, { limit, cursor })` returns the customer's history ordered by `updated_at DESC, id DESC`. The cursor format matches `catalog.products.list` — HMAC-tagged via `b.pagination`, refuses orderKey-mismatched or tamper-bit-flipped values. Order rows arrive hydrated with `ship_to` + `lines` so the account dashboard avoids a per-row second trip. The HMAC secret is `opts.cursorSecret` (required in production, dev placeholder otherwise — same posture as `catalog`). · *Storefront account routes* — `GET /account/login` and `GET /account/register` render the email / display-name forms with an inline `<script>` that drives `navigator.credentials.get` / `.create` from the JSON challenge endpoints. `POST /account/passkey/register-begin` + `/register-finish` and `/login-begin` + `/login-finish` carry the WebAuthn options + verify the attestation / assertion via `b.auth.passkey.verifyRegistration` / `verifyAuthentication`. The login flow merges any anonymous cart into the now-customer-owned cart via `cart.setCustomer` before issuing the session. `GET /account` renders the customer name + the last 10 orders via `order.listForCustomer`. `POST /account/logout` clears the session cookie. · *Sealed authenticated-session cookie* — Cookie name `shop_auth`. The payload is an AEAD-encrypted envelope (`b.vault.seal` — XChaCha20-Poly1305 keyed off the deployment's vault KEK) holding `{ customer_id, exp }` with a 14-day TTL. Attributes are `HttpOnly; Secure; SameSite=Lax; Path=/`. The challenge round-trip uses a sibling `shop_auth_chal` cookie that's also vault-sealed, Path=/account scoped, and expires after 5 minutes. A rotated vault invalidates every outstanding authenticated session — operators get logout-everywhere without a separate revocation list. **Detectors:** *Layer 1 — `customers`* — `test/layer-1-state/customers.test.js` covers register + get round-trip, email-hash uniqueness, case-fold collision (same address with capital-letter domain refuses), strict-profile validation (multi-@ / header-injection refusal), passkey enroll + list + remove, duplicate-credential refusal across customers, and counter monotonic enforcement including the WebAuthn 0-counter no-op case. · *Layer 1 — `order.listForCustomer`* — `test/layer-1-state/order.test.js` gains a `_listForCustomer` cluster: rows arrive hydrated with `lines` + `ship_to`, ordering is `updated_at DESC` (a transitioned order surfaces above newer rows), pagination round-trips the HMAC-tagged cursor, a tampered cursor refuses, customer scoping excludes other customers' orders, and bad `limit` values refuse.

- v0.0.24 (2026-05-21) — **Design-system foundation for the storefront.** The default theme's layout shell is rebuilt around a published token system. Operators forking the storefront now compose against CSS custom properties for palette, typography, spacing, radius, shadow, and motion — page renderers no longer redeclare values inline. The header gains a search form and the layout ships a brand-tinted footer. **Changed:** *Storefront `LAYOUT` exposes a CSS token system* — `:root` now declares the palette (`--ink`, `--ink-2`, `--mute`, `--hair`, `--paper`, `--bg`, `--accent`, `--accent-d`, `--accent-l`, `--danger`), a typography scale (`--font-display`, `--font-body`, `--font-mono`, `--text-xs` through `--text-3xl`, `--lh-tight`/`--lh-snug`/`--lh-body`), a 4px-base spacing scale (`--space-0` through `--space-8`), radius tokens (`--radius-sm`/`--radius-md`/`--radius-lg`/`--radius-pill`), shadow tokens (`--shadow-sm`/`--shadow-md`/`--shadow-lg`), and motion tokens (`--ease-out`, `--duration-fast`, `--duration-mid`). Forks override at `:root` instead of patching individual rules. · *Header gains a search form* — The sticky header now renders a `GET /search` form alongside the brand and cart pill (`<input type="search" name="q">`). The form posts to a route the catalog primitive will own; until that route lands the search button returns a `Not found` page. Mobile breakpoint wraps the search input onto its own row. · *Site footer* — Layout ships a brand-tinted footer carrying the shop name, a one-line tagline ("server-rendered HTML, zero npm runtime dependencies, security defaults on"), a primitive list (Built on blamejs · Server-rendered · PQC-first), and a copyright with the current UTC year. The wrapper layout adds a flexbox column on `<body>` so the footer pins to the viewport bottom on short pages. · *Accessibility + motion defaults* — Layout reset adds a skip-link to `#main`, `:focus-visible` outlines (2px accent, 2px offset) on every interactive element, ARIA labels on the cart pill + primary nav, and wraps every transition behind `@media (prefers-reduced-motion: no-preference)`. Price + total cells render in `font-variant-numeric: tabular-nums` so column alignment holds across digit widths. · *Shared building-block classes* — The layout `<style>` block defines `.grid`, `.card`, `.btn`, `.btn-secondary`, `.summary-table`, `.empty`, `.price`, `.total`, `.cart-pill`, and `.hero` against the new tokens so per-page renderers (home / product / cart / checkout / pay / order) compose them instead of redeclaring colors + spacing. **Detectors:** *Layer 1 — `storefront._layoutTokens`* — `test/layer-1-state/storefront.test.js` gains an assertion cluster that pins every required CSS custom property (palette, typography scale, spacing scale, radius, shadow, motion), the reset + a11y hooks (`box-sizing`, `:focus-visible`, `prefers-reduced-motion`, `tabular-nums`, `id="main"`), the shared classes (`.grid`/`.card`/`.btn`/`.btn-secondary`/`.summary-table`/`.empty`/`.cart-pill`/`.hero`), the search form shape (`action="/search"`, `method="get"`, `name="q"`, `type="search"`), and the footer (primitive list + copyright).

- v0.0.23 (2026-05-21) — **Storefront purchase flow — full HTTP integration test.** The smoke gate now boots a real HTTP server, seeds a product, and walks the storefront purchase flow end-to-end (home → product detail → add-to-cart → cart view → line update → line remove). A failure anywhere in the storefront route mount, the body parser, the cart session cookie, or the cart/catalog SQL surfaces in CI instead of in a production rollout. **Added:** *Layer 2 integration suite* — `test/smoke.js` already walked `test/layer-2-integration/` when present; this release populates that directory with the first layer-2 test (`storefront-flow.test.js`). The smoke orchestrator picks it up automatically — no change to the smoke gate itself. · *`storefront-flow.test.js` — end-to-end HTTP purchase flow* — Boots `b.createApp` on an ephemeral 127.0.0.1 port with the storefront routes mounted exactly as `server.js` mounts them in pure-storefront mode. Catalog + cart wire against an in-memory `node:sqlite` database loaded from the live `0001_catalog.sql` + `0002_cart.sql` migrations so the test exercises the same SQL the live D1 sees. Vault, local DB at-rest, and audit-signing all opt into plaintext mode (the temp data dir is removed at teardown); the boot-time SNTP drift check is suppressed via `BLAMEJS_SKIP_NTP_CHECK=1` so the test never touches the network. The flow walks `GET /`, `GET /products/:slug`, `POST /cart/lines`, `GET /cart`, `POST /cart/lines/:id/update`, `GET /cart`, `POST /cart/lines/:id/remove`, `GET /cart` — asserting on HTTP status, redirect targets, body shape, the `shop_sid` session cookie, line totals, and the empty-cart state. The server is shut down via `app.shutdown()` so the port releases cleanly. · *HTTP integration helpers in `test/helpers/index.js`* — Two new helpers usable by every layer-2 test: `cookieJar()` returns a minimal in-memory jar that captures `Set-Cookie` headers and replays them as a single `Cookie` header on subsequent requests (parses `Max-Age=0` for explicit eviction; ignores `Secure` since loopback traffic is plaintext by design). `httpRequest({ port, path, method, headers, form, body, jar })` wraps `node:http.request` in a promise that resolves to `{ status, headers, body }` — `body` is a UTF-8 string. Form-encoded POSTs are the dominant storefront shape; pass `opts.form` as a plain object and the helper sets the content-type and serializes the body. Default headers (User-Agent, Accept-Language, Sec-Fetch-Mode) match a realistic browser shape so tests don't trip the framework's bot-guard heuristics in setups that keep it enabled.

- v0.0.22 (2026-05-21) — **Analytics primitive + admin dashboard.** Operator-facing read-only aggregates over the orders table: window summary (order counts + net revenue + per-status buckets), revenue-by-day, top SKUs by units sold, and recent orders. Surfaced as JSON under `/admin/analytics/*` and rendered as a single-page server-rendered HTML dashboard at `/admin/dashboard`. Revenue counts `cancelled` orders at zero and subtracts `refunded` orders so the headline number tracks what reached the bank. **Added:** *`analytics` primitive* — `lib/analytics.js` ships `create({ query })` returning `{ summary, revenueByDay, topSKUs, recentOrders }`. Each method takes an `{ since, until }` window (epoch milliseconds, defaults to last 30 days, refused above 1 year span). `summary` returns `{ currency, total_orders, total_revenue_minor, by_status }` per currency — a single object for single-currency windows, an array when multiple currencies are in scope. `revenueByDay` groups by `date(updated_at/1000, 'unixepoch')` so the bins are calendar-aligned UTC days. `topSKUs` joins `order_lines` to `orders` for the window filter and excludes cancelled + refunded orders from the units count. `recentOrders` returns the most-recent N orders for the activity feed. Revenue policy: `cancelled` excluded entirely, `refunded` subtracts, every other status adds at face value. · *Admin analytics routes* — `lib/admin.js` mounts `GET /admin/analytics/summary`, `GET /admin/analytics/revenue-by-day`, `GET /admin/analytics/top-skus`, `GET /admin/analytics/recent-orders` returning JSON, plus `GET /admin/dashboard` returning a server-rendered HTML page. Routes only register when `deps.analytics` is supplied — operators opt in by constructing the primitive and passing it into `admin.mount`. The dashboard is `noindex,nofollow` and bearer-token gated alongside the existing admin surface. Query-param validation refuses non-integer `since` / `until` / `limit` with a 400. · *Server-rendered HTML dashboard* — `admin.renderDashboard` lays out an eight-card stat grid (orders + net revenue + the six FSM status buckets), an inline SVG sparkline of revenue-by-day, a top-SKUs table, and a recent-orders activity panel with status pills. Matches the storefront brand palette (`#191919` ink, `#fa4f09` accent, Montserrat headlines, Inter body). Multi-currency operators see a secondary `Other currencies in window` block with per-currency totals. Zero client-side JavaScript — every value is rendered server-side and HTML-escaped through a strict `{{var}}` substitution that refuses unknown / unused placeholders. **Detectors:** *Layer 1 — `analytics`* — `test/layer-1-state/analytics.test.js` seeds rows across pending / paid / fulfilling / refunded / cancelled statuses + multiple UTC days + multiple currencies, then asserts: `summary` returns the per-status bucket counts with refunded subtracting and cancelled excluded from revenue; the empty-window case returns a zero-filled USD shape; the multi-currency window returns an alphabetically-sorted array. `revenueByDay` groups by UTC calendar day, sorts day ASC, applies the refund-subtracts / cancelled-excluded policy. `topSKUs` aggregates units across multiple orders, sorts by units_sold DESC, excludes cancelled order lines, honors LIMIT. `recentOrders` orders by created_at DESC with the right row shape. Validation covers non-integer `since` / `until`, `since >= until`, windows > 1 year, and limits outside `[1, 100]`. Admin-route smoke covers the bearer gate, JSON route shapes, HTML dashboard shape (DOCTYPE + SVG + status pill + escaped shop name), and the bad-param 400 path.

- v0.0.21 (2026-05-21) — **Outbound merchant webhooks.** Operators register HTTPS endpoints to receive order-state-change events. Each delivery is signed via `b.webhook.signer` using a per-endpoint secret (returned ONCE at create); receivers verify with the framework's standard `Webhook-Signature` header recipe. The order primitive accepts an optional `webhooks` dependency and fans every transition out to subscribed endpoints automatically. **Added:** *Migration `0005_webhooks.sql`* — Two tables: `webhook_endpoints` (id, url, secret, events allowlist or `*`, active flag, created_at/updated_at; index on active) and `webhook_deliveries` (id, endpoint_id FK with cascade, event_type, payload_json, attempts, last_status, last_error, delivered_at, created_at; composite index on endpoint_id+created_at). The secret is generated by the framework at create time and returned ONCE — operators record it for verification; subsequent reads never reveal it. · *`webhooks` primitive* — `lib/webhooks.js` ships `create({ query, transport?, retry? })` returning `{ endpoints: { create, get, list, update, delete }, deliveries: { list, retry }, send(eventType, payload), KNOWN_EVENTS }`. `send` queries active endpoints, filters by event subscription (`*` or comma-list), signs each delivery via `b.webhook.signer({ algo: "hmac-sha3-512", keys: { v1: <secret> } })`, persists the delivery row before the POST, and updates status (1xx-3xx = delivered, 4xx/5xx or transport error = failed with `last_error`). Transient transport errors (ECONNRESET / ECONNREFUSED / etc.) retry once via `b.retry.withRetry`. Idempotency key bound into the signed string is a fresh `b.uuid.v7()` per delivery so manual retries replay safely on the consumer side. · *URL + event validation* — `endpoints.create` refuses any non-HTTPS URL via `b.safeUrl.parse({ allowedProtocols: ["https:"] })` so an operator typo (`http://`) surfaces at admin-write time rather than leaking cleartext deliveries. `events` accepts the wildcard `*` or a comma-separated allowlist drawn from the recognized event types (`order.mark_paid`, `order.start_fulfillment`, `order.mark_shipped`, `order.mark_delivered`, `order.cancel`, `order.refund`); unknown event types are refused. · *Order transition fan-out* — `lib/order.js`'s `create({ query, webhooks? })` accepts an optional `webhooks` dependency. When present, every successful `transition()` call fires `webhooks.send("order." + event, { order, transition })` after the transition persists. Delivery failures never roll back the transition — the failure already lives in `webhook_deliveries.last_error` for operator review. Backward-compatible default of `null` so existing callers (and the test suite) keep working without a webhooks instance. · *Admin routes* — `lib/admin.js` mounts `POST /admin/webhooks` (returns the row including the one-time secret), `GET /admin/webhooks`, `PATCH /admin/webhooks/:id` (url / events / active), `DELETE /admin/webhooks/:id`, `GET /admin/webhooks/:id/deliveries?limit=`, and `POST /admin/webhooks/deliveries/:id/retry` for manual re-delivery. Every mutation writes to the existing `shop_admin` audit namespace. **Detectors:** *Layer 1 — `webhooks`* — `test/layer-1-state/webhooks.test.js` exercises endpoint CRUD, URL validation (refuses http://, ftp://, malformed), event-type allowlist enforcement, signing round-trip (verifies the emitted `Webhook-Signature` against `b.webhook.verifier`), event filtering (single-event vs wildcard subscriber + inactive endpoint skip), failure persistence (5xx leaves `last_error` set + no `delivered_at`), retry on transient transport error (single retry via `b.retry.withRetry`), manual retry endpoint (failure row flips to delivered + attempts bumped), and order-transition fan-out (a `mark_paid` transition fires a `order.mark_paid` delivery against the wired-in dispatcher).

- v0.0.20 (2026-05-21) — **Catalog product search — storefront + admin.** Customers and operators can now search the product catalog by free-text query. The storefront header gains a search box that routes to `GET /search`, and the admin API gains `GET /admin/products/search?q=` for draft + archived visibility. The matcher runs case-insensitive `LIKE` against `title` and `description`, escaping the `%` / `_` LIKE wildcards so a query like `100%` matches only the literal substring. **Added:** *`catalog.products.search({ q, status?, limit?, cursor? })`* — Lowercased substring match against `title` + `description` via SQLite `LIKE ... ESCAPE '\'`. The user-supplied `q` is trimmed, capped at 200 chars, and has `%` / `_` / `\` escaped before binding so wildcard characters in the query never widen the match. Results are sorted `updated_at DESC, id DESC` and paginated through the same HMAC-tagged tuple cursor that `products.list` uses — operators can hand-roll their own admin search UIs on top without re-implementing keyset pagination. Empty / whitespace-only `q` returns `{rows: [], next_cursor: null}` rather than throwing, so storefront and admin route handlers can render an empty-state page without a try/catch. · *Storefront search route + header form* — `GET /search?q=<query>` calls `catalog.products.search({ q, status: "active", limit: 24 })` and renders the new `renderSearch(opts)` page. The shared layout header gains a `<form action="/search" method="get">` input that pre-fills with the current query on the results page. The query is HTML-escaped through the strict template engine so a reflected `<script>` payload lands as escaped text inside both the summary line and the input's `value` attribute. · *Admin search route* — `GET /admin/products/search?q=&limit=&cursor=` returns the full `{rows, next_cursor}` page with no status filter — admins see draft + archived products alongside active ones so a mis-published slug is findable before promotion. The route is bearer-token gated under the existing `ADMIN_API_KEY` and slices `q` to 200 chars before handing to the primitive. **Detectors:** *Layer 1 — `catalog._search`* — `test/layer-1-state/catalog.test.js` covers: title match, description match, case-insensitivity in both directions, empty / whitespace-only query returning empty rows, LIKE-metacharacter escape (`100%` matches only the literal `100%` product, `_idget` returns zero rows), status filter behavior (no-status returns draft + archived), pagination round-trip with cursor distinctness, and TypeError shape for non-string / over-length / bad-status / bad-limit input. · *Layer 1 — `storefront._search`* — `test/layer-1-state/storefront.test.js` covers `renderSearch` output shape (header, summary, product grid, pre-filled input), singular vs plural summary, empty-state copy distinguishing blank `q` from `q with no matches`, and reflected-XSS escape on a `<script>`-bearing query.

- v0.0.19 (2026-05-21) — **Media upload admin route — container → Worker → R2.** Admin API gains `POST /admin/media/upload` so operators can attach product imagery without pre-uploading to R2 via the wrangler CLI. The container fetches the source URL through `b.httpClient` (SSRF gate, retry, TLS 1.3 minimum), validates the served content-type matches the operator's declared one, generates a UUIDv7 key under `media/`, pushes the bytes to a new Worker R2 bridge, then records the media row via `catalog.media.attach`. The Worker holds the R2 binding so the container never carries an R2 API token; the upload bridge is gated by the same shared secret the D1 bridge uses. **Added:** *`r2Bridge` — container → Worker upload adapter* — `lib/r2-bridge.js` ships `create({ bridgeUrl, bridgeSecret, bridgePath?, timeoutMs?, httpClient? })` returning `{ put(key, body, contentType) }`. POSTs to the Worker's `/_/r2/put` endpoint with `application/octet-stream` body, `x-d1-bridge-secret` auth header, `x-r2-key` + `x-r2-content-type` metadata headers. Composes on `b.httpClient.request` so every outbound call inherits the SSRF gate, retry policy, circuit breaker, ALPN HTTP/2 transport, and TLS 1.3 minimum. Returns `{ ok, key, size }` on success; surfaces Worker error envelopes + HTTP 4xx/5xx + timeouts as typed throws (`R2_HTTP_<status>`, `R2_TIMEOUT`, `R2_BAD_RESPONSE`). · *Worker `/_/r2/put` endpoint* — `worker/index.js` accepts POST on the configurable `R2_BRIDGE_PATH` (default `/_/r2/put`). Same constant-time `D1_BRIDGE_SECRET` gate as the SQL bridge. Re-validates the `x-r2-key` shape (no leading `/`, no `..` segments, ≤ 1024 chars) + the `x-r2-content-type` matches the RFC 7231 `type/subtype` grammar (≤ 255 chars) so a compromised secret can't write arbitrary paths or smuggle control bytes into the bucket's object metadata. Writes via `env.ASSETS.put(key, body, { httpMetadata: { contentType } })`. Returns `{ ok, key, size }` JSON; refuses empty bodies + missing R2 binding with descriptive 4xx/503 envelopes. · *`POST /admin/media/upload` admin route* — `lib/admin.js` mounts the upload route when the operator wires an `r2_bridge` instance. Body shape: `{ product_id?, variant_id?, content_type, source_url, alt_text?, position?, width?, height? }`. Fetches `source_url` via `b.httpClient.request` (SSRF gate refuses cloud-metadata + RFC 1918 + loopback destinations), refuses content-type mismatches between the declared MIME and the served MIME with 422, generates a UUIDv7 key under `media/<id>.<ext>` (extension inferred from the declared content-type), uploads via the bridge, then calls `catalog.media.attach` to record the row. Returns the media row plus `asset_url` (the public `/assets/...` URL operators preview in the admin UI). The route is omitted entirely when no bridge is wired — operator opts in by setting `D1_BRIDGE_URL` + `D1_BRIDGE_SECRET`. · *`server.js` wires the bridge into the admin mount* — The application boot constructs an `r2Bridge` instance when `D1_BRIDGE_URL` + `D1_BRIDGE_SECRET` are present (the same env pair that wires the D1 backend), then hands it to `admin.mount` as `deps.r2_bridge`. The Worker passes `R2_BRIDGE_PATH` through to the container env alongside `D1_BRIDGE_PATH` so an operator can move the upload endpoint without touching the container code. **Detectors:** *Layer 1 — `r2-bridge`* — `test/layer-1-state/r2-bridge.test.js` exercises factory validation (every required + optional opt), put() happy path (URL+path concat, method, every header, octet-stream content-type, Buffer body), custom bridgePath, Worker error envelope as typed throw, HTTP 5xx with message surfaced, malformed-JSON response as `R2_BAD_RESPONSE`, AbortError → `R2_TIMEOUT`, network error preserves the original code, put() argument validation (empty key, absolute path, parent-segment escape, missing body, empty content-type, malformed MIME), and string + Uint8Array body encoding to the wire Buffer.

- v0.0.18 (2026-05-21) — **Storefront tax + shipping read from `shop_config`.** The storefront mount stops hardcoding empty tax rules + a single $0 standard shipping service and instead reads `tax.rules`, `shipping.services`, and `shipping.default_id` from the `config` primitive on every checkout. Operators set these via `PUT /admin/config/:key` and the next checkout picks up the new values (modulo the config primitive's 30s read cache); zero-rate tax + a `std` $0 shipping zone remain the documented defaults when no row is present. **Changed:** *`server.js` wires config-backed tax + shipping into the storefront checkout* — The storefront mount now constructs a single `config = bShop.config.create({})` instance at boot and hands the storefront a `tax` adapter + `shipping` adapter whose `calculate(ctx)` / `rates(ctx)` methods rebuild a fresh `bShop.tax.create({ rules })` / `bShop.shipping.create({ services })` from the latest config rows on each call. `selected_shipping_id` likewise resolves from `shipping.default_id` per request via an async resolver passed as `deps.default_shipping_id`. The admin mount shares the same config instance so writes invalidate the cache the storefront reads. · *`storefront.checkout` accepts an async `default_shipping_id` resolver* — `POST /checkout` now `await`s `deps.default_shipping_id()` when the dep is a function, falling back to treating it as a literal string otherwise. Existing operators passing a string keep working; the new shape lets the resolver re-read `shipping.default_id` from config without a container restart. **Detectors:** *Layer 1 — `storefront-config-wiring`* — `test/layer-1-state/storefront-config-wiring.test.js` exercises the config-backed wrappers against an in-memory `shop_config` table: seeds `tax.rules` + `shipping.services` + `shipping.default_id`, asserts the wrapped adapters return the operator-configured values, and confirms zero-rate defaults kick in when the config table is empty.

- v0.0.17 (2026-05-20) — **Shop config primitive + admin routes.** Operator-tunable runtime configuration keyed by name with JSON-shaped values. Backed by a new `shop_config` table in D1. Admin API gains `GET/PUT/DELETE /admin/config/:key` + `GET /admin/config?prefix=` so operators set tax rules, shipping services, brand name, support email, and any other runtime tunable without re-deploying. **Added:** *Migration `0004_shop_config.sql`* — Single key/value table: `key` (TEXT PRIMARY KEY, matches `^[a-z][a-z0-9._]{0,63}$`), `value_json` (TEXT, ≤ 64 KiB after JSON.stringify), `version` (INTEGER, bumped on every UPSERT), `updated_at` (epoch ms). Indexed by `updated_at` for change-feed semantics later. · *`config` primitive* — `lib/config.js` ships `create({ query })` returning `{ get, getFresh, put, delete, list }`. Bounded 30s in-memory cache on reads; writes + deletes invalidate the entry. Every value round-trips through `JSON.parse` at write so a corrupted blob surfaces at admin-write time rather than at customer-checkout. `list(prefix)` filters by key prefix and sorts by key for stable admin paging. · *Admin routes* — `GET /admin/config?prefix=`, `GET /admin/config/:key`, `PUT /admin/config/:key` (body: `{ value }`), `DELETE /admin/config/:key`. The PUT mutation writes a row to the `shop_admin.config.put` audit namespace alongside the existing admin write events. **Detectors:** *Layer 1 — `config`* — `test/layer-1-state/config.test.js` exercises put+get round-trip for primitives / arrays / nested objects, default-value fallback on miss, version bump on second put, prefix-filtered list with stable sort, delete + idempotent re-delete, cache invalidation on put/delete (cache returns NEW value), and corrupted-at-rest row surfacing as a typed error. Validation covers bad key shape, value > 64KiB, undefined value, and non-string prefix.

- v0.0.16 (2026-05-20) — **`pricing.format` composes upstream `b.money`.** The currency-rendering path on every storefront / email / order page now flows through the framework's Money class (`b.money.fromMinorUnits(bigint, currency).format(locale)`) instead of constructing `Intl.NumberFormat` inline. Output is byte-identical for every supported currency (USD / EUR / GBP / JPY zero-decimal all match prior renders); the win is framework-policy alignment — Money owns ISO 4217 currency exponents and locale-correct fraction-digit selection across the entire blamejs ecosystem. **Changed:** *`pricing.format` swaps inline `Intl.NumberFormat` for `b.money`* — `lib/pricing.js` now coerces the `Number` minor-unit at the boundary into a `BigInt`, hands it to `b.money.fromMinorUnits(bigint, currency)`, and calls `.format(locale)`. The upstream Money primitive carries the ISO 4217 catalog (code → exponent) so JPY (exp=0), KRW (exp=0), and JOD (exp=3) all render correctly without us re-implementing the table. The pricing primitive's external API (`Number` minor units) is unchanged — every existing consumer (cart, checkout, order, storefront, email) keeps its current call shape.

- v0.0.15 (2026-05-20) — **README refresh — full primitive surface documented.** The README's `What ships` section now reflects every primitive landed since the foundation patch: catalog, cart, pricing, tax, shipping, payment, order, checkout, email, storefront, admin. Includes a `Operator quick-start` block walking through `wrangler login` → resource provisioning → secret setting → migration apply → deploy → seed-a-product. **Changed:** *README documents every primitive* — The `What ships` section is now split into Platform / Commerce primitives / Migrations applied to D1 / Tests subsections. Each primitive entry lists its module path, its function, and the composed blamejs surface it relies on. The Operator quick-start block carries the canonical wrangler one-time setup + deploy + seed-a-product command sequence so an operator landing on the repo has an unambiguous path from clone to live shop.

- v0.0.14 (2026-05-20) — **Worker container cold-start retry — 3 attempts, exponential backoff.** Container cold-start can still surface `Failed to start container: The container is not running` when the underlying SDK's default 8-second port-wait window is exceeded under provisioning load. The single-retry-with-1.5s-backoff shipped in v0.0.9 wasn't always enough; this patch ups it to three retries with 2s / 4s / 8s backoff (~14 seconds total patience for the cold-start window). **Changed:** *Vendor refresh blamejs v0.11.36 → v0.11.37* — Caught up to upstream. **Fixed:** *`worker/index.js` cold-start retry — 3 attempts* — `_forwardToContainer` now retries up to three times on `Failed to start container` / `no Container instance` 5xx responses with 2s / 4s / 8s exponential backoff. The first attempt is immediate; legitimate 5xx (non-cold-start body shapes) short-circuit so application errors still surface immediately. POST bodies buffer once on entry and re-serve from the ArrayBuffer on each retry; GET / HEAD have no body and clone cheaply.

- v0.0.13 (2026-05-20) — **Cart line mutations — update qty + remove.** Cart page becomes interactive: every line shows an inline qty input + Update button and a Remove button. POST routes wire to `cart.updateLine` and `cart.removeLine`. A Checkout call-to-action button anchors the cart summary so customers can advance through the flow without typing a URL. **Added:** *Editable cart lines* — `lib/storefront.js` now ships `CART_LINE_EDITABLE` — replaces the read-only line template with one that includes an inline `<form method="post" action="/cart/lines/:id/update">` carrying the qty input and a separate `<form method="post" action="/cart/lines/:id/remove">` for line removal. HTML forms only support GET/POST so the verb lives in the path (no `_method` override hack). · *POST /cart/lines/:id/update + /remove* — Two new route handlers in `storefront.mount`. Update validates qty (1..99) + calls `cart.updateLine(lineId, { qty })`; returns 404 when the line is unknown. Remove calls `cart.removeLine(lineId)`. Both 303 redirect to `/cart` on success so a refresh of the result doesn't replay the mutation. Errors from the cart primitive map to 400 (TypeError) or 500 (anything else). · *Checkout CTA on the cart page* — Cart summary now anchors a `<a class="btn" href="/checkout">Checkout →</a>` button so customers can progress without typing the URL. The button inherits the accent color from the default theme — same affordance as the in-page Update/Add-to-Cart buttons. · *Vendor refresh blamejs v0.11.35 → v0.11.36* — Caught up to upstream.

- v0.0.12 (2026-05-20) — **End-to-end checkout flow — shipping form, Stripe Elements, order confirmation.** Storefront completes the cart → checkout → pay → confirmation loop. The shipping form collects email + name + ISO 3166 country/state/postal, hands the values to `checkout.confirm`, persists the order in `pending`, and redirects to a Stripe Elements payment page with the per-order `client_secret`. After payment, Stripe redirects back to a server-rendered order confirmation page that shows status + lines + totals. **Added:** *Checkout flow routes* — `lib/storefront.js` mounts `GET /checkout` (shipping form pre-filled with cart subtotal), `POST /checkout` (calls `checkout.confirm` with idempotency key + redirects to `/pay/:order_id` with a short-lived `shop_pay` cookie scoped Path=/pay/ + SameSite=Strict carrying the `client_secret`), `GET /pay/:order_id` (renders Stripe Elements payment page — loads Stripe.js from `js.stripe.com/v3/`, mounts the Payment Element, redirects to the order page on success via Stripe's `return_url`), and `GET /orders/:order_id` (server-rendered confirmation showing status, line items, and the subtotal/tax/shipping/total breakdown). · *Wired through `server.js` when Stripe is configured* — The storefront mount picks up `checkout` / `payment` / `order` / `tax` / `shipping` / `default_shipping_id` / `stripe_publishable_key` deps when `STRIPE_API_KEY` + `STRIPE_WEBHOOK_SECRET` are set. The default-fallback tax + shipping are zero-rate / zero-cost (operator-configured proper rules in `wrangler.toml` `[vars]` or a config primitive land later). Without Stripe configured, the storefront stays browseable (home / product / cart) but the checkout routes don't mount. · *Worker env passthrough* — `worker/index.js` forwards `STRIPE_PUBLISHABLE_KEY` into the container env alongside the existing `STRIPE_API_KEY` + `STRIPE_WEBHOOK_SECRET` + `ADMIN_API_KEY` set. **Detectors:** *Layer 1 — storefront checkout renderers* — `test/layer-1-state/storefront.test.js` gains `_checkoutForm` (form action + field shape + subtotal rendering), `_payPage` (Stripe.js loaded + pk + client_secret injected as JSON literals + total rendered + order id surfaced), and `_orderPage` (status + line + tax + total surfaced) tests. Six new assertion clusters.

- v0.0.11 (2026-05-20) — **Admin API — bearer-token-gated CRUD.** Single-bearer-token admin surface composed on `b.crypto.timingSafeEqual`, `b.problemDetails`, and `b.audit`. Operators set `ADMIN_API_KEY` as a Worker secret and use it as the bearer for every `/admin/*` route. Coverage: products + variants + prices + inventory + media CRUD, order get + transition, Stripe-backed refund flow that fires the order FSM refund transition automatically. Multi-admin passkey-enrolled surface lands alongside the admin browser UI in v1.x. **Added:** *`admin` — bearer-token admin API* — `lib/admin.js` exposes `mount(router, deps)` that wires `/admin/*` routes behind a `Bearer` token check. Token comparison uses `b.crypto.timingSafeEqual` so a side-channel timing attack can't recover the value byte-by-byte. Length-mismatch short-circuits before the constant-time compare (the compare requires equal lengths). Every mutation writes an audit row to namespace `shop_admin` via `b.audit.emit`; failures are RFC 9457 problem documents via `b.problemDetails.send`. TypeError from a primitive surfaces as 400; any other thrown error as 500. · *Catalog + order + refund coverage* — Routes shipped in v1: `POST/GET/PATCH /admin/products`, `POST /admin/products/:id/archive|restore`, `POST/PATCH/DELETE /admin/variants`, `POST/GET /admin/variants/:id/prices`, `POST /admin/inventory` + `POST /admin/inventory/:sku/restock`, `POST/DELETE /admin/media`, `GET /admin/orders/:id`, `POST /admin/orders/:id/transition`, `POST /admin/orders/:id/refund` (when payment + Stripe API key are wired — the refund call goes through `payment.refund` with a stable idempotency key derived from the order id, then fires `order.transition(id, "refund", ...)` so the FSM advances atomically with the Stripe side). · *Wired into `server.js`* — Admin routes mount only when `ADMIN_API_KEY` is present in env — operators opt in by setting the secret (`wrangler secret put ADMIN_API_KEY`). The Worker passes the secret through to the container at start. Stripe-backed refund routes additionally require `STRIPE_API_KEY` + `STRIPE_WEBHOOK_SECRET`. **Detectors:** *Layer 1 — `admin`* — `test/layer-1-state/admin.test.js` exercises the bearer gate (missing → 401, wrong-same-length → 401, wrong-different-length → 401 short-circuit, right → 200, lowercase `bearer` accepted), product create + update + archive round-trip, order transition round-trip + illegal-transition surface as 4xx, and factory validation. Uses an in-memory fake router that supports `:param` route registration so the path matching matches what the real `b.router` does.

- v0.0.10 (2026-05-20) — **Storefront visual upgrade — brand identity + theme palette.** The default storefront theme adopts the framework's brand identity: the `blamejs.shop` logo served from R2, a monochrome-plus-accent palette (`#191919` / `#fa4f09` / `#ffffff`) sourced from the reference ecommerce templates shipped under `.template/`, Montserrat headlines and Inter body text. The home page gains a hero section that frames the framework's identity. The CSS is still inline; the theme primitive that lifts these into a swappable directory ships in v1.x. **Added:** *Brand identity in the default theme* — `lib/storefront.js`'s LAYOUT template now references `/assets/brand/logo.png` (served from the R2 bucket the Worker is bound to) and renders the wordmark next to the logo image in the site header. The logo PNG is uploaded once via `npx wrangler r2 object put blamejs-shop-assets/brand/logo.png --file=.template/blamejs.shop_logo.png --content-type=image/png --remote`; the source asset stays local-only under `.template/` (gitignored). · *Palette + typography from the reference templates* — The default theme palette derives from the `odor-buyer-file` reference template under `.template/`: `--ink: #191919`, `--ink-2: #414141`, `--mute: #727272`, `--hair: #d9d9d9`, `--accent: #fa4f09`, `--accent-d: #d8410a`, `--paper: #ffffff`, `--bg: #fafafa`. Headlines render in Montserrat (700), body text in Inter (400-600), both from Google Fonts with `preconnect` hints. Buttons, links, and the cart pill use the accent color for interaction state. · *Hero on the home page* — `renderHome` now prepends a hero section above the product grid — communicates the framework identity ("An open-source shop, built on blamejs") to first-time visitors and gives the page presence when the catalog is empty. The hero is part of the default theme and ships in the same LAYOUT; future themes can override via the theme primitive in v1.x. · *Card + table + form refinements* — Product cards gain hover state (subtle lift + shadow), prices render in the accent color, links show a bottom border that flips to the accent on hover. Table headers adopt all-caps Montserrat 600 with a 2px ink underline, total rows render with a thick ink top border. Cart summary moves into a `.summary-table` block with `--bg` fill. Forms inherit consistent input + button styling.

- v0.0.9 (2026-05-20) — **Cart-write routes + container cold-start retry.** Storefront gains the `POST /cart/lines` route plus a session-id cookie binding so the "Add to cart" form on the product detail page actually works end-to-end. Worker forwards now retry once on the cold-start "Failed to start container" 5xx so a single cold provisioning doesn't bubble to the shopper. Body parser middleware mounted in the application boot for form-encoded + JSON request bodies. **Added:** *Cart-write route — `POST /cart/lines`* — `lib/storefront.js` mounts the cart-add route alongside the read-only HTML pages. The handler resolves the cart via a `shop_sid` HttpOnly cookie (UUIDv7 session id, SameSite=Lax — blocks cross-site form POSTs from carrying the session, which is the load-bearing CSRF defense for cart-add). When the cookie is absent, the handler mints a fresh session id, sets the cookie, and creates the cart. On success the response is `303 See Other` to `/cart` so a refresh of the result page doesn't re-submit the form. · *Cart count on every storefront page* — `renderHome` / `renderProduct` / `renderCart` accept a `cart_count` opt; the route handlers compute it by looking up the session's active cart and reading `cart.listLines(c.id).length`. The header badge (`Cart (N)`) reflects the actual session state without a separate JSON ping. · *Body parser middleware mounted at boot* — `server.js` now wires `b.middleware.bodyParser()` so every route receives `req.body` parsed from `application/x-www-form-urlencoded` (the storefront's cart forms) and `application/json` (future admin API). Mounted before any route handler so the surface is uniform. · *Container cold-start retry in the Worker* — `worker/index.js`'s `_forwardToContainer` now buffers the request body and retries once on the `@cloudflare/containers` 5xx "Failed to start container" / "There is no Container instance" responses with a 1.5s backoff. A single cold-start race during deploy rollout or instance recycling no longer surfaces to the shopper as a 500. GET / HEAD short-circuit the body buffer; POST bodies are read into an ArrayBuffer once and re-served on the retry.

- v0.0.8 (2026-05-20) — **Storefront — server-rendered HTML for end customers.** Minimum viable storefront: read-only HTML for home (product list), product detail, and cart. Renderers are pure functions composable in isolation; `mount(router, deps)` wires them into a `b.router` instance. Inline string templates with the strict `{{var}}` renderer the email primitive uses — HTML-escaped substitution, refusal of unknown / unused placeholders. The full theme primitive (file-backed templates via `b.template`, asset fingerprinting, theme inheritance, customer-buildable themes) lands in v1.x. **Added:** *`storefront` — server-rendered HTML* — `lib/storefront.js` ships pure-function renderers (`renderHome`, `renderProduct`, `renderCart`, `renderNotFound`) plus a `mount(router, deps)` that registers the read-only GET routes against a `b.router` instance. The home page lists active products with their starting USD price; the product page shows title, description, and a variant table with SKUs + per-variant prices; the cart page renders lines + subtotal + grand total. Every substituted value is HTML-escaped via the strict renderer — XSS attempts through a product title surface as `&lt;script&gt;` in the rendered markup, never as executable HTML. · *Wired into `server.js`* — When the data layer is configured (`D1_BRIDGE_URL` + `D1_BRIDGE_SECRET` present), the container boot mounts `bShop.storefront.mount(router, { catalog, cart })` so `GET /`, `GET /products/:slug`, and `GET /cart` serve HTML directly. Without the data layer, the container falls back to the JSON identity ping (useful for local smoke without wrangler dev). **Detectors:** *Layer 1 — `storefront`* — `test/layer-1-state/storefront.test.js` exercises each renderer: home with products + empty state + cart count, product detail with variants + missing-variants empty state, cart with lines + subtotal + grand total + empty state. XSS escape verified by injecting `<script>` into a product title and asserting it's rendered as `&lt;script&gt;`. Validation tests at every entry point.

- v0.0.7 (2026-05-20) — **Transactional email primitive.** Transactional templates (order receipt, ship notification, refund confirmation) composed on `b.mail` with a strict `{{var}}` renderer that HTML-escapes every substituted value and refuses unknown / unused placeholders at composition time. Operators inject a `b.mail.create(...)` mailer at boot — transports include console / memory / smtp / http / resend out of the box. **Added:** *`email` — transactional templates* — `lib/email.js` exposes `create({ mailer, from })` returning `{ orderReceipt, shipNotification, refundConfirmation }`. Each method takes the domain object (order + customer plus a per-message extra like `tracking` or `amount_minor`), renders an HTML + text body via the strict templater, and hands the message to the injected mailer. Address shape is validated against `b.guardEmail.validate` (strict profile) before sanitization — `sanitize()` strips unicode / BIDI / control bytes but does not reject malformed shapes, so the validate-first-sanitize-after order is load-bearing. · *Strict `{{var}}` renderer* — `email._render(template, vars)` substitutes `{{name}}` placeholders, HTML-escapes every value (XSS defense), and throws if the template references an unknown variable OR if any provided variable wasn't referenced. Catches both the typo-in-template bug and the rename-without-updating-caller bug at composition time, before a customer-facing email ships with `{{customer_name}}` literal text or a missing field. · *Pluggable transport via `b.mail.transports`* — blamejs ships `b.mail.transports.{console, memory, smtp, http, resend}` — operators choose at app boot. `memory` records every send in an array (used by the layer-1 test to assert on message shape without a real SMTP server). The `email.create` factory takes the mailer; no transport coupling in the shop module itself. **Detectors:** *Layer 1 — `email`* — `test/layer-1-state/email.test.js` exercises every template against a fake mailer: order receipt with totals (subtotal, tax with jurisdiction, shipping, grand total), ship notification with carrier + tracking, refund confirmation with formatted amount. Covers HTML escaping (XSS attempt via `customer.name`), strict-renderer refusals (unknown placeholder + unused variable), and address-shape validation.

- v0.0.6 (2026-05-20) — **Checkout orchestrator + webhook upgrade to upstream b.webhook.** Checkout primitive that ties cart + catalog + pricing + tax + shipping + payment + order into a two-call API (`quote` + `confirm`) plus a webhook dispatcher that maps Stripe events to FSM transitions. Payment verifier swapped from a local node:crypto fallback to the upstream `b.webhook.verify` primitive (alg `hmac-sha256-stripe`, available since blamejs v0.11.25) — the local `node:crypto` allow marker is gone. **Added:** *`checkout` — orchestrator* — `lib/checkout.js` ships `create({ catalog, cart, pricing, tax, shipping, payment, order })` returning `{ quote, confirm, handleStripeEvent, STRIPE_EVENT_MAP }`. `quote({ cart_id, ship_to, selected_shipping_id })` is read-only — composes the priced quote (lines + totals + shipping rates + tax jurisdiction). `confirm({ cart_id, ship_to, selected_shipping_id, customer, idempotency_key })` creates the Stripe PaymentIntent, persists the order in `pending`, marks the cart converted, returns `{ order, payment_intent: { id, client_secret, status } }`. `handleStripeEvent({ headers, rawBody })` verifies the signature via `payment.verifyWebhook`, looks up the order by `payment_intent_id`, and fires the appropriate FSM transition (`payment_intent.succeeded` → `mark_paid`, `payment_intent.canceled` → `cancel`, `charge.refunded` → `refund`). Re-delivered events are idempotent — if the order is already at or past the target state, the handler returns `{ handled: true, skipped: "already-advanced" }`. · *Payment verifier composes upstream `b.webhook.verify`* — `payment.verifyWebhook` now wraps `b.webhook.verify({ alg: "hmac-sha256-stripe", ... })` instead of reaching for `node:crypto.createHmac("sha256", ...)` locally. The upstream primitive ships full Stripe-spec verification (constant-time compare, multi-v1= rotation support, timestamp tolerance, optional `nonceStore` for replay defense) — composing it shrinks `lib/payment.js`, drops the `allow:non-shop-require` marker, and brings the verifier into framework-policy alignment. The `verifyWebhook` shape stays `{ ok, reason }` so checkout's handler doesn't need to change. **Detectors:** *Layer 1 — `checkout`* — `test/layer-1-state/checkout.test.js` stitches every prior primitive (catalog, cart, pricing, tax, shipping, payment, order) into an end-to-end flow against in-memory SQLite. Coverage: quote returns lines + totals + shipping rates + tax jurisdiction, confirm creates a PaymentIntent + pending order + converts the cart, confirm refuses zero-total carts, handleStripeEvent maps `payment_intent.succeeded` → `mark_paid`, re-delivered events are idempotent (`skipped: "already-advanced"`), bad signature throws `WEBHOOK_INVALID`. Payment outbound is stubbed (the orchestration is what we're testing); the real Stripe-API integration is exercised by `payment.test.js`. **References:** [blamejs v0.11.25 b.webhook.verify](https://github.com/blamejs/blamejs/blob/v0.11.25/lib/webhook.js)

- v0.0.5 (2026-05-20) — **Order primitive — FSM-driven post-checkout record.** The order primitive composes the freshly-landed `b.fsm` upstream surface to drive the post-checkout lifecycle. Orders persist in D1 with a versioned transition log; every state change goes through the FSM so illegal transitions throw at the framework boundary instead of corrupting the order record. **Added:** *`order` — FSM-driven post-checkout record* — `lib/order.js` ships `create({ query })` returning `{ createFromCart, get, byPaymentIntent, transition, setPaymentIntent, TERMINAL_STATES }`. States: pending → paid → fulfilling → shipped → delivered, plus refunded / cancelled (terminal). Events: mark_paid, start_fulfillment, mark_shipped, mark_delivered, cancel, refund. Each transition fires through `b.fsm` (which refuses illegal events at the framework boundary) and appends a row to `order_transitions` so the lifecycle is auditable end-to-end. `byPaymentIntent(piId)` is the lookup webhook handlers use to map Stripe events back to the local order. · *Migration `0003_order.sql`* — Three tables: `orders` (row per checkout, captures cart snapshot + totals + ship_to JSON + payment_intent_id with a unique index), `order_lines` (snapshot of cart_lines at order creation — immutable post-checkout), `order_transitions` (one row per FSM transition, including the initial `__init__ → pending` create-event). FK cascade on order delete removes lines + transitions. · *Vendor refresh blamejs v0.11.24 → v0.11.25* — Upstream v0.11.25 ships `b.fsm` (auditable in-process FSM with guards + onEnter/onExit + concurrent-transition serialization + toJSON/restore round-trip) and `b.money` (decimal-safe money with currency-aware arithmetic and conversion). Order composes `b.fsm`. Pricing will adopt `b.money` in a follow-up patch — the current `Intl.NumberFormat`-based formatter stays for now since it covers the storefront rendering use case. **Detectors:** *Layer 1 — `order`* — `test/layer-1-state/order.test.js` exercises createFromCart (persist + lines + init transition), the happy path (pending → paid → fulfilling → shipped → delivered with metadata captured on each transition), illegal transition refusal (can't ship before paying, can't deliver from pending, unknown events refused), cancel + refund branches, setPaymentIntent's pending-only constraint + byPaymentIntent lookup, and every validation entry point.

- v0.0.4 (2026-05-20) — **Payment primitive — Stripe adapter.** Stripe inbound webhook signature verification and outbound API calls (PaymentIntent create / retrieve / confirm / cancel + Refund) composed on `b.httpClient` and `b.crypto.timingSafeEqual`. No `stripe` npm dependency — every byte is either a Node built-in or a vendored blamejs primitive. The pluggable adapter contract lets future Adyen / Mollie / Paddle adapters land without changing caller code. **Added:** *`payment.stripe` — Stripe adapter* — `lib/payment.js` exposes `payment.create({ apiKey, webhookSecret })` returning `{ name, verifyWebhook, createPaymentIntent, retrievePaymentIntent, confirmPaymentIntent, cancelPaymentIntent, refund }`. Every outbound call accepts an explicit `idempotencyKey` so the caller (checkout / refund worker) controls retry safety. `verifyWebhook(headers, rawBody)` parses the `stripe-signature` header (multiple `v1=` entries supported for secret rotation), enforces a ±5-minute timestamp tolerance, runs constant-time signature compare via `b.crypto.timingSafeEqual`, and returns `{ ok, event }` on success or `{ ok: false, reason }` on rejection (`no-signature`, `timestamp-outside-tolerance`, `signature-mismatch`, `bad-json`). The body MUST be the raw request string — passing a parsed object throws. · *Outbound API via `b.httpClient`* — Stripe calls flow through `b.httpClient.request()` so the SSRF gate, retry policy, circuit breaker, ALPN HTTP/2 transport, and TLS 1.3 minimum all apply by default. Bodies are form-encoded with Stripe's bracket-notation for nested params (`metadata[order_id]=ord_1`, `automatic_payment_methods[enabled]=true`). The 15-second per-request timeout is operator-tunable via `payment.create({ timeoutMs })`. · *Pluggable adapter contract* — `payment.create({ adapter: "stripe", ... })` is the v1 surface. Unknown adapter names throw at construction time so a typo (`paddle` / `mollie`) surfaces before any payment flow runs. Future adapters expose the same `{ verifyWebhook, createPaymentIntent, ... }` shape — callers (checkout / order) bind to the contract, not the provider. **Detectors:** *Layer 1 — `payment`* — `test/layer-1-state/payment.test.js` exercises the webhook verifier (valid signature, case-insensitive header, multiple v1= entries for secret rotation, signature mismatch, timestamp-outside-tolerance, mutated body, missing timestamp, malformed JSON), refuses pre-parsed-object bodies, exercises the form encoder for nested + array params, exercises factory validation (`apiKey` + `webhookSecret` required, unknown adapter rejected), and exercises `createPaymentIntent` / `refund` input validation. Outbound API integration tests run against real Stripe in a later patch. **References:** [Stripe Webhooks](https://docs.stripe.com/webhooks) · [Stripe Payment Intents API](https://docs.stripe.com/api/payment_intents)

- v0.0.3 (2026-05-20) — **Tax + shipping primitives.** Pure-function tax and shipping computation against operator-declared rule tables. Both primitives ship a pluggable adapter contract; the `operatorTable` adapter built into v1 lets the framework run offline. Future Stripe Tax / TaxJar / EasyPost / Shippo adapters land as drop-in factory swaps without changing the call sites. **Added:** *`tax` — operator-table adapter* — `lib/tax.js` exposes `tax.create({ rules })` returning `{ name, calculate(ctx) }`. Rules declare `country` (ISO 3166-1 alpha-2), optional `state` and `postal_prefix`, and `rate_bps` (basis points — 1 bp = 0.01%). Match precedence is most-specific-first: country + state + postal_prefix → country + state → country → fallback (`rate_bps: 0`, `jurisdiction: "fallback"`). Tax computes against `subtotal_minor` with banker's rounding (round-half-to-even) so cumulative rounding bias across many small carts cancels out instead of drifting upward. The returned `jurisdiction` string (e.g. `"US/CA/941"`) is what storefront templates show under the tax line. · *`shipping` — operator-table adapter* — `lib/shipping.js` exposes `shipping.create({ services })` returning `{ name, rates(ctx) }`. Services declare an id, label, and zones; each zone is either flat-amount or per-gram (with optional `base_minor`, `min_amount_minor`, `max_amount_minor`). Free-over-threshold via `free_over_minor`. Zone matching uses the same most-specific-first precedence as tax. Digital-only services (`digital_only: true`) are offered only when every cart line has `requires_shipping: false`; physical services are filtered out when the cart is digital-only. `rates()` returns `{ services: [{ id, label, amount_minor, free, jurisdiction }], total_grams }` — the storefront renders one row per available service. **Detectors:** *Layer 1 — `tax`* — `test/layer-1-state/tax.test.js` covers rule validation, match precedence (postal > state > country > fallback), banker's rounding on .5 boundaries (round-half-to-even), shipTo / subtotal validation, and `subtotal × rate_bps / 10000` arithmetic on multiple bps values. · *Layer 1 — `shipping`* — `test/layer-1-state/shipping.test.js` covers flat-amount zones, per-gram zones with base + min/max clamping, free-over-threshold thresholding, zone specificity precedence, digital-only filtering, and rule-shape validation.

- v0.0.2 (2026-05-20) — **Cloudflare deploy + D1 backend + first commerce primitives.** First feature patch: complete Cloudflare deploy topology (Container + Worker + D1 + R2 + KV + Durable Objects), a `b.externalDb` adapter that targets D1 over the Worker service binding, and the first three commerce primitives — catalog (products, variants, prices, inventory, media), cart (anonymous + authenticated, price-snapshotted lines, merge on login), pricing (pure-function money math). All composed on blamejs primitives — UUIDs from `b.uuid.v7`, identifier safety from `b.guardUuid` and `b.guardMime`, cursor HMAC tagging from `b.pagination`, identifier quoting from `b.safeSql`, error responses via `b.problemDetails`, key derivation via `b.crypto.namespaceHash`. **Added:** *Cloudflare deploy topology* — `Dockerfile` (multi-stage Node 24-alpine build, non-root runtime, tini PID 1, in-image smoke gate), `.dockerignore`, and `wrangler.toml` wiring the Worker, the Container, a D1 database binding, an R2 assets bucket, a KV sessions namespace, and the Durable Object classes. Operators provision the resources with `wrangler d1 create` / `wrangler r2 bucket create` / `wrangler kv namespace create`, paste the returned ids into `wrangler.toml`, set five `BLAMEJS_*_PASSPHRASE` secrets via `wrangler secret put`, and run `wrangler deploy`. · *Edge router Worker* — `worker/index.js` — serves `/_/health` inline, passes `/assets/*` through to R2 with fingerprint-aware cache headers, verifies Stripe webhook signatures (`stripe-signature` HMAC-SHA256 over `<timestamp>.<body>` with a 5-minute tolerance window) before forwarding to the container, exposes the D1 query bridge under `/_/db/query` (gated by a shared-secret header), and forwards everything else to the container via the `Container` base class from `@cloudflare/containers` (handles lifecycle, readiness wait, port routing, scheme rewrite). · *InventoryLock Durable Object* — Per-SKU serialization point for stock decrement and release operations. Each SKU resolves to a single Durable Object instance so concurrent `decrement-if-available` calls from multiple container replicas queue safely; authoritative stock counts live in D1. Exposed as the `INVENTORY` binding. · *`externaldbD1` — Cloudflare D1 adapter for `b.externalDb`* — `lib/externaldb-d1.js` implements the `{ connect, query, close, dialect }` shape `b.externalDb.init` consumes, with two operating modes: service-binding (recommended — container reaches D1 through a sibling Worker, no D1 API token in the container) and rest-api (direct calls for development). Wire-protocol contract: SQLite parameter placeholders (`?1, ?2, ...`), `{ rows, rowCount, lastRowId }` result shape normalized across modes, AbortController-backed timeouts, jittered exponential retry on 5xx / 429 / transient network errors via `b.crypto.randomInt`. · *`catalog` — products, variants, prices, inventory, media* — `lib/catalog.js` ships five sub-modules composed on `b.externalDb` against the `0001_catalog.sql` schema. Products with slug + status (draft/active/archived), variants with options JSON + position + weight + shipping flag, versioned prices (set closes prior, multi-currency independent), inventory with stock-on-hand + held counts, media attached at product or variant scope with R2 keys and content-type validation. v7 UUID primary keys for B-tree locality. `b.guardUuid` for id validation (RFC 9562 threat catalog), `b.guardMime` for content-type validation, `b.safeSql.assertOneOf` + `b.safeSql.quoteIdentifier` for update-column allowlist, `b.pagination.encodeCursor` / `decodeCursor` for HMAC-tagged tuple cursors on `products.list`. · *`cart` — anonymous + authenticated carts* — `lib/cart.js` ships against the `0002_cart.sql` schema. Carts are keyed by `session_id` (sealed-cookie origin), survive container restarts (D1, not session memory), enforce one active cart per session via a partial unique index. `addLine` snapshots `unit_amount_minor` + `unit_currency` at add time from `catalog.prices.current()` (or an explicit override), de-duplicates by `(cart_id, variant_id)` by bumping qty. `merge(fromCartId, toCartId)` adopts an anonymous cart into a customer cart on login — line collisions sum qty, the source cart marks abandoned. `setStatus(cartId, 'converted')` makes a cart immutable post-checkout. · *`pricing` — pure-function money math* — `lib/pricing.js` ships `lineTotal(line)`, `subtotal(lines)`, `totals(cart, lines, ctx)`, and `format(amount_minor, currency, opts?)`. Every primitive operates in integer minor units (no floats), refuses to add across currencies (multi-currency carts throw), refuses negative discount/tax/shipping, and refuses discount > subtotal. `format()` composes `Intl.NumberFormat` for locale-correct rendering — zero-decimal currencies like JPY are handled automatically via the ISO 4217 minor-digit count. · *Read-only public catalog API* — `GET /api/catalog/products?status=&limit=&cursor=` returns a paginated product list (cursor is HMAC-tagged via `b.pagination`). `GET /api/catalog/products/:slug` returns one product with its variants + media. Errors surface as RFC 9457 problem documents via `b.problemDetails.respond` (TypeError → 400, missing slug → 404 with a stable `type` URN). Admin writes land later behind `lib/admin.js` (passkey + step-up); until then writes go through the wrangler CLI or direct D1 access. · *`docs/deploy-cloudflare.md`* — Operator-facing deploy recipe: one-time `wrangler` resource setup, secret management (`BLAMEJS_*_PASSPHRASE`, `D1_BRIDGE_SECRET`, `STRIPE_WEBHOOK_SECRET`), container env reference, `wrangler deploy`, verification curl commands, local development (`wrangler dev --remote` + `docker run` for offline smoke), and rollback flow. **Detectors:** *Layer 0 — `non-shop-require`* — `test/layer-0-primitives/codebase-patterns.test.js` adds a detector that refuses any `require()` in `lib/` whose target isn't a relative shop module (`./…` or `../…`). Forces operators to either compose with the blamejs equivalent (`b.crypto.sha3Hash` replaces `node:crypto.createHash`, `b.uuid.v7` replaces `node:crypto.randomUUID`, `b.safeUrl` replaces `node:url`, `b.atomicFile` replaces `node:fs` writes, etc.) or document the unavoidable dependency with a per-line `// allow:non-shop-require — <reason>` marker. Bare package names also surface here, reinforcing the zero-npm-runtime-deps rule. · *Layer 1 — `externaldb-d1` adapter contract* — `test/layer-1-state/externaldb-d1.test.js` covers factory validation, service-binding wire shape, rest-api wire shape, worker-side error envelope mapping, HTTP 5xx retry, HTTP 4xx terminal, transient network error retry, and AbortController-backed timeout surfacing as a `D1_TIMEOUT` typed error. · *Layer 1 — `catalog`* — `test/layer-1-state/catalog.test.js` runs every catalog op (products / variants / prices / inventory / media — create, read, list, update, archive/restore, cascade delete) against an in-memory `node:sqlite` database loaded from the live `0001_catalog.sql` so every CHECK / UNIQUE / FOREIGN KEY in the shipped schema is exercised end-to-end. · *Layer 1 — `cart`* — `test/layer-1-state/cart.test.js` exercises cart lifecycle (create, add/update/remove lines, listLines), price-snapshot fallback, partial-unique active-cart constraint, merge with collision/non-collision lines, customer attachment, post-conversion immutability, and validation error shapes. · *Layer 1 — `pricing`* — `test/layer-1-state/pricing.test.js` exercises `lineTotal`, `subtotal` (multi-currency refusal, empty-cart shape), `totals` (discount/tax/shipping arithmetic, discount-exceeds-subtotal refusal, cart-vs-lines currency mismatch refusal), and `format` against USD, EUR, and JPY (zero-decimal currency). **References:** [Cloudflare Containers](https://developers.cloudflare.com/containers/) · [Cloudflare D1](https://developers.cloudflare.com/d1/) · [blamejs externalDb](https://github.com/blamejs/blamejs/blob/main/lib/external-db.js)

- v0.0.1 (2026-05-19) — **Foundation.** Project scaffold for blamejs.shop — vendored blamejs framework + the release pipeline disciplines (structured release-notes generator, smoke-gated CHANGELOG rebuild, SLSA L3 provenance, Sigstore-keyless SBOM signatures, PQC sidecar signing). No feature surface yet; subsequent releases extend by composing the vendored blamejs primitives. **Added:** *Project skeleton* — Initial repository layout: `lib/vendor/` for the vendored blamejs tree, `scripts/` for the release-pipeline helpers (generator, consolidator, sha3-digest, sign-release-artifact, generate-release-signing-key, vendor-update.sh), `test/smoke.js` orchestrator, `test/layer-0-primitives/codebase-patterns.test.js` discipline gate, `release-notes/` JSON source of truth, plus the standard operator-facing artifacts (README, SECURITY, CHANGELOG, LICENSE). · *Vendor toolchain for blamejs* — `scripts/vendor-update.sh blamejs <tag>` shallow-clones the requested release tag from `github.com/blamejs/blamejs` into `lib/vendor/blamejs/` and updates `lib/vendor/MANIFEST.json` with the version + bundled-at date. The script's `--check` mode is wired into the smoke gate so drift between the vendored copy and the latest published blamejs release fails pre-push. · *Release pipeline disciplines* — Carries the same release flow blamejs runs: structured `release-notes/v<X>.json` generator with leak-vocabulary validation, consolidator that rolls non-current minors into `v<minor>.x.json`, `CHANGELOG.md` rebuilt end-to-end from the JSON tree, smoke gates on both `--check` modes so any drift between the JSONs and the markdown fails pre-push. **References:** [blamejs framework](https://github.com/blamejs/blamejs)
