This document is the boundary decided in ADR 0003: the interface that premium modules, integrations, and any third-party tool may build on — all with exactly the same access. If it isn't described here, it isn't contract, and building on it is at your own risk.
LoopCheck's API is PocketBase's standard REST API — the core adds collections, rules, and two clean HTML routes, not a parallel application API. Everything below is reachable at the same origin that serves the pages. Current release is tested against PocketBase 0.39.6; a PocketBase upgrade that changes REST behavior is a breaking change to this contract.
Contract reconciliation required (CURRENT, 2026-07-20). Contract v1 was published for the original sixteen collections. Committed migrations have since added
service_contacts,segments,service_visits,test_standards,calibrations, andcalibration_points, removed PII fields fromservices, and applied selective authentication. The auth and PII semantics are now settled: ADR 0011 is Accepted, the access matrix below is the reconciled policy (no public write rule survives outside the deliberate accountless field paths), andscripts/smoke_test.shasserts it across three tiers. What is still not promoted into an approved v1 stability promise is the collection set: the six added collections remain marked delta pending a formal contract version bump, and ADR 0014 (segments) is superseded — the segment application moved to MainLine, though the shipped schema slice stays. Resolve the collection-set version before an external release or integration commitment.
The committed schema has twenty-four application collections. The original
sixteen are the published v1 set; rows marked delta are implemented but
await a contract decision. public means no user token, auth means an
ordinary authenticated user, and admin-only means no ordinary record-rule
operation. Shape-specific create rules still apply in addition to auth.
| Collection | Contract status | Read | Create | Update | Delete |
|---|---|---|---|---|---|
projects |
v1 | public | auth | auth | admin-only |
systems |
v1 | public | auth | auth | admin-only |
tags |
v1 | public | auth | auth | admin-only |
services |
v1, changed shape | public, PII-free | auth | auth | admin-only |
service_contacts |
delta | auth | auth | auth | admin-only |
segments |
delta; ADR 0011 | public | auth | auth | admin-only |
checklist_templates |
v1 | public | auth | auth | admin-only |
template_items |
v1 | public | auth | auth | admin-only |
checks |
v1 | public | public, subject rule | never | never |
check_items |
v1 | public | public | never | never |
punch_items |
v1 | public | public | auth, lifecycle close | admin-only |
test_equipment |
v1 | public | public | auth | admin-only |
test_standards |
delta; ADR 0016 | public | auth | auth | admin-only |
calibrations |
delta; ADR 0016 | public | capture route only | never | never |
calibration_points |
delta; ADR 0016 | public | capture route only | never | never |
attachments |
v1 | public | public for check-item/punch/service-visit parents; otherwise auth | never | never |
warranties |
v1 | auth | auth | auth | admin-only |
closeout_requirements |
v1 | auth | auth | auth, log every change | admin-only |
closeout_log |
v1 | auth | auth | never | never |
warranty_claims |
v1 | auth | auth | auth only while open | admin-only |
loto_events |
v1 | public | public, LOTO shape rule | never | never |
service_visits |
delta; ADR 0017 | public | public, tag XOR system + same-project rule | never | never |
turnover_packages |
delta; ADR 0010 | public | build route only (createRule null) |
never | never |
signatures |
delta; ADR 0010 | public | auth | never | never |
segments was created after the auth migration and had briefly kept public
create/update rules; migration 1789000033 (ADR 0011) closed that gap, so its
create/update now require auth like tags, with reads public. No collection
carries a public write rule except the deliberate accountless field paths
(checks/check_items/punch_items create, test_equipment create, the
loto_events and service_visits shape-ruled creates, and the parent-scoped
attachments create).
Field-level shapes are defined by the migrations in pb_migrations/ and
explained in ARCHITECTURE.md. Load-bearing semantics
for API clients:
- Auth-refusal status codes are per-operation, not a uniform 401/403. This
is standard PocketBase behavior worth stating because it trips up auth
probes: a rule-denied list returns
200with an emptyitemsarray (never 401), a denied view returns404, a denied create returns400, and a denied update/delete returns403(or404). A client detecting "am I still authenticated?" must therefore inspect the data on a read, not the status — an expired token reads exactly like a guest. The core frontend detects expiry via theauth-refreshendpoint for this reason (ADR 0011 §6), andscripts/smoke_test.shasserts denials on data presence plus a paired positive, never on a bare status code. checks,check_items,attachments,calibrations,calibration_points, andservice_visitsare append-only (ADR 0001, hard constraint #2). No client — premium, third-party, or future core code — can ever update or delete one. Corrections are new records.- The check subject rule (enforced server-side; malformed records are
rejected no matter who sends them): a check is about exactly one of
tagXORsystemXORserviceXORsegment. Equipment checkout setstag; a system-level event setssystem; a service cutover phase setsservice; a pipeline acceptance test setssegment(migrations1789000013and1789000024). servicessemantics (ADR 0006):addressis the human-facing identifier (unique per project together withstation); the URL/QR key is the record id (/s/{id}), never the address.servicesis structurally PII-free.customer_nameandcustomer_phonelive only in the auth-gated, one-to-oneservice_contactscollection and must never be placed in URLs, QR payloads, or public exports (migration1789000020). A service's required phases = the phases that have asubject_kind='service'template; its standing = its checks folded per phase (any pass wins; else any fail; else started). The service phase values arenotice | locate_pothole | new_service | meter | tie_over | restoration; phase sets are per-subject-kind by convention — clients must not write equipment phases on service checks or vice versa.segmentssemantics (implementation delta, ADR 0014 still Proposed):segment_idis the project-scoped business label;segment_typeispressure_main | force_main | gravity_sewer. A segment uses its local PocketBase id as the planned route key, but no committed/seg/hook or segment page exists. Required phases derive fromsubject_kind='segment'templates whosetag_typeequals the segment type. Phase values arehydro | disinfection | bac_t | air_test | deflection | vacuum.- Open→resolve checks (ADR 0012):
resultalso acceptspending. A later pass/fail check setsresolvesto the pending check; neither record is edited. No committed UI yet executes this linked pair, so API clients must not infer a complete end-user workflow from the schema alone. - Frozen-copy pattern:
check_items.promptandchecks.witness_requiredare copied from the template at execution time, andchecks.test_equipment_notefreezes what instrument was used ("Fluke 754 s/n 8823, cal due 2026-09-01"). Read the frozen values when rendering history; templates and the equipment registry are living, checks are frozen. test_equipmentis a global registry (ADR 0004) — serial numbers are the identity; it is deliberately not per-project. Checks reference up to 3 records viatest_equipment_used. Calibration certificates attach viaattachments(parent_collection='test_equipment').- Instrument calibration is a separate append-only ledger (ADR 0016).
test_standardsis the editable global registry.calibrationsbelongs to one instrument tag and freezes the standard identity, certificate due date, technician/company, performed date, interval, result, notes, andstandard_expired_at_use; its orderedcalibration_pointsalways carry expected/as-found/as-left/unit/tolerance together.next_dueis derived by day-clamped calendar-month addition (performed_at + interval_months) and is never stored. Create the complete record throughPOST /api/calibrations/captureas multipart form data: header fields,points_json, and optionalreport.client_tokenis required and makes a retry idempotent. Direct records-API creates are denied so an immutable header cannot be stranded without its points. The endpoint computes the expired-at-use flag server-side and does not reject an expired standard. - Punch evidence links (ADR 0004):
punch_items.source_checkis the failed check that spawned the item;punch_items.closing_checkis the passing retest that justified closure. Both optional — clients doing retest analytics use the links where present and fall back to inference (failed check → later check, same tag + phase) where not. - Operational standing is derived, never stored on tag/system/service/
segment subjects. Lifecycle records such as punch items and closeout
requirements intentionally have stored workflow statuses; the following
readiness/acceptance states do not:
- A tag's required phases = the six checkout phases (installation,
point_to_point, energization, loop_check, functional, performance) that
have a
checklist_templatesrecord for itstag_type(filter=(tag_type='pump_centrifugal' && phase != '')), minus any phase key listed intags.phases_na(ADR 0009 — a forgiving space/comma list of phases that don't apply to that specific tag; normalize before comparing). Blankphases_na= all templated phases apply. - The
trainingphase is a milestone, not a checkout phase (ADR 0013): it is a validphasevalue (a witnessed training event) but is excluded from the six-phase checkout fold — derive and present it apart, the way the readiness matrix does. Other valid values include the six service phases, six segment phases, andleak_test; phase meaning remains scoped by subject kind/type. - A tag's standing = its checks evaluated against those six phases.
- A system's readiness = its tags' checks + its own checks + open
A-severity
punch_items.
- A tag's required phases = the six checkout phases (installation,
point_to_point, energization, loop_check, functional, performance) that
have a
punch_itemslifecycle:statusisopen | closed; closure is recorded (closed_by,closed_at,closure_note), records are never deleted.severitysemantics are fixed: A blocks startup, B blocks substantial completion, C cosmetic. A punch item hangs off atag, asystem, aservice, asegment, or just theproject(all relations optional). The current create rule makes a service or segment exclude tag/system, but it does not reject setting both service and segment. Clients must avoid that ambiguous shape, and the rule needs reconciliation before segment writes are production-supported.tags.tag_numberis unique per project, not globally. QR labels encode{baseUrl}/t/{tag_number}(hard constraint #6) — a printed-label contract; the/t/{tagNumber}route serves the tag page.tags.has_vfd(bool, landed additively with ADR 0008) marks VFD-driven equipment. Rotation checks are ordinary checks against the*.rotation/*.rotationVfdtemplates: the Hand and Auto direction values ("forward"/"reverse") and any VFD parameters live in the check'scheck_items.resultstores plainpass/fail/incomplete; the hardware-fault vs software-conflict (VFD reference parameter) distinction is derived by readers from the two direction values — a mismatch between modes means do not swap leads.checklist_templates.template_keyis a stable opaque identifier (unique when set; blank on user-created templates). Do not parse it.recurrenceblank = one-time checkout checklist;daily | per_shift | per_eventare compliance cadences.subject_kindistag | location | service | segment; location subjects have no execution surface until Phase 5, and segment subjects do not yet have a committed execution surface.template_items.response_typeis one ofpass_fail_na | value | text | photo_required(exactly four, by design).valueitems carry a displayunit;confirm_per_spec: truemeans the acceptance value is spec-governed — render with the value blank and a "per Section __" prompt (spec_referencemay carry the citation).attachmentsare polymorphic:(parent_collection, parent_id)points at the owning record — e.g. punch-item photos arefilter=(parent_collection='punch_items' && parent_id='<id>'). Warranty certificates and O&M manuals attach the same way (parent_collection='warranties' | 'closeout_requirements'). Vendor field reports useparent_collection='service_visits'; anonymous creation is allowed for that field-capture parent, as it is for check-item/punch evidence. Calibration reports useparent_collection='calibrations', and standard certificates useparent_collection='test_standards'.service_visitsare immutable vendor-visit evidence (ADR 0017): each record targets exactly onetagXORsystem, and its server create rule also requires that subject's project equal the record's denormalizedproject.purposeisinstallation_verification | startup_assistance | warranty_service | training | other. The vendor report is an appendedattachmentschild, not a mutable field. A missing report remains a valid but incomplete-evidence state that clients must flag. A tag's manufacturer certification is derived from visits withinstallation_certified=true(show the most recent certification date); it is never stored ontags. Creates include a client-generated uniqueclient_token; offline clients query it before retrying after a lost response so one queued intent produces one immutable visit.- Warranty end dates are never stored (ADR 0005). Clients derive them:
clock_start_date + duration_months(day-clamped calendar months). The stored facts are the provenance:basisis one ofsubstantial_completion | beneficial_use | startup | shipment_cap | other,basis_notecarries the exact spec language, and a blankclock_start_datemeans the trigger event has not occurred yet — a valid state, not missing data. warranty_claimsare close-once: the server accepts updates only whileclosed_atis empty (updateRule: closed_at = ''); the PATCH that setsclosed_atis the last write the record ever accepts. An open claim =filter=(closed_at=''). Corrections to a closed claim are new claims.opened_atis client-set (likechecks.performed_at);createdis the server log stamp.closeout_requirements.status(required | received | accepted) is editable workflow state, but any client that changes it must also append acloseout_logrecord (requirement,from_status,to_status,changed_by,note) — the log collection is append-only in the schema and is the trail that makes the editable status trustworthy.req_typeis one ofom_manual | spare_parts | special_tools | training | warranty_cert | test_report | other.storage_locationis free text by design (no integrations).loto_eventsis append-only LOTO visibility, never authority (ADR 0007). Server-enforced create shape: anapplymust carry itsphoto(the hung lock on the isolation point — one multipart POST, atomic) and noapplies_to; areleasemust reference anapplyon the sametagviaapplies_to, and must carryreleased_by_notewhenever itslock_holder_namediffers from the apply's (supervisor removal). A tag is locked out if any apply has no release referencing it — derived, no status field; multiple simultaneous locks (group LOTO) are normal.performed_atis client-set (events logged from a dead zone sync late);createdis the server stamp. Display obligations for any client rendering LOTO status: show the data-freshness timestamp and the line "Verify physically before any work"; degrade to an explicit stale state when data is older than ~10 minutes; and never render an affirmative "not locked out" — absence of records is "no locks recorded as of {time}", not a safety claim.turnover_packages+signaturesare append-only (ADR 0010, Phase 3). Aturnover_packagesrecord is a frozen snapshot: amanifest(the scope's checks + theircheck_items, open punch, active LOTO, closeout — a per-collection field allowlist), astanding_snapshot(the derived readiness verdict at freeze time), and acontent_hashunderhash_algosha256-canon-v1(pinned canonicalization: sorted ASCII keys, NFC, RFC 3339 UTC, decimals as strings — a verifier recomputes it). Freezing a derived verdict is not a stored-status violation: the live readiness view still derives from scratch; the package freezes a copy, likecheck_items.prompt. A re-issue supersedes the prior package for its scope (self-relation); the current package for a scope is the one no other supersedes.signaturesare recorded attestations (subject_collection/subject_id→ a package or a check;signer_name/signer_role/statement,signed_content_hashcopied from the package) — NOT qualified e-signatures; a signature requiresauth, but its identity is only as strong as the deployment. Signatures do not carry forward: a new package starts unsigned. Optional drawn marks attach viaattachments(parent_collection='signatures').- Dates:
systems.planned_startupis date-only at UTC midnight — format in UTC or western timezones display the previous day.
- Query features: PocketBase's
filter,sort,expand(including nested expands liketag.system),fields, and pagination (page,perPage, max 500 per page) on the collections above. - File URLs:
/api/files/{collection}/{recordId}/{filename}— e.g. a punch-item photo at/api/files/attachments/{id}/{file}. - Realtime: PocketBase's SSE subscriptions at
/api/realtimefor the contract collections. Prefer SSE over tight polling — the server may be a commissioning trailer's Raspberry Pi whose first job is field scans. - The clean tag route:
GET /t/{tagNumber}returns the tag page shell (HTML). It is contract because printed QR labels encode it. - The clean service route:
GET /s/{serviceId}returns the service page shell (HTML) — contract for the same reason: meter-box QR labels encode it. - Atomic calibration capture:
POST /api/calibrations/captureaccepts the purpose-built calibration form/outbox payload and commits header, point rows, and optional report together (ADR 0016). - Turnover build:
POST /api/turnover/build(auth) with{project, system?, title?, generated_by?}assembles aturnover_packagesrecord server-side (ADR 0010): it queries the ledger, freezes a canonical manifest (per-collection field allowlist), computes asha256-canon-v1content_hash, and creates the append-only package. Clients cannot POST a package directly (createRulenull) — a forged manifest could omit the failing check. The response returns{id, content_hash, hash_algo, standing, records}.
Nothing here has shipped. It is recorded so an integrator planning against this
contract knows what is coming and can avoid depending on a shape that is
already scheduled to change. Source:
ADR 0018 (proposed), the
M4–M6 track in ROADMAP.md. None of it lands before the v1
reconciliation and version bump (open-questions.md #7,
DECISIONS D12).
Breaking — plan for it:
punch_items.assigned_partyis revocabularized togc·subcontractor·vendor·controls_integrator·electrical·owner·engineer·unassigned.contractoris retired (migrated togc). Any client filtering or displayingassigned_party = "contractor"breaks.
Additive but contract-visible:
- Enum growth:
checks.result+=deferred(andpending, ADR 0012);check_items.result+=not_performed,deferred; the shared phase enum +=wet_commissioning,process_seeding;signatures.signer_role+=vendor_tech,subcontractor,gc_witness,controls_integrator. A client that switches exhaustively on any of these enums must gain a default branch. - New fields on existing collections:
checks.deferred_to_phase,checks.as_left_condition,checks.continues,checks.required_signature_roles;check_items.na_reason,check_items.value_numeric+ frozen limit fields;template_itemsexpected-range fields;checklist_templates.required_signature_rolesandgate_for_phase;punch_items.blocks_phaseandscope_gap;tagsnameplate fields;service_visitsvisit-detail fields. - One new collection: append-only
instrument_settings.
New validation that can reject a call that succeeds today:
check_itemswithresult = "na"will requirena_reason.POST /api/turnover/buildwill fail on a scope containing any check item with a blank result, naming the offending check and line.
Hash semantics: the turnover manifest gains a vendor report index, a
per-tag instrument settings table, deferred standing cells, a
countersignature QC block, and a completeness assertion. These change the
canonical bytes, so they land inside sha256-canon-v1 before the first real
package is signed, or behind a sha256-canon-v2 — undecided
(open-questions #13).
The internal SQLite layout and pb_data/ contents, the PocketBase admin
UI and admin-only endpoints, PocketBase system collections, the seed
loader's mechanics, and any endpoint or behavior not listed here. These
can change without notice.