Iteration 2 Spec — Airlock Request Lifecycle (Researcher Side)¶
OpenAPI paths¶
AirlockRequest¶
POST /requests¶
Create new request in DRAFT state.
Request body:
{
"project_id": "uuid",
"direction": "egress | ingress",
"title": "string",
"description": "string"
}
Response 201:
{
"id": "uuid",
"project_id": "uuid",
"direction": "egress",
"status": "DRAFT",
"title": "string",
"description": "string",
"submitted_by": "uuid",
"submitted_at": null,
"updated_at": "datetime",
"closed_at": null,
"object_count": 0
}
Errors: 403 if user not researcher on project. 404 if project not found or archived.
GET /requests¶
List requests. Researchers see own project requests. Checkers see requests on their projects. Admins see all.
Query params: project_id, status, direction, limit (default 50), offset (default 0).
Response 200: { "items": [...], "total": int }
GET /requests/{id}¶
Get single request with objects summary.
Response 200: same shape as POST 201 response plus objects: [OutputObjectRead].
Errors: 403 if no project membership. 404 if not found.
POST /requests/{id}/submit¶
Transition DRAFT → SUBMITTED. Enqueues agent review job.
No request body. Requires at least one OutputObject in PENDING state.
Response 200: updated request.
Errors: 409 if status != DRAFT. 422 if no objects.
OutputObject¶
POST /requests/{id}/objects¶
Upload file. Multipart form: file (binary), output_type (str), statbarn (str).
Server streams to quarantine S3, computes SHA-256 inline, creates OutputObject record.
S3 key: {project_id}/{request_id}/{logical_object_id}/{version}/{uuid4}-{filename}
Response 201:
{
"id": "uuid",
"request_id": "uuid",
"logical_object_id": "uuid",
"version": 1,
"replaces_id": null,
"filename": "string",
"output_type": "string",
"statbarn": "string",
"storage_key": "string",
"checksum_sha256": "string",
"size_bytes": int,
"state": "PENDING",
"uploaded_at": "datetime",
"uploaded_by": "uuid"
}
Errors: 403 if not researcher on project. 409 if request not in DRAFT state.
GET /requests/{id}/objects¶
List all objects for request.
Response 200: { "items": [OutputObjectRead] }
GET /requests/{id}/objects/{object_id}¶
Get single object.
Response 200: OutputObjectRead.
PATCH /requests/{id}/objects/{object_id}/metadata¶
Set/update OutputObjectMetadata for a logical object.
Request body:
{
"title": "string",
"description": "string",
"researcher_justification": "string",
"suppression_notes": "string",
"tags": {}
}
Response 200: updated metadata record.
GET /requests/{id}/objects/{object_id}/metadata¶
Get metadata for logical object.
Response 200: OutputObjectMetadataRead.
AuditEvent¶
GET /requests/{id}/audit¶
List audit events for request. Ordered by timestamp ASC.
Response 200: { "items": [AuditEventRead] }
DB migration¶
New tables¶
airlock_requests¶
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| project_id | UUID FK → projects.id | |
| direction | VARCHAR | egress / ingress |
| status | VARCHAR | AirlockRequestStatus enum |
| title | VARCHAR | |
| description | VARCHAR | default "" |
| submitted_by | UUID FK → users.id | |
| submitted_at | DATETIME | nullable |
| updated_at | DATETIME | |
| closed_at | DATETIME | nullable |
output_objects¶
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| request_id | UUID FK → airlock_requests.id | |
| version | INTEGER | 1-indexed |
| replaces_id | UUID FK → output_objects.id | nullable |
| logical_object_id | UUID | index |
| filename | VARCHAR | |
| output_type | VARCHAR | |
| statbarn | VARCHAR | |
| storage_key | VARCHAR | |
| checksum_sha256 | VARCHAR | |
| size_bytes | INTEGER | |
| state | VARCHAR | OutputObjectState enum |
| uploaded_at | DATETIME | |
| uploaded_by | UUID FK → users.id |
output_object_metadata¶
| Column | Type | Notes |
|---|---|---|
| logical_object_id | UUID PK | |
| title | VARCHAR | |
| description | VARCHAR | default "" |
| researcher_justification | VARCHAR | default "" |
| suppression_notes | VARCHAR | default "" |
| checker_feedback | JSON | default [] |
| tags | JSON | default {} |
| updated_at | DATETIME |
audit_events¶
| Column | Type | Notes |
|---|---|---|
| id | UUID PK | |
| request_id | UUID FK → airlock_requests.id | nullable; index |
| actor_id | VARCHAR | user UUID str or "agent:trevor-agent" or "system" |
| event_type | VARCHAR | namespaced e.g. request.submitted |
| payload | JSON | |
| timestamp | DATETIME | UTC server-set; index |
Enum values¶
AirlockRequestStatus: DRAFT, SUBMITTED, AGENT_REVIEW, HUMAN_REVIEW, CHANGES_REQUESTED, APPROVED, REJECTED, RELEASING, RELEASED
OutputObjectState: PENDING, APPROVED, REJECTED, CHANGES_REQUESTED, SUPERSEDED
AirlockDirection: egress, ingress
OutputType (initial set): tabular, figure, model, code, report, other
State machine rules¶
- DRAFT → SUBMITTED: requires ≥1 PENDING object; emits
request.submitted - SUBMITTED → AGENT_REVIEW: set by ARQ worker on job start; emits
request.agent_review_started - AGENT_REVIEW → HUMAN_REVIEW: set by ARQ worker on completion; emits
review.created - HUMAN_REVIEW → APPROVED / REJECTED / CHANGES_REQUESTED: set by checker POST /reviews (Iteration 3)
- CHANGES_REQUESTED → SUBMITTED: on researcher resubmit (Iteration 5)
- APPROVED → RELEASING: triggered on second approval; emits
request.releasing - RELEASING → RELEASED: set by ARQ release job (Iteration 6)
This iteration implements: DRAFT → SUBMITTED only. AGENT_REVIEW onward is Iteration 3.
Audit events emitted (Iteration 2)¶
| Event | Trigger |
|---|---|
request.created |
POST /requests |
object.uploaded |
POST /requests/{id}/objects |
object.metadata_updated |
PATCH metadata |
request.submitted |
POST /requests/{id}/submit |