◆ Canonical source
The authoritative, machine-readable specification is the source file. This page is a faithful human-readable rendering of it; the test vectors and code below are reproduced byte-for-byte. If anything in this rendering ever differs from the source file, the source file governs.
⬇ Download the canonical spec (HASH_ALGORITHM.md)Verify a record yourself
Paste the two values from a Verified export's verification appendix — the 64-character SHA-256 hash and its Verification text. Your browser checks them locally; nothing leaves this page (no upload, no lookup, no tracking).
Phase 1: pre-image check. The SHA-256 is computed in your browser with the Web Crypto API — your hash and text are never sent anywhere.
Verification status: the WageCop Verified Wage Records export (PDF / XLSX) includes a per-record verification appendix carrying, for every sealed punch, the full 64-character SHA-256 digest, its hash_version, and each hashed field at byte-exact fidelity — and, where shown, the ready-to-hash canonical payload (§3d) as a convenience. Independent third-party verification is therefore end-to-end exercisable from the Verified export alone — a verifier reproduces each hash from the appendix's fields using this specification (or, where the canonical payload is shown, by hashing it directly) and confirms byte-for-byte equality. The free Raw export and the main punch table continue to show the first 16 hex characters of the digest as a human-readable fingerprint; the full digest lives in the Verified export's appendix and server-side in punches.sha256_hash. Audience: third-party verifiers reproducing the SHA-256 integrity hash stored with every WageCop punch record. Examples: an attorney's expert witness, opposing counsel's auditor, a worker independently confirming their own data.
Purpose: ties the integrity claim made on every WageCop export ("Modifying any covered field breaks the hash") to a reproducible procedure any technically-literate party can follow without reading the application source.
Source of truth: this document is canonical. The Postgres function record_punch (defined in supabase_migrations/20260440_punches_evidentiary_hardening.sql) is the implementation; this document is its specification. If the two ever diverge, the test vector at the end of this document is the tiebreaker — the function and the spec must both reproduce it.
1. Verifier dispatch rule
Every WageCop punch row in the punches table has a hash_version smallint column. Read it first.
hash_version | Algorithm | Coverage | Source code reference |
|---|---|---|---|
1 | v1 (client-side, JSON.stringify) | 6 fields | lib/crypto.js (preserved as deprecated reference) |
2 | v2 (server-side, jsonb canonical text) | 8 fields | record_punch RPC body in migration 20260440 |
Legacy punches are hashed under v1; current punches are hashed under v2. The two algorithms coexist; a verifier dispatches on the hash_version column.
2. Algorithm v1 (legacy — pre-v1.15.3 rows)
2a. Fields, in insertion order
userId— UUID of the WageCop user who recorded the punch.punchType— string. One of:work_in,work_out,meal_out,meal_in,meal_interrupted,meal_denied,rest_start,rest_end,rest_interrupted,rest_denied.punchedAt— ISO-8601 timestamp with milliseconds in UTC. Format produced by JavaScriptDate.prototype.toISOString()(e.g.,2026-04-24T12:00:00.000Z).latitude— IEEE-754 double precision. May benullif location capture failed at punch time.longitude— IEEE-754 double precision. May benull.installId— UUID. Per-install identifier sourced from device secure storage (iOS Keychain / Android Keystore via expo-secure-store).
2b. Encoding
Apply JavaScript JSON.stringify({ userId, punchType, punchedAt, latitude, longitude, installId }):
- Keys serialized in insertion order (ECMAScript 2015+ guarantee for string keys).
- No whitespace.
- Numbers via
Number.prototype.toString()shortest-round-trip representation. - Strings JSON-escaped per RFC 8259.
nullvalues serialize as the literalnull.
2c. Digest
SHA-256 of the UTF-8 encoded payload string. Hex-encode lowercase. The hex string is stored in punches.sha256_hash.
2d. Sample v1 payload
For inputs userId="abc-...", punchType="work_in", punchedAt="2026-04-24T12:00:00.000Z", latitude=34.05, longitude=-118.24, installId="def-...":
{"userId":"abc-...","punchType":"work_in","punchedAt":"2026-04-24T12:00:00.000Z","latitude":34.05,"longitude":-118.24,"installId":"def-..."}
3. Algorithm v2 (current — post-v1.15.3 rows)
3a. Fields
The field list below preserves v1's order (per §2a) for diff-readability between v1 and v2. Serialization order is governed entirely by §3b's key sort rule and is independent of this list. A verifier reading §3a alone will get the field set right but will not produce the correct hash; §3b is the canonical encoding spec.
Same six fields as v1, plus two added in v2:
userId— UUIDpunchType— string (same enum as v1)punchedAt— ISO-8601 with milliseconds in UTC, matching JavaScriptDate.prototype.toISOString()(e.g.,2026-04-24T12:00:00.000Z). Server-formatted; the Postgres-specific format string is in §3b's "Timestamp formatting" subsection.latitude— double precision (may benull)longitude— double precision (may benull)installId— UUIDlocationAccuracy— text representation of GPS accuracy in meters (e.g.,"5m"). May benullif accuracy capture failed.notes— text. User-provided free-form note attached at punch time. May benullif the user did not attach a note.
3b. Encoding (Postgres jsonb canonical text)
Build a JSON object via Postgres jsonb_build_object(...)::text with the field-keys in the listed order. The cast to text produces Postgres' canonical jsonb text representation, which has the following deterministic properties a verifier in any language must reproduce:
Key sort rule
Keys are NOT serialized in insertion order. Postgres jsonb sorts keys by (length ASC, then byte-order ASC over the UTF-8 representation of the key). The byte-order tiebreaker is locale-independent by design — Postgres compares the raw UTF-8 bytes of each key, not their locale-aware collation order. A verifier in any language must reproduce this byte-wise ordering, not a locale-aware sort. For the v2 keys (all ASCII identifiers), byte-wise UTF-8 ordering is identical to ASCII alphabetical ordering. The resulting v2 key order is fixed:
| Order | Key | Length |
|---|---|---|
| 1 | notes | 5 |
| 2 | userId | 6 |
| 3 | latitude | 8 |
| 4 | installId | 9 |
| 5 | longitude | 9 |
| 6 | punchType | 9 |
| 7 | punchedAt | 9 |
| 8 | locationAccuracy | 16 |
Within length=9 (four keys), byte-order resolves: installId < longitude < punchType < punchedAt.
A verifier emitting JSON in any other language must apply the same (length ASC, byte-order ASC) sort to reproduce Postgres' output. JavaScript example:
// Locale-neutral comparator. JS string < / > compares UTF-16 code units;
// for any key composed of BMP characters outside the surrogate range
// (U+0000..U+D7FF, U+E000..U+FFFF) this matches UTF-8 byte order. All
// current v2 keys are ASCII so the match is exact.
const sortedKeys = Object.keys(obj).sort(
(a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0)
)
Whitespace
A single space follows each colon (": "). Pairs are separated by a comma followed by a single space (", "). No other whitespace.
Number formatting
latitude and longitude are passed into jsonb_build_object(...) as double precision. record_punch executes with SET extra_float_digits = 1 at function entry — this setting is load-bearing for the hash: it selects Postgres' shortest-round-trip serialization of the double, which is the text the ::text cast emits and the exact bytes every stored hash was computed over. The result is the canonical shortest decimal that round-trips to the double, with no trailing zeros and no scientific notation. For the canonical inputs 34.05 and -118.24, the serialized payload reads 34.05 and -118.24 literally.
This shortest-round-trip text is byte-identical to JavaScript Number.prototype.toString() (equivalently JSON.stringify(n)) for every value of magnitude 1e-6 or greater — verified across a 2,300-value sweep against the production database over the full coordinate range. The emitters diverge only for magnitudes below 1e-6: Postgres emits plain decimal (0.0000001) where JavaScript switches to scientific notation (1e-7). A verifier in any language must emit plain decimal with no scientific notation. A coordinate component below 1e-6 cannot arise outside WageCop's expected California workplace range (California is far from the equator and the prime meridian), but a conformant verifier expands such values to plain decimal regardless. GUC-sensitive check: 0.30000000000000004 must serialize as 0.30000000000000004 (its value at extra_float_digits = 1), not 0.3.
A verifier reproducing the payload must emit, for the same double, the decimal Postgres produces under extra_float_digits = 1 — e.g., 34.05, not 3.405e1 or 34.050000000000001. Compare candidate output byte-for-byte against the canonical payload in §3d (and the §5 vector) before trusting the implementation on real punch data.
Null handling
JSON null (the literal four characters n, u, l, l, no quotes). Applies to all four nullable fields (latitude, longitude, locationAccuracy, notes) when they were not captured at punch time. The Postgres jsonb_build_object(...) call inside record_punch passes these through to the canonical text unchanged, so a verifier emitting null for any of the four reproduces the input byte-for-byte.
Timestamp formatting
punchedAt is formatted as ISO-8601 with milliseconds in UTC: YYYY-MM-DDTHH:MM:SS.MMMZ (e.g., 2026-04-24T12:00:00.000Z). The Postgres format string is 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'. Matches Date.prototype.toISOString().
UUID handling
UUIDs (userId, installId) emit as standard 8-4-4-4-12 hex format strings (e.g., 00000000-0000-0000-0000-000000000001).
3c. Digest
SHA-256 of the UTF-8 encoded payload string. Hex-encode lowercase via Postgres encode(digest(payload, 'sha256'), 'hex'). The hex string is stored in punches.sha256_hash.
3d. Sample v2 payload
For the canonical test vector (full inputs + expected hash in §5):
{"notes": null, "userId": "00000000-0000-0000-0000-000000000001", "latitude": 34.05, "installId": "00000000-0000-0000-0000-000000000002", "longitude": -118.24, "punchType": "work_in", "punchedAt": "2026-04-24T12:00:00.000Z", "locationAccuracy": "5m"}
Note the key ordering: notes, userId, latitude, installId, longitude, punchType, punchedAt, locationAccuracy. Single space after each colon; comma+space between pairs.
4. What the integrity claim covers
The punches table has 16 columns. The column-level seal (table-level REVOKE UPDATE from anon + authenticated, see migration 20260440) prevents direct client updates to all of them — but the integrity claim itself only covers the 8 fields hashed in v2 (or 6 in v1). The remaining sealed columns are protected by the seal but not bound to the hash; their integrity rests on the seal alone.
| Column | v1 hashed | v2 hashed | Sealed (REVOKE) | Source |
|---|---|---|---|---|
notes | — | ✓ | ✓ | user input at punch time |
user_id | ✓ (userId) | ✓ (userId) | ✓ | server (auth.uid()) |
latitude | ✓ | ✓ | ✓ | device GPS |
install_id | ✓ (installId) | ✓ (installId) | ✓ | device secure storage |
longitude | ✓ | ✓ | ✓ | device GPS |
punch_type | ✓ (punchType) | ✓ (punchType) | ✓ | user action |
punched_at | ✓ (punchedAt) | ✓ (punchedAt) | ✓ | client clock at punch time |
location_accuracy | — | ✓ (locationAccuracy) | ✓ | device GPS |
voided_at | — | — | ✓ | server (void_punch RPC) |
void_reason | — | — | ✓ | user input via void_punch |
hash_version | — | — | ✓ | server-set |
recorded_at | — | — | ✓ | server (record_punch RPC, set at INSERT) |
sha256_hash | (the hash itself) | (the hash itself) | ✓ | server-computed in record_punch |
id | — | — | ✓ | server (uuid_generate_v4) |
created_at | — | — | ✓ | server (now()) |
shift_id | — | — | ✓ | server (record_punch shift bookkeeping) |
uploaded | — | — | ✓ | server (record_punch) |
device_id | — | — | ✓ | legacy column, no client write path |
Four sealed-but-not-hashed columns deserve specific note:
voided_atandvoid_reason— set via thevoid_punchSECURITY DEFINER RPC (with banned/length/blocklist gates and an audit-log entry topunches_void_log). Both columns are sealed against direct client UPDATE, so a void cannot be silently rolled back. The original hash on the row is unaffected by voiding — the row'ssha256_hashcontinues to verify against the original (un-voided) field set. Voids are forensically observable through the audit log, not through the hash.hash_version— the column the verifier dispatches on. Sealed so a client can't relabel a v1 row as v2 (which would make the hash fail to verify and look like tampering, and force a verifier into the wrong reproduction algorithm).recorded_at— server-attested commit timestamp set at INSERT time insiderecord_punch(migration20260448). Functionally equal tocreated_atfor any row inserted via the RPC; surfaced as a separately-named column for legal-audience discoverability and rendered under the "Officially Recorded" label in PDF / RTF / XLSX exports. Sealed-not-hashed because the hash binds what was recorded (the punch payload) —recorded_atis when WageCop received it, evidentiary metadata about the row rather than content of the row. The hash does not change whetherrecorded_atis included or not; voiding behavior is identical.
Four sealed-but-deliberately-uncovered columns (id, created_at, shift_id, uploaded, device_id) are server-managed or legacy. The hash claim makes no assertion about them.
(Note: created_at and recorded_at carry the same value for any row inserted via record_punch. The difference is naming — created_at is a Postgres-convention column on most tables in this database; recorded_at is a legal-audience-friendly alias surfaced in exports. Both are sealed.)
5. Canonical test vector
Any third-party verifier implementation should reproduce this hash from the inputs below, or know its implementation is broken.
Inputs (v2 algorithm)
userId = 00000000-0000-0000-0000-000000000001
punchType = work_in
punchedAt = 2026-04-24T12:00:00.000Z
latitude = 34.05
longitude = -118.24
installId = 00000000-0000-0000-0000-000000000002
locationAccuracy = 5m
notes = null
Expected serialized payload (jsonb canonical text)
{"notes": null, "userId": "00000000-0000-0000-0000-000000000001", "latitude": 34.05, "installId": "00000000-0000-0000-0000-000000000002", "longitude": -118.24, "punchType": "work_in", "punchedAt": "2026-04-24T12:00:00.000Z", "locationAccuracy": "5m"}
Expected SHA-256 (hex, lowercase)
2ccac848b8f9f7c217fab8440a58c74c76333736c0f9fcab6feb863ccabf0e7b
If your verifier produces this hash, your implementation is correct. If not, walk back through §3b checking key sort order, whitespace, number formatting, and UUID/null encoding.
Full-precision reference vector (the extra_float_digits = 1 pin)
The canonical vector above uses short coordinates (34.05, -118.24) whose text is identical at extra_float_digits = 0 and = 1, so it does not exercise the float-precision pin (§3b Number formatting). The vector below uses a full-precision coordinate that does — it is the worked proof that the pin is load-bearing.
Inputs: as §5 above, but latitude = 34.05223421234568, longitude = -118.49275398765432.
Expected serialized payload (extra_float_digits = 1):
{"notes": null, "userId": "00000000-0000-0000-0000-000000000001", "latitude": 34.05223421234568, "installId": "00000000-0000-0000-0000-000000000002", "longitude": -118.49275398765432, "punchType": "work_in", "punchedAt": "2026-04-24T12:00:00.000Z", "locationAccuracy": "5m"}
Expected SHA-256 (hex, lowercase):
f3d7fa8050c9b5c0fcb8467500a9e623f74c961a4d1ed516e3c50f52a3a60c78
At extra_float_digits = 0 the same inputs serialize as 34.0522342123457 / -118.492753987654, yielding a different payload and a different hash — exactly the regression the pin prevents. (Captured from prod bymuhltjmgjbcupkdnsf, PG 17.6, 2026-06-21.)
Forward-looking secondary vector (locale-divergence reference)
The canonical vector above uses 8 ASCII-only keys for which byte-order and locale collation produce identical results. The second vector below is hypothetical — it does not correspond to any real punch row today and is not exercised by test_punches_evidentiary_hardening.js. Its purpose is to lock the spec's "byte-order over UTF-8" rule (§3b) against a future schema evolution that could add a key whose locale collation diverges from byte order, by giving a concrete pair where the divergence would manifest.
Hypothetical key pair where divergence would surface:
| Key | UTF-8 bytes (hex) | First-byte | Locale collation (en-US, ICU) | Byte-order |
|---|---|---|---|---|
f | 66 | 0x66 | between e and g | between e and g |
é (U+00E9) | c3 a9 | 0xC3 | typically near e (treated as e-with-accent) | after every ASCII letter (0xC3 > 0x7A) |
A verifier using localeCompare (en-US) would order é < f. Postgres jsonb's byte-order rule places é > f (0xC3 > 0x66 at first byte). Stored hashes are produced by Postgres, so the byte-order placement is canonical; any verifier that reproduces locale collation would compute the wrong serialized payload and the wrong hash.
6. What this hash does NOT prove
- Hardware-level attestation. A cloned WageCop install could replay the same
installId(it is generated client-side and stored in device secure storage; secure storage is reachable to a sufficiently-determined adversary with device access). For hardware attestation, Apple App Attest / Google Play Integrity would be needed — out of scope today. - Real-time recording. The offline queue can hold a punch for hours or days before sync. The
punched_atfield is the user-asserted moment of the event; the server'srecorded_at(and identically-valuedcreated_at) is the moment WageCop received it (set at INSERT time insiderecord_punch, sealed against post-creation modification, surfaced in exports under the "Officially Recorded" label). Comparingpunched_atandrecorded_atreveals any sync gap. - Server-side row tampering by
service_role. Theservice_rolePostgres role retains full UPDATE access topunches. The trust assumption is thatservice_roleis held only by WageCop's admin / cron / migration paths, not by any client. - Physical accuracy of the GPS coordinates. The hash binds the coordinates to the rest of the punch payload but does not verify the device's GPS chip wasn't spoofed at capture time.
These limitations apply equally to v1 and v2.
7. Change log
- v1 — shipped pre-v1.15.0. Six fields, client-computed via
lib/crypto.js(Expo Crypto SHA-256). - v2 — shipped in v1.15.3 Bundle 1 (migration
20260440_punches_evidentiary_hardening.sql, 2026-04-24). Eight fields (v1 +locationAccuracy+notes), server-computed in therecord_punchRPC body. Closed the v1 hash-payload-scope-mismatch defect surfaced by Engineer 2's v1.15.2 post-ship review. - Snapshot seal v1 (§8) — the client-attested collection seal (
week_snapshots.seal_hash), documented in §8 below. Ground truth =lib/snapshotSeal.js(canonicalizeSnapshot); pinned by the pure-node golden vector intest_snapshot_seal.js(d168b397…). Ported into this tracked spec 2026-06-21.
8. Snapshot seal (week_snapshots.seal_hash) — v1
8.1 What it is
The snapshot seal is a single SHA-256 over the whole frozen workweek record stored in week_snapshots. It is computed on the worker's device at FREEZE and stored in week_snapshots.seal_hash. There is no server-side computation or server-side verification of the seal (by design): the server stores the value the client supplies and rejects any later mutation of the row's sealed content. The authority a verifier reproduces against is this specification plus the canonical test vector in §8.8 — reproducible by the client and by any third-party verifier in any language. Postgres is not a party to this seal (unlike the per-punch v2 hash in §3, which Postgres computes).
8.2 The three-layer integrity model (read before relying on the seal)
A sealed snapshot makes a layered integrity claim. State it precisely; do not over-claim:
- Server-attested per-punch content — each punch record inside the snapshot carries the
sha256_hashPostgres computed at record time (§3). A client cannot forge these. This is the evidentiary anchor. - Client-attested collection seal —
seal_hash(this section) proves the set of records/alerts/pay/provenance has not been altered since FREEZE. It is computed on-device, so its trust profile is closer to the v1 client hash than to the server-attested v2 hash. It is tamper-evident, not server-attested. - Server-enforced immutability — once sealed, the row's sealed columns cannot be updated (table-level REVOKE + the snapshot RPC's immutable check). Once records are archived on the server, WageCop provides no mechanism to correct or suppress archived records to anyone including the user (corrections/amendments out of scope — §8.4).
The honest one-line claim is: "server-attested raw punches + a tamper-evident collection seal + server-enforced immutability." Never describe the seal itself as "server-attested" or the computed pay/alerts as "server-verified."
8.3 Layer classification of the sealed fields
- Layer-1 (server-attested): the raw punch fields (the eight v2-hash inputs) and each
sha256_hash— independently verifiable by reproducing §3. - Layer-2 (client-derived, sealed):
resolved_alerts,pay_block,provenance. These are computed on-device. The seal proves they were not altered since FREEZE, but they are not independently server-attested. Their correctness is checkable a different way: re-derive them from the layer-1 sealed inputs (the punches + the worker settings sealed as values inprovenance.settingsand thepay_block.inputsrate + the wage-rule set whose content-fingerprint matchesprovenance.ruleSetDigest), following the re-derivation procedure in §8.11, and compare to the sealed layer-2 output. The set of wage rules applied to these records is identified byprovenance.ruleSetDigest, a SHA-256 content-fingerprint of the rule set, carried on every sealed record; the rule set is published in §8.12, where re-fingerprinting it (per §8.5) reproduces the digest, proving the match. A client that sealed wrong numbers — or applied rules that don't match the fingerprint it claims — is caught by re-derivation, not by the seal.
8.4 Sealed payload
The seal covers exactly these top-level fields (the evidentiary content available on-device at FREEZE): seal_version, workweek_start, workweek_end, depth_at_capture, captured_at, punch_records[], resolved_alerts[], pay_block, provenance.
Excluded (and a verifier must reconstruct the payload without them): seal_hash (it is the output) and the server-assigned confirmed_at / created_at / id (they do not exist when the client computes the seal). (There is no status/supersedes exclusion: a frozen snapshot is immutable and is never corrected in place — corrections/amendments are out of WageCop scope.)
8.5 Canonicalization (a verifier in any language must reproduce this exactly)
Serialize the §8.4 payload to a single canonical string, then SHA-256 it. The serialization is a hybrid — do not use an off-the-shelf JCS / RFC-8785 canonicalizer wholesale (its key-sort is wrong here):
- Key sort = §3b, applied RECURSIVELY: keys at every object level are ordered by (length ASC, then byte-order ASC over the UTF-8 bytes of the key) — the same rule as the per-punch v2 hash (§3b). ★ This is NOT JCS. A JCS/RFC-8785 implementation sorts keys by UTF-16 lexicographic order and will produce a different order (e.g.
idbeforehash_versionhere — length first — whereas JCS yieldshash_versionbeforeid), hence a different string and a wrong seal. All snapshot keys are ASCII identifiers, so byte-order equals JS string<. - Numbers = ECMAScript
Number→String(exactly whatJSON.stringify(n)emits; RFC-8785 §3.2.2.3 is the reference for the number rule only). RejectNaN,+Infinity,-Infinity; noBigInt. The snapshot seal is client-computed and uses ECMAScriptNumber→String.pay_blockfigures are JSON numbers (e.g.160, not the display string"160.00"). - Whitespace = compact: no space after
:or,. Strings RFC-8259-escaped;null→null; booleans →true/false. - Array order is significant and fixed at construction (the canonicalizer does NOT reorder):
punch_recordsordered by(punched_at, id);resolved_alertsordered by the full canonical alert identityviolationIdKey = user_id|violation_type|occurred_at(full-ms ISO)|punch_id_start(lib/violationIdentity.js) — the full key, never a partial subset, because a partial can tie and make the order non-deterministic.
8.6 seal_version
seal_version is inside the sealed payload (so the version declaration is itself tamper-evident — a downgrade relabel breaks the seal) and stored as a column. A verifier dispatches on the stored column to pick the algorithm version, then confirms the sealed payload carries the same value.
8.7 Digest
SHA-256 of the UTF-8 canonical string; hex-encode lowercase. (expo-crypto on-device; any SHA-256 for a verifier — the digest is identical across implementations.)
8.8 Canonical test vector
The representative snapshot is the VECTOR in test_snapshot_seal.js (a one-punch, one-alert frozen week); that pure-node test locks both the exact canonical string and the seal hash below.
Inputs (the §8.4 sealed payload, natural order):
{
"seal_version": 1,
"workweek_start": "2026-04-20",
"workweek_end": "2026-04-26",
"depth_at_capture": 5,
"captured_at": "2026-04-24T12:00:00.000Z",
"punch_records": [
{ "id": "00000000-0000-0000-0000-000000000001", "punch_type": "work_in",
"punched_at": "2026-04-24T12:00:00.000Z", "latitude": 34.05, "longitude": -118.24,
"location_accuracy": "5m", "sha256_hash": "2ccac848b8f9f7c217fab8440a58c74c76333736c0f9fcab6feb863ccabf0e7b",
"hash_version": 2, "notes": null, "voided_at": null, "void_reason": null, "hourly_rate": 20,
"user_id": "00000000-0000-0000-0000-0000000000aa",
"install_id": "00000000-0000-0000-0000-000000000002", "recorded_at": "2026-04-24T12:00:05.000Z" }
],
"resolved_alerts": [
{ "violation_type": "MEAL_MISSING", "occurred_at": "2026-04-24T17:30:00.000Z", "punch_id_start": "00000000-0000-0000-0000-000000000001", "dismissed_at": null, "dismiss_reason": null }
],
"pay_block": {
"inputs": { "default_hourly_rate": 20, "daily_cutoff_hour": 0, "workweek_start": "Monday", "on_duty_meal_consent": false },
"output": { "regularHours": 8, "otHours": 0, "doubleOtHours": 0, "totalHours": 8, "weightedRate": 20,
"baseEarnings": 160, "otPay": 0, "doubleOtPay": 0, "totalPay": 160,
"perRateBreakdown": [{ "rate": 20, "hours": 8, "pay": 160 }] }
},
"provenance": { "ruleSetDigest": "3bc23d0a5c0390975a38f49a2b78e80b4c963cb2d4726df841f7bb1a9aa4cf12", "settings": { "workweek_start": "Monday", "daily_cutoff_hour": 0, "first_meal_waiver": false, "second_meal_waiver": false, "on_duty_meal_consent": false }, "computedAt": "2026-04-24T12:00:00.000Z" }
}
Canonical string (§8.5 applied — 1438 bytes):
{"pay_block":{"inputs":{"workweek_start":"Monday","daily_cutoff_hour":0,"default_hourly_rate":20,"on_duty_meal_consent":false},"output":{"otPay":0,"otHours":0,"totalPay":160,"totalHours":8,"doubleOtPay":0,"baseEarnings":160,"regularHours":8,"weightedRate":20,"doubleOtHours":0,"perRateBreakdown":[{"pay":160,"rate":20,"hours":8}]}},"provenance":{"settings":{"workweek_start":"Monday","daily_cutoff_hour":0,"first_meal_waiver":false,"second_meal_waiver":false,"on_duty_meal_consent":false},"computedAt":"2026-04-24T12:00:00.000Z","ruleSetDigest":"3bc23d0a5c0390975a38f49a2b78e80b4c963cb2d4726df841f7bb1a9aa4cf12"},"captured_at":"2026-04-24T12:00:00.000Z","seal_version":1,"workweek_end":"2026-04-26","punch_records":[{"id":"00000000-0000-0000-0000-000000000001","notes":null,"user_id":"00000000-0000-0000-0000-0000000000aa","latitude":34.05,"longitude":-118.24,"voided_at":null,"install_id":"00000000-0000-0000-0000-000000000002","punch_type":"work_in","punched_at":"2026-04-24T12:00:00.000Z","hourly_rate":20,"recorded_at":"2026-04-24T12:00:05.000Z","sha256_hash":"2ccac848b8f9f7c217fab8440a58c74c76333736c0f9fcab6feb863ccabf0e7b","void_reason":null,"hash_version":2,"location_accuracy":"5m"}],"workweek_start":"2026-04-20","resolved_alerts":[{"occurred_at":"2026-04-24T17:30:00.000Z","dismissed_at":null,"dismiss_reason":null,"punch_id_start":"00000000-0000-0000-0000-000000000001","violation_type":"MEAL_MISSING"}],"depth_at_capture":5}
Seal hash (SHA-256 hex, lowercase):
d168b397fb00de8c9557f8f47cc6c093748486342ca78a2375ac98dde2d3c076
A verifier that serializes the inputs per §8.5 reproduces the canonical string, and SHA-256 of that string is the seal hash. test_snapshot_seal.js locks both byte-for-byte.
8.9 Cross-engine determinism
The number rule (§8.5) relies on ECMAScript Number→String producing byte-identical output across engines. The ECMAScript specification mandates the shortest-round-trip algorithm, so all conformant engines produce identical output; WageCop validates this on its production runtime before relying on any seal.
8.10 What the seal does NOT prove
Everything in §6 applies (no hardware attestation; service_role can write; etc.). Additionally: the seal is client-computed, so it does not by itself prove the correctness of the layer-2 computed values — that rests on re-derivation from the layer-1 inputs (§8.3).
8.11 Re-derivation procedure (punch_records is a superset)
punch_records is the session-overlap superset for the frozen week: it contains every punch of every shift-session whose worked interval overlaps the week's boundaries — including the post-boundary tails of a session that straddles into an adjacent week — plus any voided punches whose instant falls in the week (sealed as evidence). It is therefore a superset of the punches the week's pay_block.output and resolved_alerts were derived over. A verifier re-deriving the layer-2 values (§8.3) must reproduce the same windowing the device applied:
- Reconstruct the week as the half-open interval
[W_start, W_end), cutoff-anchored —W_startis the workweek-start day at the hourprovenance.settings.daily_cutoff_hour(NOT midnight);W_endis that same cutoff hour seven days later. (A punch whose local PT hour is below the cutoff belongs to the prior work-date, and may belong to the prior week.) - Drop voided punches (any carrying
voided_at) before deriving — they are sealed as evidence, never counted. - Clamp each shift-session's worked interval to
[W_start, W_end): a session straddling a boundary contributes only its in-week portion; an open / forgot-clock-out session is bounded byW_end(the week's own cutoff-anchored end) — the same clamp every session gets. - Re-derive
pay_block.outputandresolved_alertsover the clamped, voided-filtered set, supplyingW_endas the derivation clock (nowMs) — never a live clock, and nevercaptured_at— so the result is a pure function of(punches, W_start, W_end), deterministic and reproducible at any later date. (captured_atremains a sealed provenance field; it is not the pay/alert bound.)
A shift that spans multiple weeks is sealed in full in each week it overlaps; each week's seal is clamped to that week's portion, and the worked minutes are partitioned across the weeks — never summed into a single week. A verifier that follows this procedure reproduces the sealed pay_block.output and resolved_alerts exactly. A verifier that derives straight from the raw superset — skipping the clamp (step 3) or using a live clock (step 4) — will NOT match, and that is expected, not a seal failure.
8.12 Rule-set digest (provenance.ruleSetDigest)
provenance.ruleSetDigest identifies the exact wage-rule thresholds used to derive a sealed week's pay_block and resolved_alerts. It is the SHA-256 (hex, lowercase) of the §8.5 canonical string of the durable rule bundle { schemaVersion, stateCode, thresholds } — thresholds holding the jurisdiction's wage-rule values in minutes (daily/weekly overtime, the two meal positions and their waiver ceilings, the meal interval and minimum, the rest-break ladder and step, the rest minimum, the split-shift gap) and the seventh-consecutive-day count. A verifier reproduces the digest from the bundle below, confirms it matches the record's ruleSetDigest (proving which rules were applied), then re-derives the layer-2 values (§8.11) from those thresholds.
The California default bundle (canonical string, keys in §8.5 order) and its digest:
{"stateCode":"CA","thresholds":{"dailyOtMin":480,"dailyDotMin":720,"mealPos1Min":300,"mealPos2Min":600,"restStepMin":240,"splitGapMin":60,"weeklyOtMin":2400,"mealShortMin":30,"restShortMin":10,"restLadderMin":[210,360,600,840,1080,1320],"mealIntervalMin":300,"mealPos1WaivedMin":360,"mealPos2WaivedMin":720,"seventhDayConsecutive":7},"schemaVersion":1}
SHA-256 = 3bc23d0a5c0390975a38f49a2b78e80b4c963cb2d4726df841f7bb1a9aa4cf12