Skip to content

Scaffolding: trials.kind + experiments.shadow_of (no behavior change) - #1290

Draft
kyle-compute wants to merge 2 commits into
qa-rebuild/a-delete-reportsfrom
qa-rebuild/c-scaffolding
Draft

Scaffolding: trials.kind + experiments.shadow_of (no behavior change)#1290
kyle-compute wants to merge 2 commits into
qa-rebuild/a-delete-reportsfrom
qa-rebuild/c-scaffolding

Conversation

@kyle-compute

@kyle-compute kyle-compute commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Important

This PR is stacked on #1287 (PR A). Its base is qa-rebuild/a-delete-reports, not staging.

C cannot merge before A because its first migration comes after A's migration.

After A squash-merges:

git rebase --onto origin/staging qa-rebuild/a-delete-reports qa-rebuild/c-scaffolding

Then this PR's base changes to staging and it can move out of draft.

This is PR C of six (A–F) rebuilding analysis so analysis jobs run as normal trials. A is #1287 and B is #1288.

C only adds the database and filtering scaffolding needed for that system. Nothing writes analysis trials yet, so existing behavior is unchanged.

What this PR adds

Two new columns:

trials.kind
experiments.shadow_of

plus the filters and indexes needed to use them safely later.

trials.kind

Every trial now has:

kind VARCHAR(32) NOT NULL DEFAULT 'agent'

Existing and normal user trials are:

agent

Later PRs will create analysis trials such as:

qa
audit

Those analysis trials must not be counted, billed, or displayed as normal user runs.

The column lands now so every existing counter and cost query can exclude them before any analysis trials are created.

A partial index is also added:

ix_trials_kind_non_agent

It only indexes rows where:

kind != 'agent'

Almost every trial will remain an agent trial, so this keeps the index small while making analysis-trial lookups cheap.

experiments.shadow_of

Normal experiments keep:

shadow_of = NULL

Later, an experiment can have one hidden shadow experiment whose shadow_of points back to the real experiment.

Analysis trials will live in that shadow experiment instead of the real one, so they never appear in the user's normal trial list.

A partial unique index enforces:

one live shadow experiment per real experiment

uq_experiments_shadow_of_live

The uniqueness condition is:

WHERE deleted_at IS NULL

That means two workers racing to create the same shadow cannot create duplicates, while soft-deleting a shadow allows a replacement to be created later.

I verified this directly on Postgres 16:

  • INSERT ... ON CONFLICT ... DO NOTHING safely loses the race
  • a normal duplicate insert gets a unique violation
  • soft-deleting the existing shadow frees the slot
  • NULL values do not conflict

Cost filtering

first_party_spend_filter() now includes:

kind = 'agent'

That function is already the shared definition of spend that counts for:

  • org quotas
  • user quotas
  • admin cost dashboards

Adding the condition once means all of those surfaces automatically exclude future analysis-trial costs.

This PR also adds:

analysis_spend_filter()

which selects:

kind != 'agent'

PR D will use that for analysis-cost reporting.

It intentionally uses != 'agent' instead of enumerating qa, audit, etc. Any future analysis kind is therefore excluded from user spend automatically.

Trial eligibility

EligibleTrialScope now excludes analysis trials by default by adding:

kind = 'agent'

A caller that intentionally wants analysis trials can opt out with:

include_non_agent_kinds=True

The frontend also gains the TrialKind type.

Nothing reads that type yet.

Migrations

This PR adds two migrations:

trialkind01
shadowexp01

The resulting Alembic head is:

shadowexp01

I verified the migrations against Postgres 16 beyond a basic up/down test:

  • upgrading a populated database gives existing trials kind = 'agent'
  • upgrading works when the columns do not already exist, matching the real production path
  • downgrade removes both columns and both indexes
  • re-upgrade succeeds
  • both indexes are valid in pg_index
  • migrated databases and fresh create_all databases produce identical column and index definitions
  • each ALTER TABLE uses:
SET lock_timeout = '8s'

so a deploy cannot wait indefinitely behind a long-running production query

The index-validity check matters because a failed concurrent index creation can leave an invalid index behind, after which IF NOT EXISTS would otherwise silently skip recreating it.

Verification

The existing suites already have unrelated failures on fresh databases, so the regression check is a failure-set diff against this PR's parent, PR A.

Both branches were run against identical local Postgres 16 setups.

Result:

  • backend: identical 48-test failure sets
  • oddish: no existing test regressed

