Document Attachments
Uploaded files live in S3 under a catalog id (Document storage). This page is how those ids are bound to a register section, a change request, an intake submission, and the live record.
Attachment reference
Every bind uses the same wire object:
document_id
Catalog primary key from upload
label
Display string stored on the junction (or in CR payload JSON).
section_id is stored on the junction row, taken from the CR / intake / live section being saved. It is not repeated on each { document_id, label } in the client payload.
The catalog row has no label.
Where a file can be bound
CR supporting / header
g2p_change_request_documents
One change request
No. Stays on the CR
CR proposed section set
Nested change_payload[i].documents in g2p_register_change_request_payloads
One payload row
On approve, if the list is present
Intake section set
g2p_intake_section_documents
One submission section (all rows of that section share it)
On ingest, copied onto each live row created from that section
Live section set
g2p_register_section_documents
One live internal_record_id
This is the live set
Document history
g2p_register_document_history
One live record + section
Audit of ADD / REMOVE, not the current set
Record image
record_image_document_id on the register / history row
The record
Not a section junction. Profile widget
Templates, import files, and export files also use the catalog. They are not section attachments.
Data model
g2p_change_request_documents
(change_request_id, document_id)
Header evidence. section_id is the CR’s section
g2p_intake_section_documents
(submission_id, document_id)
Draft and submitted files. section_id distinguishes sections of the same submission
g2p_register_section_documents
(internal_record_id, document_id)
Current live links. Indexed (internal_record_id, section_id)
g2p_register_document_history
document_history_id
event_type ADD or REMOVE. Origin change_request_id and/or submission_id
Live PK is (internal_record_id, document_id), not per section. The same catalog id cannot be linked to two sections of one record.
Section UI schema
A section collects files when its section_ui_schema has an editable file widget (older docs / documents widgets still count). Each file widget is one slot in the form (widget-label, widget-required, accept, max size). The widget-data-path is not an ORM column. Those keys are stripped from a change payload and are not written to the register table.
section-supporting-documents on the schema is the header list (covering letter, extra certificate). It is not a live slot. Clients send those as top-level CR documents.
documents_required on g2p_register_sections means a nested / intake list cannot be empty when that flag is on.
A section may be files only, or mixed with text / table widgets.
Channels
Every channel uploads first (POST /documents/upload_documents), then attaches catalog ids.
Staff, worker, and core create change request paths share the same validation and the same two lists (header + nested).
Ingestion pipelines and partner APIs that open a CR use that same body. They do not write live section docs themselves.
Change request lifecycle
A CR always targets one section_id of one subject internal_record_id. At most one pending CR exists for that pair.
Create
Client uploads files, gets
document_ids.Body may include top-level
documents(header) and, on eachchange_payloadrow, nesteddocuments(desired live set for that row).Non-ORM keys are stripped. Nested
documentsis kept. Domain / AWE constructors see fields only.Header rows go to
g2p_change_request_documents. Nested lists stay in payload JSON.g2p_register_section_documentsis not touched.
Nested list on ADD / UPDATE:
omitted or null
Leave live section docs as they are
[]
Unlink all live section docs for that row
list
Make live docs exactly that set
DELETE must not send nested documents. Deleting the row unlinks that row’s live section docs. NO_CHANGE rows are dropped and must not carry nested documents.
A document-only section (only file widgets) requires an explicit list on ADD / UPDATE, including [].
Rejected: nested documents with no file widget; duplicate document_id in one row; ids not in the catalog; header duplicate ids; documents_required with [].
Pending read
Header
g2p_change_request_documents + presigned URLs
New / proposed section files
Nested list in payload JSON, hydrated when the key is present and not null
Old / current section files
Live g2p_register_section_documents for that section_id
Omitted nested documents means New has no documents key. The comparison UI should treat that as “same as Old”, not as an empty New. [] is the empty New.
Record images on the payload resolve to record_image_url the same way live records do.
GET /documents/get_change_request_documents returns header files only.
Verification and reject
Verifications do not copy files. Reject updates CR status only. Live section docs and document history are unchanged.
Approve
In the same transaction as field upsert and register history:
Apply domain fields (nested
documentsremoved before ORM / history schemas).Reconcile nested lists onto
g2p_register_section_documents(G2PSectionDocumentReconcileService).Header
g2p_change_request_documentsare not copied to live.
edit_action
Desired live set
DELETE
[]
ADD / UPDATE, nested omitted / null
skip
ADD / UPDATE, nested list
that list
Reconcile (advisory lock on (internal_record_id, section_id)):
In live, not in desired → delete link, history
REMOVEIn desired, not in live → insert link, history
ADDSame
document_id, differentlabel→REMOVEthenADD
Table ADD assigns internal_record_id in this session before reconcile.
Catalog ids on the nested list are re-checked at approve time.
Approved read
Old / current section files are rebuilt from g2p_register_document_history events with approved_at before this CR’s approved_at. Events from the same CR are grouped so a label change does not appear as a gap.
Intake lifecycle
Intake does not use change_payload and does not run CR section-payload validation.
Draft save
save_intake_form_submission takes section_payload (rows) and a section-level documents list.
documents
g2p_intake_section_documents
omitted / null
No-op
[]
Clear that section’s submission files
list
Exact set for that section (diff by document_id; labels updated in place)
Ids must exist in the catalog. Submission GET returns those files grouped by section_id (GET /documents/get_intake_form_documents, and on each section payload).
Deleting a submission deletes its intake junction rows. Catalog objects are not deleted.
Finalize, approve, ingest
Finalize / approve do not write live section docs. After the submission is FINAL and APPROVED, register ingest:
Upserts live register rows from intake rows.
Writes per-register row history (
submission_idset,change_request_idnull).For each section that has submission documents, upserts those files onto each live row created from that section, and writes document history
ADD(submission_id, sourceINTAKE_FORM).
Intake ingest does not emit REMOVE. Files dropped from the draft before ingest never reach live. Files already live on a pre-existing row are not unlinked by ingest.
Live reads
Get record / hierarchy
documents = all live section links for that internal_record_id (every section), plus record_image_url from record_image_document_id
POST /documents/get_section_documents
Live links for one record; optional section_id filter
CR detail
Header + proposed nested + current as above
Intake submission
Junction rows for that submission
Presigned URLs are minted at read time.
Document history vs register history
g2p_register_history_*
Field snapshot of the row at commit
g2p_register_document_history
Link add/remove for section files
Document history is written when a live link changes (CR reconcile or intake ingest ADD), not at upload and not when a header file is attached to a CR.
Default event_type is ADD (intake ingest relies on that default).
Existing databases that predate event_type and the (internal_record_id, section_id) index need:
create_all() does not alter existing tables.
Catalog delete
delete_documents removes the S3 object, then every CR / intake / live / history row for those ids, then the catalog row. Section files on live records disappear with no REMOVE history event.
Invariants
Upload and attach are separate calls.
Header CR files never become live section files.
Nested CR lists are the only CR path onto
g2p_register_section_documents.Intake lists are the only intake path onto that table.
Reject / pending CR / intake draft do not mutate live section files.
labelis display text on the junction or payload, not a schema key.One catalog id, one live record: at most one live link.
Code
Catalog + hydrate + as-of
CR validate / create / approve / fetch
File widget, strip non-ORM, nested list
Nested vs domain fields
Live exact-set reconcile
Worker CR create
Intake save / ingest
Live / hierarchy reads
Last updated
Was this helpful?