Hosted bundle submit/import v0
The hosted submit endpoint lets an authenticated user POST a
ContributionBundleV0 and have its records imported into the hosted
database through the existing thermo/kinetics upload workflows. A
submission row plus matching audit and record-link rows are created so
the contribution is fully traceable.
This is milestone 7 of the local-offline-and-hosted-submission implementation plan. The previous milestone shipped read-only dry-run; this milestone is the first write path for contribution bundles.
Core policy
Submitted records become publicly visible immediately but remain unreviewed until a curator explicitly reviews them. Validation means:
- structurally valid,
- scientifically importable under the existing backend rules,
- persisted successfully through the normal upload workflows.
Validation does not mean curated, approved, recommended, endorsed,
or "best available". The submit response and the moderation row both
report unreviewed / pending_review wording so this distinction is
explicit at every layer.
Curator review (accept/reject/queue/UI) is not in this milestone.
Endpoint
POST /api/v1/bundles/submit
- Auth: required. Either a session cookie (
tckdb_session) or an API key header (X-API-Key) is accepted, viaget_current_user. Anonymous requests are rejected with401. Invalid or revoked API keys are rejected with401. - Request body: a
ContributionBundleV0JSON document, validated by the existing bundle schema. Structurally invalid bundles fail with the normal422validation response. - Response:
ContributionBundleSubmitResult(HTTP201). - Side effects: creates one
submission, at least twosubmission_audit_eventrows (submission_createdandingestion_succeeded), onesubmission_record_linkper imported product row and per immediate identity parent, and the underlying scientific rows themselves.
Supported bundle kinds
Submit/import v0 supports the same families as bundle format v0:
thermokinetics
Mixed bundles and other families (network, statmech, transport,
computed_reaction) are rejected by ContributionBundleV0 validation
before they reach the submit handler. An unsupported kind that somehow
slips past validation is rejected by an explicit guard in the workflow.
Authentication and attribution
The authenticated hosted user is the actor for every row this endpoint creates:
submission.created_byis set to the hosted user.- Every scientific row created during the import is attributed to the
same hosted user via the existing
created_byplumbing on the thermo/kinetics workflows.
Local exporter metadata (bundle.exporter) is preserved as
provenance-only context. Local user IDs and labels are never trusted
as hosted identities. Bundle-local refs (bundle.local_refs) carry no
hosted weight either — they are intra-bundle pointers, not hosted
primary keys.
Dry-run gate
Submit/import calls the same dry-run service used by /dry-run and
applies a strict gate before any write:
A bundle is rejected (HTTP 400, DomainError) if the dry-run reports
any of the following:
bundle_validisfalse,summary.errors > 0(item-level or message-level errors),summary.unsupported > 0(any unsupported preview action).
Warnings are not blocking. Dry-run warnings are carried forward into the submit response so the client can surface them, but the import still commits.
Transaction behavior
The route uses the existing get_write_db dependency, which wraps the
request in a single SQLAlchemy transaction:
- success → commit,
- any exception → rollback of the entire bundle, including the
submission, audit events, record links, and every scientific row that the workflows created during the call.
There are no partial imports in v0. If the third upload of a five-upload kinetics bundle raises mid-workflow, the first two uploads, the submission row, and any audit/link rows are all rolled back together.
Record linkage
Submit/import creates submission_record_link rows for two layers:
- Imported product rows (
action = imported): thermorows for thermo bundles,kineticsrows for kinetics bundles.- Immediate identity parents (
action = linked): species_entryfor each thermo upload,reaction_entryfor each kinetics upload.
Deeper identity/provenance rows (species, chem_reaction, literature,
software_release, workflow_tool_release) are intentionally not
linked in v0 — that is a follow-up once a curator UI exists to consume
the richer trail. Links are deduped per (record_type, record_id) so a
bundle that touches the same species_entry from multiple uploads still
produces a single link row.
Audit events
A successful import appends two audit events:
submission_created— auto-fired by the existingcreate_submissionhelper.ingestion_succeeded— appended after the per-family workflows complete, recording how many records the import wrote.
Submission status stays at pending throughout. Ingestion success is
explicitly not moderation approval; status only changes when a
curator acts.
Submission status mapping
The existing submission_status enum has:
pending(initial),precheck_passed,auto_flagged,approved,rejected,superseded.
Submit/import v0 always lands rows at pending. That is the existing
status that best means "publicly visible via read APIs that don't gate
on review, but not curator-approved" — Submission.is_public returns
True only for approved, so application code that already gates on
that property continues to behave correctly.
The submit response surfaces pending in the raw status field and
unreviewed in the human-facing review_status field.
Request shape
POST /api/v1/bundles/submit
Content-Type: application/json
X-API-Key: tck_… // or session cookie
{
"bundle_format": "tckdb-contribution-bundle",
"bundle_version": "0.1",
"bundle_kind": "thermo",
"created_at": "2026-04-25T00:00:00Z",
"source_instance": { /* … */ },
"exporter": { /* provenance-only; not actor identity */ },
"submission": { "title": "…", "summary": "…", "source_kind": "local_bundle" },
"records": { "thermo_uploads": [ /* … */ ], "kinetics_uploads": [] },
"local_refs": { /* informational only */ },
"manifest": { /* … */ }
}
Response shape
HTTP/1.1 201 Created
Content-Type: application/json
{
"submission_id": 123,
"status": "pending",
"review_status": "unreviewed",
"bundle_kind": "thermo",
"summary": {
"records_imported": 1,
"records_linked": 1,
"warnings": 0
},
"records": [
{ "record_type": "thermo", "record_id": 456,
"action": "imported", "review_status": "unreviewed",
"local_ref": "thermo_uploads[0]" },
{ "record_type": "species_entry", "record_id": 789,
"action": "linked", "review_status": "unreviewed",
"local_ref": "thermo_uploads[0].species_entry" }
],
"messages": [
{ "level": "info", "code": "ingestion_succeeded",
"message": "Bundle imported successfully. Records are publicly visible but unreviewed; curator review is a separate, future step." }
]
}
Relationship to dry-run
Dry-run (POST /api/v1/bundles/dry-run) and submit
(POST /api/v1/bundles/submit) share the same input schema and the same
underlying validation service. Submit additionally:
- requires the dry-run to report no blocking errors,
- writes through the existing thermo/kinetics workflows,
- creates the submission/audit/link rows.
A client that wants to preview before committing can call /dry-run
first; submit also runs it internally as a gate, so the preview is not
strictly required.
What this milestone is not
Out of scope for v0:
- curator accept/reject workflow,
- moderation UI,
- frontend bundle upload page,
- local push-to-hosted UX,
- artifact import,
- network bundle import,
- raw database synchronization,
- service accounts,
- review-status exposure on read schemas (still derived from
Submission.is_public), - duplicate scientific-product detection beyond what existing workflows already perform.
Validation vs curation
This is the central policy of submit/import v0:
| State | What it means |
|---|---|
| submitted | Bundle was posted by an authenticated hosted user. |
| imported | Records were persisted through the normal upload workflows. |
| unreviewed | No curator has yet evaluated the submission. |
| pending_review | Submission is queued for future curator review (this milestone exits here). |
| accepted / approved / curated | Not produced by this milestone. |