The only difference in the oddish-only harness is the two DB-backed tests added by this PR. They require the organizations table from the backend tree, so they fail when oddish is tested against an oddish-only database and pass when both trees are present.

That is the same harness limitation as the existing test_cost_excluded_llm_keys.py test they are modeled on.

pnpm build also exits 0.

Tests added

This is an additive PR, so it includes a small set of tests for the new behavior:

  • schema assertions for both columns, defaults, and index names

  • compiled-SQL assertions proving EligibleTrialScope:

    • includes kind = 'agent' by default
    • removes it when include_non_agent_kinds=True
  • DB-backed cost tests proving:

    • a $7.00 kind='qa' trial is excluded from admin/user spend
    • analysis_spend_filter() selects that analysis trial
    • a normal $2.00 agent trial is still counted as exactly $2.00

What changes for users

Nothing yet.

After this PR:

  • all existing trials are still normal agent trials
  • no analysis trials are created
  • no frontend behavior changes
  • no existing experiment gets a shadow experiment

This PR only puts the invariants in place so PR D can safely create analysis trials without contaminating normal trial counts, costs, or experiment views.

Next: PR D — analysis-trial engine + cutover, stacked on this branch.

Two chained migrations (trialkind01, shadowexp01) plus the model columns:
trials.kind VARCHAR(32) NOT NULL DEFAULT 'agent' with a partial index on
the non-agent rows, and experiments.shadow_of VARCHAR(64) with a partial
unique index (one live shadow per experiment, enabling race-safe
INSERT .. ON CONFLICT get-or-create). Nothing writes a non-agent kind or
a shadow_of value yet.

Kind-awareness lands ahead of the writers: first_party_spend_filter()
gains a kind = 'agent' clause (so quota accounting and every cost surface
that shares it exclude future analysis runs), analysis_spend_filter()
(kind != 'agent') is added for the QA-cost surfaces, and
EligibleTrialScope excludes non-agent kinds by default with an
include_non_agent_kinds opt-out. frontend types.ts gains the TrialKind
union (type only). No behavior change on existing data: every existing
row is kind 'agent', so all filters select exactly what they selected
before.

Claude-Session: https://claude.ai/code/session_01ACF6SUXdbLFarpj3qwF1Ki
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
oddish-app Ready Ready Preview Aug 19, 2026 6:36am

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Oddish preview

Commit: 68ab5a8ca6b60b6b87e12acd8ac09ba60be693bd

Surface Link Target
Frontend https://pr-1290.oddish.app Vercel preview for 68ab5a8
Backend oddish-pr-1290 oddish-pr-1290
Database project scmffqrdtxiadqbsawgi project scmffqrdtxiadqbsawgi

Vercel deployment URL: https://oddish-4b03bhxhp.oddish.app

Plan:

  • Frontend deploy: true
  • Backend deploy: true
  • Migrations: true

This comment is updated by the PR Preview workflow.

An interrupted CREATE INDEX CONCURRENTLY leaves a same-name INVALID index
behind; IF NOT EXISTS then sees the relation and skips creation, so a
retried migration completes with an index that serves no queries. For
uq_experiments_shadow_of_live that silently breaks the shadow creator's
INSERT .. ON CONFLICT arbiter inference, which only considers valid
indexes. Before each concurrent CREATE, drop a same-name index whose
pg_index.indisvalid is false so the CREATE rebuilds it.

Drilled against Postgres 16: corrupting both indexes and rerunning the
unfixed migrations left indisvalid=f and the ON CONFLICT insert failing
with "no unique or exclusion constraint matching"; with this change the
rerun rebuilds both (indisvalid=t) and the conflict path dedupes again.

Claude-Session: https://claude.ai/code/session_01ACF6SUXdbLFarpj3qwF1Ki
@kyle-compute

Copy link
Copy Markdown
Collaborator Author

Review fix (68ab5a8): both concurrent-index migrations now recover from an interrupted build. An interrupted CREATE INDEX CONCURRENTLY leaves a same-name INVALID index; IF NOT EXISTS then skips creation, so a retried migration completed with a dead index — and an invalid uq_experiments_shadow_of_live breaks the shadow creator's INSERT .. ON CONFLICT arbiter inference. Each migration now drops a same-name index whose pg_index.indisvalid is false before the CREATE.

Drilled against Postgres 16: corrupting both indexes and rerunning the unfixed migrations left indisvalid=f with the ON CONFLICT insert failing (no unique or exclusion constraint matching); with the fix the rerun rebuilds both (indisvalid=t) and the conflict path dedupes again (INSERT 0 1 then INSERT 0 0).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant