---
title: "Document Storage"
slug: document-storage
section: Documents & Identity
status: stable
description: "The authoritative contract for how Globus stores, secures, versions, and serves document files."
last-updated: 2026-10-07
---

# Document Storage

The authoritative contract for how Globus stores, secures, versions, and serves document files. The primary population is applicant documents — passport scans, certificates, and other evidence collected across the applicant lifecycle and tied to a specific application attempt — alongside host-organization compliance artifacts (insurance, W-9, site visits). This page is the single source of truth for the Google Cloud Storage backend, the signed-URL upload flow, versioning, the proxied-download access model, and the document state machine. It documents the contract; the binary-handling endpoints themselves live in the GLOBUS Hub repo and remain 501 stubs in this docs site.

> **Globus brokers storage; clients never touch GCS directly**
>
> Every file lives in Google Cloud Storage. No portal, partner, or applicant client ever receives a raw GCS object path or a long-lived URL. Uploads go through a short-lived signed PUT URL; downloads go through a short-lived signed GET URL minted per request after an authorization and audit check. The storage backend is an implementation detail Globus owns — the contract on this page is the only stable surface integrators code against.

## Storage Backend (GCS)

All document files are persisted in Google Cloud Storage with uniform bucket-level access (no per-object ACLs) and object versioning enabled. Access is brokered entirely by Globus via signed URLs; the bucket is not public and is never fronted by a CDN. Job order documents are streamed by the Hub rather than signed: `GET /api/admin/job-orders/:id/documents/:docId/download` (permission `job_orders.view`, `job_orders.create` or `job_orders.manage`) reads objects stored under `uploads/` from the bucket named by `GCS_BUCKET_NAME` (default `globus-uploads` when unset) (rows whose stored path starts with `/uploads/` are served from local disk instead) and returns 404 `File not accessible` when the file cannot be read.

### Buckets, Prefixes & Environments

Each environment has its own bucket so non-production data can never co-mingle with the production system of record. In PPPToo (the applicant portal BFF), the `DOCUMENTS_BUCKET` environment variable names the bucket its documents-storage service uses for uploaded documents (objects under `ppp2/applications/` plus the application id). It is optional in code: when unset it falls back to `applicant-portal-documents-development`, so an environment that does not set it writes to the development bucket.

Two Hub environment variables configure this. `GLOBUS_DOCUMENT_BUCKET` is required: the bucket name for the environment (for example `globus-documents-development`). There is deliberately no default, `getDocumentBucket()` throws when it is unset, and GCS is treated as unavailable without it. `GCP_SERVICE_ACCOUNT_KEY` is optional: when set, its service-account JSON is used as the GCS credentials; when unset the client uses Application Default Credentials (Workload Identity on GKE). After an `invalid_grant` authentication failure, GCS calls back off for 5 minutes.

| Environment | Bucket | Notes |
| --- | --- | --- |
| | bucket | notes |

_(row above is a template, repeated for each data row)_

Within each bucket, objects are organized under stable prefixes keyed by the owning entity:

| Prefix | Scope | Example |
| --- | --- | --- |
| prefix | scope | example |

_(row above is a template, repeated for each data row)_

> **Applicant-scoped storage is a documented schema gap**
>
> Today the documents table requires applicationId (.notNull()), so every applicant file is bound to a single application attempt — the applications// prefix is the only entity layout backed by a table. The applicants// “My Documents” layout is the intended identity-bound design but the existing applicant_documents table requires application_id (NOT NULL), so there is no identity-bound table behind it and identity artifacts cannot be reused across re-applications without manual recopy. See the audit on Applicant vs Application Data . Reported, not fixed.

### Object Naming

Object names are derived from the server-allocated documentId (an opaque, unguessable identifier), never from the client-supplied filename. The original filename is stored only as metadata for display and on the Content-Disposition header of proxied downloads. This prevents path traversal, collisions, and information leakage through file names.

## Upload Contract

Every upload is validated twice: once at the upload-url request (declared contentType and sizeBytes), and again at finalize (the actual GCS object is inspected). The signed PUT URL itself constrains the byte range and content type, so a client cannot exceed the declared size or change the type after the URL is issued.

### Accepted Types & Size Limits

| Category | Accepted MIME types | Max size |
| --- | --- | --- |
| category | mime | maxSize |

_(row above is a template, repeated for each data row)_

> **Limits are platform policy, not per-contract**
>
> The size caps and MIME allow-list above are the platform-wide GCS upload policy. The Host Document Center and any other document surface enforce these centrally — individual endpoint contracts do not redefine them. A request that exceeds the cap or declares a disallowed type is rejected with the standard validation error envelope before any signed URL is issued.

### Antivirus Scan & Hashing

- Content check, no antivirus scan. There is no asynchronous malware scan or quarantine step. The Hub upload routes run a dependency-free magic-byte content sniffer (server/lib/uploadContentSniffer.ts) that rejects files whose leading bytes match executable, script, HTML or SVG content regardless of the declared type.
- Content hashing. The Hub does not persist a SHA-256 digest of uploaded bytes on applicant_documents or host_documents (only the document package model carries a checksum), so hash-based integrity verification and duplicate detection are not implemented.

### Upload Guards on the Hub Upload Routes

The Hub's multipart upload routes apply two guards before a file is stored: an upload-specific rate limit and a magic-byte content check. The content check is a dependency-free signature sniff of the file's first bytes — it is **not** an antivirus or malware scan.

- **Routes covered.** Staff `POST /api/documents/upload` (permission `document.verify`), applicant-portal `POST /api/portal/applications/:appId/documents`, and the applicant profile-photo upload (PUT). All three sit behind the rate limit and the content check. The same rate limit also guards three more multipart uploads: Host Portal `POST /api/partner/host/offer-contracts`, and staff `POST /api/admin/partners/organizations/:orgId/offer-contracts` and `POST /api/admin/partners/organizations/:orgId/host-documents`.
- **Rate limit.** An IP-keyed, in-memory sliding window placed before multer buffers the file. Default 20 uploads per 60 seconds per IP, overridable with `UPLOAD_THROTTLE_LIMIT` and `UPLOAD_THROTTLE_TTL_SECONDS`. Over the limit returns `429` with `{ error: "Upload rate limit exceeded", detail, retryAfter }`.
- **General document routes (staff upload, portal upload).** The file's sniffed type is rejected only when it is a dangerous class regardless of the claimed MIME type or extension: executable (MZ/ELF), script (shebang, PHP, ASP-style tags), HTML, or SVG. Nothing else is narrowed — there is no declared-type allowlist on these routes. A rejection returns `400` with `code: "UNSAFE_FILE_CONTENT"`.
- **Photo uploads.** The staff route's `photo` document type (jpeg/png only, 5 MB) and the profile-photo upload additionally require the signature to match a recognised image type (jpeg, png, gif, webp, heic); a ZIP/OLE2 container, PDF or executable declared as a photo is refused. Staff photo rejections return `400 UNSAFE_FILE_CONTENT`; the profile-photo route returns `415 UNSUPPORTED_MEDIA_TYPE`. Content whose signature is not recognised at all defers to the declared-MIME gate that already ran. That declared-MIME gate is what returns `415` with `UNSUPPORTED_PHOTO_FORMAT` (body `{ error, code }`) when a staff `photo` upload declares a type other than `image/jpeg`, `image/jpg` or `image/png`; it runs before the size and signature checks, and `POST /api/upload-photo` returns the same code for the same types.
- **Size ceiling.** The multer limit is 20 MB; an over-limit file is returned as `413` (`FILE_TOO_LARGE`, or `PHOTO_TOO_LARGE` for photos).

## Signed-URL Upload Flow

The client never streams file bytes through Globus and never sees the GCS bucket. The flow is a three-step handshake: request a signed URL, PUT directly to GCS, then finalize.

