Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .plans/experimental-models-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
| Phase 3 — Variant Picker + Routing | [done] | Experimented public ids route through the deterministic picker, load routing details from Postgres after Redis membership pre-check, and go directly to the selected partner upstream. |
| Phase 4 — Usage, Metrics, Reporting | [done-core] | Attribution rows and R2 prompt bodies are written after microdollar usage. Admin request log reads the rows inline. Live aggregate reporting and `model_experiment_request_stats` are deferred until a report consumer needs them. |
| Phase 5 — Admin tRPC + UI | [done-core] | Admin CRUD, state transitions, variant version hot-swap, key rotation, UI tab, and request log exist. `getLiveStats` and prompt inflation via `getPromptByHash` are still deferred. |
| Phase 6 — Specs + Tests | [partial] | Router, picker, prompt persistence, and partitioning tests exist. Durable spec file `.specs/model-experiments.md`, AGENTS registration, and soft-delete policy test are still owed. |
| Phase 6 — Specs + Tests | [partial] | Router, picker, prompt persistence, partitioning, and soft-delete policy tests exist. Durable spec file `.specs/model-experiments.md` and AGENTS registration are still owed. |

**Current schema:**

Expand Down Expand Up @@ -351,7 +351,7 @@ Full canonical post-`transformRequest` request bodies are stored in a dedicated
- The opt-in copy for each preview model must disclose that prompts may be retained for experiment analysis and partner evaluation, and that users are responsible for not submitting PII, secrets, customer data, or other sensitive content they do not want retained under that experiment policy. v1 must not run a real partner experiment until that model-specific opt-in/disclosure exists.
- Prompts collected under experiment opt-in use a dedicated experiment retention policy and are not governed by the default `microdollar_usage_metadata` soft-delete policy.
- Concretely: `softDeleteUser` does **not** delete `model_experiment_request` rows and does **not** delete the referencing R2 objects. The `on delete cascade` on `usage_id` only fires if the underlying `microdollar_usage` row is hard-deleted (which `softDeleteUser` does not do today). A dedicated experiment-data wipe path removes prompt references by setting prompt hash columns to `__deleted__`, then relying on R2 GC for blob cleanup.
- The spec documents this explicitly as the policy. A test in `apps/web/src/lib/user.test.ts` locks the policy in code: after `softDeleteUser` runs, an experiment-attributed user's `model_experiment_request` rows (and the referenced R2 objects) are still present.
- The spec documents this explicitly as the policy. A test in `apps/web/src/lib/user/index.test.ts` locks the policy in code: after `softDeleteUser` runs, an experiment-attributed user's `model_experiment_request` row and `request_body_sha256` are still present.

**Wipe semantics.**

Expand Down Expand Up @@ -535,13 +535,13 @@ Targeted tests:
- Prompt write decoupling: simulating an R2 `PUT` failure does not roll back the `microdollar_usage` write; the `model_experiment_request` row still lands with `__failed__`, and Sentry is notified.
- Truncation: a serialized request body exceeding 4 MB of UTF-8 is truncated deterministically to valid UTF-8, the resulting hash is stable across runs, and `was_truncated = true` is recorded.
- `getPromptByHash` admin tRPC procedure returns the original content for a known hash and `null` for an unknown hash; sentinel values are rejected or handled before the tRPC call, and non-admin callers are rejected.
- Soft-delete policy: after `softDeleteUser` runs against a user who participated in an experiment, that user's `model_experiment_request` rows are still present (including `request_body_sha256`), and the referenced R2 objects are still present. (Locks the consent-based retention policy in code.)
- Soft-delete policy: after `softDeleteUser` runs against a user who participated in an experiment, that user's `model_experiment_request` rows are still present, including `request_body_sha256`. (Locks the consent-based retention policy in code.)

## Caching, Privacy, and Logging

- Prompt-cache behavior needs no change. `applyTrackingIds` salts by provider/user/task, while upstream providers key on `(model, cache_key)`, so different internal checkpoints naturally separate caches.
- `model_experiment`, `model_experiment_variant`, `model_experiment_variant_version`, and `model_experiment_request` hold no direct PII.
- The prompt-hash column on `model_experiment_request` and the R2 prompt bucket together hold user-authorized experiment data. The opt-in disclosure places responsibility on users not to submit PII, secrets, customer data, or other sensitive content they do not want retained for experiment analysis or partner evaluation. Retention is governed by explicit experiment opt-in and the dedicated experiment retention policy, not the default `microdollar_usage_metadata` soft-delete policy (see Prompt Storage > GDPR and consent). Automatic retention-window enforcement is a follow-up, not v1. The policy should be locked in by a test in `apps/web/src/lib/user.test.ts` asserting that `softDeleteUser` does not delete experiment rows or R2 objects.
- The prompt-hash column on `model_experiment_request` and the R2 prompt bucket together hold user-authorized experiment data. The opt-in disclosure places responsibility on users not to submit PII, secrets, customer data, or other sensitive content they do not want retained for experiment analysis or partner evaluation. Retention is governed by explicit experiment opt-in and the dedicated experiment retention policy, not the default `microdollar_usage_metadata` soft-delete policy (see Prompt Storage > GDPR and consent). Automatic retention-window enforcement is a follow-up, not v1. The policy is locked in by a test in `apps/web/src/lib/user/index.test.ts` asserting that `softDeleteUser` does not delete experiment attribution rows or prompt hashes.
- `client_request_id` is opaque and per-message. It is joinable to user activity through `model_experiment_request.usage_id`. The `on delete cascade` on `usage_id` only fires for hard deletes of `microdollar_usage`, which `softDeleteUser` does not perform.
- Do not log full request bodies for experimental traffic into `api_request_log`. The dedicated R2 prompt store is the only persistence mechanism for experiment prompt content; `api_request_log` remains allowlist-only and unrelated to experiments.
- Do not put `client_request_id` or experiment fields into Sentry input payloads; keep them to usage/metrics storage.
Expand Down Expand Up @@ -677,7 +677,7 @@ R2 prompt store:

