Skip to content

[CI] Fold @otta-sh/service into the EmDash plugin — work order 02 complete - #296

Merged
vedanshujain merged 187 commits into
mainfrom
feat/in-process-commerce
Sep 21, 2026
Merged

vedanshujain merged 187 commits into
mainfrom
feat/in-process-commerce

Conversation

@vedanshujain

Copy link
Copy Markdown
Contributor

This branch folds the standalone @otta-sh/service commerce HTTP layer directly into the EmDash plugin, so that what were cross-process REST calls are now in-process calls against the domain ports. The separate service package and its deployment are gone, the storefront and admin talk to the commerce layer through an in-process client, and the temporary vendored/tarball EmDash build that carried the required host changes has been dropped in favour of the real upstream release — emdash@0.38.0, which ships PR #2980. The result is one deployable instead of two, with no wire format left to drift.

Full battery

Run fresh against the released build (emdash 0.38.0 wired in, vendored tarballs deleted), on the final content of this branch:

Tier Result
lint (oxlint + domain-purity dependency check) PASS — oxlint clean, 0 dependency violations
typecheck PASS
build (recursive, incl. astro check) PASS — emdash 0.38.0 confirmed wired in
test (default/SQLite) PASS — 224 files, 4348 passed, 820 skipped, 14 todo
test:pg (Postgres, incl. the no-oversell concurrency contract) PASS — 60 files, 1551 passed, 7 skipped
test:d1 (T3, production dialect) PASS — 14 files, 525 passed, 0 skipped
test:e2e PASS — 13 passed, 21 skipped (pre-existing seeded-data gates, unrelated)
install --frozen-lockfile PASS

All tiers exited 0.

The battery was run against chore/unvendor-emdash's final commit; the merge of that branch into the integration branch produced a tree identical to the tested one, so the summary above describes this branch's tip exactly.

What's included

