Skip to content

feat(data-export): add organization data exports - #5212

Merged
RSO merged 9 commits into
mainfrom
feat/org-data-export
Aug 13, 2026
Merged

feat(data-export): add organization data exports#5212
RSO merged 9 commits into
mainfrom
feat/org-data-export

Conversation

@St0rmz1

@St0rmz1 St0rmz1 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an organization data export alongside the existing personal one. An
organization export selects rows by organization_id, so it covers work members
did in the organization's context plus the rows the organization owns outright,
and excludes members' personal activity. Selection never enumerates members. The
organization tag is on the row itself, so a former member's organization work is
included and a current member's personal work is not. Membership only decides
whether the requester may ask.

The branch also carries three changes to the existing personal export.

Export subject

  • user_data_exports gains subject_type and organization_id, with a CHECK
    constraint tying them together and partial unique indexes giving one active
    export per user and one per organization. Two admins pressing the button share
    one job rather than generating duplicate copies.
  • Every warehouse source now has a user variant and an organization variant of
    its query. The predicate allow list gained organization_id = $1, and a test
    pins each query's scope to what its name claims.
  • The export file header now names subjectType and organizationId, so a
    consumer does not have to infer which kind of export it is holding.
  • New "Organization exports" card on the data exports page, listing each
    organization the signed in person may export. History rows gained a badge
    showing whether a row is personal or an organization's.

Fixes ordering that silently dropped rows

Both journal-style queries cast a bigint to text and aliased it back to the
column's own name, for example most_significant_position::text AS most_significant_position. ORDER BY resolves a bare identifier to an output
column in preference to a table column, so the pages were ordered
lexicographically. The WHERE clause cannot see output names, so it kept
comparing the raw bigints.

Keyset paging requires the ordering and the cursor predicate to agree. They did
not, so the cursor was taken from a row that was not the numerically largest,
and later pages both repeated rows and skipped others permanently. Text and
numeric order diverge wherever digit counts differ, and
least_significant_position counts up from small numbers.

Confirmed on real query plans, which showed a Sort on the text cast sitting
above indexes that already cover the intended ordering:

Sort Key: ((most_significant_position)::text), ((least_significant_position)::text)
Sort Key: ((system_prompt_prefix_id)::text), (COALESCE(organization_id, '-'::text))

The casts keep their text output, since bigint precision has to survive JSON.
Only the aliases are renamed, so ORDER BY resolves to the columns. Re-measured
after the change, both are bare index scans with no sort. A structural test
enforces that an output alias never reuses the name of a column the ORDER BY
mentions.

This affects the personal export that is already shipped, not only the
organization work.

Fixes a row loss defect in the personal export

system_prompt_prefix was paginated on system_prompt_prefix_id alone, but that
table's grain is the triple of prefix id, user and organization, so the id
repeats. Verified against the warehouse: one user carries prefix id 2 twice, once
with an organization and once without, and one organization carries prefix id 1
nine times across nine users. A single column keyset cursor pages with
id > cursor, so any duplicate landing on a page boundary was dropped silently.
The cursor is now composite, and both variants were checked against real rows.

Deleted rows are labelled rather than dropped

The warehouse retains rows prod has deleted. Records from the three tables
carrying _snowflake_deleted now get softDeleted: true when the row was
deleted. Nothing is filtered out, since the export is meant to be a truthful copy
of what is held. Only a positive deletion is reported, because a live row and a
row whose state is not yet known are indistinguishable until a table's reload
runs. Project and prompt records gained an id so the label has something to
attach to, matching the other sources which already emitted one.

Tolerates warehouse tables that have not loaded

The warehouse is loaded table by table and this ships ahead of it. Previously a
missing table threw, was treated as retryable, burned four queue retries and dead
lettered the whole export. A single information_schema probe now runs before
anything is written, and the header names what was set aside in
unavailableSources next to includedSources. The probe checks columns rather
than just table existence, because a table loaded earlier can lack a column a
newer query selects.

Verification

Every query the Worker issues was run against the real snowflake-exports
database, for one user and one organization, checking both returned content and
query plans.

  • Personal export, all seven queries: availability probe, identity, and the
    five sources. Two bugs found (see the ordering section above), five passed.

  • Organization export, all five sources in the order the Worker runs them,
    with the same page sizes.

  • Attribution cross-checked against production. The user owns no App Builder
    projects and created two that the organization owns, which is why his
    personal export correctly returns none.

  • App Builder attribution proven by set comparison rather than sampling.
    Messages tagged to the organization whose project is not the
    organization's: 0. Messages of the organization's projects not tagged to
    it: 0. Both counts 41,967, so the two sets are identical.

  • Ordering fix re-measured. Both queries are bare index scans with no sort.

  • Availability probe verified. All six tables carry every column their
    source requires, so unavailableSources is empty.

  • Request and download an export end to end through the UI.

