Skip to content

feat(uploads): keep every upload's original, rows, row errors and log, insert-only (M3b + M3c server, #107) - #138

Merged
amar-python merged 2 commits into
mainfrom
feat/107-m3b-source-preserved
Oct 28, 2026
Merged

amar-python merged 2 commits into
mainfrom
feat/107-m3b-source-preserved

Conversation

@amar-python

@amar-python amar-python commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Refs #107. Part M3b of ART Milestone 3, plus the server side of M3c. The frontend side of M3c follows in a separate PR. The parts and the product owner's 28 Oct decisions are on #107.

What it does

  • Migration 0007 adds three tables to each tenant:
    • uploads: one row per upload attempt, with id, filename, SHA-256, byte size, uploader, mode, outcome and row counts. The outcome is one of imported, partly_imported, failed, duplicate or rejected. csv_file_id names the registry row the upload produced.
    • upload_blobs: the file's bytes exactly as received.
    • source_rows: the parsed rows as text, with row 0 the header.
  • All three are insert-only. Triggers reject UPDATE, DELETE and TRUNCATE, like audit_log in 0006. The shared guard function now names the table it stopped.
  • The upload route stores the original for every outcome, including failed, duplicate and rejected uploads, and returns its uploadId.
  • Originals survive deletes and overwrites. Deleting or overwriting a migrated file removes its table and registry row, never its original. csv_file_id is a plain value, not a foreign key, so the original stays (decision 3: overwrite itself stays until M5).
  • New routes:
    • GET /api/csv/uploads (list_files) lists every upload attempt.
    • GET /api/csv/uploads/{id}/original (read_rows) returns the exact bytes with an X-Content-SHA256 header.
  • GET /api/csv/files now includes uploader and upload_id.
  • M3c, server side (second commit): uploads also stores row_errors (capped at API_MAX_ROW_ERRORS) and logs. GET /api/csv/uploads/{id} (list_files) returns them, so any session in the tenant can read why rows failed, not only the uploading browser.

Known limitation

The loaders commit their own transaction, so the original is stored just after the load commits. That's the same window as the upload audit row. If storing it fails, the error is logged at ERROR, and the upload still reports its outcome with uploadId: null. M6's single-transaction import (ADR-0002) closes this window.

Verification

Check Result
Local CI integration replica (PostgreSQL 16), at the second commit 326 collected, 326 passed, 0 failed, 0 skipped
Evals 26 of 26
make lint Clean
make health 88 passed
New tests/test_preserved_uploads.py (7 tests) Partly-failed upload's reasons and log read back by a separate client; Exact bytes and SHA-256 round-trip; rows with quoted commas and embedded newlines kept; uploader shown on /files; a failed upload keeps its original; partly-failed and duplicate outcomes; overwrite keeps the replaced original; UPDATE and DELETE rejected on all three tables; unknown id gives 404

I broke the code three times to check that the tests catch it. Each time the expected test failed:

What I removed or weakened Test that failed
The guard trigger on source_rows The reject test, whose DELETE then succeeded
partly_imported mapped to imported The outcomes test
Row errors stored as an empty list The cross-session test

Also changed:

  • test_migrations and test_tenant_isolation now expect head 0007.
  • The tenant foreign-key comparison now counts only the T&E tables. The two new upload foreign keys aren't part of the cloned T&E set.
  • docs/ROLES.md lists the new routes, plus /api/audit/verify from M3a, which I'd left out.
  • VCRM:
    • BR-23 is now ✅. The tenant is the schema the row lives in (decision 6), not a column.
    • BR-24 is ⚠️ Partial until the runtime role (M3d), and until edits are kept separately (M4).
    • The headline is now 20 of 38.

Generated by Claude Code

…y (M3b, #107)

Migration 0007 adds per-tenant uploads, upload_blobs and source_rows.
Each upload attempt records its filename, SHA-256, size, uploader,
mode, outcome and counts, with the file's bytes and parsed rows.
All three tables reject UPDATE, DELETE and TRUNCATE. Overwriting or
deleting a migrated file no longer loses its original (the product
owner moved retiring overwrite to M5, 28 Oct).

New routes: GET /api/csv/uploads (list_files) and
GET /api/csv/uploads/{id}/original (read_rows), which returns the
exact bytes with an X-Content-SHA256 header. /api/csv/files now names
each file's uploader.

VCRM: BR-23 verified; BR-24 partial until the runtime role (M3d).
CI replica 325/0/0, evals 26/26, lint and health clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YPoXzBSj89ZHgw7nfTmbB
…er (M3c backend, #107)

The uploads row now stores why rows failed (capped at API_MAX_ROW_ERRORS)
and the step log, inside the same insert-only row. New route
GET /api/csv/uploads/{id} (list_files) returns them, so any session in
the tenant can read them, not only the browser that uploaded the file.
Product owner's scope addition on #107, 27 Oct.

CI replica 326/0/0, evals 26/26, lint clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YPoXzBSj89ZHgw7nfTmbB
@amar-python amar-python changed the title feat(uploads): keep every upload's original file and rows, insert-only (M3b, #107) feat(uploads): keep every upload's original, rows, row errors and log, insert-only (M3b + M3c server, #107) Oct 28, 2026
@amar-python
amar-python merged commit 1c5db31 into main Oct 28, 2026
3 checks passed
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