- Client calls POST .../documents/upload-url with . Globus validates the request, allocates a pending documentId under the _pending/ prefix, and returns . On the applicant side this handshake is wrapped by a workflow step submission (POST /api/applicant/steps/:id/submit) rather than called directly.
- Client PUTs the file bytes directly to uploadUrl using the returned headers (typically Content-Type and x-goog-content-length-range). The signed URL enforces the declared type and byte range.
- Client calls POST .../documents with to finalize (host documents may also carry an expiresOn). Globus verifies the GCS object exists, moves it to its permanent prefix, and persists the metadata row (host documents: gcsObjectPath, contentType, fileSize, reviewState="pending_review"; applicant documents: fileUrl, contentType, fileSize, state="pending"), then emits document.uploaded.
Finalize identifies the upload by the opaque `uploadId` alone. `uploadId` is an opaque token that the Hub resolves to the GCS object path on its own side. It exists only in the host-document handshake (the Host Portal upload flow); there it is AES-256-GCM encrypted and carries the object path, the owning host organisation and an expiry one hour out, so the client can neither read nor forge it, and finalize refuses a malformed, tampered, expired or other-organisation token with `400 INVALID_UPLOAD_TOKEN` (staff and applicant-portal uploads are single multipart POSTs with no `uploadId`; the HMAC-signed portal session token only authenticates the applicant-portal call and is not an upload token); neither the initiation response nor the finalize body carries a `gcsObjectPath`, and a client must not require or send one (the Host Portal client previously demanded that field and failed before the signed PUT; it now requires only `uploadId` and `uploadUrl`). In the Host Portal the handshake runs against `{endpoint}/uploads/initiate`, a direct PUT using the returned method and headers, then `{endpoint}/uploads/finalize`. If the browser PUT throws (typically a cross-origin block), the client falls back to an authenticated server relay at `{endpoint}/uploads/relay`, which initiates its own signed destination, transfers the bytes and finalizes server-side.

