Wishlist design-conflict fixture (P9-08)
This fixture is the repeatable source of the demo conflict (AGENTS.md sections 4, 32.4, 54, 55).
It holds two complete implementations of shared wishlists on top of fixtures/acme-shop. Rhumbatron
publishes them as real ChangeSets without a model call. Executable Evidence decides the winner.
Designs
Section titled “Designs”flowchart TB
base[Acme Shop canonical] --> a[Design A<br/>mutable_table]
base --> b[Design B<br/>append_only]
a --> conflict{Both Intents on<br/>wishlist:membership}
b --> conflict
conflict -->|fork_candidates| ca[Candidate A]
conflict -->|fork_candidates| cb[Candidate B]
ca --> ra[p95 414 to 509 ms<br/>rejected on performance]
cb --> rb[p95 under 1 ms<br/>verified, waits for approval]
| Design A | Design B | |
|---|---|---|
| Data model | Normalized mutable tables. Remove and revoke update wishlist_collaborators rows in place. |
Append-only membership event log. Each event updates a materialized current-member set. |
| Read path | Every request computes access from the collaborator table. That is one scan per check, and one check per item for addedByRole. |
Reads use the materialized set: one map lookup per check. |
| Task key | wishlist-membership-table |
wishlist-membership-log |
The two designs share the HTTP contract, the page public/wishlist.html, the acceptance and
authorization tests, and the benchmark (fixtures/wishlist/files/shared). Only
src/wishlist/model.ts and one model test differ. Both Intents name wishlist:membership and
declare incompatible storage models (mutable_table and append_only). The deterministic
detector therefore reports a confirmed conflict with fork_candidates.
Why Design A fails
Section titled “Why Design A fails”bench/wishlist.bench.ts loads the same seeded dataset for both designs: 30,001 wishlists,
2,000 products, about 360,000 memberships and 240,000 items. It then sends the traffic of a busy
shared list to the newest list, which has 42 members and 120 items. The traffic is 80% reads and
20% item adds and removes, as a collaborator. Design A does about 120 scans of the 360,000-row
membership table per read. This cost comes from recomputing access from the table. The benchmark
contains no sleep. Design B does 120 map lookups. bun run bench runs the cart and wishlist benchmarks and reports the
slowest endpoint’s p95 as the API p95.
Measured on an Apple Silicon laptop (bun run demo:wishlist --local):
| Check | Design A | Design B |
|---|---|---|
build (bun run typecheck) |
pass | pass |
unit (bun test) |
25 pass | 25 pass |
| contract | 8 added, all declared | 8 added, all declared |
| protected Cart invariant | 10 pass | 10 pass |
security (--test-name-pattern authorization) |
6 pass | 6 pass |
| performance (p95 < 200 ms) | 414 to 509 ms: fail | 0.07 to 0.10 ms: pass |
The Project Sandbox (basic, 1/4 vCPU) is slower. There, A is slower still, and B stays far
below the budget. The wishlist benchmark stops after 15 s with at least 12 samples, so A stays
inside the 120 s check timeout.
With the page in public/, the Candidate plan also has the Browser Run check (risk high, UI
touched). The shop root has no data-testid="wishlist", so the browser check runs its section
35 API scenario. This contract satisfies that scenario.
Pipeline
Section titled “Pipeline”-
POST /v1/changes/:changeId/fixture-changesets{"design": "a" | "b" | "both"}. The route answers 404 unlessFIXTURES_ENABLEDis"true"orENVIRONMENTisdevorlocal(workers/api/src/fixtures.ts). Terraform setsFIXTURES_ENABLED = "true"in dev and prod. The route requires auth, Project membership, an active Project, and a Change increated(unplanned) orverifying. It queuesfixture.publishon the integration Queue.GETon the same path shows the published designs. -
The integration Worker (
workers/integration/src/fixture-changesets.ts) does these actions for each design:- It records a Work Graph Task (
completed, model policyfixture,touchesAuthorization). - It forks the canonical repo into
ws-<run>. - It commits the design on the canonical commit with isomorphic-git. The commit date is fixed, so a retry is a no-op.
- It inserts a
task_runsrow (tierfixture, zero model calls) and aproposedChangeSet.
It sets the Change to
verifyingwith riskhigh, the acceptance criteria, and the Cart invariant. It then declares both Intents on the Change’s ResourceShardDO. It records the conflict and its Challenge with the agents’ detector and conflict ID rule. Events use actorsystem:fixture. - It records a Work Graph Task (
-
POST /v1/changes/:changeId/candidatestakes an optional{"changeSetIds": [...]}subset, so design A and design B become separate Candidates. Without a body it composes every proposed ChangeSet, as before.
Each Workspace fork has a task_runs row, so Project cleanup and the fork sweep delete it.
Commands
Section titled “Commands”bun run demo:wishlist --local # both designs, local checks, no Cloudflarebun run demo:wishlist --local a # one design; add --keep to keep the temp checkoutbun run demo:wishlist # dev: new Change on acme-shop-demo, then A and B Candidatesbun run demo:wishlist --change chg_... # dev: resume or re-print an existing ChangeThe remote run needs acme-shop-demo (bun run demo:project) and the deployed dev Workers.
At the end, A is rejected on performance, and B is verified and waits for human approval.
Limits
Section titled “Limits”- The shop’s store is in memory per isolate. On the deployed shop Worker, wishlist state and share links are lost on an isolate restart. Isolates do not share them.
- The fixture applies to the Acme Shop baseline. A promotion can change
src/app.ts,src/store.ts,package.json,README.md, orpublic/index.html. After that, publishing stops with an anchor error and does not commit a wrong patch.