[CI] Fold @otta-sh/service into the EmDash plugin — work order 02 complete - #296
Merged
Merged
Conversation
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
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 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)); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch folds the standalone
@otta-sh/servicecommerce 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:
lint(oxlint + domain-purity dependency check)typecheckbuild(recursive, incl.astro check)test(default/SQLite)test:pg(Postgres, incl. the no-oversell concurrency contract)test:d1(T3, production dialect)test:e2einstall --frozen-lockfileAll 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.mdcarries the decisions, the record of the retired vendored build, and the follow-ups. Note that this file is not yet onmain— it arrives with this merge.Rollback is "revert this merge commit".
🤖 Generated with Claude Code
https://claude.ai/code/session_01CQbJYWWm8tf8owshm7XRp8