Files
PacketStation/docs/SCHEMA.md
T
spencermandClaude Opus 5.5 ca407a2db3
ci / frontend (pull_request) Successful in 43s
ci / server (pull_request) Successful in 11m3s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 3m52s
drawings: VISUAL's rev is the source of truth; attach it when M-Files holds it Effective or Retired
When M-Files' Effective rev differs from the rev VISUAL calls, the drawing
job looks that rev up (MFilesKit DrawingLookup.revision). If M-Files has
it as Effective or Retired, the job copies it into drawing_revisions and
attaches it to the draft with no flag and no Clerical approval, so the
draft can auto-release. Past draft, already attached or with a hand
drawing: unchanged, no flag and no change order.

Any other status, or no document at that rev, still flags rev_mismatch
with a note saying why. Packet Station only ever attaches Effective or
Retired drawings. rev_approve stays for that case only.

This replaces the "Use rev V as VISUAL calls it" choice from #46: the
use=called path in rev-approve and its button are removed. A packet that
already kept the rev that way keeps it, since that rev is now used anyway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 16:05:57 -06:00

45 KiB

Packet Station schema

Status: phases 1 to 4 built (identity, organisation, projects, packets, change orders, documents, jobs, VISUAL and drawing caches, the warehouse columns in use, the notification_events placeholder). Phase 5 adds the shop columns, the checklist templates and the clock rules (migration 0006). Phase 6 adds the Q.C. columns, the Q.C. check items, the Q.C. folder metadata, the ITP header, line and block details and the ITP log (migration 0007). Phase 7 adds the print log and the data book on the seal (migration 0008). Phase 8 adds no table and no migration: the PM report exports use new print_log kinds and presence uses two new session events. Phase 9 adds the notification rules and delivery. The DDL is in server/packetstation/migrations/; this file explains it. See PLAN.md, "Schema".

Conventions

Identity bigints, except users, keyed on the AD objectGUID. Postgres enums for workflow states. Every timestamp is timestamptz in UTC. Soft delete uses removed_at, removed_by and removed_reason. Append-only tables give the app role insert and select only, plus a trigger that refuses updates and deletes.

Database roles

A superuser for bootstrap, the migrate role ps_migrate (owns the database and the schema, runs migrations) and the app role ps_app. The worker shares ps_app and sets application_name to ps-worker, so the two are told apart in pg_stat_activity; a separate ps_worker role was not needed (decided in phase 3). ps_app holds CONNECT and TEMPORARY on the database (the feed COPYs each batch into temp tables) and no CREATE on the schema.

Identity (phase 1, migration 0002)

Enums: role_kind (admin, clerical, clerical_supervisor, warehouse, warehouse_supervisor, qc, qc_supervisor, pm, shop_worker, shop_lead, shop_supervisor), session_kind (password, pin), sig_factor (password, pin), dept_kind (support, shop).

  • users: id uuid is the AD objectGUID, stable across renames. Caches sam_account (unique, case-insensitive), upn, display_name, emp_no (text; indexed but not unique, because a duplicate must be storable and flagged), email, welder_stamp, active, directory_flags jsonb (disabled, missing or duplicate employee ID), ad_synced_at, last_signin_at. A user whose AD account is disabled or gone keeps the row, marked inactive.
  • user_roles: (user_id, role, dept_code) rebuilt at every sign-in, at the 15-minute role refresh and by the AD refresh. Office roles carry their department code, admin carries null, shop roles carry the two-digit code. Only the highest shop level per department is stored.
  • sessions: token_hash (HMAC-SHA256 of the cookie token with the session key), kind, device_id for PIN sessions, expires_at (absolute), last_seen_at (idle), locked_since, revoked_at and revoked_reason, ip, user_agent, roles_snapshot jsonb ({"roles": [...], "groups": [...]}), roles_checked_at, stepup_failures, hidden_since (the last hidden-tab report, cleared by the next request).
  • devices: registered shop devices. name, token_hash of the device cookie, registered_by, last_seen_at, revoked_at, revoked_by.
  • user_pins: one row per user with a PIN. Argon2id hash, failed_count (across sign-in, unlock and signature), disabled_at, set_at. Never readable by Admin in clear; Admin may delete the row.
  • signin_attempts: append-only. account (lower(login), emp:<emp_no> or user:<uuid>), ip, kind (password, pin, unlock, signature, pin_set), ok, reason, user_id, session_id. Both rate limits count the failed rows here.
  • signatures: append-only. Who (user_id, account and display_name snapshots), the session_id and session_kind, the factor, action, meaning, packet_id, subject_type, subject_id and a content_hash (sha256 of the canonical JSON of what was signed). Later phases reference it from every sign-off row.

Append-only tables use the ps_append_only trigger from 0001 and revoke UPDATE, DELETE and TRUNCATE from ps_app.

Organisation (phase 1 seed, phase 2 completes)

  • departments: code (text PK, CL, WH, QC, PM, 01 to 14), slug (the AD group short name, unique), name, kind, sort, active, supervisor_group (false only for PM). A trigger refuses changes to code and slug. Seeded with the 18 departments from the brief and the old depts.js. Migration 0003 adds weld (bool, default false), true for 03, 04, 05, 06, 07 and 10. A weld department gets the welding checklist, and submit there needs at least one weld and a filler.
  • stations: (dept_code, name, sort, active), seeded with the old station lists (Stall 1 to 6, Station 1 to 4, Hydro Bay 1 and 2, Blast Booth, Paint Booth 1 and 2).
  • user_station_limits: (user_id, station_id) primary key. A worker with rows here may only work on packets at those stations. No rows means any station in the department. The rows go when the user row goes.

The 50 AD group names are derived from departments plus PS-Admins: PS-<slug> and PS-<slug>-Supervisor for support departments, PS-<code>-<slug>-<Worker|Lead|Supervisor> for shop departments. packetstation ad-groups --list prints them.

Enums (phase 2, migration 0003)

Declaration order matters for packet_status: it is the workflow order, so status >= 'qc' works in SQL.

Enum Values in order
packet_status draft, warehouse, ready, assigned, in_progress, review, qc, complete, filed
wh_stage incoming, pulling, hold, pulled
co_review pending, approved, rejected
co_origin visual, drawing, manual
source_kind visual, hand
project_status open, hold, closed
route_kind databook, shipping
drawing_state none, needed, attached, rev_mismatch, not_released, not_found, lookup_failed, hand
itp_status open, signed, reopened, complete (0007)

Smaller closed sets are text with a CHECK: projects.source and every *_src (visual, hand), project_aliases.kind (renumber, unit_renumber, import), packet_pages.type (drawing, info, overlay), bom_lines.src (visual, hand), qc_inspections.result (accepted, rejected), itp_lines.result (approve, reject, na). Migration 0007 adds packet_docs.kind, qc_docs.type and itp_rejects.kind (see "Q.C. (phase 6, migration 0007)"). Null means unset in each.

Projects and units (phase 2)

  • projects: number (text, unique among live rows on upper(number)), name, customer, po, due, status (project_status), notes, source (hand or visual), needs_details (the feed made it and Clerical has not filled it in), version for field CAS. Order-derived values carry provenance: customer_src, po_src, order_date_src and ship_date_src say visual or hand, and customer_visual and po_visual keep what VISUAL said. The feed never overwrites a hand value. Soft delete.
  • units: project_id, lot (text), name, due, status, notes, the same *_src companions, order_date, ship_date, version. A CHECK keeps lot to 1 to 3 upper-case letters or digits, and a lot of digits only is exactly 3 long (001, 7A). Unique among live rows on (project_id, lot). Soft delete.
  • project_aliases: alias (text PK, upper-cased by CHECK), project_id, unit_id (null for a project number), kind (renumber, unit_renumber, import), created_at. Every current and old number of every project and unit is here, so a lookup by any number resolves, and a renumber cannot reuse a number that was ever taken.
  • project_renumbers: append-only. at, by, project_id, unit_id, old_number, new_number, reason.
  • View v_units: every units column plus number (project.number || lot, so L999 and 001 make L999001), project_number, project_name, project_status and due_effective (the unit's due, else the project's).

Packets (phase 2)

Header

packets is the header. The columns that matter:

  • Placement: unit_id, dept_code, op, title, customer, drawing, rev, wps.
  • Workflow: status (packet_status), status_at, station_id, welder_id and welder_stamp (a snapshot taken at assign), for_sup_id, wh_skipped (true when Clerical sent the draft straight to a department or to Q.C.), wh_sent_at, routed_to (route_kind, set at Q.C. accept), return_note (the reason shown after a send-back), vclosed_at and vclosed_by, databook_at.
  • Preconditions: heat_map is nullable on purpose. Null means not answered yet, and the warehouse step needs an answer.
  • Source: source_kind (visual or hand), source jsonb, visual_key (the feed's match key), visual_rel, visual_want, drawing_state, flags text[], visual_fp_applied (the fingerprint the packet matches), visual_closed_at, visual jsonb (system-written sub-keys).
  • Counters: next_weld_no, next_heat_no, next_page_no and next_co_no, all starting at 1. See "Versioning".
  • version, created_at, created_by, updated_at, soft delete.

Indexes: packets_visual_key is unique on visual_key where the key is set and the row is live. A removed packet keeps its key, so two removed rows may share one, but only one live row may hold it. Also (status) for live rows, (unit_id) and (dept_code, status) for the boards.

The trigger ps_packet_touch sets updated_at on every UPDATE. It does not touch version.

Children

  • packet_work_orders: wo_base, lot, split, sub (all text; the last three default to '' so the key has no nulls), seq, qty, target_hrs, op, description, visual_status, missing_runs and gone_at (the feed's "work order gone" check), sort, row_version. Phase 3 adds partspecs, rev and dwg. Unique on (packet_id, wo_base, lot, split, sub, seq) with nulls not distinct, since migration 0004 (see "VISUAL and drawing caches").
  • packet_pages: n (the packet page number, from next_page_no), type, asset_id, pn (page within the asset), dwg, rev, co_id (the change order that added it), void_co_id, void_at, void_by, void_new_n (a replaced page is voided, never deleted), row_version. Unique on (packet_id, n).
  • bom_lines: item, qty, part_id (text), description, dwg_ref, cl_item, length, material, heat, na, issued, loc, pulled, pulled_at, pulled_by, mtr_asset_id, src, vkey, whs, comm, cons, sub, grp, sort, row_version. Unique on (packet_id, item).
  • bom_shorts: bom_line_id (cascades with the line), qty, qty_open, reason, ref, eta, note, opened_at, opened_by, closed_at, row_version. At most one open short per line (unique on bom_line_id where closed_at is null).
  • short_receipts: append-only. short_id, packet_id, qty, heat, note, at, by.

What the warehouse columns mean in practice (phase 4):

  • bom_lines.pulled: the line is off the shelf. The first tick sets pulled_at and pulled_by, and sets issued to qty when it was empty. Unticking clears all three of pulled, pulled_at and pulled_by. The first tick while the warehouse stage is incoming or on hold also starts pulling (a packet_wh row with auto true).
  • bom_lines.issued: how many pieces went out. Less than qty means the rest should be on a short.
  • bom_lines.loc: where the parts were taken from, as the warehouse types it.
  • bom_lines.heat: the heat number of the pieces issued, checked against the heat pattern. A receipt adds its heat to the line, so a line with parts from two heats reads H1 / H2 (heats joined with " / ", no repeats).
  • bom_lines.na: the part has no traceability. An N/A line needs no heat, takes no heat, and does not count toward the release gate.
  • bom_lines.mtr_asset_id: the mill test report for the line, an assets row that must be a PDF, PNG or JPEG. Null clears it.
  • A line "needs a heat" when it is not N/A, not a sub-assembly (sub is Y), is pulled, has issued above 0 and has no heat. The release gate counts these lines (see POLICY.md).
  • bom_shorts: one open short per line at most. Opening a short sets the line's issued to qty minus the short quantity and ticks it pulled. qty is what was short in total, qty_open what is still missing. reason is one of the closed short reasons, ref the PO or requisition, eta the expected date. Editing an open short keeps what was already received in qty. "Not short after all" closes it (closed_at) and sets issued to the line's qty. While a short is open, the line's heat, issued and location change only through the short.
  • short_receipts: one row per delivery, never changed. Each receipt lowers qty_open, adds its pieces to the line's issued and its heat to the line's heat. The receipt that brings qty_open to 0 closes the short.
  • packet_wh: reason is one of the closed hold reasons when the stage is hold, and note is up to 200 characters.
  • packet_stages: one row per visit to a shop department. dept_code, station_id, welder_id, welder_stamp, checklist jsonb (key to {done, at, by, stamp}, written per key with jsonb_set), filler, filler_lot, preheat, notes, the clock (clock_elapsed_ms, clock_running_since, clock_started_at; only the server writes them, see "The clock"), qc_snapshot, opened_at, closed_at, row_version. At most one open stage per packet.
  • welds: weld_no (from next_weld_no), page, fx and fy (fractions of the page), bfx and bfy (0006), wo_base, piece, items int[], type, size, process, stamp, welder_id, dept_code, stage_id, carried_from, at, by, row_version, soft delete. Unique on (packet_id, weld_no) among live rows.
  • heat_tags: tag_no (from next_heat_no), page, item, fx, fy, bfx and bfy (0006), carried_from, at, by, row_version, soft delete. Unique on (packet_id, tag_no) among live rows.
  • heat_table_placements: page, x, y, s, carried_from (0006), by, at. Unique on (packet_id, page), written as an upsert.
  • packet_wh: the warehouse stage history. stage (wh_stage), by, at, reason, note, auto, closed_at. The current row has closed_at null and is unique per packet. A stage change closes it and inserts the next.
  • packet_transitions: append-only. Every status change: at, by, action, from_status, to_status, from_dept, to_dept, reason, signature_id, it_reason. Replaces the old hist and sentBack lists.
  • qc_inspections: stage_id, sent_at, sent_by, book, spool, location, sent_note, checks jsonb, weld_exam jsonb (weld_no to {result, note}), nde_method, nde_result, nde_report, hardness, nace, inspector, stamp, notes, result, result_at, result_by, result_note, signature_id, row_version, closed_at, ncr (0007). At most one open inspection per packet. See "The Q.C. inspection".
  • qc_history: append-only. at, result, inspector, note, dept_code, snapshot jsonb.
  • packet_seals: the HMAC-SHA256 hash written at filing, key_id, sealed_at, sealed_by, signature_id, and broken_at, broken_by, broken_reason when IT reopens. At most one unbroken seal per packet. Migration 0008 adds the filed data book (databook_asset_id, databook_pages) and open_wos. See "Close and file (phase 7, migration 0008)".
  • packet_log: append-only. The user-visible history: at, by, by_name and role (snapshots), action, note, it_reason, signature_id, transition_id. Indexed on (packet_id, id).
  • packet_visual_state: one row per packet, written by the worker. legs jsonb, ticket_hours, order_ref, observed_at.

Every who column references users (id). Every signature_id references signatures (id).

Shop (phase 5, migration 0006)

Column additions

  • welds: bfx and bfy (double precision, null). The label (bubble) position as fractions of the page. fx and fy stay where the arrow points.
  • heat_tags: bfx and bfy, the same.
  • heat_table_placements: carried_from (int, null). The packet page number the table was copied from on a change order page replacement.

A null bfx means the label has not been moved. The client then draws it at the old default offset: bfx = clamp01(fx + (fx > 0.85 ? -0.05 : 0.05)) and bfy = clamp01(fy + (fy < 0.12 ? 0.07 : -0.07)), where clamp01 keeps a value between 0.01 and 0.99.

The API checks the values the columns have no CHECK for:

Value Rule Refusal
fx, fy, bfx, bfy 0 to 1, or null for bfx and bfy 422
welds.type Fillet, Groove, Fill, Seal or Tack 422 invalid_weld
welds.process GMAW, FCAW, SMAW, GTAW or SAW 422 invalid_weld
welds.size 5/16" on insert when missing
welds.piece 1 or more 422
welds.wo_base one of the packet's work order bases 422 invalid_wo
heat_table_placements.x, y 0 to 0.97 422 invalid_heat_table
heat_table_placements.s 0.6 to 2.1, in steps of 0.15 from the screen 422 invalid_heat_table
page of a heat table placement a live or voided drawing page of the packet 422 invalid_page
page of a new or moved weld or heat tag not a voided page 422 void_page

Checklist templates

The settings row checklist_templates holds the shop checklists. Migration 0006 inserts the defaults with ON CONFLICT DO NOTHING, so a row already there is kept. The value:

{
  "weld":  [{"group": "Before welding", "key": "dwg", "text": "Drawing {drawing} Rev {rev} matches the packet"}, ...],
  "plain": [{"group": "Before starting", "key": "dwg", "text": "..."}, ...]
}

weld is the list for a weld department (departments.weld true), plain for every other shop department. Each entry has a group, a key and a text. The text may hold the placeholders {drawing}, {rev} and {wps}, filled from the packet.

List Group Keys
weld Before welding dwg, heat, wps, fit, pre
weld After welding vt, stamp, clean, qty
plain Before starting dwg, prev, mat
plain When done work, insp, qty

When the row is missing or malformed, the defaults in domain/checklists.py apply and a warning is logged. The read model and the checklist write both use the loaded templates. No screen edits the row in phase 5. Phase 9 (Admin) adds that.

packet_stages.checklist maps each key to {done, at, by, stamp}. by is the user id. stamp is the user's welder_stamp, else the packet's. A key not in the department's list is 422.

Carried marks

Welds, heat tags and heat table placements belong to the packet, not to the stage. Routing a packet to another department copies nothing. The read model computes a carried flag instead:

  • A weld is carried when stage_id is set and is not the open stage's id. It was logged in an earlier department's stage.
  • A heat tag is carried when its at is before the open stage's opened_at. The warehouse or an earlier department placed it.

The carried_from columns on welds and heat_tags (from 0003) are not written by routing.

There is one copy, on a change order page replacement. When a feed change order is approved and a drawing page is voided, each heat table placement on the voided page is copied to its new page with carried_from set to the old page number. The log says Heat table copied to new sheet with the note from the VOID copy, check its position on the new revision. Welds and heat tags stay on the void page.

The clock

packet_stages has three clock columns (from 0003):

  • clock_elapsed_ms (bigint, default 0): the stored total.
  • clock_running_since (timestamptz): when the clock last started. Null while it is stopped.
  • clock_started_at (timestamptz): the first start of the stage.

Only the server writes them, with the database now(). No request field sets them, and a value from the client is never used.

Event Write
START, or clock start clock_running_since = now() when it is not running; clock_started_at = coalesce(clock_started_at, now())
clock pause clock_elapsed_ms += now() - clock_running_since; clock_running_since = null
clock resume clock_running_since = now()
PULL_BACK, UNASSIGN, SUBMIT fold: clock_elapsed_ms += now() - clock_running_since when running; clock_running_since = null

A clock op in the wrong state is 409 clock_state.

The read model gives server_now (the database now()) and stage.clock: {elapsed_ms, stored_ms, running, running_since, started_at}. stored_ms is the column. elapsed_ms is live: the stored total plus now() - clock_running_since while running. Each closed stage has the same object, with running false. The client adds the time since the fetch to elapsed_ms for the ticking display only.

Q.C. (phase 6, migration 0007)

Column additions

  • qc_inspections: ncr (text). The NCR number given with a Q.C. reject, upper case (NCR-2026-0021).
  • packet_docs: note (text, up to 300 characters). kind gains a CHECK: one of nde, hydro, dimensional, pmi, coating, coc, customer, ncr, other.
  • qc_docs: title, note, superseded_at and superseded_by_user (the person who filed the newer revision). Two CHECKs: qc_docs_scope (exactly one of project_id and unit_id is set) and qc_docs_type (one of ITP, WPS / PQR, NDE procedure, Hydrotest procedure, PMI / material, Calibration certificate, Customer spec, Other; the label itself is stored). Indexes qc_docs_project and qc_docs_unit on the current rows (superseded_by null).
  • itp_status gains complete. A signed-off ITP is complete. signed and reopened stay in the enum unused: PostgreSQL cannot drop an enum value, and a reopened ITP is open again with reopen_* set.
  • itps: no (the ITP number, <unit number>-ITP at start), hdr jsonb (the header: client, pname, pno, po, unit), created_at, created_by.
  • itp_lines: initials (from the inspector's display name at approval), cust_code (the customer code for this line, null means the template's), cust_by (the customer witness or reviewer, as typed), cust_at (date), cust_rec and cust_rec_at (who recorded the customer entry, and when).
  • itp_rejects: ncr is now nullable (a reject can wait for its NCR number). kind (text, reject or ncr by CHECK, default reject): reject is the reject itself, ncr is the NCR number entered later.
  • itp_blocks: signed_at is now nullable with no default, so a block row can hold its fields before it is signed. New: qn (the QN number), remarks, cust_name, cust_at (date), cust_by and cust_rec_at (the customer complete entry and who recorded it), row_version.
  • itp_log (new, append-only): itp_id, at, by, by_name (snapshot), action, note, signature_id, it_reason. Indexed on (itp_id, id). The user-visible history of an ITP, as packet_log is for a packet.

ALTER TYPE ... ADD VALUE runs inside the migration's transaction. That is allowed because 0007 does not use the new value itself.

Q.C. check items

The settings row qc_check_items holds the Q.C. check items. Migration 0007 inserts the defaults with ON CONFLICT DO NOTHING, so a row already there is kept. The value:

{"groups": [
  {"group": "Data book package", "items": [{"key": "db_heats", "text": "Heat number recorded for every BOM item"}, ...]},
  {"group": "Physical spool",    "items": [{"key": "ph_dim", "text": "Dimensions verified against {drawing} Rev {rev}"}, ...]}
]}
Group Keys
Data book package db_heats, db_mtr, db_weldmap, db_wps, db_ck
Physical spool ph_dim, ph_heat, ph_stamp, ph_clean, ph_qty

The texts may hold {wps} (else the WPS), {stamp} ([75] with a trailing space, or empty), {drawing} (else the drawing) and {rev} (else -), filled from the packet. When the row is missing or malformed, the defaults in domain/checklists.py apply and a warning is logged. No screen edits the row in phase 6.

qc_inspections.checks maps each key to {done: true, at, by, stamp} (the inspection's stamp) or {done: false}, written one key at a time with jsonb_set. A key not in the list is 422 invalid_key. qc_inspections.weld_exam maps a weld number to {result, note, at, by}, where result is accept, reject or empty. nde_method is one of None required, RT, UT, MT, PT; nde_result is empty, Acceptable or Rejectable. nace is no longer written: the NACE label is computed from the packet's WPS.

The Q.C. inspection

  • SEND_TO_QC (from review) and SEND_DRAFT_TO_QC (from draft) open a qc_inspections row with book, spool, location, sent_note and the stage. Each opens a fresh row with empty checks and exam.
  • At most one open row per packet (qc_inspections_open, unique on packet_id where closed_at is null).
  • QC_ACCEPT and QC_ACCEPT_AND_ROUTE set the result and close the row into qc_history. The stage is closed with the inspection as qc_snapshot.
  • QC_REJECT sets the result, the reason as return_note and the ncr, and closes the row into qc_history at once. The packet goes to review with no open inspection. A second SEND_TO_QC opens a new row; the first stays in qc_history.
  • A qc_history row keeps at, result, inspector, note (the result note) and dept_code as columns, and the full inspection row in snapshot (checks, weld exam, NDE fields, ncr, result_by, location, sent time).

Close and file (phase 7, migration 0008)

Column additions

  • packet_seals: databook_asset_id (uuid, references assets), the data book PDF built and stored when the packet was filed. databook_pages (int), its page count. open_wos (text[]), the work orders the feed showed open in VISUAL when Clerical filed anyway, as wo_base/lot text (L489052/1), or null.

The print log

print_log (new, append-only): one row per print or export.

Column Meaning
id bigserial
at when, default now()
by, by_name the user and a snapshot of the name
session_id the session that asked
packet_id, unit_id, itp_id what was printed; unit_id and itp_id for an ITP; all three null for a PM report
kind traveler, map-h, map-w, clean, void, databook, doc, itp, csv, databook-export, itp-pdf; phase 8 adds report-csv and report-pdf
what the human label as printed: Job traveler, Drawing with heat map (sheet 2), MTR - item 3
pages the page count
asset_id the document printed for kind doc; the filed data book for databook
stamp the stamp line as printed
stamp_sig the 16 hex characters signed into the stamp
ip the client address

Indexed on (packet_id, id) and (itp_id, id). GET /api/packets/{id}/prints reads it newest first. Prints and exports write no packet_log row. They are not packet changes.

The seal and the data book

  • FILE writes the packet_seals row, then builds the data book in the same transaction, stores it as an assets row (source databook, source_ref {packet_id, seal_id}) and sets databook_asset_id and databook_pages on the seal. A data book that cannot be built fails the filing. Nothing is filed half way.
  • ADMIN_REOPEN sets broken_at, broken_by and broken_reason on the unbroken seal. The row and its data book stay. They are the record of what was filed.
  • Filing again after a reopen inserts a second seal with its own data book. The unique partial index allows one unbroken seal per packet, so the broken rows stay beside it.
  • The packet read shows the newest seal row, broken or not, with its data book, and seals (how many rows the packet has).
  • The signed ITP PDF filed at sign-off is an assets row with source itp_signoff, stored on itps.signoff_asset_id.

Project Management and presence (phase 8, no migration)

Phase 8 adds no table, no column and no migration. The Project Management reports read the existing tables (packets, units, projects, packet_stages, packet_transitions, packet_wh, qc_history, bom_shorts, change_orders, visual_labor) and never VISUAL.

  • print_log gains two kinds, with no schema change (kind is free text): report-csv for a PM CSV export and report-pdf for the production status report. packet_id, unit_id and itp_id are null on these rows. what is <table name> · CSV · <scope label> or Production status report · PDF · <scope label>. pages is the page count for the PDF and null for the CSV. The audit row beside it has the action report exported.
  • ps_events (the NOTIFY channel the SSE hub listens on) carries two new session events beside revoked: {"event": "session", "session_id", "action": "locked"} and "unlocked". They are published by the lock and unlock endpoints and by the server's idle lock, where sessions.locked_since is set or cleared. The hub uses them to hide and show that session in presence. Presence itself lives in the app process, not in the database.

Change orders (phases 2 and 3)

  • change_orders: co_no (from next_co_no; unique per packet), origin (co_origin), review (co_review), minor, hold, held_by, held_at, ecn, reason, issued_*, approved_* and rejected_* (each with a *_signature_id), vis, waiting and staged jsonb (feed-shaped), target_fp, row_version. At most one pending VISUAL change order per packet (unique on packet_id where origin is visual and review is pending), so the feed re-aims it instead of stacking new ones.
  • co_items: the page changes. old_page, old_dwg, old_rev, new_page, new_dwg, new_rev, added.
  • co_required_depts: (co_id, dept_code) primary key. The departments that must verify.
  • co_verifications: append-only, keyed on (co_id, dept_code). by and signature_id are required, plus at, note, it_reason. A primary key violation means the department already verified. A verification cannot be withdrawn.
  • View v_open_cos ports the old openCOs(): packet_id, co_id, co_no, review, hold, pending_depts (text[], the required departments without a verification, sorted by code). A row appears when review is approved and pending_depts is not empty, or when review is pending and hold is true. A pending change order without hold holds nothing. A rejected one never appears. The policy's CO hold and the boards' open-CO count read this view.

Documents (phases 2, 4 and 6)

Nothing in this section is ever deleted. A packet document comes out of the data book by soft removal. A Q.C. folder document is replaced by a newer row that the old one points at. ITP rows are corrected forward, and the history is in the append-only itp_rejects and itp_log.

  • assets: id uuid, sha256 (indexed, for dedupe), size, mime, original_name, storage_key (the path under ASSETS_DIR, unique), source (upload by default), source_ref jsonb, pages, uploaded_by, uploaded_at, packet_id (the packet it was uploaded for, without a foreign key; packet_pages and packet_docs are the real links).

Asset kinds (phase 4). An upload names its kind in the purpose field, and source_ref keeps {"purpose": kind}. No purpose means document. The type is read from the content, never from the name or the browser.

Kind Types Limit (setting)
drawing PDF 25 MB (upload_max_bytes)
mtr PDF, PNG, JPEG 25 MB (upload_max_bytes)
document PDF, PNG, JPEG 25 MB (upload_max_bytes)
bom XLSX, CSV 5 MB (upload_max_bytes_bom)

mime is one of application/pdf, image/png, image/jpeg, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet and text/csv. An XLSX is a zip that holds [Content_Types].xml and xl/workbook.xml; a plain zip is refused. A CSV is UTF-8 text with no NUL and no control character other than tab, CR and LF. An upload the virus scanner refuses is never stored, so it has no row.

Packet documents

packet_docs: asset_id, kind (the closed set below), title (the kind label when none is given), note, added_at, added_by, and removed_at, removed_by, removed_reason. The asset must be a PDF, PNG or JPEG.

kind Label
nde NDE report (RT / UT / MT / PT)
hydro Hydrotest report
dimensional Dimensional report
pmi PMI report
coating Coating / paint report
coc Certificate of conformance
customer Customer inspection report
ncr NCR
other Other

Taking a document out of the data book sets the three removed_* columns, with a reason. The row stays. The packet read lists it with removed set, so the record shows what was taken out, by whom and why. A row already taken out cannot be taken out again (409 already_removed).

Q.C. folder documents

qc_docs: one row per filed document, on a project (project_id) or a unit (unit_id), never both. type (the label), title, rev, note, asset_id (PDF, PNG or JPEG), auto (filed by the ITP sign-off, titled with the ITP number and "signed off" as the old build, rev null), added_at, added_by, and the supersession columns superseded_by, superseded_at, superseded_by_user.

A new revision is a new row. In the same transaction, under SELECT ... FOR UPDATE of the old row, the old row gets superseded_by (the new row's id), superseded_at and superseded_by_user. The old row is never changed otherwise and never deleted. A row is current while superseded_by is null. The revision must be in the same folder as the row it replaces (422 invalid_replace); an old row that already points at a newer one is 409 already_superseded.

A unit folder shows its own current rows and inherits the project's current rows. A packet's read lists both as qc_folder_docs.

The ITP

  • itps: one per unit (unit_id unique). template jsonb is a copy of the code template ITP_Generation_Revised_2, frozen when the ITP is started, so a later template change never moves an existing ITP. no, hdr, status (open or complete), signoff_at, signoff_by, signoff_signature_id, signoff_asset_id (the signed PDF, set at sign-off since phase 7), reopen_at, reopen_by, reopen_reason, created_at, created_by, version (bumped by every ITP write).
  • itp_lines: one row per template item, all made when the ITP is started, so every line has a row_version from the first read. key is the item number (1.3, unique per ITP). result is null (open), approve, reject or na (N/A is stored as the result). by, at and initials are the approval; removing it clears those and result and keeps ncr and the reject history. ncr is the NCR of the latest reject, note the line note. cust_code, cust_by, cust_at, cust_rec, cust_rec_at are the customer entry. row_version is for the If-Match on approval. A line is done when it is N/A or approved.
  • itp_rejects: append-only. One reject row per reject (ncr may be null), and one ncr row when the number is entered later. line_key, ncr, note, kind, at, by. The rows survive the later approval of the line.
  • itp_blocks: one row per signature block, all made when the ITP is started (block_key is the number of the section that closes the block, 3 to 9; unique per ITP). An unsigned row holds qn, remarks and the customer complete fields. signed_at, signed_by, signature_id are the PESCO Q.C. signature. Removing it clears those three and keeps the rest.
  • itp_log: append-only. One row per ITP write: by, by_name, action, note, signature_id (block sign and sign-off), it_reason (an admin edit). See POLICY.md, "Log texts".

Append-only set

These tables have the ps_append_only row and TRUNCATE triggers, and ps_app holds only SELECT and INSERT on them:

audit_events, signin_attempts, signatures, project_renumbers, short_receipts, packet_transitions, qc_history, packet_log, itp_rejects, itp_log (0007), print_log (0008), co_verifications.

The app role is refused by privilege first. The migrate role owns the tables, so the trigger refuses it. The views v_units and v_open_cos are SELECT only for ps_app.

Audit, settings, jobs (phases 1 and 3)

  • audit_events (migration 0001): append-only. at, user_id, session_id, role_snapshot (system for the worker), action, subject_type, subject_id, packet_id, detail jsonb, it_reason, ip. A hash chain: row_hash is sha256 over prev_hash and the canonical JSON of the row, and prev_hash is the row before it. BRIN index on at.
  • settings (migration 0001): key (text PK), value jsonb, version, updated_at, updated_by. Phase 3 adds the row feed, the feed settings as one JSON object (see FEED.md, "Settings"). Phase 5 adds the row checklist_templates (see "Checklist templates"). Phase 6 adds the row qc_check_items (see "Q.C. check items"). A missing key takes its default. A save bumps version and sends ps_notify('ps_settings', ...) so the worker reschedules.

Phase 3, migration 0004:

  • job_requests: one row per "Run now". job, requested_at, requested_by, note, claimed_run_id and claimed_at (set by the run that took it). Index on job where claimed_at is null. The app inserts the row and NOTIFYs ps_job_request in the same transaction.
  • job_runs: one row per run. job, trigger (schedule, request, chain, cli), requested_by uuid[] (the requests claimed), started_at, finished_at, status (running, ok, degraded, failed, skipped, abandoned), counts jsonb, errors jsonb (a list of redacted strings), build_version, visual_target ("<server> / <database>"), host. Index on (job, id DESC).
  • job_schedule: one row per job (job PK). next_run_at, interval_seconds, paused, updated_at. Written by the worker after each run for the Admin tab. Seeded for visual-feed, drawing-lookup and housekeeping.
  • worker_heartbeat: a single row (id = 1 by CHECK). at, build, host, pid, running text[] (the jobs running now). Upserted every 30 seconds.

Notifications (phases 4 and 9)

Phase 4, migration 0005:

  • notification_events: one row per alert. id bigserial, kind (text, for example assets_quota), severity (info, warning or critical by CHECK, default warning), subject (what the alert is about, for example assets), detail jsonb (for the quota alert, the volume usage: total, used, free, pct, warn), created_at, resolved_at (set when the condition clears), acknowledged_at and acknowledged_by (an Admin saw it). The unique index notification_events_open on (kind, coalesce(subject, '')) where resolved_at is null keeps one open alert per kind and subject, so a check that runs again does not stack rows. The table is not append-only: the app sets resolved_at and the acknowledgement. 0005 also grants ps_app USAGE and SELECT on every sequence in the schema.

Phase 9 builds the delivery on this table: rules, recipients, exclusions, outbox, sent log, state.

VISUAL and drawing caches (phase 3)

Migration 0004. Every ID part is text (wo_base, lot, split, sub, part IDs, order IDs), with '' in place of null in a key. seq and piece are int. Closed sets are text with a CHECK. The worker fills these tables. Screens read them, never VISUAL. FEED.md says how each is written.

  • visual_batches: one row per feed run. run_id, started_at, finished_at, server, database, site, since and until (the window), window_rows, tracked_keys, missing_keys, suspect, counts jsonb.
  • visual_rows: one row per work order operation (TYPE W), unique on (wo_base, lot, split, sub, seq). The contract fields: res, dwg, rev, ps (partspecs), descr, qty, tgt, rel, want, st (the status code F, R, U, C, X or ''), part, barcode. row_hash (sha256 of the canonical row), first_batch, last_batch and gone_batch (set when a row in play was not read; cleared when it returns). Indexes on (wo_base, lot), on dwg, and on rel for rows not gone.
  • visual_materials: one row per material line, unique on (wo_base, lot, split, sub, seq, piece). part (null for a line with no part), qty, issued, whs, loc, st, istat, dwg, descr, comm, cons, mat_hash, first_batch, last_batch, gone_batch. Index on (wo_base, lot).
  • visual_orders: one row per customer order (cust_order_id PK). status, customer_id, customer_name, customer_po_ref, order_date, desired_ship_date, promise_date, last_batch.
  • visual_order_links: kind (base or project), key (the base or the project number), cust_order_id, rank (open first, newest first), lines int[], top_jobs text[], last_batch. Unique on (kind, key, cust_order_id). Replaced for every key read.
  • visual_labor: ticket hours per leg, unique on (wo_base, lot, split, sub, seq). hours, tickets, last_ticket, observed_at. No cost or pay column.
  • visual_row_changes: what VISUAL changed, per batch. batch_id, the key and seq, kind (added, changed, gone, returned), before and after jsonb (the row as text values), at. Index on batch_id. visual_material_changes is the same plus piece.
  • visual_ignored_wos: work orders kept out by the go-live date, unique on (wo_base, lot, split, sub). rel, project, unit_number, res, dwg, first_seen_batch, last_seen_at, and pulled_in_at, pulled_in_by, pulled_in_reason when Clerical pulls one in.
  • visual_resource_map: resource (PK), target (fold, ignore or a two-digit department code, by CHECK), confirmed, updated_at, updated_by. Seeded from the old build's visual_map.json, every entry unconfirmed. "Electical" is spelled that way in VISUAL.
  • drawing_lookups: one row per drawing number as VISUAL writes it (dwg PK, trimmed). The last answer: checked_at, ok, rev (released), name, obj_id, obj_version, file_id, file_version, file_size, base (H sheet), draft_rev, draft_where, in_drafting, note. Retry state: fail_count, failing_since, next_check_at. The fetched file: asset_id, sha256. visual_rev_seen is the VISUAL rev at the last lookup, so a rev change moves the drawing to the front of the queue. A rev mismatch keeps the staged M-Files copy here for approval.
  • drawing_revisions (0009): other revs of a drawing that M-Files still holds, one row per (dwg, rev). The drawing job writes it when a packet's VISUAL rev differs from the Effective rev and M-Files has that rev as Effective or Retired, and attaches drafts from it. controlled (M-Files' Controlled Status, for example Retired), state (workflow state, for example Superseded), checked_at, name, the M-Files identity (obj_id, obj_version, file_id, file_version, file_size), base (H sheet), and the fetched copy (asset_id, sha256).

Column additions in 0004:

  • packets: visual_cancelled_at and wo_gone_at, so boards can filter on cancelled and gone without reading flags.
  • packet_work_orders: partspecs, rev and dwg, the leg's own planner instructions, rev and drawing as VISUAL has them. The header keeps the highest rev. The unique key now includes seq: 0004 drops the 0003 unique on (packet_id, wo_base, lot, split, sub) and adds UNIQUE NULLS NOT DISTINCT (packet_id, wo_base, lot, split, sub, seq), because the feed puts two operations of one work order on one packet. Hand drafts keep seq null, so they still have one row per key.
  • projects: order_date_visual, ship_date_visual, order_ref jsonb (the chosen order and how it was chosen), and the Clerical pin order_pin (a customer order ID), order_pin_by, order_pin_at, order_pin_reason.
  • units: customer, po, customer_visual, po_visual, order_date_visual, ship_date_visual, order_ref, and the same pin columns.
  • change_orders: nothing new. vis, waiting, staged, target_fp and minor came in 0003.

Versioning

  • packets.version goes up by one in every write transaction that touches the packet or any of its children. The write pipeline does it explicitly (UPDATE packets SET version = version + 1 ... RETURNING version) after the caller's SQL. No trigger bumps it, so a read never does. It is the packet ETag and the NOTIFY payload.
  • projects.version, units.version and itps.version play the same part for those rows. An ITP write locks the itps row (SELECT ... FOR UPDATE), not a packet row, and bumps itps.version. A Q.C. folder revision locks the row it replaces.
  • Child rows that are edited in place carry row_version. A PATCH sends If-Match: <row_version>. A mismatch is 409 row_conflict with the current row. The write bumps row_version.
  • Every write takes SELECT ... FOR UPDATE on the packet row first. That lock replaces the old 8-second lease and serialises the counters: next_weld_no, next_heat_no, next_page_no and next_co_no hand out the next number with UPDATE packets SET next_weld_no = next_weld_no + 1 RETURNING next_weld_no - 1, so two concurrent adds never get the same number.
  • Transitions do not check version. They compare and set on expect: {status, dept}, so a weld edit never blocks a send.