The finalize route (`POST /api/partner/host/documents/finalize-upload`, also served on the deprecated `/api/host/documents` alias) answers rejections in the envelope `{ error: { code, message, details, traceId } }`: `400 INVALID_UPLOAD_TOKEN` (see above), `400 INVALID_OBJECT_PATH` (the path resolved from the token is not under `host-documents/` plus the caller's organisation id), `400 OBJECT_NOT_UPLOADED` (the signed PUT has not put the object into GCS yet) and `400 FILE_TOO_LARGE` (the stored object exceeds the host document size limit). Separately, the staff preview `GET /api/admin/partners/organizations/:orgId/host-documents/:id/preview` returns `404 OBJECT_NOT_FOUND` when the document row exists but its stored object cannot be found.

### Orphan Reaping (TTL Expiry)

A signed upload URL is short-lived (minutes, not hours). If finalize is never called — the client abandons the upload, the network drops, or the PUT succeeds but the finalize does not — the pending object and its placeholder row become orphans. A scheduled reaper sweeps the _pending/ prefix and deletes any object whose expiresAt has passed without a finalize, along with its placeholder row. Finalize against an expired or reaped documentId returns a validation error and creates nothing. Orphan reaping is part of the platform's scheduled batch jobs — see Batch Jobs .

## Versioning

Documents are never overwritten in place. Replacing a document creates a new version and retains the previous one for the audit window.

- Host documents — explicit replace. POST .../documents/:id/replace uses the same signed-URL handshake, then links the new row to the prior one via previous_document_id and emits document.replaced. This explicit version chain lives on the host document surface.
- Applicant documents — re-upload versions the existing row. Staff and portal uploads both go through one re-upload-aware path: a second upload of the same document type for the same application (and dependent scope) archives the prior file into document versions and overwrites the row in place, incrementing its version number, rather than inserting a second row. Applicants can read their own version history at `GET /api/portal/documents/:id/versions` (staff-only fields such as reviewer and review notes are stripped). The applicant documents table has no previous_document_id chain, but a reviewed qualification document is never overwritten: the replacement carries supersedes_document_id and the original is kept with state superseded and superseded_by_document_id. A portal upload over an already-approved document is refused with `409 DOCUMENT_ALREADY_APPROVED`; only a staff re-upload replaces an approved document. For multi-file document types (police checks, contract pages), the `IStorage` interface also provides `addApplicantDocumentFile`, which adds one more file without replacing the files on record: a rejected or placeholder file is replaced by the new upload, otherwise the file becomes the primary row or an `additional_file` row. The re-upload path accepts an optional `targetDocumentId` to replace exactly one row (a rejected or placeholder file of a multi-file type).
- Old versions are retained. Superseded objects stay in GCS for the full audit retention window. Bucket-level object versioning provides a second layer of protection against accidental deletion or overwrite.
> **Consistent with the Host Document Center**
>
> The replace semantics here are the storage-side definition of the document.replaced event documented on the Integration Contract . The endpoint table and event payloads there and here describe one system.

## Access Model — Proxied Downloads

Raw GCS URLs are never exposed to clients. Every read is brokered by Globus so the authorization check and the audit record happen on every access — not just at upload time.

- Signed, short-lived GET URLs. .../documents/:id/download and .../documents/:id/preview mint a fresh signed URL per request, after the caller's permission is verified. The URL expires in minutes and cannot be reused once expired.
- No durable URLs. A signed URL is never stored, cached, or returned in a list response. Clients re-request a URL each time they need to render or download a file.
- Existence is not leaked. A request for a document the caller is not authorized to see returns 404, never 403.
## Document State Machine

Every document a person or host org is expected to provide moves through a small, well-defined state machine. Missing &rarr; Uploaded &rarr; Verified is the happy path; Rejected and Expired both send a slot back to Missing for completeness purposes.

| State | Meaning | Entered when |
| --- | --- | --- |
| | meaning | entered |

_(row above is a template, repeated for each data row)_

> **How these states map to the schema**
>
> The display states map onto the applicant_documents.state column (host documents use host_documents.review_state; legacy applicant documents store approved where qualification documents store verified): pending renders as Uploaded, verified as Verified, rejected as Rejected, and expired as Expired. Missing is derived — the absence of a usable row for a required slot, never a stored value. Host documents default to review_state pending_review.

The Verified state is what the Document Completeness Signal aggregates per applicant, and the review workflow that drives these transitions lives on the Queue System Document Review page. This page owns the storage and access contract; the review queue owns the human workflow.

## RBAC & Visibility

Who can read which document is governed by the platform's RBAC model and the applicant-vs-application visibility split, not by storage-layer ACLs. The storage layer simply refuses to mint a URL when the brokered authorization check fails.

- Application-scoped and applicant-scoped documents follow the applicant vs application visibility rules — a partner sees only what its visibility matrix allows.
- Host-scoped documents are gated by host-portal permissions: host.documents.view for reads and host.documents.manage for upload/replace/delete, with a visibleToHost flag filtering internal-only artifacts out of host responses entirely.
- Sensitive document types (passport, ID) inherit the platform PII masking rules — see the Roles & Governance model.
## Applicant Document Expiry Policy

Whether an applicant document can expire is a property of its document type, not of the individual upload. Each document type carries `expiry_handling`:

| Value | Meaning |
| --- | --- |
| `intrinsic` | The document itself has a real expiry date (for example a passport). Only these types accept an `expiresAt` value and only these feed the document-expired exception. |
| `compliance` | Freshness is decided by a compliance rule, not by a stored expiry date. |
| `none` | The document never expires (the default). |

- **Seeded values.** `passport` is `intrinsic`; `insurance`, `police-clearance`, `proof-of-funds` and `proof-of-student-status` are `compliance`; every other type is `none`. Photo expiry dates, which were never meaningful, were cleared.
- **Authoritative source rule.** `intrinsic` can only be set for a document type that has an authoritative expiry capture source; Passport is currently the only one (its workflow snapshot is projected onto the applicant document). Passport is locked to `intrinsic`. Violations return `409 DOCUMENT_EXPIRY_POLICY_UNSUPPORTED`.
- **Uploads.** A staff upload that supplies `expiresAt` for a non-intrinsic type is refused with `400 DOCUMENT_EXPIRY_NOT_SUPPORTED`; an unparseable date returns `400 INVALID_DOCUMENT_EXPIRY`.
- **Document-expired exceptions.** Only documents of `intrinsic` types, with an expiry date in the past and a state other than rejected, archived or superseded, count as expired for queue membership and fact projection.
- **Administration.** `GET /api/admin/document-types` lists the catalogue with each type's policy (permission `settings.global.manage` or `document.view`); `PATCH /api/admin/document-types/:id` with `{ expiryHandling }` changes it (permission `settings.global.manage`, audited as `document_type.expiry_handling.update`). Moving a type away from `intrinsic` clears stored expiry dates for that type's applicant documents and reconciles the affected applications' exception queues. The admin screen is the Document Types page (`/admin/document-types`), listed with the workflow configuration items in the staff sidebar.

Host-organization documents carry their own `expiresOn` (see Host Documents) and are not governed by this policy.

## Program Document Requirements and Education Policies

**Program document-requirement and education-policy saves.** `PUT /api/admin/programs/:id/document-requirements` (permission `programs.manage`, API key scope `admin.write`) replaces the whole required-document matrix for a program in one transaction. The body is `{ requirements: [...] }` with `applicationType`, `seasonId` (null for program-wide), `documentTypeId` and `isRequired` per row; `GET` on the same path (permission `programs.view`) returns the rows plus the document types, seasons and application types that can be chosen. `PUT /api/admin/programs/:id/education-document-policies` (same permissions) replaces the per-degree-level transcript policy the same way with `{ policies: [...] }` rows of `degreeLevel` and `transcriptRequired`; a degree level with no row means the transcript is required. An unknown program returns 404. Rejected payloads return `400 VALIDATION_ERROR` with the code in `details.code`: `UNKNOWN_DOCUMENT_TYPE` (details `documentTypeId`), `UNKNOWN_SEASON` (a season id that does not belong to the program; details `seasonId`), `INVALID_APPLICATION_TYPE` (details `applicationType`; the route body schema already limits `applicationType` to the recognised set, so this is the service-level check), `DUPLICATE_MATRIX_ROW` (the same application type, season and document type twice in the payload), `INVALID_DEGREE_LEVEL` (details `degreeLevel`) and `DUPLICATE_POLICY_ROW` (the same degree level twice; details `degreeLevel`).

The matrix rows are stored in `program_document_requirements`. When requirements are resolved for an application, a season-specific row overrides the program-wide row (`seasonId` null) for the same document type, and a row with `isRequired` false waives that document type for the context. Each scope level holds at most one row per program, application type and document type.

## Retention & Lifecycle

- Retention window. Documents are retained for the compliance audit window required by the program (J-1 record-keeping obligations). Superseded versions and soft-deleted documents remain recoverable for this window.
- Hot vs. cold storage. Active-program documents stay in standard (hot) storage. A lifecycle rule transitions objects that have not been accessed for a defined period to a colder, lower-cost storage class, keeping them retrievable for audit without paying hot-storage rates indefinitely.
- Soft-delete then hard-delete. Portal deletes are soft (the row is flagged, the object retained). Hard deletion is staff-only and only permitted after the retention window lapses. Lifecycle rules purge expired pending/orphan objects automatically.
## Encryption at Rest & Key Management

All objects are encrypted at rest by GCS. Document buckets use customer-managed encryption keys (CMEK) held in Cloud KMS so key rotation and access are auditable and under Globus's control rather than purely Google-default. Encryption in transit is enforced (signed URLs are HTTPS-only). The CMEK and rotation posture for document storage mirrors the controls described for OCR processing on the OCR Compliance Ledger .

## Audit Logging

Every read and every write is audited. Minting a download or preview URL records who accessed which document and when; uploads, replacements, and deletions record the actor and the resulting state change. Because access is brokered (no durable URLs), the audit log is a complete record of document access, not just of uploads. These records surface in the real-time Audit Console under the document-scoped audit vocabulary.

**Backing tables.** `document_types` is the document type catalogue: `name`, unique `code`, `description`, `is_required`, `category` (default `supporting`), `is_uploadable`, `document_wave` and `expiry_handling` (`intrinsic`, `compliance` or `none`). `reviewer_document_types` assigns document type codes to reviewers (`user_id` and `document_type_code`, unique per pair): in the document review queue routes, a reviewer who is not a full document reviewer can act only on assigned types and otherwise gets 403 `FORBIDDEN` (not assigned to review this document type); god-mode, API keys and full reviewers are not restricted. `document_review_audit_logs` records each applicant document review transition (`document_id`, `from_state`, `to_state`, `action`, `reviewer_id`, `reviewer_name`, `notes`, `reason_code_id`). `host_document_review_audit_logs` is the host document equivalent, keyed by `reviewer_email` because host documents identify reviewers by email; it backs the host document `review-history` route.

## Per-Surface Contracts

Documents reach Globus through three portal surfaces — applicant, partner, and host. Every surface uses the same storage and retrieval methods defined above: the three-step signed-URL upload handshake, proxied short-lived signed-GET downloads with a per-request authorization and audit check, content hashing and antivirus on finalize, the shared document state machine, CMEK encryption at rest, and full read/write audit. No surface gets a bespoke upload path or a durable URL. What differs per surface is only who authenticates, what the document is scoped to, and who may read it — captured below.

> **One storage contract, three doors**
>
> The sections below do not re-derive storage, upload, versioning, or access rules — those are defined once, above, and apply uniformly. Each surface section only states its identity, its ownership scope, and its visibility rules, then points back to the canonical flow. If a rule here ever appears to conflict with the canonical sections above, the canonical section wins.

### Applicant Documents

Applicant documents are the largest population — passport scans, certificates, language tests, and other evidence the participant uploads across their lifecycle. They are application-scoped: the documents table carries a required applicationId, so each file is bound to one application attempt. The intended identity-bound applicants// “My Documents” layout is a documented schema gap — see the storage-backend callout above and Applicant vs Application Data .

- Identity. The applicant authenticates with X-Applicant-User-Id and can only ever touch documents bound to their own current application.
- Upload. The Applicant Portal uploads through `POST /api/portal/applications/:appId/documents` (multipart, applicant-owned application only, document type by code or id, 20 MB, rate limit and content check as above, written to GCS with no local-disk fallback and a typed `502` on storage failure); document-collecting workflow steps (POST /api/applicant/steps/:id/submit) also wrap the flow.
- Retrieval. Reads go through the same proxied, short-lived signed-GET model — never a raw GCS URL.
- Versioning. A re-uploaded document of the same type versions the existing row in place (prior file archived); there is no previous_document_id chain on the applicant side — see Versioning.
- Staff document requests. `DELETE /api/applications/:id/document-requests/:documentId` (permission `document.verify`, application scope enforced) withdraws a document request that staff created with `POST /api/applications/:id/document-requests`. It returns `200` with `{ id, cancelled }`, and repeating it on an already-withdrawn request also succeeds with `cancelled` false. Once the applicant has uploaded a file it returns `409 CONFLICT` with `details.code` `DOCUMENT_REQUEST_HAS_UPLOAD`; the upload is then a submission for the normal review actions. A document id that belongs to another application returns `404`.
- Visibility. The applicant sees all of their own documents. Partners and hosts see only the slices their visibility matrix and step scope allow, and the platform PII registry masks identity and medical documents on every non-staff surface — governed entirely by applicant vs application visibility rules , not by storage-layer ACLs.
### Partner Documents

“Partner documents” cover two distinct populations the recruiting partner touches, both stored and served through the canonical contract:

- Partner-owned org documents. The partner agreement, quota letters, and partner-side compliance artifacts owned by the partner organization (not by any applicant). These are org-scoped and the partner has full CRUD against its own document center via /api/portal/documents.
- Partner-uploaded applicant intake documents. During intake the partner may upload application-scoped documents on behalf of a recruit. After intake the partner has read-only review status only and cannot view documents the applicant later uploads directly unless they are flagged partner-visible.
- Identity. The partner authenticates with X-Partner-Org-Id; every read and write is ownership-verified so a partner never sees another partner's org documents or another partner's applicants.
- Upload & retrieval. Both populations use the same signed-URL upload flow and the same proxied-download access model as host and applicant documents.
- Visibility. Org documents are visible only within the owning partner org. Application-scoped intake documents follow the applicant vs application visibility rules — a partner reads a recruit's documents only through /api/partner/applicants/:applicantId/documents, with identity and medical documents masked by the PII registry.
### Host Documents

Host documents are host-organization compliance artifacts (insurance, W-9, site visit, housing inspection, operating agreement) owned by a host org, not by an applicant or placement. They are the canonical worked example used throughout this page, and their full endpoint table, auth, bidirectional visibleToHost visibility, validation, and webhook payloads live on the Integration Contract — Host Document Center . Hosts also get a read-only, redacted view of placed participants' application-scoped documents via /api/host/placements/:id/documents. Host reads and writes use the identical storage and retrieval contract defined above. The staff list (GET /api/admin/partners/organizations/:orgId/host-documents) always returns the selected company's own documents in `documents`; when document inheritance is enabled it also returns the governance parent's documents separately as read-only `inheritedDocuments`, with `inheritedFromParentOrgId` and `inheritedFromParentName` (null when nothing is inherited). Inherited rows are never merged into the actionable list, and staff review acts only on the company's own rows. Stored host documents are evidence/facts for Rules Engine evaluation — for Camp and Camp SWT hosts the Business License, Workers Compensation, and Camp Accreditation objects feed the Camp Host Verification Policy via the Facts Resolver — but storage only holds the artifacts and their metadata; it never computes document validity or the verification outcome.

## Endpoint Reference

A consolidated cross-surface index of the document endpoints restated for storage context. The per-surface contracts above break these out by applicant, partner, and host. The canonical definitions live on the Integration Contract (Applicant Portal Endpoints) and the Host Document Center . All remain 501 contract stubs in the docs site; the real implementation lives in the GLOBUS Hub repo.

| Method | Path | Purpose |
| --- | --- | --- |
| method | path | desc |

_(row above is a template, repeated for each data row)_

## Document Events

Storage mutations emit on the standard event outbox. The payloads are defined canonically in the Integration Contract event catalog ; the storage-relevant subset is:

| event | desc |
| --- | --- |

## Document Package Portal Endpoints

Document packages group the required document types for a program into a single applicant-facing bundle. The three routes in the table below (list packages, fetch one package, submit a package) were proposed in the Aug 11 2026 release capsule but are NOT implemented in BackOfficePrototype; the implemented package routes are listed after the table:

| Method | Path | Purpose |
| --- | --- | --- |
| GET | /api/portal/document-packages | List the document packages available to the applicant in their current program |
| GET | /api/portal/document-packages/:id | Fetch the full package configuration — required document types, upload instructions, and completion state |
| POST | /api/portal/document-packages/:id/submissions | Submit a document package for staff review |

**Implemented package routes (BackOfficePrototype).** The routes below are registered in the Hub today. All require the verified portal access token (`portalOrIntegrationAuth("integrations.portal", { requireVerifiedToken: true })`), so identity is bound to the token's applicant; ownership mismatches return non-disclosing 404s. The package status, list, download and translation routes return flat bodies (the portal SDK does not unwrap envelopes); the criminal-background status route returns the standard success envelope.

| Method | Path | Purpose |
| --- | --- | --- |
| GET | /api/portal/applications/:appId/document-packages | List the application's document-package requirements (`{ requirements }`); `403` when the token's application differs from `:appId` |
| GET | /api/portal/document-packages/:requirementId/status | Applicant-safe state for one requirement (processing status, status message, whether re-upload is allowed, translation availability, original/translated filenames); never exposes internal errors, OCR text or reviewer notes |
| GET | /api/portal/document-packages/artifacts/:artifactId/download | Backend-proxied byte stream for an applicant-safe artifact — never a signed GCS URL. Ownership and a role allowlist are enforced, every permitted download writes a PII access audit entry, internal artifacts (OCR text extractions, watermarked drafts) return 404, and `?disposition=inline` requests inline rendering |
| GET | /api/portal/criminal-background/status?applicationId= | Every document-package requirement on the application with its current record's processing/review state and original + current translation artifact refs (each with a portal-scoped `downloadUrl`), plus `translationFallbackRequired` and the detected-language label |
| POST | /api/portal/document-packages/:requirementId/applicant-translation | Applicant-supplied translation fallback (multipart `file`: pdf, jpeg or png, up to 20 MB); only valid while the current record is `awaiting_applicant_translation`; returns `201`, or `409` with `TRANSLATION_FALLBACK_NOT_AVAILABLE` / `NO_CURRENT_RECORD` |

**Staff translation routes (BackOfficePrototype).** Two staff-only routes act on the current record of a document-package requirement. Both require a session, refuse non-staff principals with `403`, and return flat bodies (`{ error }`, with a `code` on the typed failures below). `POST /api/document-packages/:requirementId/upload-translation` (permission `document.background_check.upload_translation`) takes a multipart `file` (pdf, jpeg or png, up to 20 MB), a `translationType` of `manual` or `certified`, and for `certified` a `certifiedMetadata` JSON string (missing or non-JSON returns `400 VALIDATION_ERROR`; metadata that fails its schema returns `400` with the issue list). It stores the file under `uploads/document-packages/` in GCS and returns `201` with the new artifact (`id`, `artifactRole`, `versionNumber`, `isCurrent`). Other failures: `400 NO_FILE` (no file), `415 UNSUPPORTED_FORMAT` (type not pdf, jpeg or png), `413 TRANSLATION_TOO_LARGE` (over 20 MB, including the multer abort), `502 STORAGE_FAILED` (the GCS write failed and nothing is recorded) and `409 TRANSLATION_UPLOAD_REJECTED` (the service reported no current record or missing certified metadata). `POST /api/document-packages/:requirementId/regenerate-translation` (permission `document.background_check.regenerate_translation`) re-runs the AI translation pipeline in the background and returns `202` with `{ success, recordId }`; `409 REGENERATION_IN_PROGRESS` means the pipeline is already running for the record, and `409 REGENERATION_REJECTED` means the requirement has no current record or no original artifact to translate.

> **Portal-side submission flow**
>
> These endpoints underpin the planned portal-side document submission flow. They are the portal read/submit surface over the package system; the underlying files still move through the canonical signed-URL upload handshake and proxied-download access model defined above.

**Staff document package routes (BackOfficePrototype).** These require a signed-in session (`isAuthenticated`) and a `document.background_check.*` permission, except that the two applicant-readable routes below also admit an applicant principal. `GET /api/document-packages/:requirementId` returns the full staff view and `GET /api/document-packages/:requirementId/records` returns the record version history as `records` (both `document.background_check.view`). `GET /api/document-packages/:requirementId/applicant` returns the applicant-safe projection (no internal errors or reviewer notes); it is open to an applicant principal (ownership enforced in the service) or a holder of the view permission, and returns 404 when not found. `GET /api/document-packages/application/:applicationId` lists an application's requirements (partner callers get a masked list). `POST .../:requirementId/review` records a decision on a translation artifact (`accepted`, `rejected`, `translation_inaccurate` or `manual_review_required`; `document.background_check.review`). `POST .../:requirementId/request-replacement` asks the applicant for a replacement (body `reason`; `document.background_check.request_replacement`). `POST .../:requirementId/upload-translation` stores a staff translation (`document.background_check.upload_translation`, staff principals only). `POST .../:requirementId/regenerate-translation` restarts the translation pipeline and answers 202 with `recordId` (`document.background_check.regenerate_translation`, staff principals only).

**Translation upload and review error codes.** `POST .../review` (body `artifactId`, `status`, optional `notes` and `rejectionReasonCode`; answers 201) returns 409 `TRANSLATION_REVIEW_BLOCKED` when the decision is `accepted` but the current translation is marked inaccurate or its latest review needs manual review, so it cannot be verified until a manual translation is uploaded or the translation is regenerated. `POST .../upload-translation` (multipart `file` plus `translationType` `manual` or `certified`; `certifiedMetadata` JSON is required for certified, else 400 `VALIDATION_ERROR`) returns 400 `NO_FILE` when no file is sent, 415 `UNSUPPORTED_FORMAT` unless the file is pdf, jpeg or png, 413 `TRANSLATION_TOO_LARGE` above 20 MB, 502 `STORAGE_FAILED` when the GCS write fails (there is no local fallback), and 409 `TRANSLATION_UPLOAD_REJECTED` when the requirement has no current record; success is 201 with the new artifact. `POST .../regenerate-translation` returns 409 `REGENERATION_IN_PROGRESS` while a regeneration is running and 409 `REGENERATION_REJECTED` when there is no current record or no original artifact; an unknown requirement returns 404.

## Out of Scope

- Implementing or un-stubbing the document endpoints — they remain 501 contract stubs; the GLOBUS Hub repo owns the binary handling.
- OCR / Document AI extraction behavior — covered by the Private OCR and OCR Compliance Ledger pages.
- Applicant-document and partner-document upload UIs — owned by their respective portals.
- The human review workflow (review hub, review mode, reviewer scoping) — owned by the Queue System.
## Related Pages

Integration Contract — Host Document Center

The canonical endpoint table, auth, validation, and webhook payloads for host documents. This page is the storage-side counterpart; that page is the API surface.

Queue System — Document Review

The human review workflow and Document Completeness Signal that drive the Uploaded &rarr; Verified transition. Storage rules now live here, not there.

Applicant vs Application Data

How document visibility splits between applicant-scoped and application-scoped records, and what each partner type may read.

OCR Compliance Ledger

CMEK / key-rotation posture and the privacy controls for documents processed by Document AI after they are stored.

Batch Jobs

Where the orphan-reaper and lifecycle sweeps that keep the pending prefix clean are scheduled.

Roles & Governance

The permission model and PII masking rules that decide whether a signed download URL is minted at all.
