ID-328 {328.1} RESEARCH — cluster D: e2e data preconditions on a zero-ambient-data nightly
ID-328 {328.1} RESEARCH — cluster D: e2e data preconditions
Section titled “ID-328 {328.1} RESEARCH — cluster D: e2e data preconditions”Date: 25/07/2026 (S492)
Scope: id-328 cluster D only — the “4 specs depend on unseeded staging data-state”
item. Cluster B (ingestion 502/503, corpus bucket) and the PW_MAX_FAILURES cap-raise
are explicitly out of scope; the cap is already 60 at
.github/workflows/e2e-nightly.yml:515.
Tier: RESEARCH ({328.1}). No behaviour is ratified here; the implementation design
is {328.3} TECH.md.
Headline
Section titled “Headline”The cluster-D framing is wrong in its diagnosis and half-wrong in its inventory.
- Only one of the four failures was ever a data-state problem. Two were a UI label rename, one was environment-dependent copy, and one was a test of a route deleted eight months of sessions ago.
- The real mechanism is not “unseeded staging”. It is that
{128.10}moved the nightly onto a fresh ephemeral Supabase branch with no ambient data by construction (.github/workflows/e2e-nightly.yml:40). Specs that had silently borrowed a precondition from staging’s ambient corpus lost it. The S477 diagnosis read the symptom correctly and attributed it to the wrong cause. - Three of the four cases are already fixed on
main({135.32},{128.23}). What remains is a latent defect that{128.23}made strictly worse, and that no nightly run has yet observed.
What exists today
Section titled “What exists today”The four specs, resolved by evidence
Section titled “The four specs, resolved by evidence”The item names three cases for “4 specs” plus a fourth in its Notes. Resolved against the
blob reports of the S477 basis run 29495141839 (2026-07-16, headSha a2bbf06c) —
downloaded and parsed, not inferred:
| # | Failure (S477 run) | Item’s name for it | Actual cause |
|---|---|---|---|
| 1 | bid-session.spec.ts:407 — from_bid survives filter apply then clear in /browse | ”browse active-filter” AND “bid-session:407” | Tests /browse, deleted at {131.17}; asserts a from_bid mechanism never implemented anywhere |
| 2 | change-reports-page.spec.ts:219 — empty state shows hero with generate controls | ”empty change-reports” | Hero body copy has two variants gated on account age; not data-state |
| 3 | governance-review.spec.ts:153 — skip button advances… | “non-empty review queue” | Locator /Skip/; the control was renamed to “Next item” |
| 4 | governance-review.spec.ts:208 — keyboard shortcut help dialog opens | ”non-empty review queue” | Same rename — asserted the string Skip to next item |
Correction to the item’s inventory: “browse active-filter” and “bid-session around
line 407” are the same single test, double-counted. The failing locator at
bid-session.spec.ts:407 was
getByRole('button', { name: /clear (all )?filters?/i }) — the filter-clear control on
/browse. There were four failing test cases across three spec files, not four
spec files. No separate browse-filter spec existed at that SHA: browse-search.spec.ts
was deleted on 2026-07-09 (efaa69ea, {128.14}), a week before the S477 run.
Current state on main (verified against run 30147033487, 2026-07-25 06:10, plus the two commits that landed after it)
Section titled “Current state on main (verified against run 30147033487, 2026-07-25 06:10, plus the two commits that landed after it)”- #1 — RETIRED. The whole
from_bid Persistence (P1-30)describe block was removed at{135.32};e2e/tests/bid-session.spec.ts:381-400now carries the retirement note. The audit found the persistence mechanism “was never implemented anywhere in the codebase (no reader offrom_bidexists inlibrary-content.tsx,use-library-filters.ts, oruse-corpus-search.ts)”. No seed data was ever required. Confirms the brief’s stale-context warning. - #2 — FIXED at
{128.23}(021dd78e). Diagnosis:app/change-reports/page.tsxrenders two hero variants;useAccountAge()(hooks/use-account-age.ts) setsisNewAccountfromauth.users.created_at < 24h. A cold ephemeral branch seeds its users minutes before the run → first branch; long-lived staging → second. The assertion was relaxed to the substring both variants share. - #3, #4 — FIXED at
{128.23}. Both were theSkip→Next itemrename (review-action-bar.tsx,review-content.tsxshortcut table).
The seeding machinery
Section titled “The seeding machinery”| Layer | What it provides | File |
|---|---|---|
supabase/seed.sql (408 lines) | auth users, user_roles, application_types, form_types, 1 workspace, guides, feed_prompts, feed_sources, company_profiles, taxonomy. Zero source_documents / q_a_pairs / change_reports. | supabase/seed.sql |
bun run seed:e2e-users (one-shot, per CI job) | 4 test users + roles; seedPublicationReviewFixture → exactly 1 source_documents row (publication_status='in_review'); taxonomy_domains('e2e-seeded-domain') + governance_config reference data | scripts/seed-e2e-users.ts:352 |
workerData worker fixture (per worker, lazy) | 12 source_documents (10 core + 2 intel) + 2 q_a_pairs, 2 workspaces, 2 form_instances + 4 questions + 2 responses, 2 notifications, 1 intelligence workspace/feed source/prompt, 3 feed articles, 5 record_embeddings, record_lifecycle freshness buckets | e2e/fixtures/test-data-fixture.ts:148 |
| Prefix convention | [E2E-S{shard}-W{worker}] when sharded, [E2E-W{worker}] otherwise | test-data-fixture.ts:158-161 |
| Per-worker teardown | Deletes by id / by prefix, in FK order | test-data-fixture.ts:787-871 |
| Global safety sweep | q_a_pairs, source_documents, workspaces, notifications by [E2E- / [E2E Test] prefix | e2e/global-teardown.ts:104 |
The cold-branch baseline — the real cluster-D mechanism
Section titled “The cold-branch baseline — the real cluster-D mechanism”.github/workflows/e2e-nightly.yml:40 states it outright: “A fresh branch has NO ambient
data by construction.” The governance review queue
(app/api/review/queue/route.ts, default status='unverified' per
ReviewQueueParamsSchema at lib/validation/schemas.ts:238) filters
.neq('publication_status','draft') + .is('record_lifecycle.verified_at', null) +
record_lifecycle!inner. Against that filter:
| DB state | Governance-queue items |
|---|---|
Cold branch, after seed.sql | 0 |
+ seed:e2e-users | 1 (the pub-review fixture row) |
+ any one workerData instantiation | 13 (publication_status defaults to 'published' — confirmed against staging information_schema; verified_at left NULL; record_lifecycle minted by trg_record_lifecycle_mint_source_document) |
| Platform staging (local / PR-smoke) | 45 unverified source_documents, ambient |
The latent defect: a precondition that is borrowed, never declared
Section titled “The latent defect: a precondition that is borrowed, never declared”e2e/tests/governance-review.spec.ts contains zero references to workerData — yet
three of its tests carry comments asserting the opposite:
:31— “The worker fixture seeds 10+ unverified content items, so the review toolbar must be present.”:41— “Seeded data guarantees a non-empty queue.”:259— “Worker fixture seeds 10+ unverified items, so the action toolbar is the deterministic state.”
11 of the file’s 13 tests resolve getByRole('toolbar', { name: 'Review actions' }),
which renders only when queue.length > 0. Only :20 and :270 are queue-independent.
Playwright’s documented semantics (verified via Context7 against
docs/src/test-fixtures-js.md): “worker-scoped fixtures are set up lazily if
required… Fixtures that are not used by any tests or hooks are never set up.”
Empirically proven this session. Running
bunx playwright test e2e/tests/governance-review.spec.ts -g "review page renders the seeded queue toolbar" --workers=1 passes — and the output contains no
[Worker N] Seeded: … line. The fixture never ran; the toolbar rendered off staging’s
45 ambient rows.
This is exactly the defect {128.23} found and fixed in the wave1 specs (“neither wave1
spec destructured workerData, so the shared fixture never ran at all”). It was left
unfixed in governance-review.spec.ts, whose failures that session looked like pure
label drift.
{128.23} then made it materially worse. The rewritten next button advances to the following queue item (governance-review.spec.ts:216-244) now asserts
/^Review item 1 of / then /^Review item 2 of / — raising the requirement from ≥1
queue item to ≥2. On a cold branch a worker that has not instantiated workerData
sees exactly 1. The test therefore passes only when some other spec happened to run
earlier on the same worker. No nightly has observed this yet: {128.23} landed at 15:19
on 25/07, nine hours after the 06:10 run.
Why {128.9} left these four out
Section titled “Why {128.9} left these four out”{128.9} (“make the ~96 ambient-data specs self-seeding with hard asserts”, S424) ran
against the shared staging DB and targeted the conditional false-pass class —
if (await X.isVisible().catch(…)) { … } guards that silently pass on an empty DB
(docs/reference/testing/test-philosophy.md §2.1). None of the four were in that class
at that time:
- #1 tested a deleted route — a retirement question, not a seeding one.
- #2 had already been de-ambiented by bl-115/WS3 (route mocks + a tagged seed fixture).
- #3/#4 were label drift, invisible to a data-coupling audit.
The data-precondition exposure appeared later, when {128.10} removed the ambient
corpus these specs had been silently borrowing from. {128.9} did not miss them; the
defect did not yet exist in that form.
Issues found
Section titled “Issues found”I-1 — form_instances has no safety sweep; 381 orphan rows on staging (resolve-first)
Section titled “I-1 — form_instances has no safety sweep; 381 orphan rows on staging (resolve-first)”Live query against Platform staging rbwqewalexrzgxtvcqrh:
- 381 of 396
form_instancesrows carry an[E2E-W*]prefix — ~127 distinct worker prefixes × 3 rows, first and last seen 2026-06-25. e2e/global-teardown.tssweepsq_a_pairs,source_documents,workspacesandnotifications. There is noform_instancessweep at all. The only cleanup istest-data-fixture.ts:866, deleting by id — which never runs for a worker killed by a crash, a shard timeout, or amaxFailuresabort.
This is the same failure shape as the 23 orphaned workspaces from last session, at 16× the volume, and it has been accumulating for a month.
I-2 — Orphaned record_embeddings are unreachable by any sweep (OWNED BY id-364 — do not action here)
Section titled “I-2 — Orphaned record_embeddings are unreachable by any sweep (OWNED BY id-364 — do not action here)”Superseded. This finding is already filed and audited in full as id-364 (“Referential integrity for
record_embeddings.owner_id+ schema-wide FK-less column audit”, committedd8f377f8, S480). id-328 must not act on it. Retained here only because it constrains the seed-shape design (see the constraint below).
The e2e-visible slice: 66 orphans across the two owner kinds the worker fixture writes —
56 owner_kind='source_document', 10 q_a_pair. That is a subset, not the total. The
schema-wide figure is 206 of 384, the extra 140 being owner_kind='reference_item',
a leg the e2e fixture never touches and which this research did not initially query.
id-364’s count is the correct one; the 66 above is the portion attributable to the e2e
teardown path.
Mechanism: record_embeddings is polymorphic with no FK cascade (DR-036), and
cleanupContentItemsByTitlePrefix (global-teardown.ts:40) reaches embeddings only via
a prefix-matched owner row. Once the owner is gone the embeddings are unreachable by
every existing sweep. (entity_mentions orphans: 0 — it has a real FK.)
Binding constraint for any new seed shape: every seeded row must be reachable by a
prefix or tag predicate on its own columns, evaluated by global-teardown.ts,
independently of whether its parent row still exists.
I-3 — change_reports fixture rows leak on an aborted worker (build-on, but note)
Section titled “I-3 — change_reports fixture rows leak on an aborted worker (build-on, but note)”e2e/fixtures/change-reports-fixture.ts tags rows in
metadata->>e2e_change_report_fixture_run_id — a key global-teardown.ts does not know
about. If the serial afterAll never runs, three rows leak permanently and would flip the
next run’s empty-state premise. Currently masked only because that test route-mocks its
reads. Same class as I-1/I-2; smaller blast radius.
I-4 — createTestWorkspace is dead and broken (resolve-first — delete)
Section titled “I-4 — createTestWorkspace is dead and broken (resolve-first — delete)”e2e/helpers/data-factory.ts:106 inserts type: 'kb_section' — a column DROPPED at S246
WP2b T2 and replaced by the NOT-NULL application_type_id FK. Zero callers. It is a
live trap for anyone told to “seed a workspace” and would throw
Could not find the 'type' column of 'workspaces' — the exact error
si-starter-pack-seeding.spec.ts produced in the 25/07 nightly.
I-5 — Stale header on governance-review.spec.ts (build-on)
Section titled “I-5 — Stale header on governance-review.spec.ts (build-on)”The file header (:8) advertises “empty queue handling”. There is no empty-queue test
in the file — every queue test asserts non-emptiness. Worth correcting alongside the seed
fix, because a future author reading that header would try to add one, and §“Mutual
satisfiability” below explains why that would be unsound as written.
I-6 — source_documents.content_owner_id missing on staging (out of scope — route)
Section titled “I-6 — source_documents.content_owner_id missing on staging (out of scope — route)”Four publication-bulk-action.e2e.spec.ts failures in the 25/07 nightly are
Could not find the 'content_owner_id' column of 'source_documents' in the schema cache;
the column is absent from staging’s information_schema. Schema parity, not cluster D —
already tracked in the {128.10} tail. Recorded here only so it is not re-diagnosed.
Mutual satisfiability — “empty change-reports” vs “non-empty review queue”
Section titled “Mutual satisfiability — “empty change-reports” vs “non-empty review queue””Direct answer: they never contend. They live on different tables —
change_reports versus source_documents + record_lifecycle — with no shared column,
no shared route, and no shared fixture. Both are satisfiable simultaneously in one shared
DB across parallel shards, today, with no per-worker scoping whatsoever.
The question worth answering is the general one behind it, and its answer is asymmetric:
-
Non-emptiness is monotone under parallel seeding. Any worker’s insert only ever adds rows; every other worker still sees at least its own. It is sound to assert as a lower bound. Per-worker scoping buys attribution and teardown — not satisfiability. This is why the review-queue precondition needs seeding, not scoping.
-
Emptiness is anti-monotone. One concurrent insert falsifies it. On a shared DB it can never be made true by scoping the write; only by scoping the read. Two sound mechanisms exist:
- Intercept at the HTTP boundary —
page.routethe list/latest endpoints. This is whatstubEmptyChangeReports(change-reports-page.spec.ts:52-88) already does. - Narrow the query with a worker-unique predicate the UI genuinely sends — e.g. a
search box filtered to
workerData.prefix, asgotoLibraryFiltereddoes inqa-library.spec.ts. Only available where the surface exposes such a filter.
For
change_reports, mechanism 2 is unavailable: the table has noworkspace_idoruser_idcolumn, and/api/change-reports/latest|listread it whole (change-reports-fixture.ts:7-13). Mechanism 1 is therefore load-bearing, not belt-and-braces. - Intercept at the HTTP boundary —
Sharding makes this concrete. playwright.config.ts:33 sets fullyParallel: true.
Playwright’s filterForShard (verified via Context7 against
packages/playwright/src/runner/testGroups.ts) shards parallel files at the individual
test level, while describe.serial groups ship whole to the shard containing their first
test. So change-reports-page.spec.ts’s empty state test and its populated state
serial block can — and routinely will — execute on different shards concurrently
against one DB. Without the route mock that test is a coin flip. It was written correctly.
Rule this yields, for TECH.md: An e2e assertion of absence against a shared DB must be enforced at the read boundary; an assertion of presence may be enforced at the write boundary, as a lower bound.
Best practice
Section titled “Best practice”docs/reference/testing/test-philosophy.md§2.1 — “E2E conditional false-pass”: theif (visible)guard “silently passes on empty DBs or missing fixtures; the hard expect fails honestly”. A test whose precondition is borrowed rather than declared is the same defect one level up: it does not false-pass, it false-flakes.- §1.6 — factory functions with optional overrides.
e2e/helpers/data-factory.tsis the established home; any new seeding helper belongs there or ine2e/fixtures/. - Playwright (Context7,
docs/src/test-fixtures-js.md): worker fixtures are lazy and never set up unless requested. A comment claiming a fixture ran is not a dependency; the destructure is. - Playwright (
dispatcher.ts): retries re-run the test, not the worker fixture. A precondition lost to another worker’s teardown is inherited unchanged by every retry — so--retries=1cannot mask this class. e2e/.claude/skills/playwright-best-practices— Page Object / fixture discipline; preconditions belong in fixtures, not in prose.
Prior decisions & context
Section titled “Prior decisions & context”{128.10}— ephemeral Supabase branch per nightly run. The change that created this problem class..github/workflows/e2e-nightly.yml:40.DR-085— “Supabase branches never inherit parent API config… treat ephemeral-branch parity as explicit provisioning work, never as inheritance.” The same principle extends to data: nothing a branch needs may be assumed present.{128.9}— the ~96-spec self-seeding wave. Memory recall (mempalace, S424 agent transcripts) confirms Liam’s ratified disposition for reference data was “(b) treat-as-ambient → SEED”, implemented inscripts/seed-e2e-users.tsas idempotenttaxonomy_domains+governance_configrows — not in the per-worker fixture. This is the governing precedent for where a corpus-wide baseline belongs.{128.14}/{135.32}— the/browse+/item/[id]retirement sweeps that disposed of cluster-D case #1.{128.23}—021dd78e, this session: fixed cases #2/#3/#4, took nightly failures ~28 → 6, and introduced the ≥2-item requirement analysed above.{128.18}— the sibling debris item (feed_sources/feed_articlesteardown gap). I-1 and I-2 are the same class and should be routed together.DR-036—record_embeddingsis the single embeddings home. Explains the no-cascade orphan class in I-2.- Related specs:
specs/id-128-test-ci-integration/S403-coverage-audit.md,specs/id-128-e2e-nightly-restructure/S423-whole-system-investigation.md.
What are we not thinking about?
Section titled “What are we not thinking about?”- Local and nightly now have structurally different data baselines — 45 ambient rows
versus 0. A spec can be permanently green locally and permanently red nightly, and
nothing in the PR gate can catch it:
e2e-smokeruns against mutable staging. Every precondition-borrowing spec is invisible until a nightly happens to schedule it onto a cold worker. There is no cold-DB gate anywhere in CI. - Eight concurrent workers, one branch DB.
--shard=n/4 --workers=2. One worker’s fixture teardown deletes rows another worker’s spec is mid-read on.global-teardown.tsalready documents this race for the pub-review fixture (“a real cross-shard race in the sharded nightly”) — it applies to every corpus-wide read, including the review queue. - Retries cannot rescue this class (see Best practice). A precondition lost to a sibling worker’s teardown is lost for all attempts.
- Placement is a real decision, not a detail. The
{128.9}precedent puts corpus-wide baselines in the one-shotseed:e2e-users. A≥2-item review-queue floor is arguably baseline, not per-worker — and putting it in the per-worker fixture makes every worker pay for it and makes teardown racier. - The orphan classes are unbounded and still growing.
{128.10}removes them for the nightly (the branch is deleted), but PR-smoke and local runs still target Platform staging, so I-1/I-2 keep accumulating there regardless. - Nineteen of 36 specs never request
workerData. Several of those assert on corpus-wide state. This research proved the mechanism for one of them; the same audit has not been run across the other eighteen.
If this breaks in three months, why?
Section titled “If this breaks in three months, why?”The weakest assumption is that file-ordering luck keeps supplying preconditions that specs never declare.
Today governance-review.spec.ts passes because some spec that does request
workerData happens to run earlier on the same Playwright worker. That is not a
dependency the code expresses — it is an artefact of the run schedule. The drift vectors
are all routine and all invisible: adding or deleting a spec, changing shardTotal,
changing --workers, editing nightlyExcludedSpecs, or renaming a file. Any of them
re-partitions the run and silently changes which specs inherit a warm DB.
It will present as an unreproducible flake in a spec nobody touched, in a shard nobody changed, three weeks after the commit that actually caused it — and the first instinct will be to add a timeout.
What makes it robust: declare the precondition. Either the destructure
({ authenticatedPage, workerData }) or a documented baseline in seed:e2e-users, so the
dependency lives in the code and fails loudly at seed time rather than quietly at
assertion time.
Second-most likely: the orphan classes (I-1, I-2) reach a scale where a sweep is no longer safe to write casually — 381 rows today, no upper bound, and no sweep that can distinguish a live fixture row from a month-old corpse.
Open questions
Section titled “Open questions”OQ-1 — Where does the review-queue baseline live?
(a) Destructure workerData into governance-review.spec.ts (per-worker, follows
{128.23}’s wave1 fix), or (b) extend seedPublicationReviewFixture / add a
seedReviewQueueFixture to scripts/seed-e2e-users.ts (one-shot baseline, follows the
ratified {128.9} reference-data precedent). Recommendation and trade-off in TECH.md
§2. Owner call.
OQ-2 — Does the form_instances sweep + the 381-row cleanup belong to id-328 or
{128.18}? They are the same class as {128.18}’s feed_sources/feed_articles gap.
Recommend routing to {128.18} and having id-328 state only the constraint. (I-2, the
record_embeddings orphans, is not part of this question — it is already owned by
id-364.) Owner call.
OQ-3 — The change-reports hero assertion. {128.23} relaxed it to the substring both
variants share, so neither variant is now pinned. Leave as-is, or route-mock the
account-age read and assert the exact copy for one deterministic variant? Low stakes;
worth a decision so it is not re-relaxed later.
OQ-4 — Is a cold-DB pre-merge gate wanted? Nightly-only detection means a
precondition-borrowing spec merges green and surfaces days later. A cheap partial answer
is a lint/AST rule: a spec whose comments claim the worker fixture seeded something must
destructure workerData. Owner call on appetite.