How LoopCheck is built and why. Read alongside CLAUDE.md (the
constraints) and the commented migrations in pb_migrations/.
Browser (stock phone camera → URL) PocketBase (single Go binary)
┌─────────────────────────────┐ HTTP same-origin ┌──────────────────────────────┐
│ pb_public/*.html + Alpine.js │ ───────────────────► │ REST API /api/collections/* │
│ reads window.location │ ◄─────────────────── │ SQLite (embedded) │
│ origin — no host config │ │ pb_migrations/ (schema+seed) │
└─────────────────────────────┘ │ pb_hooks/ (/t/{tag} route) │
└──────────────────────────────┘
No build step, no bundler, no CDN. Every page is a static file that talks to
the same origin it was served from. PocketBase serves pb_public/, applies
pb_migrations/ on startup, and runs pb_hooks/.
Twenty-four committed application collections. Relations are drawn
child → parent; PocketBase system/auth collections are not counted.
projects
├── systems (project; parent_system → systems, self-nesting)
│ └── tags (project, system)
├── tags (project, system?) tag_number unique per project
├── services (project) one customer service connection
│ └── service_contacts (service) auth-gated customer PII
├── segments (project) station-to-station pipe subject
├── service_visits (project, tag? XOR system?) APPEND-ONLY vendor visit ledger
├── punch_items (project, tag?, system?, service?, segment?) lifecycle
└── (checks hang off tags, systems, services, and segments)
checklist_templates
└── template_items (template) ordered line items
checks (tag XOR system XOR service XOR segment, template?) ledger
└── check_items (check, template_item?) APPEND-ONLY
test_equipment (global registry) checks reference it (≤3)
attachments (parent_collection, parent_id) polymorphic files
test_standards (global editable registry) certificate provenance
calibrations (tag, standard) APPEND-ONLY header
└── calibration_points (calibration) APPEND-ONLY point rows
warranties (project, tag) editable registry of contract facts
closeout_requirements (project, tag) workflow state (required→received→accepted)
└── closeout_log (requirement) APPEND-ONLY status trail
warranty_claims (project, tag) close-once ledger
loto_events (project, tag; applies_to → loto_events) APPEND-ONLY apply/release ledger
turnover_packages (project, system?; supersedes → turnover_packages) APPEND-ONLY frozen snapshot
signatures (subject_collection, subject_id → package XOR check) APPEND-ONLY attestation
| Collection | Purpose | Mutability |
|---|---|---|
projects |
One capital job. Owner / EOR / contractor names. | editable |
systems |
Process system, nestable, startup_sequence orders the march. |
editable |
tags |
One piece of equipment, keyed by P&ID tag_number. |
editable |
services |
One customer service connection, keyed by street address. |
editable |
service_contacts |
Auth-gated customer name/phone, one-to-one with a service. | editable |
segments |
One station-to-station pipe test subject, keyed by segment_id per project. Frozen / maintenance-only — linear acceptance moved to MainLine (ADR 0014 superseded, 2026-07-18); schema stays, no LoopCheck UI. |
editable |
checklist_templates |
A named checklist for one tag_type + phase (or a process log). | editable |
template_items |
One ordered line: text + response_type + unit + confirm_per_spec + spec_reference. | editable |
checks |
One checklist event against a tag XOR system XOR service XOR segment. | append-only |
check_items |
The answered lines of a check. | append-only |
punch_items |
A defect/open item. Severity A/B/C. open→closed. | close only |
test_equipment |
Global instrument registry: description, make/model/serial, cal_due. |
editable |
attachments |
A file (photo, cal cert) pointed at any record. | append-only |
test_standards |
Global calibration-standard registry: identity, owner, certificate dates. | editable |
calibrations |
One purpose-built instrument calibration with frozen standard provenance. | append-only |
calibration_points |
Ordered expected/as-found/as-left rows for one calibration. | append-only |
warranties |
Who backs a tag, on what basis, and when the clock started. | editable |
closeout_requirements |
One deliverable owed per tag (O&M manual, spares, training…). | editable (logged) |
closeout_log |
The trail behind every requirement status change. | append-only |
warranty_claims |
Warranty failures: opened → vendor notified → closed. | close-once |
loto_events |
Lockout/tagout applies and releases — a visibility ledger, never an authority. | append-only |
service_visits |
Manufacturer-representative visits against a tag XOR system, with report evidence. | append-only |
turnover_packages |
A frozen, sealed snapshot of a scope's checkout — the signed turnover deliverable (built server-side). | append-only |
signatures |
A recorded attestation (name/role/statement) bound to a package or check by content hash. | append-only |
tags.tag_numberis unique per project, not globally (composite unique indexproject + tag_number). Two plants can each have a PMP-101. The/t/{tag_number}page resolves by number and, on the rare cross-project collision, shows a project chooser instead of guessing.systems.parent_systemis a self-relation, added in a secondapp.save()inside the migration (a relation field cannot point at a collection that does not exist yet).- A check's subject is exactly one of tag / system / service / segment
(ADR 0001,
ADR 0006):
equipment checkout runs against a tag; a system-level event (functional
demonstration) runs against a system; a cutover phase runs against a
service; a linear acceptance test runs against a segment. All four relation
fields are optional at the field level; the
collection's
createRuleenforces exactly-one server-side — append-only means a malformed record would be permanent, so the rule is the gate, not the UI. Migration1789000024repeats ADR 0006's nullable-relation recipe for the fourth subject. - Services are the first non-equipment subject (ADR 0006): a water main
replacement's cutover sequence (notice → locate/pothole → new service →
meter → tie-over → restoration) runs the SAME checklist engine —
subject_kind: "service"templates, service phases appended to the shared phase enum, required phases derived from the template library exactly like a tag's. Theaddressis the human-facing identifier everywhere a tag_number would appear, but the URL/QR key is the opaque record id (/s/{id}) — addresses are neither unique nor URL-clean, and the PII rule forbids person-identifying data in URLs. Migration1789000020movedcustomer_nameandcustomer_phoneinto the auth-gated, one-to-oneservice_contactscollection and removed those fields from publicly readableservices. The boundary is structural rather than dependent onfields=discipline. checksandcheck_itemsfreeze a copy of the template text (check_items.prompt) at the moment of the check, so a historical check reads correctly even after its template is later edited. Templates are living; checks are frozen.punch_itemsare not the ledger — they have a real open→closed lifecycle, soupdateRuleis permissive enough to close one. But the closure is recorded (closed_by/closed_at/closure_note) and the record is never deleted.attachmentsis polymorphic —(parent_collection, parent_id)rather than a file field on every collection. Punch-item photos today; calibration certs and nameplate photos on checks later, with no schema change.- Vendor field-service visits are immutable evidence
(ADR 0017):
service_visitsrecords a representative's visit against exactly onetagXORsystem; its create rule also verifies the denormalizedprojectagrees with that subject. The signed PDF/photo uses the existing polymorphicattachmentsparent (parent_collection='service_visits'). A missing report is allowed so a failed second upload cannot erase the visit, but readers must flag that evidence gap loudly. A tag's Manufacturer-certified installation: {date} line derives from its flagged visits (most recent certification date); no certification field is stored on the tag. Warrantybasis_notemay cite the visit by convention, with no hard module coupling. A uniqueclient_tokenmakes offline create retries idempotent. - Capture-side fields land before crews log
(ADR 0002): tags
carry the instrument-index facts (
area,spec_section,loop_numbers,plc,mcc_breaker,drawing_refs, populated by the CSV import's header synonyms); systems carry an optionalplanned_startupdate; andwitness_requiredis template data frozen onto each check at execution — same freezing pattern as the prompt text. tags.has_vfd(ADR 0008) is a capture-side boolean in the same spirit: whether the motor is VFD-driven is an equipment-schedule fact, imported from the CSV (has_vfd/vfdsynonyms;vendormaps to the existingmanufacturer). It selects which rotation template the capture UI offers (*.rotationvs*.rotationVfd— two templates, not a conditional one). The rotation check itself is an ordinary append-only check; its pass / swap-leads / check-VFD-parameter reading is derived from the two recorded direction values, never stored.tags.phases_na(ADR 0009) is a capture-side text list of the checkout phases that do NOT apply to this specific tag (an across-the-line motor has no configuration phase; a hardwired float switch has no analog loop check). It refines the derived required-phases set —required = has a template for the tag_type AND not in phases_na— so the standing plate and readiness matrix don't false-red a phase that was never owed. Still an applicability input, not a stored status; a blank value (the common case) means every templated phase applies. Forgiving free text: the derivation normalizes both sides, soloop_check,Loop Check, andloopcheckall match.- Training is a signable milestone, not a checkout phase
(ADR 0013): a
*.trainingtemplate (subject_kind: "tag",witness_required: true) runs through the ordinary check engine — a signed, witnessed, dated training event per equipment. Itsphasevaluetrainingis a MILESTONE deliberately kept OUT of the six-phase "checkout complete" fold (the readiness matrix places "Training Completed" beside the checkout columns, not inside them); the tag page and readiness matrix derive it and show it separately.phases_nagoverns applicability (a tag owing no training marks it N/A). The closeouttrainingrequirement stays for the deliverable (materials handed over) — a distinct fact from the event. The enum gainstrainingadditively (Migration J,1789000021); the training templates load in the following loader (1789000022) after the enum opens — the same separate-file-and-later-loader ordering the service phases used. - Warranties record the clock, not the deadline
(ADR 0005):
the single biggest warranty fight is when the clock started, so a
warranty stores the
basis(substantial completion / beneficial use / startup / shipment cap / other), the exact spec language (basis_note), the trigger date (clock_start_date— optional, because during commissioning the event usually hasn't happened yet), andduration_months. The end date is derived in the UI, never stored.closeout_requirements.statusis editable workflow state, but every change is appended to the immutablecloseout_log;warranty_claimsare updatable while open and frozen server-side the momentclosed_atis set (updateRule: "closed_at = ''") — corrections after closure are new claims. loto_eventsis a two-event ledger, not a locks table (ADR 0007): anapplyand itsreleaseare separate append-only records; the release points back viaapplies_to(a self-relation, added second-save likesystems.parent_system). The photo of the hung lock is a required file field on the apply event itself — one atomic multipart POST — because the two-step create-then-attach could permanently strand an append-only record without its constitutive evidence, and no rule onattachmentscan require a child record. The createRule enforces the shape (apply must carry a photo; a release must reference an apply on the same tag; a releaser whose name differs from the holder's must carryreleased_by_note— the supervisor-removal justification rides into the immutable record). The app never decides who MAY release; it records who did.- A turnover package freezes a derived verdict, and that is not a
stored-status violation (ADR 0010):
a signature attests to the ledger as it stood on the day it was signed,
but readiness is derived and the ledger keeps growing — so the thing signed
must be frozen.
turnover_packagesfreezes amanifest(the scope's checks- their
check_items, open punch, active LOTO, closeout — a per-collection field allowlist), astanding_snapshot(the derived verdict at freeze time), and acontent_hashunder the pinnedsha256-canon-v1canonicalization (a verifier recomputes it). This is the same movecheck_items.promptalready makes: the live readiness view still derives from scratch; the package stores a copy as historical evidence, so nothing anyone reads as "current" can drift (hard constraint #4 holds). The package is assembled server-side inpb_hooks(POST /api/turnover/build) because a client cannot be trusted to compute the manifest of what it claims the ledger contains — createRulenullblocks direct client creates. A re-issue supersedes the prior package for its scope (self-relation); the current package is the one no other supersedes, and signatures do not carry forward.signaturesare recorded attestations bound bysigned_content_hash— not qualified e-signatures.
- their
- Test equipment is traceable; punch items link to their evidence
(ADR 0004):
checks reference up to three
test_equipmentrecords and freeze a human-readable note of what was used; punch items optionally point at the failed check that spawned them (source_check) and the passing retest that cleared them (closing_check). The registry is global — serial numbers are the identity, and meters move between jobs. - Instrument calibration is a purpose-built ledger
(ADR 0016),
not a fifth checklist response type.
calibrationsfreezes the standard's identity, certificate due date, and the server-derivedstandard_expired_at_useflag;calibration_pointspreserves each expected, as-found, and as-left triplet. Both collections are append-only. The editabletest_standardsregistry can roll forward after recertification without rewriting history.next_dueand the instrument badge derive from the latest calibration'sperformed_at + interval_months, never from stored status.
The twenty-four collections above are the complete committed application schema. Both collections this section previously listed as planned have shipped, and the ADRs behind them are now Accepted:
signatures+turnover_packages(ADR 0010, accepted 2026-08-04) — migration1789000026, the server builder/canonicalizer (pb_hooks/turnover.pb.js+turnover_canon.js), andsign.html/turnover.htmlare all committed and counted in the twenty-four above.turnover_packagesis an append-only frozen snapshot of a scope's checkout at a moment (a manifest of the included records under a per-collection field allowlist, asha256-canon-v1content hash, and the derived readiness verdict frozen into the record);signaturesis an append-only, polymorphic attestation bound to that frozen content by its hash. The golden vector is enforced at the build route — a drifted canonicalizer makesPOST /api/turnover/buildrefuse rather than seal a hash no independent verifier could reproduce — andscripts/smoke_test.shasserts the pinned algo, digest shape, hash determinism across a rebuild, and supersession. Core stores the facts; the compiled PDF turnover binder stays the paid sidecar (ADR 0003). The API v1 contract bump for these two is still outstanding (M3, D12): an accepted ADR is a settled design, not a published contract version.
service_contacts is no longer planned: migration 1789000020 committed the
structural PII split, and ADR 0011 is Accepted.
Nothing in this subsection is shipped behavior. It is design capture from
ADR 0018 (proposed),
recorded here so a future reader knows the shapes were reasoned about rather
than left open. The sequenced work is the M4–M6 track in
docs/ROADMAP.md; do not implement any of it before ADR
0018 is Accepted. Field observations from a multi-vendor plant startup drove
every line of it.
| Change | Shape | Where |
|---|---|---|
| Deferral as a fact | checks.result += deferred; checks.deferred_to_phase |
M4.1 |
| Process-plant phases | phase enum += wet_commissioning, process_seeding (additive; no per-project phase table) |
M4.1 |
| Ball-in-court | punch_items.assigned_party revocabulary (breaking), blocks_phase, scope_gap |
M4.2 |
| As-left condition | checks.as_left_condition |
M4.2 |
| Vendor evidence | service_visits += time_on_site/time_off_site, date_end, result, open_items, phase, check, continues (no new collection) |
M4.3 |
| Explicit resolution | check_items.result += not_performed, deferred; check_items.na_reason required when na |
M5.1 |
| Typed values | template_items += expected_min/expected_max/expected_text/comparison, frozen onto check_items alongside value_numeric — not a fifth response type |
M5.2 |
| Nameplate | tags += hp, voltage, full_load_amps, phase_count, rpm, frame, nameplate_note |
M5.2 |
| Instrument configuration | new append-only instrument_settings (current settings derived as the latest unsuperseded record) |
M6.1 |
| Signature roles | checklist_templates.required_signature_roles, frozen onto checks; signatures.signer_role += four roles; missing countersignatures derived |
M6.2 |
| Phase gates | checklist_templates.gate_for_phase; a gate is an ordinary system-subject check that warns, never blocks |
M6.3 |
| Continuation | checks.continues — deliberately distinct from resolves (abandoned ≠ running as designed) |
M6.4 |
Two things about this table are load-bearing. First, the turnover manifest
additions it implies are hash-affecting: the vendor report index, the
instrument settings table, the deferred-cell grid, the countersignature QC
block, and the completeness assertion all enter the canonical bytes, so they
must land before the first real package is signed or behind a
sha256-canon-v2 (ADR 0018, and open-questions #13). Second, the
punch_items.assigned_party change is breaking on a v1-published
collection and needs the ADR-0003 version bump — which is why the whole track
follows M3's API reconciliation
(DECISIONS D20).
Migrations 1789000019 and 1789000020 implement a selective lockdown:
field-facing reads stay public; check/check-item, punch-flag, and LOTO creation
stay accountless; office setup and lifecycle writes require auth; warranty,
closeout, and service-contact data are auth-gated; self-signup is disabled.
Two gaps are current at the 2026-07-12 snapshot. Most committed office pages do
not yet send the user token the backend now requires, and segments was created
after the lockdown with public create/update rules and a remaining TODO(auth).
Do not treat the access matrix as production-verified until those are
reconciled and exercised end to end. Details:
docs/current-state.md.
There is no status column on a tag or a system. Status is always computed:
- A tag's checkout standing = a fold over its
checksper phase, same rule as a service's (any pass wins — a passing re-test clears an earlier fail, and the fail stays in the ledger; else any fail; else any record = incomplete; else not started), evaluated against its required phases (the phases that have a template for its tag_type, minus any the tag marks N/A inphases_na— derived, see §9 and ADR 0009). Built on the tag page (tag.html, Phase 2b): the standing plate, the check-history ledger, the printablecheck_view.html, and a free CSV export of the tag's whole record. A regression after a pass is a new punch item, not a downgrade of whether the phase was ever accepted. - A system's readiness = its open
punch_itemsgrouped by severity, plus its tags' checks, plus the system's own checks (tag-XOR-system subjects, ADR 0001). The project home and system pages compute the severity counts live on each load (fields=id,severity,systemkeeps the payload tiny). - A service's cutover standing = a fold over its
checksper phase (any pass wins — a passing re-test clears an earlier fail, and the fail stays in the ledger; else any fail; else any record = started), evaluated against its required phases (the phases that have asubject_kind='service'template — derived, ADR 0006). The cutover board computes every row live from three paged reads. - A segment's acceptance is intended to derive from its required segment templates and append-only check ledger, including linked pending/resolution pairs. The schema and template inputs exist, but no committed segment page or acceptance fold renders this state yet; it is not a shipped workflow.
- A warranty's end date and status (active / expires in N days / expired /
awaiting trigger event) =
clock_start_date + duration_months, computed at render time with day-clamped UTC month arithmetic (ADR 0005). A claim's open/closed state = whetherclosed_atis set. - A project's closeout completion = accepted
closeout_requirementsover total, folded per system on the closeout dashboard. - A tag's lockout state = any
loto_eventsapply with no release referencing it (ADR 0007). Group LOTO falls out naturally: all active locks show, and the tag stays locked out until every one is released. Unlike every other derivation, this one is display-pessimistic: the UI carries the sync timestamp, degrades to an amber stale state past 10 minutes, and never renders an affirmative "not locked out" — a stale punch list wastes a walk; a stale lock display can hurt someone.
This is a deliberate invariant: a stored status can drift out of sync with the ledger; a derived one cannot. It costs a query; it buys trustworthiness.
Designed, not built: ADR 0012 adds a running state to the standing fold
(an unresolved pending open event) and ADR 0018 adds deferred (a signed
deferral whose target phase has no later record). Neither is implemented. With
both, the fold reads: any pass wins → else any fail → else any unresolved
pending = running → else any deferral not yet picked up = deferred → else any
record = incomplete → else not started. That is the ceiling: a field UI
cannot render a sixth state legibly, so no state is added without retiring one.
Note that deferred and phases_na are different facts — phases_na means
the phase never applied to this tag, deferred means it applies, it is owed,
and it moved. Collapsing them would let owed scope disappear.
QR labels encode /t/PMP-3101 — a plain, human-readable URL, because the P&ID
tag number is the project's language and a stock phone camera opens plain URLs
with no app. PocketBase serves static files by exact path, so /t/{tag_number}
would 404 on its own.
pb_hooks/main.pb.js registers GET /t/{tagNumber} and serves tag.html via
e.fileFS($os.dirFS(...), "tag.html"). The address bar keeps the clean URL (no
redirect); tag.html reads the tag number back out of window.location.pathname
and queries the API. Opening tag.html?n=PMP-3101 directly also works, as a
fallback.
Services get the same treatment at /s/{serviceId} (→ service.html), but
keyed by the opaque record id, not the address — addresses are unique only
per project, full of abbreviation drift, and the PII rule keeps
person-identifying data out of URLs and QR codes (ADR 0006). Meter-box QR
labels encode /s/{id}; the page displays the address big. There is no
committed clean segment route.
import.html ingests a real instrument index / equipment schedule and is
deliberately forgiving:
- A correct CSV parser (quoted fields, commas/newlines inside quotes,
escaped
"", CRLF) — not a naivesplit(','). - Header synonyms —
Tag No.,tag_number,TAGNUMBER,equipment tagall map totag_number(compared after lowercasing and stripping non-alphanumerics). Column order is irrelevant; extra columns are ignored. - Loose tag-type matching —
Pump,MOV,motor operated valve,flow,mag meterresolve to the enum; anything unrecognized becomesother. - Systems created on the fly — a missing
system_numberis created once and its id reused across rows; blank system numbers are allowed. - Preview before write, then a report: tags created, systems created, duplicate tag numbers skipped (the unique index rejects them as HTTP 400), blank rows skipped, and any errors.
A messy sample lives in examples/sample_instrument_index.csv.
import_services.html applies the same philosophy to a water main job's
service list (district account export or takeoff): address is the only
required column; station, account/parcel, customer name/phone, meter number,
size, and material map through their own synonym table; duplicates —
(project, address, station) unique index — are counted as skipped, so
re-imports are safe.
- One shared stylesheet,
pb_public/app.css(design tokens + components), so every page stays consistent without duplicating CSS and without a build step. - Check execution (
check.html) writes the ledger with a deliberate shape: the overall result is derived, never picked (any fail → fail; any required line unanswered → incomplete; else pass — an incomplete run is a legitimate record of a crew getting pulled off). Every line is written, answered or not, so the record shows what was skipped. Writes go check → items → photos, in that order — a mid-sequence failure leaves a visible partial record, never a lost check. The template's prompt text,witness_required, and a human-readable test-equipment note are frozen onto the record at submit (ADRs 0001/0002/0004). A failed line offers punch-item creation carryingsource_check; the tag page's close flow offers the tag's passing checks asclosing_check. - Reading the ledger back (
tag.htmlPhase 2b,check_view.html): the tag page derives its checkout standing per required phase (any pass wins, §3) and lists the check history newest-first; each row opens the printable single-check record, which renders the frozen prompts/results/photos exactly as signed. A free per-tag CSV export folds the whole ledger (checks + their frozen line items) client-side — the field tier can always get its record back out, never paywalled. - Warranty & closeout (ADR 0005) adds a section to the tag page (derived
warranty badge, tap-to-call vendor, deliverables, claims) and three project
views:
closeout.html(tags × requirement types matrix; advancing a status writes thecloseout_logentry),warranty.html(derived expirations, soonest first, ≤90 days flagged), andcloseout-package.html(the print-friendly Warranty Log & Closeout Package — the compiled PDF version is paid-tier territory, ADR 0003).import.htmlgains a warranties mode with the same header-synonym forgiveness, loose basis matching included. - Readiness matrix (
readiness.html) is the turnover output spec (forms-library review §3): a per-project, read-only, print-friendly grid with one row per tag folding both worlds the owner reads together — the checkout phases (from the check ledger, any-pass-wins, phases the tag marks N/A viaphases_nashown as such, phases the type has no template for as—) and the closeout deliverables (from the closeout module) — with a completion date in each cell (what a schedule and the binder consume). A pure presentation join: no schema, every cell derived at render from records other pages already write. This is the free deliverable; the compiled, hyperlinked PDF binder is the paid sidecar (ADR 0003). - Punch list (
punchlist.html) answers the second of the three questions as a listing rather than a counter.system.htmlandproject.htmlshow open A/B/C counts; this is the report behind them — every item grouped A → B → C in the fixed color language, with description, subject, assigned party, who flagged it, and (optionally) the closed items too. Scope comes from the query string:?project=for the whole job,?system=for one system. Like the readiness matrix it is a pure presentation view — no schema, no stored status, derived live frompunch_itemsat render, with the as-of time printed on the sheet. A punch item can hang off a tag, a system, a service, a segment, or the project alone, and all five render, so a project-scope report never silently drops records; the service subject is expanded foraddressonly, never a customer field (the PII rule). Print CSS makes the walkdown sheet the free deliverable and a client-side CSV exports the same roll-up — a compiled, branded punch report would be sidecar work (ADR 0003). - Service cutover (ADR 0006) reuses the engine end to end:
service.htmlmirrors the tag page (derived phase strip, check ledger, punch flow);check.htmltakes?service=and offers thesubject_kind='service'templates;cutover.htmlis the per-project board — every service by station, phase chips derived live, filters for stuck-phase / open-punch / "noticed but not tied over > N days," print CSS for the free deliverable (the compiled district-facing Cutover Status Report is paid-tier, ADR 0003);notice_batch.htmllogs a block's 48-hour notices in one pass, each with its own door-hanger photo, written as full normal checks with per-row retry so a dead spot loses nothing. Public service pages fetch withfields=lists that exclude customer PII (CLAUDE.md rule). - LOTO (ADR 0007) renders its banner above everything else on the
tag page — above the header, above the equipment identity. Apply demands
the photo before submit; release demands picking which lock, and a
releaser name that differs from the holder's demands the note (mirroring
the server rule). Writes queue in a small
localStorageoutbox when offline and sync on reconnection — queued events render in the banner marked "not yet synced", never silently missing.loto.htmlis the project board: active locks by system, oldest first, lock age visible (age ≥ 7 days flags amber — the forgotten-lock early warning). - Rotation & VFD (ADR 0008) is a specialized capture UI on the ordinary
ledger.
rotation_check.htmlis a glove-sized page whose live guidance is the point: as the tech taps Hand and Auto direction, it derives and shows the reading — pass, hardware fault (both reverse → swap leads, LOTO first), or software conflict (mismatch → do NOT swap leads, fix the VFD parameter) — before any wiring is disturbed. The template variant is picked by the tag'shas_vfdflag (offered fromtag.htmlfor pumps and motors); the record is a standard append-only check, direction stored as the two frozenvaluelines.rotation.htmlis the per-project board: every rotating tag by system, chipped by its latest check's derived reading, with the VFD parameters, print-friendly and CSV-exportable (free tier). - Instrument calibration (ADR 0016) has three free surfaces:
calibration.htmlis the phone capture form with five-point flow and three-point level/pressure defaults plus a durablelocalStorageoutbox;standards.htmlmaintains the certificate registry and highlights the fixed 14-day window;calibrations.htmlsorts a project's instruments by derived next due, overdue first. The tag page carries the derived 30-day badge and renders the complete as-found/as-left history. - Each page is an Alpine component; only
labels.htmlloads the QR library, so every page stays well under the ~50 KB JS budget. - The field tech's name is remembered in
localStorage(lc_name) so it is typed once per phone. - Severity has a fixed color language everywhere: A red, B amber, C grey.
- A stored
statusfield on tags/systems. Rejected — it drifts from the ledger. Status is derived. (Hard constraint #4.) - A forms designer / conditional checklists. Rejected — templates are flat ordered line items. Branching logic is out of scope forever. (Constraint #5.)
- A file field on each collection for photos. Rejected in favor of one
polymorphic
attachmentscollection, so future phases attach evidence to checks and tags with no migration. - Editing/deleting checks to "fix" them. Rejected — checks are append-only; a correction is a new check. This is the whole value proposition of the turnover deliverable. (Constraint #2.)
- A redirect for
/t/{tag}→/tag.html?n=. Rejected — a redirect loses the clean URL from the address bar and adds a round-trip on a slow connection. The hook serves the file in place instead. - Global tag-number uniqueness. Rejected — real programs run multiple plants that reuse tag numbers. Uniqueness is per project.
- Building shutdown/compliance collections now. Rejected as building ahead.
The domain content lives in
seed/so it is not lost; the collections and UIs come in Phases 4–5. - Seeding the shutdown runbook as flat checklist templates. Rejected — a
flattened copy loses the offsets, hold points, and roles, and would diverge
from the real runbook in
seed/. One representation, loaded when Phase 4 can hold it. - A stored required-phases-per-tag_type map. Rejected — the map is exactly
"which phases have a template for this tag_type," so it is derived from the
template library. A separate copy would be one more thing to drift. (The
same derivation now serves services: their required phases are the phases
with a
subject_kind='service'template. Per-tag exceptions ridetags.phases_na, a small subtraction on the derivation — ADR 0009 — not a stored map that replaces it.) - A line-level
N/Afor a phase that doesn't apply to a tag. Rejected (ADR 0009) — wrong grain: when a whole phase doesn't apply there is no check to run and no line to mark;tags.phases_naexcludes the phase from the derived required set instead. Aselectfield bound to the phase enum, and a positive "applicable phases" list, were both rejected in that ADR too (the first couplestagsto the enum and imports poorly; the second inverts the safe default so every tag would need data entry to avoid under-requiring). - A polymorphic
subjectstable for check subjects. Rejected (ADR 0006) — PocketBase relations cannot span collections, sosubject_idwould be an untyped text id: no referential integrity, noexpand, every query joining through a second hop. One nullable relation per subject kind plus the exactly-one createRule keeps real foreign keys and stays boring to extend. - Forking the checklist engine per subject kind. Rejected — one ledger, one immutability enforcement, one frozen-copy pattern, one future PDF engine. A service check and an equipment check are the same record shape.
- Storing the derived warranty end date. Rejected (ADR 0005) — it drifts from its inputs, and it erases exactly the provenance (basis + trigger date) a warranty dispute is about. Same invariant as tag status.
- A mutable status-log text field on closeout requirements. Rejected in
favor of the append-only
closeout_logcollection — a text field any client can rewrite is not a trail. - A TrenchNote integration for spare-parts locations. Rejected —
storage_locationis free text; a TrenchNote reference goes there as text. The wall between the products stays up. - The app as part of energy-control decisions. Rejected permanently (ADR 0007): no interlocks, no permissions to release, no "safe to work" indicators, no green all-clear state. LOTO in LoopCheck is a visibility layer; the physical locks and the written energy-control procedure are the only authority, and the app must never be usable as evidence that equipment is safe to touch. Feature requests in this direction get a no.
- A confident "not locked out" display. Rejected (ADR 0007) — absence of lock records is a statement about the ledger, not about the plant. The strongest claim the UI makes is "No locks recorded as of {time}," with the verify-physically line, degrading to amber when stale.
- LOTO photos via the polymorphic
attachmentstwo-step. Rejected (ADR 0007) — non-atomic on an append-only record whose photo is required, and unenforceable server-side. The apply carries its own required file field. - npm / a framework / a bundler / a CDN. Rejected by the no-build, runs-on-a-Pi, works-with-no-internet constraints. (Constraint #3.)
- A
conflictvalue inchecks.result, or a dedicatedrotation_checkscollection (ADR 0008). Rejected — a rotation check is an ordinary check; both the hardware fault (reverse in both modes) and the software conflict (reverse in one mode) storefail, since both block startup, and the hardware-vs-software reading is derived from the two direction values frozen incheck_items. A fourth enum value duplicates derived truth and bumps the API contract; a separate collection forks the ledger. - Instrument calibration as ordinary checklist items (ADR 0016). Rejected — splitting expected/as-found/as-left into separate flat lines loses their pairing and expands the deliberately small response model.
- Blocking capture when a standard is expired. Rejected — the record of what happened is better than silence. The server freezes the red flag.
- Stored
next_dueor calibration status. Rejected — both derive from the latest append-only calibration, its interval, and today's date. - Vendor scheduling, service contracts, or billing. Rejected (ADR 0017) — LoopCheck records that a representative visited and preserves the report. Planning trips, administering vendor agreements, and reconciling invoices are separate systems and would blur the module's evidence-only boundary.
The following are rejected by ADR 0018 (proposed) — recorded here so the alternatives are not re-proposed as new, with the caveat that the ADR itself is not yet Accepted:
- A per-project
phasescollection. Rejected — a stored map that drifts from the template library, which is the same thing already rejected above for required-phases. A project configures its phases by configuring its template library; the shared enum grows additively. - Vendor-facing accounts, portals, or integrations. Rejected — vendor service techs will always use their own paper or PDF forms. That is a premise, not an adoption problem. LoopCheck is the GC's master ledger that indexes the vendor's record and captures the few structured facts a dispute turns on.
- Blocking check submission until every line is answered. Rejected — it breaks the field tier for a crew that gets pulled off and converts a recoverable partial record into no record. Ambiguity is legal at capture and illegal at signature/freeze instead (DECISIONS D22).
- A fifth response type for measured values. Rejected — constraint #5.
Acceptance limits are template metadata on the existing
valuetype, and they freeze onto the check item so a later template edit cannot retroactively change whether a reading passed. - Replacing
check_items.valuewith a numeric field, or storing a value line's derived pass/fail. Rejected — "480/478/481 V" and "3.2 mils TIR" are real readings a number cannot hold, so the text stays the record and the numeric is a companion; and the verdict derives from the reading against the frozen limit, like every other status. - Instrument configuration inside
calibrations, or as checklist lines. Rejected — "what is it set to" and "does it read true" are different facts on different cadences. A separate append-onlyinstrument_settingsledger, with current settings derived as the latest unsuperseded record and no stored "current" flag. - A unique index on
tags.serial_number. Rejected — blank serials are the norm and a duplicate serial is a finding to surface, not a write to reject. - One relation serving both
resolvesandcontinues. Rejected — it would make "running as designed" and "abandoned mid-run" the same shape in the standing fold, the same errorpending-vs-incompletealready avoided. - Hard-blocking a phase on an unsatisfied entry gate. Rejected — ADR 0007's record-never-authorize principle applied outside safety. An unsatisfied gate warns prominently; a crew that runs the phase anyway has made a decision, and the ledger records visibly that they did.
- A second per-tag-per-phase rollup page. Rejected —
readiness.htmlis that page; a parallel matrix is drift by construction.
scripts/setup.sh downloads the right PocketBase binary. On first
./pocketbase serve, every migration in pb_migrations/ applies in filename
order, creating all twenty-four committed application collections and loading 49
checklist templates (352 line items) from the six *templates.json files in
seed/ (see section 9). migrate down rolls each back,
including the seed (it deletes exactly the templates it loaded, by
template_key; template_items cascade with them). pb_data/ is git-ignored:
it is the operator's data, not source.
scripts/smoke_test.sh proves this reproduces end to end and stays coherent
as modules accumulate: it boots a throwaway instance from the committed
migrations, runs the full seed_demo.sh, and asserts every collection
exists, each module's demo seed populated (a non-empty count — a probe that
silently skips a whole module is the failure it exists to catch), and the
append-only / close-once rules still hold. Each module is verified in
isolation in its own session; this is the gate for the combined stack,
meant to run before a new migration or module lands.
The calibration demo includes an adjusted pass, an overdue instrument, and a record whose standard was expired at use; the smoke test also proves that both calibration headers and point rows reject updates.
Decision record: ADR 0001.
seed/ holds the maintainer-authored domain template library as JSON
(checkout templates, compliance logs, service cutover templates, tie-in
shutdown runbook — schema in seed/README.md). Seed migrations are loaders,
not copies: the initial and later service, rotation, training, segment, and
tank loaders read the six *templates.json files at migration time and hold no
checklist content of their own, so the library exists in exactly one place.
Later loaders follow the enum-opening migration they depend on and skip existing
template_keys where an earlier fresh-database loader already saw the same
source file.
Editing the library = edit seed/, reset the database (pre-release
workflow).
The checkout library also carries mechanical acceptance forms — the
Uncoupled Motor Run Test (pump_centrifugal/motor, at energization) and the
Mechanical Alignment Report (pump_centrifugal, at installation), plus a
gate seat-leakage line on the MOV energization template. These are additional
templates at phases their tag_types already own, so the derived
required-phases map is unchanged — the same way the rotation pair shares
energization. They are the first templates to populate template_items. spec_reference (the governing standard or equipment-spec criterion for spec-governed
lines); to let the seed loader set it, spec_reference is created with
template_items (1789000004) rather than by Migration A (ADR 0002).
The Tank Leak Test (seed/tank_templates.json, loaded by 1789000028
after migration 1789000027_N adds the tank tag_type and leak_test phase)
is the first production-seed open→resolve form (ADR 0012): a tank/basin is
an ordinary tag whose 48-hr leak test is signed at fill with result: "pending"
and resolves at 48 hr as a separate check pointing back via checks.resolves.
The open→resolve schema already shipped (Migration K, 1789000023); the
open→resolve execution UI and the derived "running" standing state are not
built yet — a gap shared with the already-seeded bac-t collection template, to be
built once for both.
Mechanics worth knowing:
- The loader resolves
seed/via__hooks + "/../seed/"—__hooks(the absolute path ofpb_hooks/) is defined inside migrations too, so the read works no matter which directory the binary was launched from. - The JSON's camelCase phases (
pointToPoint) are mechanically converted to the schema's snake_case (point_to_point) on load.template_key("pump_centrifugal.loopCheck") is stored verbatim — it is an opaque identifier, never parsed. template_keyhas a partial unique index (WHERE template_key != ''): seeded templates can't collide, while user-created templates may all leave it blank.- A tag's required phases are derived, not stored: the phases that have a
template for its tag_type, minus any listed in
tags.phases_na(ADR 0009 — a per-tag applicability input for phases that legitimately don't apply to one specific tag). Verified equal to the map inseed/README.mdfor tags with no exceptions — same principle as section 3, one less thing to drift. A service's required phases derive identically from thesubject_kind='service'templates (ADR 0006). recurrenceblank = a one-time checkout checklist;daily/per_shift/per_eventare the compliance cadences (Phase 5).- Not loaded:
seed/shutdown_runbook_tie_in.json. The runbook's structure (time offsets, hold points, roles with notification triggers) cannot be represented as a flat checklist without losing what makes it a runbook. It stays as captured domain knowledge until Phase 4 builds real shutdown collections.
(Resolved 2026-07-09 — this section previously documented a schema divergence
between the migration seed and seed/; the migrations were reconciled to the
seed schema: four response types, unit, confirm_per_spec, finer tag_type
names, template_key, subject_kind.)
LoopCheck is open-core. Premium (office deliverables: turnover compiler, risk alerts, dashboards) is a sidecar in a separate private repo that talks to the core's public REST API like any third party — no private doors, no in-process hooks, read-only in v1. The original sixteen collections were published as API contract v1 (docs/API.md); the committed schema now has twenty-four, so eight are marked delta pending a version bump. The PII/auth semantics are settled (ADR 0011, Accepted); the collection-set version bump is deliberately sequenced to M3. Breaking changes still need an ADR and a contract version bump. Full decision and the alternatives rejected (PocketBase hook layer, shared database): ADR 0003. External contributions require a future CLA/DCO (CONTRIBUTING.md).