GDPR test:

- `apps/web/src/lib/user.test.ts` (add test asserting `softDeleteUser` does **not** delete `model_experiment_request` rows or referenced R2 objects)
- `apps/web/src/lib/user/index.test.ts` (asserts `softDeleteUser` does **not** delete `model_experiment_request` rows or prompt hashes)

Admin and routing:

Expand Down
95 changes: 95 additions & 0 deletions apps/web/src/lib/user/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ import {
impact_conversion_reports,
github_branch_pull_requests,
model_eval_ingestions,
microdollar_usage,
model_experiment,
model_experiment_variant,
model_experiment_variant_version,
model_experiment_request,
} from '@kilocode/db/schema';
import { eq, count, sql } from 'drizzle-orm';
import {
Expand Down Expand Up @@ -151,6 +156,11 @@ describe('User', () => {
await db.delete(organization_user_limits);
await db.delete(organization_memberships);
await db.delete(free_model_usage);
await db.delete(model_experiment_request);
await db.delete(model_experiment_variant_version);
await db.delete(model_experiment_variant);
await db.delete(model_experiment);
await db.delete(microdollar_usage);
await db.delete(user_feedback);
await db.delete(cloud_agent_feedback);
await db.delete(user_admin_notes);
Expand Down Expand Up @@ -1872,6 +1882,91 @@ describe('User', () => {
).toBe(1);
});

it('should preserve model experiment attribution and prompt hashes', async () => {
const user = await insertTestUser();
const usageId = randomUUID();
const createdAt = '2026-05-25T12:00:00.000Z';
const requestBodySha256 = 'a'.repeat(64);

await db.insert(microdollar_usage).values({
id: usageId,
kilo_user_id: user.id,
cost: 0,
input_tokens: 100,
output_tokens: 50,
cache_write_tokens: 0,
cache_hit_tokens: 0,
created_at: createdAt,
provider: 'custom',
model: 'partner/checkpoint-rc1',
requested_model: 'kilo/preview-experiment-test',
has_error: false,
});

const [experiment] = await db
.insert(model_experiment)
.values({
public_model_id: 'kilo/preview-experiment-test',
name: 'Soft-delete retention test',
status: 'active',
created_by_user_id: user.id,
})
.returning({ id: model_experiment.id });
if (!experiment) throw new Error('Failed to insert model experiment');

const [variant] = await db
.insert(model_experiment_variant)
.values({
experiment_id: experiment.id,
label: 'A',
weight: 1,
})
.returning({ id: model_experiment_variant.id });
if (!variant) throw new Error('Failed to insert model experiment variant');

const [variantVersion] = await db
.insert(model_experiment_variant_version)
.values({
variant_id: variant.id,
upstream: {
internal_id: 'partner/checkpoint-rc1',
base_url: 'https://partner.example.com/v1',
},
encrypted_api_key: { iv: 'iv', data: 'data', authTag: 'authTag' },
created_by: user.id,
})
.returning({ id: model_experiment_variant_version.id });
if (!variantVersion) throw new Error('Failed to insert model experiment variant version');

await db.insert(model_experiment_request).values({
usage_id: usageId,
variant_version_id: variantVersion.id,
allocation_subject: 'user',
client_request_id: 'client-message-id',
request_kind: 'chat_completions',
request_body_sha256: requestBodySha256,
was_truncated: false,
created_at: createdAt,
});

await softDeleteUser(user.id);

const [usage] = await db
.select()
.from(microdollar_usage)
.where(eq(microdollar_usage.id, usageId));
expect(usage?.kilo_user_id).toBe(user.id);

const [attribution] = await db
.select()
.from(model_experiment_request)
.where(eq(model_experiment_request.usage_id, usageId));
if (!attribution) throw new Error('Expected model experiment attribution to be retained');
expect(attribution.request_body_sha256).toBe(requestBodySha256);
expect(attribution.client_request_id).toBe('client-message-id');
expect(new Date(attribution.created_at).toISOString()).toBe(createdAt);
});

it('should preserve Kilo Pass subscriptions and issuance chain', async () => {
const user = await insertTestUser();

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,8 @@ export class SoftDeletePreconditionError extends Error {
* - stytch_fingerprints (abuse detection)
* - referral_code_usages (financial, references anonymized user)
* - kiloclaw_subscriptions, kiloclaw_earlybird_purchases, kiloclaw_email_log (retained records)
* - model_experiment_request (experiment attribution and prompt hashes retained
* under the dedicated experiment retention policy)
* - kiloclaw_scheduled_action_targets (retained operational records;
* - transactional_email_log (retained outbox marker, financial record;
* user_id FK references the anonymized kilocode_users row and optional
Expand Down