Increments INC-A0 through INC-D6 — all of Phase A (host pin, boundary, structural port, contract suite), Phase B (the EmDash-backed stores: inventory, cart, orders, product commerce, coupons, rules, identity, reporting), Phase C (in-process client, storefront and admin cutover, payments on WebCrypto, settlement and cron sweeps), and Phase D (staging cutover, D1 release gate, service retirement and deletion, and the swap onto the upstream release). Each landed as its own reviewed PR into the integration branch (#246–#295, 43 merges), under the usual one-PR-one-thing and merge-commit-only conventions. Net: 653 files changed.

Durable record

plans/work-order-02-fold-service-into-plugin-memory.md carries the decisions, the record of the retired vendored build, and the follow-ups. Note that this file is not yet on main — it arrives with this merge.

Rollback is "revert this merge commit".

🤖 Generated with Claude Code

https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8

vedanshujain and others added 30 commits September 13, 2026 12:35
Otta's commerce truth is moving onto the EmDash plugin-storage API, which means
depending on four conditional-write primitives. `updateIf` is merged upstream;
the revision-based `getVersioned` / `compareAndSet` / `compareAndDelete` are
still an open pull request. Waiting on a release would block every increment
behind it, and a reference implementation would be a drift surface from the day
it was written. So the host is a locally built merge of upstream `main` and that
pull request, packed into `vendor/` and committed.

Four tarballs, not three. `@emdash-cms/admin` is required because the core build
imports its unreleased `./portable-text-table` subpath, and
`@emdash-cms/registry-client` for the same reason — the core build reaches an
unreleased `listing-policy` export, and without the tarball importing the root
`emdash` entry throws a named-import error at module instantiation. The pattern
is worth remembering: the host monorepo's workspace packages can carry source
newer than the release their version names, and the core build links against the
workspace copy.

The overrides are load-bearing, and not all in the same way. `emdash` and
`@emdash-cms/admin` fail LOUDLY without them: the vendored tarballs cross-pin
each other at versions that do not exist on the registry, so the install simply
stops. `@emdash-cms/cloudflare` is the quiet one — its published release pins
`emdash` exactly, and the registry can satisfy that, so dropping only its
override lands a second, stock `emdash` in the store and the Worker bridge binds
to the copy WITHOUT the primitives: no install error, no type error. They live
in `pnpm-workspace.yaml` because pnpm ignores `pnpm.overrides` in `package.json`
without warning, and the pins never float — a stray `emdash@1.0.0` exists on
npm and is not the latest release of this host.

`sites/staging/test/host-pin.test.ts` is what makes the invariant self-checking:
one `emdash@` in the store, `PluginStorageRepository` on the root entry,
`runMigrations` plus the renumbered migration as the tail of `emdash/db`'s
migration list, and all four primitives present on a repository over an
in-memory better-sqlite3 database. Everything downstream rests on those four
facts, so they are asserted rather than assumed.

`scripts/vendor-emdash.sh` is the reproduction recipe: frozen install only, no
unpinned fallback; a post-merge assertion that migration numbers are unique,
because the collision this merge resolves is semantic and an auto-merge can
"succeed" with two 076s; and a post-pack assertion that the packed core's `dist`
really carries the four primitives and the renumbered migration.
`vendor/README.md` pins the base commit, the merge commit and the branch head
the tarballs are built from, the migration number, and each conflict resolution;
`vendor/otta-emdash-cas.diff` is the same resolutions machine-readably, so a
future base can be checked with `git apply --check` instead of a clone. The
branch is pushed to the project's own fork, never force-pushed, because its head
is what the tarballs were built from.

Two consequences worth naming. The staging site's generated host types were
stale and are regenerated from the new host: richer media typing, the collection's
`variants` field where the stale file had none, and no more `commerce?: unknown`
— that field was deleted from the collection long ago. And
the new host writes an applied-migration manifest next to the site on build,
which is derived from the installed host, so it is ignored rather than tracked.

Also here: the `wrangler` catalog entry moves ^4.68 → ^4.99 because the vendored
`@emdash-cms/cloudflare` declares `peerDependencies.wrangler >= 4.99.0`; and
`engines.node: ">=22.16"` — the host's own floor — is declared on the root
manifest and on the two manifests that resolve the host, with CI pinning the
major line only so the floor is honoured without narrowing to one minor.

Verified: lint, typecheck, format and `pnpm -r build` clean; the full vitest
battery green; all 20 sandbox suites, the five Block Kit screen suites, the two
React console suites, the staging site-config suite and the new host-pin guard
green; `pnpm test:e2e` at its documented no-stack state; a clean
`rm -rf node_modules && pnpm install --frozen-lockfile` leaving exactly one
`emdash@` in the store; and a throwaway consumer confirming all four primitives
over a real database, including both stale-revision refusals. R0b's pinned
assertions were walked item by item — Block Kit is byte-identical, the
draft-only-save `updatedAt` freeze still holds, and the harness never
implemented the runner interface that gained an optional member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[CI] Vendor the emdash CAS build and move the host pin to 0.37.1-otta.1
… and a real-repository dialect harness

The package the commerce adapters will live in, and the two things that have to
exist before any of them can be written: the port they bind to, and a harness
that runs that port against a real host repository rather than a stand-in.

`src/storage-access.ts` is the seam. It declares exactly the nine methods an
adapter needs — the five plain reads and writes, and the conditional-write group
`updateIf` / `getVersioned` / `compareAndSet` / `compareAndDelete` — written in
terms of the host's own storage types, imported as types only. The filter
algebra and the result unions are named once rather than copied; three of them
(`WhereClause`, `QueryOptions`, the query page) the host does not export at all,
so they are derived from the collection interface it does export, which is the
one spelling that cannot drift. The two error shapes it exports no class for are
restated as structural interfaces with predicates, because an error crossing the
sandbox bridge arrives as a plain object carrying the fields, never as an
instance of anything. Production injects `ctx.storage` and tests inject a real
repository; because that is the only surface an adapter sees, changing which
build of the host supplies it is a dependency change, not an adapter rewrite.
`collectionOf` is the one audited narrowing from the untyped map to a typed
collection, so an adapter states its document type once instead of casting per
call site, and a collection the descriptor never declared fails by name.

Three depcruise rules replace the blanket EmDash ban this package had to be
exempted from, and the exemption is a handoff rather than a hole — which is worth
spelling out, because the first version of it claimed "nothing is lost" while
leaving `test/**` free to import react. What is actually true:
`store-emdash-runs-no-host-code` bans `emdash` and @emdash-cms/* in `src` as
runtime imports and permits type-only ones, and it is its own rule precisely so
that allowance cannot leak — written as one merged clause it also permitted
`import type { Pool } from "pg"`, which is how a module starts being written
against a host it must never touch. `store-emdash-is-sandbox-clean` carries the
perimeter with no type-only escape: no DB driver, no filesystem or socket
builtin, no HTTP client, no sibling server package, matched by negative lookahead
so a future store package is banned the day it is created.
`store-emdash-no-console-react` binds the WHOLE package, tests included, because
no legitimate Node test here imports react. Four plants prove each edge: runtime
host import fails, `react` in a test fails, a type-only host import passes, a
type-only `pg` import fails.

The harness builds its collections out of real `PluginStorageRepository`
instances over in-memory SQLite and, when a Postgres connection string is
present, over a schema of its own. One database per test FILE, rows cleared
between cases: the migration set is 77 migrations and a database per test cost
~2.5s a case on Postgres for no isolation the reset does not give. Emptying the
table is also the only reset that KEEPS what the schema is for — the storage
table's revisions are assigned by a trigger a migration creates, so a hand-built
or recreated table would leave every compare-and-set looking at an unchanging
revision and quietly agreeing with itself. The Postgres migration call carries
the sibling package's three-attempt retry for the reason recorded there, and
teardown ends the admin pool and drops the schema through `finally`, so a
rejecting `destroy()` cannot leak a connection pool or litter a shared database.
Declared indexes reach each collection through the repository's constructor
argument, indexes and unique indexes composed exactly as the host composes them.

The suite pins the round trip, the indexed query with ordering, the page ceiling
and one page past it, `count`, `delete`, the guarded decrement that applies once
and then stops at its guard, the guarded update that never inserts,
create-if-absent, the stale-revision refusals for both set and delete, and the
refusal to query a field the collection never declared — asserted on the field,
not on the host's wording. A collection declared with a unique index proves the
composed allow-list is real, and the README records what it does not buy: the
host's index-materializing function is unexported, so no physical index exists in
either tier, uniqueness is enforced nowhere here, and once-only has to come from
a conditional write. Postgres adds the case SQLite structurally cannot express,
because it serializes writes in one process: ten concurrent compare-and-sets on
one revision, exactly one applying, every loser either refused or retryably
aborted — nothing else counts as losing — and the surviving document the
winner's.

Two build-level facts, stated where they can be checked. The host is external to
the bundle: left bundlable, the declaration rollup walks its whole type graph and
fails. And the `emdash` specifier is the plain registry version even though no
published release carries the primitives this port is written against — so
adopting a release is a one-line change — which makes the workspace override
load-bearing in the meantime, and the package description, README and changeset
all say so rather than letting a pin imply a compatibility that does not hold.
The package also declares no `@types/node`: pinning one of the host's peers
differently from every other consumer makes pnpm materialize a second copy of the
same host tarball, which the host-pin suite counts and fails on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Adapters] Scaffold @otta-sh/store-emdash with the StorageAccess port and a real-repository dialect harness
… per SKU with embedded holds

The full 13-method InventoryStore port over document storage, on one aggregate
document per SKU with the live holds embedded in it.

The holds live inside the inventory document because an inventory decrement is
not idempotent unless the row records who applied it. So the decrement is ONE
compareAndSet on inventory/{sku} in which the onHand >= qty guard (computed in
JS), the new count and the hold record all commit together — no oversell and
once-only are the same atom.

Reserve is a two-step with exactly ONE crash window: claim reservation_keys/{key}
create-if-absent, carrying the sku, the qty and the minted reservation id → the
inventory compareAndSet → update the key document to its terminal ReserveResult.
The window is "claim written, compareAndSet not yet run", and it is healed rather
than merely tolerated — any replayer finds the claimed document and completes it
deterministically, reusing the RECORDED reservation id instead of minting a
second one, so the decrement happens exactly once and every caller gets the same
answer. A sweeper reaps claims nothing replays. What the embedded aggregate
removes is the SQL adapter's SECOND window (a pending reservation flipped to held
separately from the decrement); the claim window cannot be removed by any
single-document primitive, because the claim and the units live in different
documents by necessity. An OUT_OF_STOCK reserve mints nothing at all.

reservation_index is what pays for the embedding. Six port methods take
reservation ids with no sku, and a hold embedded per SKU cannot be found from an
id alone; the index document is written before the hold, so an id absent from it
is provably unknown — which preserves the port's asymmetry (commitMany throws for
a truly unknown id, adoptMany folds one into lost). Its create-if-absent result is
asserted, so a colliding id is loud rather than silently adopted. It also carries
the reservation's terminal state, because pruning a hold would otherwise erase the
difference between "never existed" and "existed and was released".

The terminal outcome is written to the key document BEFORE the hold is pruned, so
a replay after a prune answers from it instead of looking fresh and decrementing
again. That ORDERING is only observable under fault injection, which belongs to
the race-and-crash tier; the suites here pin its consequence.

The inventory CAS re-reads the key document on any attempt that finds no hold. No
hold is not proof the decrement never happened: a peer completing the same claim
may have created, committed and PRUNED the hold in between, and a committed prune
leaves onHand low with nothing to show for it, so writing a second hold there
would lose units permanently and silently. A terminal key document ends the
attempt with the recorded answer instead.

Every ledger is bounded. adjust/restock/removeStock keep their once-only record in
inventory_movements — one document per key carrying the full intent and then the
recorded answer, which is also what makes a key reused for a different movement
the port's typed rejection. The hot aggregate keeps only a 256-entry ring of
recently applied keys plus one field per hold, so no map on it grows without
limit; the residual that bound leaves is accepted as bounded and written down as a
contract the sweeper must satisfy.

adjust re-derives rather than refusing, as the SQL reference does: a completion
reads the hold's current qty and applies the absolute target against it, and the
claim's recorded fromQty is audit, not a guard. Its only outcomes are the port's
own, and every caller — claim winner or same-key loser — derives its answer from
the durable record, so one key cannot produce two answers.

Cross-SKU work is honest about not being atomic: adopt/adoptMany/commitMany/
releaseAdopted classify every id up front (duplicates collapsed), then apply one
compareAndSet per SKU, each idempotent by reservation id.

Contention is answered with bounded full-jittered retry and a documented ceiling
(CAS_MAX_ATTEMPTS = 12; the depth a writer can lose is bounded by the units on
hand, not the size of the crowd — the race measures 6). Exhaustion throws the
typed retryable StorageContentionError, carrying the last retryable host abort as
its cause, never OUT_OF_STOCK: a shopper who could have bought must not be told
the item is gone. The 503 mapping is a later increment.

Verified: the domain's inventoryStoreContract green on both dialects over real
storage repositories, no adapter-introduced skips, plus the concurrency suite on
the tier that can race — exactly the stocked number of winners on every loop, the
count ending at zero, retry depth strictly inside the ceiling, and a shared-key
burst resolving to one reservation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Adapters] Implement EmdashInventoryStore over one inventory document per SKU with embedded holds
…orage on both dialects

The document model INC-A2 landed has windows a contract suite cannot see: the
claim written before the units move, the terminal answer written before the hold
is pruned, a movement applied before its claim is marked. This adds the tier that
opens each one deliberately, on the real repository, and proves it heals with
exactly-once semantics.

Fault injection is extracted into `test/helpers/fault-injection.ts` — wrappers
that delegate every method to the real collection and only park a chosen call or
throw on it, so a crash is "let write A land, make write B throw" and the document
a replay heals is the one the host would really have left behind. Every case reads
the documents back before replaying, and every case carries the assertion that
would fail if the write order were reversed; a case that only proved "a replay
works" would pass under the forbidden order too. The contract suite's inline gate
and its always-losing decorator now come from the same helper.

Seam (e) is the load-bearing one: prune-before-terminal cannot be injected, because
the store does not do it, so it is pinned from the other side — the terminal write
is parked, and while it is parked the hold must still be live and the released units
still off the shelf.

Seam (f)'s eviction half asserts the ACCEPTED residual rather than papering over it:
past the applied-movement ring's bound a stock movement re-applies and an adjust
whose hold is also gone refuses, both named after the sweeper contract that closes
them so nobody "fixes" the test instead of the sweeper.

The two SQL race files are ported over the same harness with the same shapes. The
adjust race is driven at the port rather than through the cart use-case, because
this adapter's cart store is a later increment and the atomicity lives at the port.
The merchant shape gained a classification step: this adapter retries a
read-modify-write where the SQL one degraded under a single guarded UPDATE, so a
typed retryable failure is counted and reported, and the invariants are asserted in
the form that survives it.

And the contention budget R2 has no structural fix for is now asserted, not merely
printed: max attempts 6 for M=5/N=50 over 20 loops and 2 for M=1/N=100, both stable,
both M+1 because only M writes can succeed before the guard answers everyone else
with no write at all. The budget is 8, strictly below the ceiling of 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Test] Prove the inventory store's races and crash seams over real storage on both dialects
…rkers pool, nightly

D1 is the dialect a deployed storefront runs on, and nothing tested it. The
conditional-write primitives ride the host's SQLite branch there by inference:
`updateIf` is one `UPDATE … SET data = json_set(…) … RETURNING data`, and only
that path depends on the revision trigger the conditional-write migration creates
— `put` and `compareAndSet` assign their own `crypto.randomUUID()`. better-sqlite3
runs the same SQL against a different engine build in a different process model, so
agreement was an assumption. This tier turns it into evidence.

The repository instantiates inside workerd from the root `emdash` entry directly —
no stub plugin, no sandbox-bridge fallback. The predicted obstacle (the root
entry's `astro:content` / `@tiptap/core` / `virtual:emdash/*` graph) belongs to the
host's source tree; the published dist has it bundled and resolved. The dialect
comes from the host's own `createDialect` reading the `DB` binding out of
`cloudflare:workers`, which makes this the only tier that observes the host's
wiring rather than ours; the schema comes from the full `runMigrations` set; and
miniflare gets the storefront's own compatibility date and flags, so a divergence
found here means something about production.

No divergence from sqlite or Postgres on any primitive or contract case. The
primitive suite is the Node one case-for-case — the ten-way compare-and-set
included, because starting ten attempts on one revision before any of them writes
is a claim about `WHERE revision = ?` and not about scheduling — plus the
assertions the Node tier has no reason to make: the revision triggers exist on D1,
and they fire for a writer that supplies none. `inventoryStoreContract` passes in
full, 50 cases, no skips, and that now includes the W1 crash-window case: it reads
`abandonPending` off the harness and returns early when absent, so a harness
without the hook passes it while asserting nothing. `maxCasAttempts` on D1 is 2 of
a ceiling of 12.

The race runs the M=5/N=50 shape, and the file says plainly what it means here:
one miniflare isolate interleaves promises but never executes two statements at
once, so this is an interleaving check — strictly stronger than the sequential
contract path, strictly weaker than simultaneity. Atomicity under simultaneous
writers stays the Postgres tier's job and the no-oversell gate.

The crash tier is ported, not reused. Its eighteen cases live inside a closure
passed to `describeEachDialect`, which imports better-sqlite3 and pg at module
scope and cannot load in workerd at all; making them portable means splitting that
harness, which is its own change. The seams whose failure would be a dialect
failure are ported — both injection mechanisms, the ordering rule, and the
cross-SKU `commitMany` — and the four that are not are named in the file header
and the README rather than quietly dropped.

Wiring: its own vitest project and config, invoked by `pnpm test:d1`, kept out of
the default battery so the root `fileParallelism` guard under Postgres is
untouched. The D1 files are `*.spec.ts` so neither the default project's glob nor
`scripts/pg-test-files.sh` can pick them up. CI runs it nightly and on manual
dispatch, never per PR, under a 30-minute ceiling: it boots workerd and migrates a
database per file, which is a real budget item for evidence that only moves when
the adapter or the host build does. Everything is the local miniflare simulator —
no account, token, remote database or deployment is involved.

What the toolchain costs, said out loud rather than discovered later. The pool pins
its wrangler and miniflare exactly, and that miniflare pins its own workerd
exactly, so this adds a third workerd build that only the nightly executes and
every install pays for — and because pnpm picks the highest workerd in the graph,
`sites/staging`'s `@astrojs/cloudflare` peer-resolves workerd 1.20260710.1 ->
1.20260911.1, so the storefront build runs the newer runtime from here on (its
wrangler is unchanged at 4.110.0). Overriding wrangler back to the catalog version
was tried and undoes neither effect, because miniflare's pin is what carries
workerd, so the override is deliberately absent rather than forgotten; the whole
toolchain is enumerated in `minimumReleaseAgeExclude` instead. `@emnapi/runtime`
floats 1.11.1 -> 1.11.3 in one sharp snapshot, which merely makes the lockfile
self-consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Test][CI] Run the store-emdash contract and races on D1 under the workers pool, nightly
…, and record it in ADR-0018

`plugin-is-sandbox-clean` forbade `@otta-sh/domain` in all three of its `@otta-sh`
clauses. That ban was never "the plugin must not know the domain" — it was a proxy
for "the plugin must not acquire IO", and the property it stood in for is enforced
directly by `domain-is-io-free`: the domain has zero runtime dependencies and no
`node:` imports. The proxy now blocks the composition the adapter split was designed
for, so it goes: `domain` is dropped from all three clauses, `admin-react` stays in
all three (the one-hop escape from the console quarantine stays shut), and
`store-[^/]+` becomes `(?!store-emdash(/|$))store-[^/]+` so the storage adapter is
admitted while `store-postgres` — and any store package added later — stays banned by
default. The IO and builtin bans, the service and payment-adapter clauses and the
optional-`node:` spelling are untouched.

While there, two silent misses are closed.

The sibling-adapter, service and payments bans existed only in the `^packages/…`
spelling, so an UNDECLARED import — which pnpm's strict isolation leaves as a bare
specifier that never resolves to a package path — tripped nothing at all. The same
list is now mirrored into both `@otta-sh` specifier clauses of both rules. This is
the same class of miss as the `^node:`-only builtin clause, and it is now covered by
three cases of its own.

And `@otta-sh/store-emdash` may no longer import `@otta-sh/plugin`. The plugin
injects the store into the adapter, so an import in that direction would make the
adapter depend on its own caller; no rule caught the inversion.

The rules are now executed rather than read. A new boundary test cruises the repo's
real config — copied verbatim, never restated — over a generated fixture tree and
asserts the NAME of the rule each planted import trips: driver, builtin, React
console and SQL store still fail; the domain and the storage adapter now pass; an
unresolved sibling package fails in all three positions; a runtime host import fails
while an `import type` passes; the plugin import and a type-only driver import fail.
The tree is built in a temp directory per run, so nothing forbidden ever exists
inside the repo. The existing text-scan guard is untouched — it catches ambient
globals no import graph can see.

ADR-0018 records the decision: the plugin may own commerce truth in-process on
`ctx.storage`; it amends ADR-0006 Decision 2's "no direct DB/storage access" clause
and only that clause, reaffirms Decision 1 and states exactly what its suites prove
today, what they are obliged to prove when storage lands, and what the D1 tier does
and does not observe; narrows "zero EmDash dependency" to "zero EmDash runtime
dependency" for the adapter package; and amends ADR-0014 Decision 5 for the duration
of the vendored host build. Both amended records gain the pointer front matter this
repo's own convention uses, and ADR-0006 gains a trailing amendment block matching
the precedent ADR-0014 set.

Behaviour-neutral: no package source changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[CI][Docs] Admit the domain and store-emdash into the plugin boundary, and record it in ADR-0018
… behind a transitional mode flag

Six modules each hand-rolled the same four-line `new HttpCommerceClient({
fetch: ctx.http.fetch, baseUrl: COMMERCE_SERVICE_BASE_URL, ...serviceToken })`,
across nineteen call sites — four modules behind a near-identical private
helper, two inline: the PDP commerce loader, the cart, checkout and account
routes, the entitlement download route, and the four content-sync hooks. Cutting commerce over to an
in-process client would therefore have been a six-file diff with six chances to
miss one. It is now a one-line diff in one file.

`src/commerce/make-commerce-client.ts` is the composition root. In "http" mode
it constructs exactly what those six sites constructed — same base URL, same
`ctx.http.fetch` as the only egress, same ADR-0007 write-gate token read from
write-only plugin kv, and still no header at all when the token is unset, so the
wire is byte-identical. `src/commerce/in-process-commerce-client.ts` is the
other branch: it `implements CommerceClient` so the compiler, not a reviewer, is
what guarantees the stub spans the whole port, and every one of its 25 methods
is `async` and rejects with one typed `NotImplementedError` until the bodies
land. Async, not a synchronous throw: the port is promise-returning and callers
compose it, so a synchronous throw would escape those shapes by a path the real
implementation never will.

`checkEntitlement` is now declared on the `CommerceClient` port rather than only
on the HTTP adapter. The download route called it through the concrete class;
routing that route through the factory means it is handed the port, so the port
has to carry it. The adapter already implemented exactly that signature, so this
changes no behaviour — only what the type system knows.

`ALLOWED_HOSTS` becomes mode-resolved at module load rather than derived
unconditionally: in "http" mode it is byte-identical to before (the single host
from COMMERCE_SERVICE_BASE_URL, which is what every build, every vitest run and
the sandbox harness resolve to), and in "in-process" mode it is empty for now.
It stays a `string[]` value rather than becoming a function on purpose: the
descriptor, the sandbox entry's http access, the three sync-hook defaults and
both guard suites all consume it as a value, and the descriptor shape must not
move here.

`resolveCommerceMode` lives in its own module rather than in `manifest.ts`
because the workerd sandbox harness does not bundle `manifest.ts` — it writes a
hand-rolled copy of that module's exported surface, so anything declared there
would simply be missing from the sandbox bundle. An unrecognized
`__OTTA_COMMERCE_MODE__` throws rather than falling back: the value is a
build-time define, and silently defaulting to "http" would ship a site talking
to a service the operator believed had been folded in.

Packaging: `@otta-sh/domain` and `@otta-sh/store-emdash` are promoted to
`dependencies` and marked `noExternal`, so the in-process client's future
imports land inside the emitted bundle instead of surviving as bare specifiers —
which in workerd fail at module instantiation, not anywhere readable.
`test/bundle-imports.test.ts` runs the build itself and asserts on the emitted
output for that reason; the only bare specifier it permits is
`@otta-sh/admin-presentation`, which is IO-free and deliberately external.

DELETED AT INC-D3b, all of it: `__OTTA_COMMERCE_MODE__`, `resolveCommerceMode`,
the factory's mode branch, `COMMERCE_SERVICE_BASE_URL` and the derivation of
`ALLOWED_HOSTS` from it, `HttpCommerceClient`, and the four admin HTTP clients
(`admin-orders-client`, `admin-products-client`, `admin-rules-client`,
`reporting-client`). The flag buys exactly one thing — the ability to run the
extracted client contract against both implementations and prove them
behaviourally identical before the HTTP transport is removed. It is not
permanent architecture and nothing may be designed around it.

The four admin HTTP clients are NOT routed through the factory: they are
function-export modules over a bare `{ fetch, baseUrl }` transport rather than
implementations of this port, so folding them in is its own change.

Zero behavioural change. Every pre-existing plugin suite and all 20 workerd
sandbox suites are green unmodified; the only test-count delta is the three new
files here and one added site-config case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Plugin] Route every commerce-client construction through one factory behind a transitional mode flag
…sts so both transports can run it

The HTTP client's test body IS the spec for the commerce client surface, and a
spec only one transport can execute cannot prove a second transport equivalent.
Lift the transport-agnostic cases out of the eight client test files into
`packages/plugin/test/contracts/commerce-client-contract.ts`, exported as the
three slices the in-process client will consume, and bind them to the existing
live-service harness from a new tier file.

The contract knows nothing about how a call travels: every case is arrange
backend state -> call a client method -> assert the returned value. Transports
supply a `CommerceClientTier`: `name`, `setup`/`teardown`, `reset`, `makeClient`,
an OPTIONAL `makeAdminClients` and an `arrange` API with `product` and `cart` —
derived from what the source files actually seed, which is only commerce rows and
carts, through the clients' own writes. A case whose subject is a write method
still calls that method directly; only a case that merely needs a product or cart
to exist goes through `arrange`. No clock, id or hold-expiry hooks: the lifted
cases control time solely through explicit watermark arguments and identity
through explicit idempotency keys, so there is nothing for a hook to do yet. An
admin slice handed a tier with no admin clients fails loudly at collection rather
than running empty, and every case uses disjoint ids — which is the only reason a
tier may implement `reset()` as a no-op, as the README says.

The wire assertions are not weakened and not moved. Request shape, headers
(including both gate tokens), path encoding, status-to-error mapping and the HTTP
statuses the quote route answers on stay in the transport's own files, which are
deleted with the transport. Five cart cases reached past the client to
`POST /checkout/quote`; `quoteCheckout` is on the port, so each is split — the
computed totals in integer minor units and every typed refusal reason are
asserted through the client here, and only the status code stays behind. Three
more cart cases asserted a client half and a wire half together and split the
same way. `admin-rules-client.test.ts` had no wire-specific residue at all and is
deleted outright. Three test names that stated wire mechanics are renamed to the
behaviour they actually assert.

Behaviour-neutral. The `expect(` accounting closes exactly: 166 textual
occurrences before and after, 165 real assertions plus one commented-out future
assertion on both sides. Test count 1008 -> 1013, exactly the five splits, with
the leaf-name diff containing those and the three renames and nothing else.
Live-service gating is unchanged — the same six files skip without a database,
and `pnpm test` still requires no live service. No `src/` diff.

The contract is what survives INC-D3b; the tier file dies with the HTTP client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Test] Extract the commerce-client contract from the HTTP client's tests so both transports can run it
…nd the SQL guards they replace

Records the document model the commerce adapters are built on: an invariant
spanning two facts lives in ONE document; a coupling spanning two aggregates is
made idempotently completable by any replayer and swept. `updateIf` for
contended pure-counter writes, `compareAndSet` with bounded jittered retry for
everything multi-field.

Inventory is described as built, not as planned: a durable per-key claim, a
reverse index written before the hold, then the guarded decrement and the hold
in one compare-and-set — a two-step with one healed crash window and one
mitigated one, plus two early out-of-stock exits that differ in whether they
consume the key. The terminal-answer-before-prune ordering rule, the bounded
movement ring with its accepted residual and the sweeper contract that closes
it, and the contention budget are recorded with the numbers the code asserts,
distinguished from the numbers that are only measured and logged.

Five couplings needed a ruling because the naive translation is wrong, and each
is recorded as design with its owning increment: coupon redemption inverts the
claim order because there is no transaction to roll a refused bump back; the
email lease and the orders customer filter each denormalise an OR into one
indexed field; live-sku uniqueness across products and variants becomes a claim
document, replacing two partial unique indexes; refunds keep a four-state
capacity lifecycle arbitrated only on the reserve path.

Because the Kysely stores are deleted later in this effort, section 7 snapshots
the guard semantics of every statement the design replaces — predicate, the
invariant it holds, the document write that now holds it, and the suite that
proves it, saying so where a suite must still be written. Places where the plan
disagreed with the code are corrected in favour of the code, including the
refund row lock (a real timestamp write, not `FOR UPDATE` and not a
self-assignment), the outbox write (a do-nothing conflict, not an upsert), the
batch methods (two take id sets, not four), the lost finalizer (it echoes the
winner rather than rolling back work), and order creation (only the items insert
is multi-row). Two hazards are named rather than inherited silently: the login
throttle has no unique constraint to fall back on, and cross-customer address
isolation must become an explicit ownership check.

ADR-0013, 0016 and 0017 are unchanged and respected; nothing is amended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Docs] ADR-0019: commerce aggregates are one storage document each, and the SQL guards they replace
… and intent-claimed inventory edges

The cart is the first commerce aggregate whose invariants cross into another one,
so the shape is explicit rather than implied. `carts/{cartId}` carries the lines
map keyed by sku (the old `(cart_id, sku)` unique index, made structural), the
embedded mutation ledger (the old `cart_mutations` table, read and written in the
SAME conditional write as the line it records) and a denormalized `holdExpiresAt`
(the old `expires_at <= now` scan target, since the filter algebra has no OR and
cannot reach inside a map). One lookup collection, `cart_mutation_index`, answers
the two port signatures that are handed an identifier with no cart id — and that
second hop is also the sweep's scoping, because a raw reserve has no cart claim.

Every mutation that touches stock is a bracket: claim in the ledger, the inventory
movement through `InventoryStore` and nothing else, then a completion that lands
the line and `completed: true` together. Hold expiry is the same shape with a
once-only token — onto the line, or onto the outstanding claim when the crash left
no line — so a partial expiry is completable by any replayer, only the minter
reports the reclaim, and stock returns exactly once. A fresh token is refused
while the reservation is already terminal, which is the obligation the inventory
tier hands every reaping path.

THE ATTACH GUARD IS A GUARDED WRITE, as `CartStore.upsertLine`'s contract requires:
the deadline stamp and the guard are one act, and a mere read cannot substitute
because the sweep can reap the hold between the read and the cart write. Since the
port declares no such method and widening it is a domain change this package may
not make, the capability is adapter-local — `HoldDeadlineStamper.stampHoldDeadline`,
implemented by `EmdashInventoryStore` as one compare-and-set in which the
`state = 'held'` precondition, the ownership check and the new deadline commit
together, returning false for an unknown, pruned or adopted hold and never touching
a non-held one. `EmdashCartStore` asks for `InventoryStore & HoldDeadlineStamper`
and calls it inside the compare-and-set step of `upsertLine` and `adjustLine`,
before the cart write (the SQL's fixed step order), so the guard is re-evaluated on
every attempt; a refusal is the port's `HoldExpiredError`. That also keeps
`adopt`/`adoptMany`'s `expiresAt > now` scope satisfied for cart holds, which a
cart-only deadline would have broken outright, and it is what makes the two
tolerated `release` refusals recognizable by type rather than by message.

`adjustLine`'s reconcile is a REPAIR, not a retry: once the key is completed the
mutation must never re-apply, but the stored qty still owes the hold agreement, so
a divergence is fixed in place with the completion preserved — a bare retry would
find `completed` and hand back the stale line.

Tests: the domain contract runs green on sqlite, Postgres and D1 with no skips; the
three dialect suites and the cart race gate are ported off the SQL adapter (the
crashed-hold state is now PRODUCED by the real claim and reserve rather than
hand-seeded); a new seams suite opens the interruption points, and the file states
which of its cases inject a fault and which do not rather than claiming all do; a
regression case drives `adoptMany` over a hold the cart attached, which is the only
thing that would notice the stamp going missing; a Postgres case races two
different-key adjusts and pins the convergence the repair buys. The inventory
contract harness now calls the real `stampHoldDeadline` instead of patching the
document by hand.

Also types the inventory store's `release` refusal on a non-live hold — the bare
`Error` becomes `ReservationNotReleasableError` with the same message — and fixes
the README's recorded drift on the merchant removal shape's measured contention
failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Adapters] Implement EmdashCartStore with an embedded mutation ledger and intent-claimed inventory edges
…adoption intent over one order document

`OrderStore` over EmDash plugin storage, on one aggregate document per order plus
two claim collections — ADR-0019 §3/§4/§7.9/§7.10 applied to the order aggregate.
First of three increments on the port: creation, the guarded transitions, the audit
spine, order expiry and the three cross-aggregate hold intents.

Creation is a claim, then a create-if-absent, then a promotion. `order_keys/{key}`
is claimed first and carries the WHOLE prepared document, so a replayer finishes an
interrupted create byte for byte — same order id, same minted line ids — and the
claim is promoted only after the order document exists, because a terminal key over
a missing order reads as "already minted" and would lose the checkout. Both halves
of that window are healed by ordinary calls, and both are fault-injected.

Snapshot immutability is structural: `items` is a `readonly` array of `readonly`
fields written only by the creating write, and every later write is `{ ...doc, … }`,
carrying the same array by reference. A product edit cannot reach it, and neither
can a future method without a compile error.

Each transition is ONE compare-and-set: the flip guarded on the revision and on
`state === fromState`, the appended audit event, and the first-wins outbox entry per
`(orderId, toState)`. Parking that single write proves all three are absent
mid-write and present after — the atomicity the SQL adapter got from a transaction.
Expiry adds the deadline to the guard and then releases the order's adopted holds;
if that release fails after a durable flip it is swallowed and left to the sweeper,
because the port's return means "did this call win the flip".

Adoption, commit and release span N inventory documents, so each is intent → per-id
idempotent write → completion, with the intent recorded on the order document by the
same write as the state change that implies it, and one declared index
(`holdsPendingAt`) carrying the earliest outstanding intent so the sweeper can find
it. Every completion is guarded on the order's state and closes the intent
stamp-only otherwise: re-adopting a paid order's committed holds would report every
id lost and invent a stock anomaly. The commit completion drives the SINGULAR
`commit` per id — `commitMany` skips an already-committed id and leaves its hold
live over spent units — and folds both a lost hold and an unknown reservation id
into `lost` rather than wedging the sweeper on one order.

Two corrections to ADR-0019 §4, for that ADR's next amendment. `payments.provider_ref`
UNIQUE was GLOBAL, so the dedupe is a claim document, `payment_refs/{providerRef}`,
and a reference held by another order is refused with a typed error rather than
recorded twice under a ceiling that reads the captured sum. And per-order NOTES do not
belong in this document — operator free text with no natural bound — so INC-B8 gets
`order_notes/{orderId}:{noteId}` indexed on `orderId` instead.

`recordPayment` and `flagReconciliation` land here although they sit in INC-B3's area:
both are on settle's path, so the races and the end-to-end flow cannot run without
them. `recordPayment` throws rather than silently no-op'ing on a missing order, and
`listExpirable` throws a typed, retryable signal rather than truncating a scan.

Methods INC-B3 and INC-B4 own throw a typed `NotImplementedInIncrementError` naming
their increment, and every contract case that needs one is a `test.todo` carrying the
same name (46 of them). Because the domain's suites register every case for the whole
port and import `test` themselves, the 22 cases this increment owns live in
`test/order-contract-b2.ts` as a semantically verbatim COPY (helper renames only) —
scaffolding scheduled for deletion when INC-B4 lands, and guarded meanwhile by
`test/order-contract-drift.test.ts`, which reads the domain suites as text and
requires the copy's titles plus its todo names to cover their case set exactly.

Verification: 22 B2-classified contract cases green on sqlite, Postgres and D1; both
checkout races green on Postgres with table-wide upper bounds restored (single-line
5/5 paid over 8 loops, max 7/12 attempts; multi-line 3 ids/order over 6 loops, no
half-commit, no paid order's hold left live, max 10/12); eight crash seams green on
both Node dialects (six inject a fault and read the state back; two are
completion-robustness cases that inject nothing); the order document held under an 8 KB cap by a test that prints
its size; every pre-existing file at its previous count. Also narrows
`stampHoldDeadline`'s `expiresAt` to non-null — a deadline-less hold is exactly the
one adoption classifies as lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Adapters] Implement EmdashOrderStore creation, transitions and hold-adoption intent over one order document
…lment and cancellation inside the order document's CAS

The refund ceiling `min(Σ captured, frozen total)` is arbitrated INSIDE the single
compare-and-set that appends the ledger row, against that same document's embedded
`payments[]` and `refunds[]` — the document revision doing what the SQL's row lock on
`orders` did, so two concurrent refunds can never each read the same headroom. The
four-state capacity lifecycle lives in that write: `reserved` and `unverified` hold
capacity, `voided` releases it, `finalizeRefund` is status-guarded and never
re-arbitrates. A full refund drives `→ refunded` through the same flip transform every
other state change uses, so "refunded with no refund recorded" is unreachable.

`refund_keys/{key}` is the once-only guard that replaces `refunds.idempotency_key`
UNIQUE and the only handle the settle half of reserve-before-issue has; like the order
key it carries the whole prepared row, so a crash before the order write is completed
with the same refund id rather than reserved twice, and a refused arbitration leaves
the key usable exactly as the SQL did.

`resolveReconciliation` is an equality-guarded compare-and-clear, so a resolution never
clobbers an anomaly re-raised since the operator read it. Fulfillment and cancellation
ride the guarded flip via its `envelope` rather than a parallel copy; cancellation also
records the hold-release intent, because a cancelled order no longer claims its holds —
and `releaseAdopted`'s adopted-only guard is what keeps that safe on an order cancelled
after settle, which is now its own case. The email-outbox lease landed alongside them:
the fulfillment and cancellation specs both assert that exactly one notification
drains, so it is a dependency of this increment's own gate.

`CAS_MAX_ATTEMPTS` rises from 12 to 24 — a package-wide change, made here because this
is where it was measured. The order document's contention bound is money movements
(`2 × refunds-that-fit + 1`: a gateway refund writes twice) rather than inventory's unit
bound, and the refund race measured a depth of 11 against the old ceiling. Every
per-shape assertion in the package is an upper bound at or below the constant and the
hand-set `CAS_ATTEMPT_BUDGET` of 8 is untouched, so the extra attempts buy jittered
backoff on a path that would otherwise raise the typed retryable and alter no
invariant; the measured effect is that the merchant removal shape stops raising it.

Proven by the three domain contract suites in full on sqlite, Postgres and D1 — plus
the four Postgres-only concurrency cases the SQL suites carry, ported unchanged; the
thirteen staged contract cases un-todo'd (33 remain, all for the lists increment); the
refund and reconciliation races, which now assert the compare-and-set depth as well as
printing it; and four new crash seams — a refund claim whose order write never landed,
a crashed finalize, a crashed void, and a cancellation whose release crashed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Adapters] Implement EmdashOrderStore refunds, reconciliation, fulfillment and cancellation inside the order document's CAS
… views and the outbox locator

The last four `OrderStore` methods the document adapter was missing — `listOrders`,
`countOrders`, `listForCustomer` and `linkGuestOrders` — plus the outbox locator the
transitions increment shipped as known debt. `NotImplementedInIncrementError` is deleted:
every port method now has a real implementation and the class had zero throw sites.

The admin list is where a store with no OR in its filter algebra diverges most from the
SQL it replaces, so the shape is explicit rather than implied. The predicate has TWO OR
dimensions and they cross:

* the search's order-id PREFIX arm rides `searchKey` = `orderId.toLowerCase()` as a single
  `startsWith` — anchored, folded on both sides, a whole id its own prefix, `""` matching
  everything;
* the search's `buyer_ref` arm rides `startsWith` on `buyerRefLower`. The port documents an
  unanchored SUBSTRING and the filter algebra has no substring operator, so the arm is
  anchored — ADR-0019 §6.1's ratified narrowing. An operator can still type an address or
  its local part; what is lost is the mid-string reach. Four contract cases stay registered
  as named todos saying exactly that (a mid-string fragment, a bare `%`/`_`, a bare `\`, and
  the count under the substring predicate);
* the search's line-sku arm rides a derived collection,
  `order_sku_index/{foldedSku}:{orderId}`, indexed `[sku, createdAt]`. The pair is the
  document id, so a multi-line order owns ONE pointer and one-row-per-order is structural;
  the pointer copies the order's frozen `createdAt`, so the LIST arm takes its own keyset
  top `limit + 1` rather than resolving every pointer a sku ever collected. The COUNT has
  no page to stop at and stays `O(matches)`, bounded by `maxListPages × LIST_PAGE_SIZE`
  with a typed `ScanPageLimitError` past it;
* the customer key keeps its UNION and needs `buyerRefLower` as a declared index: a
  contract case pins the edge ADR-0019 R3 left conditional (a `buyerRef`-only key must also
  return an order already linked to a customer id). Arms are merged for the list and summed
  by inclusion–exclusion for the count, so an order matching several arms is counted once.
  One field is named by both dimensions, and the overlap is resolved arithmetically rather
  than by an object spread that would drop a predicate;
* the keyset cursor is re-derived from the port's value position, not round-tripped through
  the host's opaque token, whose seek re-reads the cursor row. A deleted cursor row is
  therefore not a paging fault, and that self-describing position is what makes merging
  arms exact. The adapter's total order is code-unit `createdAt DESC, id DESC` while the
  host breaks ties under the database's collation, so every arm is drained to the end of
  its boundary TIE GROUP before the page is sliced — without that, a boundary inside a
  `createdAt` tie group silently drops a row on Postgres and not on SQLite.

`markEmailSent`/`rescheduleEmail` find their entry through `outbox_keys/{entryId}` instead
of walking the `emailDueAt` index. The locator is bracketed after the flip that enqueues
the entry and the walk survives as a one-shot heal — but an id neither can resolve now
raises the typed, retryable `OutboxEntryUnlocatableError` rather than settling silently: an
already-drained entry always has a locator, so a quiet return could only ever have hidden a
still-`sending` entry whose lease would lapse into a double send. Both pointer collections
read a refused create-if-absent back and raise `DerivedPointerConflictError` when the
incumbent names another order. The by-sku index heals the other way round — written before
the key is promoted, so any key replay re-asserts it idempotently; a crashed create whose
key is never replayed stays a sweeper residual.

Test staging ends here: the transition and timeline suites call the domain's own contract
functions directly, and only `orderStoreContract` keeps a copy, shrunk to hold the four
blocked cases (43 of 47 active). Two new crash seams (the derived index, the locator), a
ported `outbox-dispatch`, and `order-list-cases.ts` — the document model's own list, search,
customer-union, tie-group, multi-arm-paging and locator statements — shared by both Node
dialects and D1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
[Adapters] Implement EmdashOrderStore lists, counts, search, customer views and the outbox locator
…and state the outbox locate semantics

The orders-list `search` guarantee becomes the ratified narrowing of ADR-0019 §6:
an anchored PREFIX on the order id, an anchored PREFIX on the folded buyer
reference, an EXACT folded purchase-time line sku, with `%`, `_` and `\` in the
search string compared as characters rather than as pattern syntax.

The contract states a FLOOR, not a ceiling. An adapter may match more — a store
whose SQL can serve an unanchored `LIKE` keeps the buyer-reference arm as a
substring superset — so the four narrowed cases assert only what every store must
provide and never assert that a mid-string fragment fails; a store that cannot
serve the superset pins its own narrower behaviour in its own package tests.

- the buyer_ref case is renamed SUBSTRING → PREFIX and drops the mid-string
  assertion, keeping whole-address and leading-fragment matches;
- the `%`/`_` and `\` cases keep their titles and their escaped-metacharacter
  assertions, and re-express the bare-metacharacter edge within prefix semantics
  (an address that literally starts with the character is reached; one free of it
  is not) by membership, so a substring superset stays conformant;
- `countOrders` counts under prefix searches on the id and the buyer reference,
  an exact sku, and a search reaching one order through two arms at once — one
  row, count one — and pins list length against count for each.

The in-memory fake needed no change (substring is a superset of prefix) and no
adapter was touched: the suite is green on the fake, and on SQLite and Postgres,
unmodified.

`markEmailSent`/`rescheduleEmail` also gain the locate semantics they never
stated: an adapter that cannot find the entry `claimNextEmail` handed it must
throw a typed retryable error rather than silently succeed — a SQL store's
guarded update addressed by primary key is the no-op form, a document store that
must re-read the owning aggregate throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
…, and correct the port's adapter attributions

The narrowed copy of `orderStoreContract` and its drift guard are DELETED. They
existed only while the port guaranteed an unanchored `buyer_ref` SUBSTRING that a
document store's filter algebra cannot express: the copy held four cases as named
todos, and the guard compared its title set against the domain suite's so that the
two could never drift apart. The contract now guarantees the ratified anchored
PREFIX (ADR-0019 §6), which that store serves, so both files go and the domain
suite runs directly — all 47 cases active on SQLite, Postgres and D1, no todos,
with no change to any adapter's source. `packages/store-emdash/README.md` is
restated to match: the buyer-reference arm is an anchored prefix, and the store's
own narrower statement — a mid-string fragment finds nothing — stays pinned in its
package tests rather than in the domain contract.

The port's docblocks also stop attributing one adapter's mechanism to all of them:

- the SQL rendering of `search` is labelled the FLOOR's spelling, with the note
  that no shipped adapter emits it (the SQL stores emit the unanchored `%…%`, and a
  document store emits no SQL at all);
- "WHY `EXISTS`, NEVER A JOIN" becomes ONE ROW PER ORDER as the INVARIANT, with the
  correlated `EXISTS`, the fake's `lines.some(...)` and a document store's
  per-`(sku, order)` key named as three ways of reaching it;
- pattern escaping is the SQL adapters' business, not every adapter's;
- `markEmailSent` no longer calls the SQL update GUARDED — it addresses the row by
  primary key and nothing more — while keeping why a zero-row outcome is a safe
  silent no-op there and a throw in a document store;
- `OrderCustomerKey` no longer justifies its exactness by "a substring would fold
  two customers", since a prefix folds them just as well.

Cross-references in `coupon-store`, `product-commerce-store` and the product
contract restate the orders search as prefix-only; the contract's own stale "that
widening belongs to buyer_ref alone" and the customer-filter case title follow. The
changeset records the admin Orders search label and empty-state copy as affected
consumers, to be reworded separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
…tract

[Domain] Narrow the order-search contract to a prefix floor and run it directly on every store
…bedded

One aggregate document per product with its variants inside it, plus a
`sku_owners/{sku}` claim document for live-sku uniqueness across both grains.

The four database features the SQL adapter leaned on each become a document
write: the two-guard conditional upsert becomes one compare-and-set whose guards
are computed against the value it just read; the `updated_at` compare-and-set
plus its zero-row classifier keep the same order inside that write; the two
partial unique indexes become the claim document, whose `live` flag is what
"unique among live rows only" now means; and the written-down lock order over
product, inventory and variant rows is retired by embedding — two writers under
one product contend for one revision, so the interleaving the order existed to
forbid is unreachable rather than merely ordered.

The sku rename is an intent-claim: the target is claimed create-if-absent, then
ONE compare-and-set on the source zeroes it and stamps the carry, then the target
adds the units iff its bounded ring lacks the token, then the source clears the
stamp. The token is derived from the write's own idempotency key, so a replay
recomputes it and adds nothing twice, and any replayer finishes a partial from the
source document alone. The live-hold refusal is a read of that same document.

Two deviations from the design's index table, both forced and both recorded: the
publish gate is filtered through a text mirror because a boolean cannot be bound
as a filter value on one dialect, and `titleLower` is not declared because the
port's search is a substring and the filter algebra has no substring operator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011NjdC8awspUte5wML6eY2X
vedanshujain and others added 27 commits September 19, 2026 09:16
T3 — the contract suites and the races over real D1 inside workerd — has been
running as an informational nightly job. It is the dialect the storefront ships
on and the only tier that exercises the host's own Kysely wiring, so nothing
should reach main without it being green.

The `d1` job now also runs on a pull request whose base is `main` and on the
`main` push that merge produces, which is what "release" means here: the
integration branch's own PR, not the per-increment PRs into it. Those still
gate on the targeted local run — several minutes of workerd per increment buys
nothing the release run doesn't. The nightly schedule and workflow_dispatch
paths are untouched, and the job deliberately takes no `needs:`, because `unit`
is skipped on the nightly schedule and a skipped dependency would skip this job
with it. Making it a required status is a branch-protection setting; the job
comment says which name to require and why requiring it is safe for the PRs
where it does not run.

`pnpm test:d1` was undocumented outside the plan. It is now in CLAUDE.md's
command block, CONTRIBUTING.md's edit loop and DEVELOPMENT.md §2, each saying
the same three things: it is a separate vitest project that the root `pnpm test`
does not include, it is entirely local (miniflare's simulator — no account,
token or remote database), and it costs minutes because it boots workerd and
re-migrates per file.

No vitest config changed. The D1 project keeps its own config file, outside the
root config's `packages/*/vitest.config.ts` glob, so the root's
`fileParallelism: PG_CONNECTION_STRING === undefined` guard still belongs to the
Postgres tier alone — DEVELOPMENT.md now records why that separation exists.

Verified: pnpm lint, pnpm typecheck and pnpm format clean; pnpm test:d1 green at
14 files / 525 tests in 182s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
[CI] Promote the D1 tier from nightly-only to a release gate
The commerce service is folded into the plugin, so the plumbing that let the
plugin choose between talking to it over HTTP and running in-process no longer
selects anything. This deletes that plumbing rather than leaving a switch with
one position.

Gone: the `__OTTA_COMMERCE_MODE__` and `__OTTA_COMMERCE_SERVICE_URL__`
build-time defines, `resolveCommerceMode`, `COMMERCE_SERVICE_BASE_URL`, and the
`makeCommerceClientFor` / `makeAdminClientsFor` mode-dispatching factories —
`makeCommerceClient(ctx)` and `makeAdminClients(ctx)` now construct the
in-process clients unconditionally. `ALLOWED_HOSTS` is no longer derived through
the mode; it is Stripe plus whatever the two egress defines bake in.

Gone with them: the `settings:serviceToken` / `settings:internalToken` kv keys,
`readAdminTokens`, and the Settings form's "Service connection" group. These
were transport credentials for authenticating to the commerce service, and
there is no service left to authenticate to.

The service's `wrangler.jsonc`, its `wrangler deploy` scripts and its `wrangler`
devDependency are removed, as is DEPLOYMENT.md's dedicated service-Worker
section. The `sites/staging` deployment sections are untouched.

Collapsing the mode turned 18 sandbox suites red: they booted without declaring
`storage` and asserted against a stub HTTP server that nothing calls any more.
Those suites are retrofitted to seed and read real `ctx.storage` rows. A handful
of tests were deleted rather than weakened, each because its subject no longer
exists (token round-trips, header forwarding, request-log assertions) or because
it is unreachable as a black-box against real storage.

The HTTP client classes and their wire-contract suite deliberately survive for
the follow-up increment that removes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…he coverage the retrofit dropped

Two reviewers found that collapsing the commerce client to in-process left both
a user-facing lie and a set of weakened or silently-deleted tests.

The copy first: 31 error and banner strings across eight admin screens still
told operators to "check the service connection and the admin token in
Settings" — a service and a Settings field this branch deletes. They now name a
recourse that exists ("retry in a moment"), keeping the E-7 "fault in the
console itself — not your data" disclaimer. The fail-closed page_load banners
for /coupons, /reports, /shipping and /tax are covered again as non-sandbox unit
tests, which is the gap that let the wrong copy through.

Coverage restored where the subject still exists: T12's per-delivery call counts
(final row state cannot tell a no-op activate from an absent one), T19's
omission-vs-null distinction against a seeded row, the tax duplicate-id
draft-loss assertion that had become a comment, settings-widget's exact label
matches and its E-1 degradation case, the PLP invariant guard's total-cost and
both-sides dedup assertions, the orders paging and non-cursor-refusal cases, and
the partial-refund arithmetic figure.

Moved to a seam that does not need a gateway: the refund idempotency key's
positive case, resolveStockContext's filterUnavailable branch (the deletion
rationale was wrong — products-read.ts computes it from exactly that condition),
and the refundedCents fallback. The checkout success paths are parked as
test.todo rather than deleted, and the reports degradation case no longer
asserts the regression as the spec.

The sandbox harness now derives a production-shaped allowlist for the Stripe
settle path, so a lost Stripe grant cannot stay green; the retired placeholder
host is gone. DEPLOYMENT.md no longer instructs a build the build ignores, the
dead COMMERCE_SERVICE_URL plumbing is swept, and the plan records that D3b's
factory collapse already landed in D3a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…todo items

Substitutes the #TBD-checkout-stripe-gateway and #TBD-reports-degradation
placeholders with the real tracking issues (#286, #287) filed against
otta.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…e collapse missed

The 18-file sandbox retrofit in 336f1b6 stopped wiring a live commerce service
into `loadPluginInSandbox` and moved every suite onto the plugin's own document
store. These two were missed: both still started the Kysely/Postgres-backed live
service and booted the sandbox without declaring `storage`, so every route call
threw the in-process store's `MISSING_STORAGE_MESSAGE` instead of answering.
Nobody saw it because both suites were gated on `PG_CONNECTION_STRING` and
therefore skipped — silently green — in the review loop. Run with Postgres, they
were 9 failed / 3 passed.

Both now follow the pattern the other sixteen established: boot with
`storage: true`, seed and read real rows through the same `@otta-sh/store-emdash`
adapters the plugin composes, and declare NO allowed hosts so any `ctx.http` call
from these routes throws. The live service is untouched and still used by the
HTTP client suites.

The Postgres gate is REMOVED rather than kept: the service was the only thing in
either file that needed a database, and a gate that skips is exactly what let
these rot through a whole retrofit.

What each case asserts is unchanged, with two exceptions that no longer had a
subject. Logins are redeemed through the plugin's own `login/verify` route, with
the challenge issued host-side because this transport dispatches no mail yet; the
download fixtures grant the entitlement under the same deterministic
`ent:{order}:{sku}` key `settleOrder` writes, whose own path is covered by the
settle suite. The download suite's old "the allowlist blocks the service host"
case is replaced by the failure mode the collapse actually introduced: a boot
with no document store authorizes nothing and fails closed.

Verified: both files 12/12 green against Postgres and without it; the
plugin + site-staging tier 2291 passed / 130 skipped / 14 todo / 0 failed
(+12 passed, -12 skipped vs the prior baseline); lint, typecheck and
format:check clean. Perturbing the `storage` wiring turns 8 of the 12 red with
the in-process storage error, so the fix is load-bearing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
[Plugin] Retire commerce service Worker deployment surface
INC-D3b. With commerce running in-process inside the plugin since INC-D3a,
the standalone Hono service and the Kysely/Postgres store adapter are both
dead code. Both packages go entirely, along with their project references.

`@otta-sh/store-postgres` took its 24 migrations, every `Kysely*Store`, its
`.`/`./pg`/`./testing` subpaths, 29 `*.dialects.test.ts` files and 12
`*.pg.test.ts` race files with it. NONE of that concurrency coverage is
lost: every one of the 12 race files already has a same-named, re-pointed
`store-emdash` counterpart from the Phase A/B increments, and all 12 were
run against the local test Postgres and confirmed green BEFORE this
deletion — adjust-concurrency, coupon-no-over-redeem, no-oversell,
no-oversell-cart, no-oversell-checkout, no-oversell-checkout-multiline,
refund-race, resolve-reconciliation-race, restock-concurrency,
rules-cas-race, sku-rename-race and variant-sku-rename-race, 52 tests
passing. The no-oversell gate is intact; it just runs over the document
adapter now.

Nothing had to be rescued from the package. `store-emdash`'s dialect
harness builds its own Kysely `PostgresDialect`/`SqliteDialect` straight
from `kysely`, `pg` and `better-sqlite3`, all declared in its own
package.json, and `store-emdash/src/id-gen.ts` has carried its own copy of
`uuidIdGen` since it was written precisely so this copy could go.

`test:pg` needs no change to keep selecting the right files: its glob
walks `packages/*/test`, so it now resolves to 60 `store-emdash` files and
zero deleted ones. The CI `integration` job, its Postgres service
container and `test:pg` are all untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…ervice harness

INC-D3b. INC-D3a collapsed `makeCommerceClient`/`makeAdminClients` to the
in-process tier unconditionally, which orphaned every HTTP client behind
them. They go here, with the tests that only ever exercised the wire.

Deleted: `HttpCommerceClient` (whole module), and the four admin HTTP
clients — `AdminOrdersClient`, `AdminProductsClient`, `AdminRulesClient`,
`ReportingSettingsClient` — with their `*Options` types; the six
`http-commerce-client*.test.ts` suites; `commerce-client-contract.http.test.ts`;
`test/helpers/start-live-service.ts`; and `@hono/node-server`, which had
no other user. `commerceClientContract` now has one tier, the in-process
one.

What did NOT go: the wire types and the four `*Surface` ports, which the
console's surviving in-process code depends on throughout. Each Surface
was `Pick<ThatClient, …>`, so deleting the class would have taken the port
with it. Each is now an explicit interface with the signatures lifted
verbatim — Orders 12 methods, Products 6, Rules 25, ReportingSettings 6.
That is a strictly better home for them: the `Pick`-over-a-nominal-class
idiom existed only because `#`-private fields made the class unassignable,
and the property it was protecting ("every method is listed, so adding one
without deciding what the in-process tier does is a compile error") is
preserved. The four `InProcess*Client` classes now `implements` their
Surface, so tsc checks the lifted signatures structurally rather than
taking them on trust. The files are renamed to say what they now are:
`admin-*-client.ts` → `admin-*-surface.ts`, `reporting-client.ts` →
`reporting-settings-surface.ts`.

`test/helpers/stub-commerce-server.ts` is KEPT, renamed to
`stub-http-server.ts`. The plan had it deleted as a stand-in for the HTTP
transport, but it is not one any more: it is a generic recording HTTP
server, and three surviving sandbox suites use it for things that have
nothing to do with commerce — `sandbox-harness.test.ts` and
`in-process-egress.sandbox.test.ts` as an email-API endpoint proving
`allowedHosts` egress control, `stripe-settle-route.sandbox.test.ts` to
back a Settings re-render while asserting no secret leaks. Deleting it
would have silently dropped that coverage.

`playwright.config.ts` was a live break, not just stale prose: its
webServer stack still booted `packages/service/src/index.ts` and waited on
its `/health`. The stack is one process now, so that entry and the dead
`E2E_SERVICE_URL` knob are gone; §0.3's rule that no e2e surface may name
port 5432 is untouched and still enforced by `harness.spec.ts`.

The rest is prose: comments across the plugin, domain, store-emdash,
admin-react and the staging site that cited `@otta-sh/service` or
`@otta-sh/store-postgres` as present tense now read as history. The
synthetic fixture strings in `depcruise-boundary.test.ts` are deliberately
left — they test unresolved-specifier handling and must name packages that
do not resolve.

On the plan's open question for INC-D4: `packages/plugin/src/types.ts` is
unchanged, because the premise was a misreading. It holds no commerce wire
types at all — every hand-mirrored block in it mirrors the HOST (EmDash's
Block Kit contract) plus `HttpAccess`, the `ctx.http` capability surface,
which stays for the email API and the payment gateways. The types that did
mirror the service's wire format are the `*Wire` interfaces in the admin
surfaces and `commerce-client.ts`; the call to keep them, and the narrower
question left for D4, is recorded at the top of `commerce-client.ts`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
INC-D3b. `changeset version` hard-fails on a changeset naming a package
that is no longer in the workspace, so this is part of the deletion rather
than housekeeping after it.

Of 148 unreleased changesets, 59 named `@otta-sh/service` or
`@otta-sh/store-postgres` in their frontmatter. 51 also named a surviving
package and were re-pointed — the dead frontmatter line dropped, every
surviving line and the note kept. 8 were entirely about a deleted package
and are removed outright. 140 changesets remain.

Five of the re-pointed notes needed a prose edit too, because their
summaries made a claim that spanned the dead and surviving packages and
stopped being true once the line went: a count of "all four published
packages", a dangling "store-postgres stays patch" clause, and three
paragraphs explaining bump reasoning for a package that will never be
bumped again.

Left deliberately: 25 notes still describe, in the past tense, what
shipped inside those packages at the time. That is accurate history and
`changeset version` only reads frontmatter, so nothing breaks — but the
released CHANGELOG will carry bullets attributed to packages that no
longer exist, which is worth a pass in the D4/D5 docs increments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
Review of the deletion increment found the changesets under-stated it and a
handful of guards that died with `@otta-sh/service` had no surviving
counterpart. This closes both.

**Changesets.** A new changeset records the increment itself: the six public
exports leaving `@otta-sh/plugin`'s entry point (a `minor`), and the removal of
`@otta-sh/service` and `@otta-sh/store-postgres`. Neither deleted package can be
named in frontmatter — changesets refuses a release plan for a package whose
directory is gone — so the removal is recorded in the prose of the package that
outlived them. The remaining changesets were swept: the earlier pass trimmed
their frontmatter but left bodies narrating deleted packages, dead REST routes
and deleted client classes, all of which would have published verbatim into
surviving packages' CHANGELOGs. Two changesets described nothing that survived
and are deleted.

**The two concurrency races are re-created, not just mourned.** The once-only
note append under a shared idempotency key, and the single audit event under
racing state flips, were local to the deleted adapter's dialect suites, so
`store-emdash` never inherited them. Both now run against `EmdashOrderNotesStore`
and `EmdashOrderStore` as `runIf(ctx.canRace)` cases in the existing
`describeEachDialect` blocks, on Postgres, where a race is real.

**Restored negative guards.** Cart lines carry no price; a guest's read of a
fulfilled or cancelled order drops the staff witness and the cancellation
detail. Each is a typed whitelist at the producer, so these are belt-and-braces
— but they are the assertions the deleted suite held. The admin route's
`public: false` gate, which had lost its only coverage, is pinned at the
manifest.

**Corrected a false comment.** Two comments claimed no guard existed for
`serializeCart` dropping `state`. It does: `serializeCart` is annotated
`: CartWire`, whose `state` is required, so dropping it fails to compile. The
comments now say so. Three `skipIf(tier.payments)` cases that skip on every
tier now explain themselves and point at their domain-layer coverage.

Smaller unasserted bounds in the in-process admin clients are tracked in #289.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
Both cases were mutation-tested and neither died. Breaking the loser path in
`EmdashOrderNotesStore.append` (return `appended: true` unconditionally) and in
`EmdashOrderStore`'s guarded flip (report a won flip on a refused
compare-and-set) left both suites GREEN. The CAS logic is correct; the tests
were not reaching it.

**Why.** `Promise.all` over N store calls is not a race here. `pg.Pool` opens
connections lazily, so the first caller takes the one warm connection and
completes its whole read-modify-write while its peers are still finishing a TCP
connect. Instrumented on the note-append shape: all 8 callers entered within
3 ms, the winner's pre-read returned at +15 ms and its create-if-absent
committed at +28 ms, and the other 7 pre-reads returned at +47 ms or later —
every one of them finding the committed note. So all 7 took the replay branch
and no two callers ever held the same revision. The `markPaid` case degenerates
the same way, one step earlier: the peers read `state: "paid"` and refuse at the
`doc.state !== fromState` guard without issuing a compare-and-set at all.

**The fix** is a barrier, not more callers. `barrierCall` joins the existing
fault-injection decorators: it holds the first N matching writes until every one
has arrived, then releases the crowd into the real repository at once. Arrival
at the barrier is itself the proof of contention — a caller only gets there
after its own read decided to write — so the two cases now assert
`barrier.arrived()` alongside their outcome. The barrier is one-shot, so the
retry each loser performs passes straight through and the crowd cannot deadlock.

Both mutations were re-applied against the barriered tests: 3/3 runs RED, with
8 of 8 appends and 12 of 12 flips claiming a win. Restored, 3/3 runs GREEN.

Two changesets from the same review round: `admin-wire-completeness` restates
the operator-facing warning that died with the REST service — the three admin
lists still issue their count concurrently with the page read, so each request
peaks at two host connections, now on the in-process path.
`entitlements-check-auth` gets back the concrete false→true behaviour change
that justifies its `minor` on `@otta-sh/domain`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…ostgres

[Adapters][Plugin][Test] Delete packages/service and packages/store-postgres
INC-D3b removed packages/service and packages/store-postgres. Two kinds of
reference to them survived, and both are the sort that rots quietly.

`.dependency-cruiser.cjs`: `plugin-is-sandbox-clean` banned `service` in all
three of its spellings — the node_modules path, the bare specifier and the
`^packages/` path. A ban on a package that cannot be imported is a clause no
fixture can exercise, so nothing would notice if it stopped matching, which is
precisely the failure mode the `^node:`-only builtin clause had for months.
Dropped, with the reasoning recorded in the rule's own comment next to the
three narrowings before it.

store-postgres was never named literally in that rule: it was caught by the
`(?!store-emdash(/|$))store-[^/]+` lookahead, which is untouched. That is the
difference the cleanup turns on — the lookahead is a statement about the store
family and still bans a store-postgres reintroduced tomorrow, whereas `service`
was a statement about one dead package. `domain-is-io-free` and
`store-emdash-is-sandbox-clean` still name `service`; they are out of this
increment's scope and are flagged rather than edited.

`depcruise-boundary.test.ts`: the "unresolved service import is forbidden" case
tested the clause that is gone, so it goes with it. The store case is the one
worth keeping — it is the only one exercising the RESOLVED half of the store
ban — so its fixture moves from `store-postgres` to a `store-sqlite` stub that
is deliberately not a real package, and the suite stops depending on whichever
SQL adapter happens to exist.

CLAUDE.md: the `[Service]` tag row named an area that no longer exists, and the
status paragraph listed `@otta-sh/service` among the packages under
`packages/`. The adapters row loses its postgres/sqlite/d1 examples for the
same reason.

CI needed no change and gets none: there is no service matrix and no service
deploy job — `unit` and `integration` are both workspace-wide. Verified by
grep, not assumed. The Postgres service container and `test:pg` stay:
store-emdash still needs a real database for the no-oversell race.

No changeset: nothing under `packages/*/src` changed, so no published
package's behaviour moves.

Verified: pnpm lint green (551 modules, no violations), pnpm typecheck green,
pnpm format:check green, depcruise-boundary.test.ts 16/16 passing,
changeset status exit 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
Two files outside this increment's named scope carried the same rot it was
opened to clear, and nothing later in the plan reaches them.

CONTRIBUTING.md's PR-tag table was a copy of the one CLAUDE.md already had
fixed: a `[Service]` row for a package that no longer exists, and an adapters
row naming postgres/sqlite/d1. A contributor reading it would pick a tag for a
deleted package. Fixed the same way CLAUDE.md was — the `[Service]` row is
gone, and the adapters row names the three adapter packages that survive.

DEVELOPMENT.md §3 claimed the REST API in `@otta-sh/service` mirrors the port
1:1 and that the contract suite runs against `HttpCommerceClient` over a live
test server. Both were deleted with the service. CLAUDE.md's non-negotiables
cite DEVELOPMENT.md as the authority for that section, so the two documents
contradicted each other. Restated rather than dropped: what verification looks
like now is the same behavioral suite, running every case it ever ran, against
the one in-process tier over a real document store, with `ctx.http` bound to a
rejecting stub.

`domain-is-io-free` and `store-emdash-is-sandbox-clean` still hardcode
`service`. That is outside this increment's scope and is a comment-level
inaccuracy rather than a behavior change — a ban on a package that cannot be
imported can never fire — so each now carries a one-line pointer to #291
instead of being edited here.

The reported intermittent failure in depcruise-boundary.test.ts did not
reproduce: ten consecutive standalone runs were 16/16 green. The harness has no
shared mutable state to race on — one mkdtemp per run, every filesystem call
synchronous, depcruise invoked through spawnSync, and cases within the file
sequential. No test change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
[CI][Docs] Trim stale service/store-postgres references from tooling and docs
…EPLOYMENT

The commerce service is gone from the tree; these are the documents that still
described it.

- README: delete "Why two parts" outright rather than rewriting it — there is no
  split left to explain, and the surrounding sections already say what the
  architecture is. Correct the "separate databases" architecture line: commerce
  truth and CMS content share the site's single D1 database, commerce in the
  host's per-plugin document store namespaced by plugin id and collection.
- DEPLOYMENT: drop the Node + Postgres service shape entirely and renumber; there
  is one deployable and one database, so no shape table, no service secrets or
  env vars, and no asymmetric-rollback caveat (already removed with the mode
  plumbing — nothing left to drop). Retarget the secrets and operations sections
  at what actually exists: two Worker secrets, payment/email credentials in
  write-only plugin kv, the build-time allowedHosts perimeter, and the
  site-cron-drives-executor / plugin-task-every-15m cron split.
- ADR-0020 (accepted): one deployable. Answers ADR-0002's five "a service may
  remain preferable" reasons one by one as rejected, pre-launch, with no users;
  records the Stripe-secret trust widening and what bounds it; fixes the
  settle-route requirement as public-and-must-stay-public with the unconditional
  Stripe HMAC as the trust anchor (correcting the planning note that called for a
  non-public route, which cannot receive a webhook on this stack) and names that
  no test pins the flag yet; states the site-owned webhook endpoint is permanent;
  states a future service is re-derived from the unchanged domain ports, never
  kept on standby. It does not deprecate ADR-0001, and says so.
- ADR-0002 marked superseded in part: the split is undone, the ports-and-adapters
  discipline stands and is what made the deletion safe.
- adr/README: the 0002 supersession, the 0018/0019 forward references resolved,
  the 0020 entry, and the now-reversed "separate commerce database" queued item.

Docs only. pnpm lint, typecheck and format:check green; changeset status exit 0
with no new changeset (no published package changed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…references, and stop overselling Stripe

Close the review findings on INC-D4.

- README: rewrite the "three parts" intro as one deployable (the plugin owns
  commerce truth in-process via @otta-sh/store-emdash on ctx.storage), and fix
  the screenshot caption, the quick-start comment, the product-model line, the
  ports-and-adapters bullet, the deployment bullet, the repository-layout table
  (drop the two deleted packages, add the two admin ones) and the Status
  section. The Postgres-required concurrency line is left alone: it is a true
  statement about the domain contract suite, not about the deleted service.
- README: the Workers deploy guide is DEPLOYMENT.md §2, not §3.
- sites/staging/README: re-point every DEPLOYMENT.md citation at the new
  numbering and describe the real secret set; SERVICE_API_TOKEN is gone.
- ADR-0018/ADR-0019: resolve the ADR-0020 forward-references in the bodies, not
  just in the index.
- DEPLOYMENT.md: the Stripe secret key is stored but consumed by no live payment
  path — say so instead of promising payable checkout and refunds, and note in
  the egress section that payments-stripe does not yet route through
  ctx.http.fetch, matching ADR-0020 §2's caveat.
- hold.ts: DEFAULT_HOLD_TTL_MS is both default and effective value; drop the
  CART_HOLD_TTL_MS citation into a deleted section.
- Normalize ADR-0002's supersession wording to the repo's "partially
  superseded" precedent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
[Docs] One deployable: describe the current architecture in README, DEPLOYMENT.md, and a new ADR
…d the two that don't

Closes the plan's definition-of-done item 13 with a backward-looking record beside
the forward-looking plan it closes out: the outcome, the measured numbers behind
R2/R3/R6/R7, the pinned host SHAs and migration number, the conflict resolutions,
and what Phase D deleted.

Every figure is cited to the artefact it came from. R2 (CAS contention) and R3
(document size) have real, asserted, in-repo numbers, taken from the live tables in
packages/store-emdash/README.md rather than ADR-0019's, which predate the ceiling
raise from 12 to 24. R2's adversarial merchant shape is recorded as the documented
exception it is.

R6 and R7 are recorded as gaps rather than filled with estimates. R6 has one real
before/after from INC-A6 (+11.05 kB raw, +4.15 kB gzip for admitting the domain and
the store adapter) but no figure for the fully-folded Worker with the payment
gateways, and nothing at INC-B10a where the plan asked for it. R7's writes/second
was never measured anywhere -- the D1 tier explicitly proves primitive correctness
and disclaims performance. An invented number in a historical record is worse than
an acknowledged gap, so both say so plainly.

Section 6 is reserved, and left empty, for the vendor/README.md content INC-D6 will
move here when it deletes vendor/. Section 5 says INC-D6 has not run rather than
guessing at what it will find. No README pointer: README has no further-reading or
history section and links neither plans/ nor docs/, so adding one would have been
forcing a fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…mbers two reviews caught

Revision round on INC-D5 against two independent REQUEST-CHANGES reviews whose
findings converged. Every fix was re-verified against the source rather than
against the reviews.

- HttpCommerceClient was NOT "verified absent". The class definition is gone,
  but live docs still describe it as current: CLAUDE.md lines 37/52/95 (the
  HTTP-task verification path, "need a backing service", "reaches the service
  only via ctx.http"), packages/plugin/README.md:15 ("Transitional") and
  packages/plugin/test/contracts/README.md:5. Named as an open follow-up, with
  the changeset/ADR-0002/ADR-0007/plans-archive hits called out as legitimately
  historical. The §4 INC-D3c row no longer claims CLAUDE.md was fully swept.
- R2 measured depths topped out at 9-11 and understated the worst case. Added
  the three deeper recorded shapes from store-emdash's per-shape table: restock
  +10 racing 40 reserves = 13, sequenced restock then 40 reserves = 12, and 20
  removals racing 20 reserves = 15.
- The adversarial-exception section mixed eras. The 11-29 typed contention
  failures are the PRE-raise 12-attempt measurement; split pre-raise from
  current and added the live figure that was missing entirely — 15 of 24
  attempts, 0 typed failures, asserted at <= 90.
- The equivalence proof was not INC-B10c's alone. INC-A7 found only 28 of 165
  HTTP assertions were transport-agnostic, so each increment added its own
  slice: B10a, B10b-i/ii, B10c-i (#272) and B10c-ii (#273). Also restored the
  truncated #293 title.
- INC-A6 is PR #252 alone; #251 is INC-A5 (boundary amend + ADR-0018).
- payments-stripe entered the perimeter at INC-C1b/#276; payments-x402 entered
  separately at INC-C5/#281 (commit 5f304d1), when the in-process settle path
  made x402-wiring a real runtime import. The R6 bundle caveat is unchanged.
- The summary blockquote said R6 "does not" have a real number while §R6 calls
  the +4.15 kB delta real; reworded to "neither has the figure the plan asked
  for" so the file stops contradicting itself.
- §1 recorded only wins. Added the accepted cost per ADR-0020 §2: the Stripe
  secret's trust surface widened into the plugin's kv, bounded but stored
  without a live consuming payment path (payments-stripe still defaults to
  globalThis.fetch, not ctx.http.fetch).

Verification: pnpm lint, typecheck, format:check and changeset status all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
[Docs] Work order 02: memory note recording the outcome, measured numbers, and vendoring pins
`0.38.0`, published 2026-09-15, is the first release cut after the
conditional-write PR merged upstream, so the primitives Otta's commerce truth
rides on — `updateIf`, `getVersioned`, `compareAndSet`, `compareAndDelete` —
are released code now rather than a locally built merge.

The four `file:` overrides are DELETED rather than repointed, because every
reason they existed is gone at this release. `@emdash-cms/admin@0.38.0` exports
`./portable-text-table` and `@emdash-cms/registry-client@0.6.0` exports
`./listing-policy` — the two unreleased subpaths whose absence forced those
siblings to be vendored beside the core. And the quiet override,
`@emdash-cms/cloudflare`, still pins `emdash` exactly but now pins `0.38.0`,
which is the version the manifests themselves name, so the exact pin and the
manifests agree and one copy resolves with no help: one `emdash@0.38.0` in
`node_modules/.pnpm`, one key in the lockfile.

That agreement is a coincidence, not a guarantee, which is why
`host-pin.test.ts` is kept rather than deleted. A future
`@emdash-cms/cloudflare` that pins some other exact `emdash` puts a second copy
in the store and binds the Worker bridge to a host WITHOUT the primitives — no
install error, no type error. The test is what makes that loud; the remedy, if
it ever fires, is to bring back an exact `emdash` override.

`minimumReleaseAgeExclude` grows rather than shrinks: the four packages were
absent from it only because `file:` tarballs bypass the release-age check
entirely. They resolve from the registry again, so the whole 0.38 train is
listed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…he memory note

`vendor/` was always temporary: four tarballs, a recorded diff and a rebuild
script, kept only until a release carried the conditional writes. `0.38.0`
does, so the directory goes — 9.6 MB of committed binaries and the script that
produced them.

Nothing is lost with it. `vendor/README.md` was the authoritative record of the
build — the pinned base and PR-head SHAs, the migration number, why each of the
four tarballs was required, why each override was load-bearing, the conflict
resolutions and the build evidence — and all of it moves into §6 of the work
order's memory note, which was reserved for exactly this. The temporary "how to
rebuild the tarballs" framing is replaced by what the release swap settled, and
the generalisable lesson is kept past the vendoring: the host monorepo's
workspace packages can carry source newer than the release their version names,
so any sibling whose unreleased source the core reaches has to be vendored
alongside it — which is what both extra tarballs were.

§5 records the R13 outcome. The released build's migration runner is
byte-identical to the vendored one — 76 migrations, same order, same
`077_plugin_storage_revisions` tail — so a database migrated by the vendored
build holds exactly the rows the released runner expects, by name. No staging
D1 rename was needed and none was performed. The read-only procedure for
checking it is recorded anyway, because the next host bump may genuinely need
it, along with the rule that applied rows are compared by NAME, never by count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…irections

The gate asserts the set of skip-shaped constructs across the sandbox suites
EXACTLY, so it fails when its allowance list is too generous AND when it is too
strict. It had become both at once, and the branch's `pnpm test:e2e` has been
red for several increments as a result — found while re-running the full
battery on the released host, not caused by it.

Too generous: `ALLOWED_SKIPS` still permitted a `describe.skipIf` in
`account-routes` and `download-route` under the documented Postgres gate. The
mode-collapse retrofit moved both suites onto the plugin's own document store,
so neither is conditional any more and every sandbox suite runs
unconditionally. The allowance outlived the thing it allowed.

Too strict: it did not permit the thirteen `test.todo` cases in
`storefront-checkout` or the one in `reports-widget`, all deliberately parked
rather than deleted or inverted when the HTTP transport went, each naming its
blocking work in its own title so the coverage stays visible.

Both corrected, and the matcher tightened to require a trailing `(`. These
suites explain their parked cases at length, so a bare-word match counted every
backticked `test.todo` in a comment as a skip and would have filled the
allowance list with entries that are not code. The gate is stricter after this
than before it, and the parked-case count is now a number a reviewer can argue
with — it should only ever go down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
…ons/ reference

Both independent PR #295 reviewers flagged that pnpm-workspace.yaml's
one-copy comment said "invariant, not a coincidence" while going on to
describe how a future @emdash-cms/cloudflare release could break it —
the opposite of what the commit message for 2d6b6d4 and the memory
note's §5 already say. Flipped the polarity to match: it's a
coincidence of agreement, not an invariant.

Also fixed one sentence in the memory note's §5 that referenced a
nonexistent `migrations/` directory listing; migration names live in
runner.ts, as the rest of the same paragraph already correctly uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8
[CI] INC-D6: de-vendor the EmDash host onto the released emdash@0.38.0
Comment thread .github/workflows/ci.yml Outdated
Comment on lines 100 to 117
for await (const chunk of req) chunks.push(chunk as Buffer);
const reply = (status: number, body: unknown): void => {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
@vedanshujain
vedanshujain merged commit 914684f into main Sep 21, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants