Phase 6: Q.C. inspection, project folders, packet documents and the digital ITP
ci / frontend (push) Successful in 37s
ci / server (push) Successful in 9m18s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 2m17s

The Q.C. screen (KPIs, boards, project folders), the Q.C. tab on the packet
page (check items from the qc_check_items settings row, visual exam per weld,
NDE, hardness with the NACE label, notes), accept with a route and a
signature gated by qc_missing_empty, reject with a reason or an NCR number
closing the inspection into qc_history, Q.C. folder documents per project and
unit with superseding revisions, packet documents as a closed set with
take-out reasons, the digital ITP per unit from the frozen template (line
initials with CAS, unsign, N/A, customer codes, rejects with NCR, block
signatures, sign-off with the PDF hook, admin reopen, the ITP log), hold-point
warnings by department page group, the scoped policy check for unit and
project writes, migration 0007, tests, Playwright scenarios and docs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-17 20:08:53 -06:00
co-authored by Claude Fable 5.1
parent 8bb1f7ae4d
commit 00531cb329
64 changed files with 11715 additions and 260 deletions
+207 -9
View File
@@ -4,7 +4,7 @@ Who may do what to a packet, and when. This file is written from the code in `se
## Purpose
The policy is a Python port of the old `guard.js`. It runs only on the server. Every packet write calls `policy.check()` inside the write transaction, under the packet row lock. Every packet read returns `can`, `blocked` and `holder` from the same rules, so the screens only show what the server will accept.
The policy is a Python port of the old `guard.js`. It runs only on the server. Every packet write calls `policy.check()` inside the write transaction, under the packet row lock. Every packet read returns `can`, `blocked` and `holder` from the same rules, so the screens only show what the server will accept. The Q.C. folder and ITP writes belong to a project or a unit, not to a packet; they call `policy.check_scoped()` under their own row lock (see "Scoped checks").
The package is pure functions with no I/O:
@@ -12,7 +12,7 @@ The package is pure functions with no I/O:
- `principal.py`: `Principal`, `PacketState`, `principal_from_roles()`.
- `custody.py`: `holds()`, `works_on()`, `holder_name()` and the Who predicates.
- `preconditions.py`: the named preconditions and their messages.
- `engine.py`: `check()`, `evaluate()`, `can()` and `Decision`.
- `engine.py`: `check()`, `check_scoped()`, `evaluate()`, `can()` and `Decision`.
## Roles
@@ -101,8 +101,6 @@ Statuses in workflow order: draft, warehouse, ready, assigned, in_progress, revi
| edit_wos | CL | draft | | | | | | |
| edit_bom | HOLDER | draft, warehouse, SHOP | | | | | | |
| wh_receive_short | WH | any | | | | short_open | | |
A holder of the packet who is not in the warehouse receives a short through edit_bom (see "Receive by custody").
| edit_heats | HOLDER | warehouse, SHOP | | | | | | |
| edit_heat_table | HOLDER | warehouse, SHOP | | | | | | |
| edit_welds | HOLDER | SHOP | | | | | | |
@@ -123,17 +121,28 @@ A holder of the packet who is not in the warehouse receives a short through edit
| co_reject | CL | any | | | | reason_given | Reject a change order | yes |
| co_hold_toggle | CL | any | | | | | | |
| co_verify | CO_VERIFIER | any | | | | | Verify a change order for the department | |
| itp_edit_line | QC | any | | | | | | |
| itp_block_sign | QC | any | | | | | Sign an ITP block | |
| itp_sign_off | QC | any | | | | | Sign off the ITP | |
| itp_reopen | QC_SUP (Admin through the bypass) | any | | | | reason_given | | yes |
| qc_doc_add | CL_OR_QC | no packet (scoped) | | | | | | |
| itp_start | QC | no packet (scoped) | | | | | | |
| itp_line_sign | QC | no packet (scoped) | | | | | | |
| itp_line_unsign | QC | no packet (scoped) | | | | | | |
| itp_edit_line | QC | no packet (scoped) | | | | | | |
| itp_block_sign | QC | no packet (scoped) | | | | | Sign an ITP block | |
| itp_block_unsign | QC | no packet (scoped) | | | | | | |
| itp_sign_off | QC | no packet (scoped) | | | | | Sign off the ITP | |
| itp_reopen | QC_SUP (Admin through the bypass) | no packet (scoped) | | | | reason_given | | yes |
| vis_update_draft | SYSTEM | draft | | | | | | |
| vis_retire_draft | SYSTEM | draft | | | | | | |
| vis_open_co | SYSTEM | any but draft and filed | | | | | | |
| vis_auto_release | SYSTEM | draft | warehouse | | | heat_map_answered, drawing_attached | | |
A holder of the packet who is not in the warehouse receives a short through edit_bom (see "Receive by custody").
The `vis_*` actions are system only. People can never do them, and the worker can do nothing else.
The Q.C. folder and ITP actions ("no packet (scoped)") belong to a project or a unit, not to a packet. They are checked with `check_scoped()` (see "Scoped checks"), never with the packet status, custody or the filed lock.
qc_reject takes an NCR number in place of the reason text (see "Q.C. (phase 6)", "Reject").
## Preconditions
Each precondition is a named predicate over the packet state and the request input. They are never bypassed, Admin included. The first one that fails gives the message.
@@ -176,6 +185,17 @@ Each precondition is a named predicate over the packet state and the request inp
The packet API answers 403 with the code for most failures, and 400 for `it_reason_required` and `reason_required`.
### Scoped checks
`check_scoped(principal, action, *, extra=None, it_reason=None) -> Decision` runs the same steps as `check()` without a packet, for the actions that belong to a project or a unit (the Q.C. folder and the ITP):
1. **System.** A system principal fails (`forbidden`). A system-only action fails every person (`system_only`).
2. **Admin bypass.** An admin not allowed by the rule's Who passes with an IT reason of at least 8 characters, else `it_reason_required`. The decision is marked `admin_bypass`.
3. **Who.** Code `forbidden`, "Your access doesn't allow this."
4. **Preconditions,** the input ones in rule order. `reason_given` fails as `reason_required`.
There is no removed, status, change-order hold, filed lock or custody step: there is no packet and no holder. The ITP and Q.C. folder writes call it inside their own transaction, under the `itps` row lock or the lock on the Q.C. document being replaced. They never take a packet row lock, because they change no packet and must not wait on, or block, the packets of the unit.
## Signatures
These actions need a signature: submit, send_to_qc, route_from_review, qc_reject, qc_accept, qc_accept_and_route, file, admin_reopen, edit_document_set (attach only), rev_approve, co_issue, co_approve, co_reject, co_verify, itp_block_sign and itp_sign_off. The rule's signature text is the meaning that is signed.
@@ -190,7 +210,8 @@ The request carries `sign: {factor, secret}`. The write pipeline calls the phase
- **Header scalars** use field-level compare-and-set: the body sends `set` and `base`. A key whose base differs from the current value is 409 `field_conflict` with the current value. A key with no base is written as is.
- **Work orders** are a draft-only full replace with If-Match on the packet version.
- **Shorts and change-order actions** are state compare-and-set. Checklist keys are written one key at a time. The heat table position is an idempotent upsert. A change-order verification is an insert, so a second one is "already verified".
- **Append-only tables** never conflict: packet_transitions, packet_log, qc_history, short_receipts, project_renumbers, itp_rejects and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them.
- **ITP lines** are approved under the `itps` row lock, with a state check and an optional `If-Match: <row_version>`. ITP header fields use the same `set` and `base` field compare-and-set as packet headers.
- **Append-only tables** never conflict: packet_transitions, packet_log, qc_history, short_receipts, project_renumbers, itp_rejects, itp_log and co_verifications. Triggers refuse UPDATE, DELETE and TRUNCATE, and the app role has no grant for them.
## Reads: can, blocked and holder
@@ -433,6 +454,175 @@ The `action` and `note` columns of `packet_log` for each shop write. A departmen
The submit hours are the stage's `clock_elapsed_ms` after the stop, with one decimal.
## Q.C. (phase 6)
### Who inspects
Q.C. holds a packet in status qc. EDIT_QC (QC, status qc) covers every write to the open inspection: the check items, the visual result per weld, the NDE fields, hardness, the inspector name, the stamp and the notes. A write with no open inspection is 409 `no_inspection`.
| Write | Endpoint | Concurrency |
|---|---|---|
| One check item | `PUT /api/packets/{id}/qc/checks/{key}` `{done}` | one key by `jsonb_set` under the packet row lock, no If-Match. Unknown key 422 `invalid_key`. |
| One weld's visual result | `PUT /api/packets/{id}/qc/welds/{no}` `{result?, note?}` | one weld by `jsonb_set`. The weld must be live on the packet (422 `invalid_weld`). `result` is `accept`, `reject` or empty. |
| The other fields | `PATCH /api/packets/{id}/qc` `{set, base}` | field-level compare-and-set over `inspector`, `stamp`, `nde_method`, `nde_result`, `nde_report`, `hardness`, `notes`; 409 `field_conflict` with the current value. |
`nde_method` must be one of `None required`, `RT`, `UT`, `MT`, `PT` (422 `invalid_field` "Pick an NDE method from the list."). `nde_result` is empty, `Acceptable` or `Rejectable`. Setting the method to `None required` clears the result and the report number. The phase 2 flat body (with `If-Match` on the inspection's `row_version`, merging `checks` and `weld_exam`) still works; a body with `set` selects the field compare-and-set. The NDE rules apply to both. Hardness is labelled `Hardness (NACE)` when the packet's WPS contains `NACE`. The server computes this; no request sets it.
### Accept
Two actions accept. Both are Q.C. from status qc, held by an open change order, gated by `qc_missing_empty`, and signed.
| Route | Action | Body | Result |
|---|---|---|---|
| Clerical – Data Book | qc_accept | `routed_to: "databook"` | complete, routed to the data book |
| Shipping | qc_accept | `routed_to: "shipping"` | complete, routed to Shipping |
| A shop department | qc_accept_and_route | `dept: "09"` | ready in that department, a new stage opened |
The signature meanings are "Q.C. accept" and "Q.C. accept and route". The accept closes the inspection into `qc_history` and closes the stage with the inspection as its `qc_snapshot`. The note in the log counts every live weld of the packet.
### What Q.C. still needs
`qc_missing_empty` reads the list built by `checklists.qc_missing()`. Its message is `Q.C. still needs: ` followed by the parts joined by `, ` and a full stop. `blocked.qc_accept` and `blocked.qc_accept_and_route` carry it, so the screen shows Accept disabled with the reason.
| Part | When |
|---|---|
| `3 inspection items` | that many check items are not done |
| `2 welds without a visual result` | that many live welds have no accept or reject |
| `a rejected weld (reject the packet instead)` | any weld is rejected |
| `NDE result` | an NDE method other than `None required` is set and the result is not `Acceptable` |
| `inspector name` | the inspector is empty |
### Reject
qc_reject is Q.C. from status qc back to review, signed ("Q.C. reject"). It takes a reason, an NCR number, or both:
- An NCR number goes through `validate_ncr`: trimmed, upper case, then the pattern `NCR-` four digits `-` four digits. A bad one is 422 `invalid_ncr` "Enter an NCR number like NCR-2026-0021."
- The reason checked by `reason_given` is the NCR alone (`NCR-2026-0021`) when no text is given, else `NCR-2026-0021 · <reason>`. So an NCR number stands in for the reason, and the text has no minimum when an NCR is given.
- With neither, the answer is 400 `reason_required` with the rule's message. A reason without an NCR needs 8 characters.
The reject stores the NCR on the inspection, sets the packet's `return_note` to the reason, and closes the inspection into `qc_history` at once. The packet in review has no open inspection; the read model gives the reject as `qc_last`, and the screens show the "Q.C. rejected" callout from it. The supervisor's next send to Q.C. opens a fresh inspection.
The change-order hold applies to both accepts and to file. It does not apply to the reject: Q.C. can always send a packet back.
The NCR link is `https://ncr.pescoinc.biz/ncrs/<number>`. `domain/ncr.py` `validate_ncr` is the hook for checking the number against pesco-ncr. Today it accepts any well-formed number. The check against pesco-ncr is a later phase.
### Packet documents
DOC_ADD and DOC_REMOVE are Clerical or Q.C. (CL_OR_QC), in any status but filed (the filed lock applies).
- `POST /api/packets/{id}/docs` `{asset_id, kind, title?, note?}`. `kind` is one of the nine kinds (see [SCHEMA.md](SCHEMA.md), "Packet documents"); any other is 422 `invalid_kind` "Pick a document type from the list." The asset must be a PDF, PNG or JPEG (422 `invalid_asset`). The title defaults to the kind's label and is up to 160 characters. The note is up to 300 characters.
- `DELETE /api/packets/{id}/docs/{doc_id}` `{reason}` takes a document out of the data book. The reason needs 3 characters after cleaning (400 `reason_required` "Say why it comes out of the data book."). The row is kept with who, when and why. A second take-out is 409 `already_removed`.
Drawings are never packet documents. They come from M-Files or the hand attach only.
### Q.C. folder documents
QC_DOC_ADD is Clerical or Q.C. (CL_OR_QC), checked with `check_scoped()`. `POST /api/qc/docs` `{project_id | unit_id, type, title?, rev?, note?, asset_id, replaces?, it_reason?}`:
- Exactly one of `project_id` and `unit_id` (422 `invalid_request`).
- `type` is one of `ITP`, `WPS / PQR`, `NDE procedure`, `Hydrotest procedure`, `PMI / material`, `Calibration certificate`, `Customer spec`, `Other` (422 `invalid_type` "Pick a document type from the list.").
- The asset must be a PDF, PNG or JPEG (422 `invalid_asset`). Limits: title 140 (defaults to the type), rev 12, note 600.
- `replaces` files a new revision. The replaced row must be a current document of the same folder (422 `invalid_replace`). One that already points at a newer row is 409 `already_superseded`. The old row is kept and points at the new one. Nothing is deleted.
Reads (`GET /api/qc/home`, `/api/qc/folders`, the project and unit folder reads) need any signed-in session. PM reads and changes nothing.
Clerical may add Q.C. folder documents. The old build allowed Q.C. and Admin only. This is wider than the old build; Seth can narrow it to QC.
### The ITP
Each unit has at most one digital ITP, started from the code template `ITP_Generation_Revised_2` and frozen at start. Reads (`GET /api/units/{unit_id}/itp`) need any session. Every write runs `check_scoped()` with its action, then finds the ITP (404), then refuses a complete one (409 `itp_complete`), locks the `itps` row, bumps `itps.version` once, and writes one `itp_log` row per change, one audit row for each (`itp.<action value>`, so `itp.itp_start`, `itp.itp_line_sign`; subject `itp`; detail `{text, note, unit_id}`, plus `signature_id` on a signed one) and a change event, in one transaction. Block sign and sign-off check their gates before the signature, so a refused step costs no PIN attempt, and check them again under the lock.
Every line and block row is made at start. A Q.C. user's packet read still lists the scoped actions in `can`; they mean nothing on a packet.
| Action | Who | Endpoint | Gate |
|---|---|---|---|
| itp_start | QC | `POST /api/units/{unit_id}/itp` | 409 `itp_exists` when the unit has one |
| itp_edit_line | QC | `PATCH /api/itps/{id}` (header), `PATCH .../lines/{key}`, `POST .../sections/{n}/na`, `POST .../lines/{key}/reject`, `POST .../lines/{key}/ncr`, `PATCH .../blocks/{key}` | see below |
| itp_line_sign | QC | `POST /api/itps/{id}/lines/{key}/sign` | the line is not approved (409 `already_signed`) or N/A (409 `line_na`); a rejected line needs its NCR (409 `ncr_required` "Enter the NCR # before approving this line.") |
| itp_line_unsign | QC | `POST /api/itps/{id}/lines/{key}/unsign` | the line is approved (409 `not_signed`); only the inspector who initialed it, or an admin (403 `forbidden` "Only the inspector who initialed this line, or an admin, can remove it."); refused while the block is signed (409 `block_signed` "Undo the section signature first.") |
| itp_block_sign | QC, signature "Sign an ITP block" | `POST /api/itps/{id}/blocks/{key}/sign` | every line of the block done (409 `block_not_ready` "Every line must be initialed or N/A first."); not already signed (409 `already_signed`) |
| itp_block_unsign | QC | `DELETE /api/itps/{id}/blocks/{key}/sign` | the block is signed (409 `not_signed`); the signer or an admin (403 `forbidden` "Only the inspector who signed this block, or an admin, can remove it."); clears the signature and keeps the block's fields |
| itp_sign_off | QC, signature "Sign off the ITP" | `POST /api/itps/{id}/signoff` | every block signed (409 `blocks_unsigned` "Every section must be signed first.") |
| itp_reopen | QC_SUP, or an admin through the bypass | `POST /api/itps/{id}/reopen` `{reason, it_reason?}` | a reason of 8 characters; the ITP is complete (409 `itp_open` "The ITP is open already."). An admin who gives no `it_reason` has the reason used as both. |
Line approval is a compare-and-set: the `itps` row lock, the state check above, and an optional `If-Match: <line row_version>` (409 `row_conflict`). Approval sets `result approve`, `by`, `at` and the initials (the first letter of each word of the display name, upper case, at most three). Unsign clears the result, the approver, the time and the initials, so the line is open again. The line's `ncr` and every `itp_rejects` row stay.
The itp_edit_line writes:
- Header (`no`, `client`, `pname`, `pno`, `po`, `unit`): field compare-and-set with `{set, base}` (409 `field_conflict`), 120 characters each. `no` cannot be empty (422 `invalid_field`).
- Line details: `na` is refused on an approved line (409 `already_signed`) or one with an open reject (409 `line_rejected`). `cust_code` is one of `R`, `R/AU`, `AU`, `W`, `H`, `NA`, or empty for the template's (422 `invalid_field` "Pick a customer code from the list."). `cust_by` 80, `note` 500. `cust_at` defaults to today when a name is given. A change that changes nothing writes no log and no version.
- Section N/A: sets or clears N/A on every line of the section. Marking N/A is refused when any line of the section is approved (409 `already_signed`) or has an open reject (409 `line_rejected`).
- Reject: refused on a line that is approved (409 `already_signed`), N/A (409 `line_na`) or already rejected (409 `line_rejected`). The NCR is optional and validated when given. An `itp_rejects` row (kind `reject`) is added.
- NCR entered later: the line must have an open reject without an NCR (409 `no_open_reject`). An `itp_rejects` row (kind `ncr`) is added.
- Block fields: `qn` 40, `remarks` 400, `cust_name` 80, `cust_at` (today when a name is given), `cust_clear` clears the customer entry. An empty `cust_name` without `cust_clear` is 422 `invalid_field` "Enter the customer rep's name." No signature.
A write to a complete ITP, other than the reopen, is 409 `itp_complete` "The ITP is signed off and locked. An admin can reopen it." The sign-off sets status `complete` and calls the phase 7 hook `pdf.itp.file_signed_itp_pdf()`. Today the hook returns nothing. When it returns an asset, the sign-off sets `signoff_asset_id` and files a `qc_docs` row on the unit (type `ITP`, `auto`), superseding any earlier automatic ITP document of the unit. A reopen sets status `open` with the reason. Block and line signatures stay; the inspector undoes what must change.
ITP actions never look at a packet. A filed packet in the unit does not lock the ITP, because the ITP belongs to the unit (the phase 5 follow-up, open question 28).
### Blocks
A signature block closes at every template section marked to sign, and covers the sections since the last block. The template gives blocks `3` (sections 1 to 3), `4`, `5`, `6`, `7`, `8` and `9` (the final block). A line is done when it is N/A or approved. A block is ready when every line in it is done, so a block of N/A lines only can be signed.
### Hold points
A hold code is `H` or `W`, alone or after a slash (`R/H`), on the PESCO code or the customer code (the line's own customer code when set, else the template's). An open hold point is a line with a hold code that is not done, or any rejected line.
The packet read gives `itp_hold` while the unit's ITP is open: the open hold and rejected points, filtered by the packet's department page group. It is a warning only. No precondition reads it, and no step is blocked by it. Q.C. and the shop check with each other before moving past a point.
| Department | Pages it sees |
|---|---|
| 01, 02, 03, 04, 05, 10 | WP (Weld Parts) |
| 06, 07, 13 | C (Code) |
| 09 | WP and C |
| 08, 11, 12, 14 | FA (Final Assembly) |
| CL, WH, QC, or an unknown code | every page |
### Log texts
The `action` and `note` of `packet_log` for each Q.C. packet write.
| Write | action | note |
|---|---|---|
| Check ticked | `Q.C. checked: <item text>` | |
| Check unticked | `Q.C. unchecked: <item text>` | |
| Visual result set | `VT accept · W3`, `VT reject · W3` | the note when given |
| Visual result cleared | `VT cleared · W3` | |
| Weld note only | `Q.C. record changed` | `W3 note` |
| NDE method | `NDE method: RT`, `NDE method: -` when cleared | |
| NDE result | `NDE result: Acceptable`, `NDE result: -` when cleared (no line of its own when `None required` cleared it) | |
| Other Q.C. fields | `Q.C. record changed` | the changed fields, `inspector Quinn QC · stamp Q1 · hardness 198 HB · report RPT-1 · notes changed` (`<field> cleared`, `notes cleared`) |
| Flat-body checks or weld exam merge | `Q.C. record changed` | |
| qc_accept to the data book | `Q.C. accepted → Clerical – Data Book` | `Quinn QC [Q1] · 3 welds VT accepted`, plus ` · RT Acceptable` when an NDE method other than None required is set, plus ` · <note>` when the step carries a note |
| qc_accept to Shipping | `Q.C. accepted → Shipping` | the same |
| qc_accept_and_route | `Q.C. accepted → 09 Hydro` | `Quinn QC [Q1] · from 03 Weld Parts` |
| qc_reject | `Q.C. rejected → supervisor` | `NCR-2026-0021 · <reason> · welds W1, W3` (parts present only; the rejected welds by number) |
| Document added | `Document added` | `<label> · <title> · <file name>` |
| Document taken out | `Document taken out of the data book` | `<title> · <reason>` |
One PATCH that changes several kinds of field writes one line for each. The arrow and the en dash in `Clerical – Data Book` are the old build's texts. The stamp shows in brackets only when set. The inspector is the inspection's `inspector` field.
The `action` and `note` of `itp_log` (and the audit row's `detail.text`) for each ITP write. Dates are `M/D/YYYY` in America/Denver.
| Write | action | note |
|---|---|---|
| Start | `ITP started` | `L073028-ITP · template ITP_Generation_Revised_2` |
| Header field | `ITP header changed`; `ITP plan no. changed` for `no` | `client: ACME`; the new number for `no` |
| Line approved | `ITP line 1.3 approved` | `after NCR NCR-2026-0021` when the line was rejected before |
| Line unsigned | `ITP line 1.3 initial removed` | `was Quinn QC 10/17/2026` |
| Line details | `ITP line 1.3 updated` | the changes joined by `, `: `marked N/A`, `applies again`, `customer code W`, `customer code as template`, `customer Rick Inspector`, `customer cleared`, `customer date 10/17/2026`, `note` |
| Line rejected | `ITP line 1.3 rejected` | `NCR NCR-2026-0021 · <note>` or `NCR # pending · <note>` |
| NCR entered later | `ITP line 1.3 NCR number entered` | `NCR NCR-2026-0021` |
| Section N/A | `ITP section 5 marked N/A`, `ITP section 5 applies again` | the section title |
| Block fields | `ITP block 3 QN # changed`, `ITP block 3 remarks changed` | the value |
| Block signed | `ITP block 3 signed` | `Sections 1–3` or `Section 4` |
| Block unsigned | `ITP block 3 signature removed` | |
| Customer complete | `ITP block 3 customer complete recorded`, `ITP block 3 customer complete cleared` | `Rick Inspector` (the cleared name on a clear) |
| Sign-off | `ITP signed off` | `PDF filed` when the hook filed one, else `PDF filing reserved for phase 7` |
| Reopen | `ITP reopened` | the reason |
A Q.C. folder document writes an audit row `qc_doc.add` or `qc_doc.revise` (subject `qc_doc`), with `doc_id`, `project_id`, `unit_id` and `replaces` in the detail, `detail.text` `Q.C. document added` or `Q.C. document revised` and `detail.note` `L670 · WPS / PQR · WPS-101 GTAW/SMAW Rev 2 · wps.pdf`.
## Worker writes (phase 3)
The worker has no session. It writes through the same policy as a person, as the system principal `Principal(user_id=None, system="visual-feed")`. Each write locks the packet row (`FOR UPDATE SKIP LOCKED`; a row locked by someone else is left for the next run), builds the packet state, runs `policy.check()`, makes its change, bumps `version`, and writes a `packet_log` row (by nobody, by name "VISUAL feed", role `system`), an audit row (`packet.<action>`, role `system`) and a change event. See [FEED.md](FEED.md) for when each write happens.
@@ -504,3 +694,11 @@ The old policy was looser than the old screens in places. The rebuild follows th
| The old screen asked for a weld of the current department before submit, but the policy did not check welds. | welds_recorded counts the welds of the packet's current department, on the server. After a route the new department marks its own. | Each department records its own welds. | Yes |
| A mark could be placed on a VOID sheet. | A VOID sheet refuses new marks and moves (422 `void_page`). The marks already there stay. | Marks belong on the current revision. | Yes |
| The station clock ran in the browser, and the elapsed time was saved from the browser. | The server keeps the clock with the database time. No request sets the elapsed time. | The clock is a record. A browser clock can be wrong or changed. | No |
| Packet document kinds and Q.C. folder document types were whatever the screen sent. | Both are closed sets checked on the server (and by a CHECK in the database). | The data book sorts and labels by kind and type. | Yes |
| A packet document could be taken out of the data book with no reason. | A take-out needs a reason of at least 3 characters. The row is kept with who, when and why. | A document leaving the record is a decision on record. | Yes |
| A Q.C. reject took any non-empty text. | A reject needs a reason of at least 8 characters, or a well-formed NCR number, or both (open question 27). | A short note like "no" says nothing to the supervisor. An NCR number points to the full record. | Yes |
| An ITP line rejected without an NCR could not be approved only because the screen disabled the button. | The server refuses the approval until the NCR number is entered (409 `ncr_required`). | The screen alone could be skipped. | Yes |
| ITP block signatures and the sign-off were PIN checks in the browser. | Both are signatures verified on the server and stored with a content hash. | A signature is a record. | No |
| The ITP sign-off was offered when the screen saw every block signed. | The sign-off is a server rule: every block signed (409 `blocks_unsigned`), and a complete ITP refuses every write but the reopen. | The screen alone could be skipped. | Yes |
| The screen limited removing an ITP line's initials to the same inspector or an admin, and refused it while the block was signed. Nothing else checked. | The server enforces both (open question 18). | The initials are that inspector's record. The screen alone could be skipped. | Yes |
| Any Q.C. user could remove a block signature. | Only the signer or an admin, while the ITP is open (open question 30). | A signature is that person's record. | Yes |