Visual Changes

Before After

New "Organization exports" card, shown only to people who own or administer at
least one organization. History rows gained a personal or organization badge.
Screenshots still needed.

Reviewer Notes

Authorization deliberately does not use ensureOrganizationAccess. That
helper grants owner to any is_admin caller, and every procedure on this
router is adminProcedure, so using it would authorize on Kilo staff status
rather than on organization membership. A Kilo employee with /admin access is
not a member of a customer's organization and cannot export it. The rule lives in
packages/db/src/organization-export-access.ts and is shared by the router and
the Worker, which re-checks independently. When the two carried separate copies,
the Worker's lacked parent organization inheritance, and an export generated,
showed as ready, then refused every download while holding the organization's
only active export slot.

Migration touches a live table. 0213 drops and rebuilds a partial unique
index on user_data_exports and adds two CHECK constraints without
NOT VALID, all in one transaction. Both constraints are satisfied by every
existing row, since subject_type defaults to user and organization_id is
null, so this is lock duration rather than a risk of failing. Worth a count on
that table before merging if it is larger than expected.

Organization exports run the same 13 minute one shot generator as personal
ones, with no persisted cursor. A sufficiently large organization can exhaust
that budget and fail terminally with no partial progress retry.

The re-request throttle is still the temporary 5 minute value and predates
this branch, but organization exports now share that window while the message
still says 24 hours. Restoring it is one edit, since both paths share
createExportRequest.

microdollar_usage_metadata has no organization index. Its organization
query currently plans as a parallel sequential scan over 315 GB, and the Worker
pages, so each page would repeat that scan. An index on (organization_id, id)
is being built. Organization exports should not be enabled for that source until
it lands. The other four organization sources are all served by existing indexes
with no sort.

  Adds a second export subject alongside the existing personal one. An
  organization export selects on organization_id, so it covers work members
  did in the organization and excludes their personal activity, and reaches
  org-owned rows that carry no user at all.

  Selection never enumerates members: the organization tag is on the row, so
  a former member's org work is included and a current member's personal work
  is not. Membership decides only whether the requester may ask.

  Unique per organization rather than per requester, authorised at request and
  again at download, with the Worker re-checking membership independently.
  Resolves against the two-step emailed-code download from #5204: the
  organization branch moves into requireDownloadableExport, the shared gate
  for both download steps, so neither can be reached on stale authority.

  Migration renumbered to 0213 behind main's 0212.
Comment thread apps/web/src/routers/user-exports-router.ts Outdated
Comment thread packages/db/src/migrations/0213_neat_beyonder.sql
@kilo-code-bot

kilo-code-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

The incremental commit fixes the silent row-loss ordering defect in the two composite-cursor export queries with no new findings; one previously-acknowledged migration SUGGESTION remains open.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1

The incremental change table-qualifies the ORDER BY cursor columns in the cli_sessions and system_prompt_prefix queries, so pages sort on the underlying bigint columns rather than their ::text output aliases — consistent with the numeric tuple comparison in the WHERE clause. The set-wide regression test correctly detects cast-shadowed names while excluding dot-qualified references. Verified: no new issues in the changed code.

Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/db/src/migrations/0213_neat_beyonder.sql 5 Index builds (and the DROP) run without CONCURRENTLY on the live user_data_exports table; harmless if still pre-launch-empty, otherwise use concurrent builds. Author confirmed the table is pre-launch with under 20 rows and ships as-is.
Files Reviewed (2 files)
  • services/user-data-export/src/source-adapters.ts - 0 issues (ORDER BY output-alias shadowing fixed in both composite-cursor queries)
  • services/user-data-export/src/source-adapters.test.ts - 0 issues (new set-wide guard against cast-shadowed ORDER BY names)

Fix these issues in Kilo Cloud

Previous Review Summaries (2 snapshots, latest commit 986d491)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 986d491)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1

The incremental commit fixes the previous WARNING: requireDownloadableExport now collapses only the UNAUTHORIZED authorization denial to NOT_FOUND, while connection drops, timeouts, and other infrastructure errors propagate and reach Sentry. Verified against requireExportableOrganization, which throws only UNAUTHORIZED on denial, so no legitimate verdict leaks. One SUGGESTION remains on the migration, which the author has acknowledged (table is pre-launch, under 20 rows).

Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/db/src/migrations/0213_neat_beyonder.sql 5 Index builds (and the DROP) run without CONCURRENTLY on the live user_data_exports table; harmless if still pre-launch-empty, otherwise use concurrent builds. Author confirmed the table is pre-launch with under 20 rows and ships as-is.
Files Reviewed (2 files)
  • apps/web/src/routers/user-exports-router.ts - 0 issues (previous WARNING resolved: bare catch narrowed to the UNAUTHORIZED denial only)
  • packages/db/src/migrations/0213_neat_beyonder.sql - 1 issue (unchanged since previous review; carried forward)

Fix these issues in Kilo Cloud

Previous review (commit 89b99f3)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1

The organization-export feature is well structured: a single shared access predicate (@kilocode/db/organization-export-access) keeps the web router and the export Worker in agreement, deliberately avoids the is_admin elevation in ensureOrganizationAccess, and is backed by a thorough role/inheritance test matrix. Warehouse scoping is sound — both subject variants filter on a single owner column that never matches NULL, the composite system_prompt_prefix cursor fixes a real duplicate-key pagination defect, and the column-level availability probe is a genuine improvement over table-existence probing. No memory-leak concerns in the client changes (no timers/subscriptions added; all state via React Query). Two minor findings below.

Issue Details (click to expand)

WARNING

File Line Issue
apps/web/src/routers/user-exports-router.ts 335 Bare catch converts DB/infrastructure errors in requireExportableOrganization into NOT_FOUND, masking outages as "Export not found" — narrow the catch to the expected TRPCError

SUGGESTION

File Line Issue
packages/db/src/migrations/0213_neat_beyonder.sql 5 Index builds (and the DROP) run without CONCURRENTLY on the live user_data_exports table; harmless if still pre-launch-empty, otherwise use concurrent builds
Files Reviewed (17 files)
  • apps/web/src/app/(app)/data-exports/DataExportsClient.tsx - 0 issues
  • apps/web/src/app/(app)/data-exports/data-export-contract.test.ts - 0 issues
  • apps/web/src/app/(app)/data-exports/data-export-contract.ts - 0 issues
  • apps/web/src/routers/user-exports-router.test.ts - 0 issues
  • apps/web/src/routers/user-exports-router.ts - 1 issue
  • packages/db/package.json - 0 issues
  • packages/db/src/migrations/0213_neat_beyonder.sql - 1 issue
  • packages/db/src/migrations/meta/0213_snapshot.json - 0 issues (generated)
  • packages/db/src/migrations/meta/_journal.json - 0 issues (generated)
  • packages/db/src/organization-export-access.ts - 0 issues
  • packages/db/src/schema.ts - 0 issues
  • pnpm-lock.yaml - 0 issues (generated)
  • services/user-data-export/src/contracts.test.ts - 0 issues
  • services/user-data-export/src/databases.ts - 0 issues
  • services/user-data-export/src/source-adapters.test.ts - 0 issues
  • services/user-data-export/src/source-adapters.ts - 0 issues
  • services/user-data-export/src/worker.test.ts - 0 issues
  • services/user-data-export/src/worker.ts - 0 issues

Fix these issues in Kilo Cloud


Reviewed by kimi-k3 · Input: 57.9K · Output: 5.3K · Cached: 356.4K

Review guidance: REVIEW.md from base branch main

St0rmz1 and others added 2 commits August 12, 2026 16:47
…ares

`ORDER BY most_significant_position` bound to the SELECT list's
`most_significant_position::text AS most_significant_position` rather than to
the bigint column, because a bare name in ORDER BY resolves to a matching
output column before an input column. The cursor tuple in the WHERE clause
cannot see output aliases, so it kept comparing the bigint.

Two silent failures followed. The page was ordered lexicographically while the
cursor advanced numerically, so a page's last row was not its cursor maximum
and the next page's `>` skipped whatever the text order had deferred -- the row
loss the composite cursors were built to prevent. Reproduced on Postgres with
positions (10,9) (10,10) (10,100): page 1 returned (10,10) and (10,100), and
(10,9) was then excluded from every later page. And no btree can serve a text
ordering of a bigint column, so each page bitmap-scanned and sorted the whole
owner's rowset: 116,522 startup cost per page against 0.42 once qualified,
measured on a 115k-row organization.

Table-qualifying the shadowed name makes it an input reference again, which is
what both the index and the cursor comparison are built on. The COALESCE key
needs no qualifier -- an expression already binds to the input column.

Guarded for the whole set rather than these two queries: no column selected as
`X::type AS X` may appear as a bare name in that query's ORDER BY.
@RSO
RSO enabled auto-merge (squash) August 13, 2026 09:07
@RSO
RSO merged commit 1f233c4 into main Aug 13, 2026
69 checks passed
@RSO
RSO deleted the feat/org-data-export branch August 13, 2026 09:16
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.

2 participants