# WageCop Punch Hash Algorithm — Verifier Reference

**Verification status (v1.15.3):** the WageCop application currently surfaces only the **first 16 hex characters** of the 64-character SHA-256 digest in PDF / RTF / XLSX exports (see `lib/exportPdf.js` `formatPunchHashForExport`). A 16-char prefix is not enough information for a third-party verifier to reproduce the full hash and assert byte-for-byte equality against the canonical specification in this document. **Independent third-party verification is therefore not yet end-to-end exercisable from a published export alone.** The full 64-character hex digest is stored server-side in `punches.sha256_hash` and is exposable via the Supabase API to any party with appropriate read access; the gap is purely in the export presentation layer. A reference build that surfaces the full hash in exports — and a paired JS reference implementation reproducing the canonical test vector in §5 — is filed as `FINAL_LIST GG` (v1.16+). Until that ships, treat this specification as the authoritative algorithmic reference and the server-side `sha256_hash` column as the operational source for verifier round-trips.

**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/00000000000000_baseline.sql`; originally `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` |

Pre-v1.15.3 punches were hashed under v1. Punches recorded after the v1.15.3 release (2026-04-2X) are hashed under v2. The two algorithms coexist; verifier dispatches on `hash_version`.

---

## 2. Algorithm v1 (legacy — pre-v1.15.3 rows)

### 2a. Fields, in insertion order

1. `userId` — UUID of the WageCop user who recorded the punch.
2. `punchType` — string. One of: `work_in`, `work_out`, `meal_out`, `meal_in`, `meal_interrupted`, `meal_denied`, `rest_start`, `rest_end`, `rest_interrupted`, `rest_denied`.
3. `punchedAt` — ISO-8601 timestamp with milliseconds in UTC. Format produced by JavaScript `Date.prototype.toISOString()` (e.g., `2026-04-24T12:00:00.000Z`).
4. `latitude` — IEEE-754 double precision. May be `null` if location capture failed at punch time.
5. `longitude` — IEEE-754 double precision. May be `null`.
6. `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.
- `null` values serialize as the literal `null`.

### 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 v1.15.3 Bundle 1:

1. `userId` — UUID
2. `punchType` — string (same enum as v1)
3. `punchedAt` — ISO-8601 with milliseconds in UTC, matching JavaScript `Date.prototype.toISOString()` (e.g., `2026-04-24T12:00:00.000Z`). Server-formatted; the Postgres-specific format string is in §3b's "Timestamp formatting" subsection.
4. `latitude` — double precision (may be `null`)
5. `longitude` — double precision (may be `null`)
6. `installId` — UUID
7. `locationAccuracy` — text representation of GPS accuracy in meters (e.g., `"5m"`). May be `null` if accuracy capture failed.
8. `notes` — text. User-provided free-form note attached at punch time. May be `null` if 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:

```js
// 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)
)
```

**Caveat for future schema evolution:** the `< / >` operators on JavaScript strings compare UTF-16 code units. If a future v3+ algorithm adds a key containing a supplementary-plane character (U+10000 and above, encoded as a UTF-16 surrogate pair), JS string comparison would diverge from Postgres' UTF-8 byte order at the U+E000..U+FFFF / U+10000+ boundary. For bulletproof correctness across any conceivable key shape, encode each key to a UTF-8 byte array and compare byte-by-byte. For the current 8-key set and any plausible identifier-style ASCII addition, the snippet above suffices.

#### 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`. On the jsonb number path Postgres serializes each double via `float8out`, which is governed by the `extra_float_digits` GUC — and `record_punch` pins that GUC to `1` (`baseline.sql:1682`). At `extra_float_digits = 1` the output is the **shortest decimal string that round-trips** to the stored double: no trailing zeros, no scientific notation for values in the typical GPS range. For the canonical inputs `34.05` and `-118.24` the payload reads `34.05` and `-118.24` literally; for a full-precision coordinate such as `34.05223421234568` it reads `34.05223421234568` in full (at `extra_float_digits = 0` the same double would truncate to `34.0522342123457` — a different byte string, hence a different hash).

A verifier reproducing the payload in another language must emit the **shortest decimal string that round-trips** to the same double — i.e. `float8out` at `extra_float_digits = 1`, which is byte-identical to JavaScript `Number.prototype.toString()` for every value with |x| ≥ 1e-6 (the normal California/WageCop GPS range). For example `34.05`, not `3.405e1`; and `34.05223421234568`, **not** the `extra_float_digits = 0` truncation `34.0522342123457`. Compare your candidate output byte-for-byte against the canonical payload in §3d (and the full-precision reference in §5) before trusting the implementation on real punch data.

(**The `extra_float_digits = 1` pin is load-bearing, not forward-safety.** `record_punch` sets it at function entry, and the jsonb double path **is** governed by it — the doubles serialize via `float8out`, which the GUC controls. Dropping the pin — e.g. a future `CREATE OR REPLACE record_punch` that omits the `SET` — does not alter already-stored hashes (rows recorded under the pin still verify against this canonical efd=1 recipe), but every full-precision-coordinate punch recorded *after* the drop would serialize at `extra_float_digits = 0` (`34.05223421234568` → `34.0522342123457`), so its own stored SHA-256 would no longer match the recipe and authentic evidence would falsely read as *tampered* — a silent, going-forward corruption. Prod-verified 2026-06-21 against project `bymuhltjmgjbcupkdnsf` (PG 17.6); the §5 full-precision vector + `test/test_verification_preimage.mjs` are the worked proof, and the comment at `baseline.sql:1682` guards the source.)

#### 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. Bundle 1's 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_at`** and **`void_reason`** — set via the `void_punch` SECURITY DEFINER RPC (with banned/length/blocklist gates and an audit-log entry to `punches_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's `sha256_hash` continues 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 inside `record_punch` (migration `20260448`, v1.15.3 pre-wrap). Functionally equal to `created_at` for 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_at` is *when WageCop received it*, evidentiary metadata about the row rather than content of the row. The hash does not change whether `recorded_at` is 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.

If a future v3+ algorithm ever adds a non-ASCII key, this vector becomes the regression-test scaffold: pin it to a specific schema, compute the canonical hash via Postgres, and the verifier-correctness test gains a row that catches locale-aware sorters silently passing on the all-ASCII canonical vector while failing on real data.

---

## 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_at` field is the user-asserted moment of the event; the server's `recorded_at` (and identically-valued `created_at`) is the moment WageCop received it (set at INSERT time inside `record_punch`, sealed against post-creation modification, surfaced in exports under the "Officially Recorded" label). Comparing `punched_at` and `recorded_at` reveals any sync gap.
- **Server-side row tampering by `service_role`.** The `service_role` Postgres role retains full UPDATE access to `punches`. The trust assumption is that `service_role` is 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 the `record_punch` RPC 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 in `test_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:

1. **Server-attested per-punch content** — each punch record inside the snapshot carries the `sha256_hash` Postgres computed at record time (§3). A client cannot forge these. **This is the evidentiary anchor.**
2. **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**.
3. **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 in `provenance.settings` and the `pay_block.inputs` rate + **the wage-rule set whose content-fingerprint matches `provenance.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 by `provenance.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. `id` before `hash_version` here — length first — whereas JCS yields `hash_version` before `id`), 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 what `JSON.stringify(n)` emits; RFC-8785 §3.2.2.3 is the reference *for the number rule only*). **Reject `NaN`, `+Infinity`, `-Infinity`; no `BigInt`.** The snapshot seal is client-computed and uses ECMAScript `Number`→`String`. `pay_block` figures 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_records` ordered by `(punched_at, id)`; `resolved_alerts` ordered by the **full canonical alert identity** `violationIdKey = 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:

1. **Reconstruct the week as the half-open interval `[W_start, W_end)`, cutoff-anchored** — `W_start` is the workweek-start day at the hour `provenance.settings.daily_cutoff_hour` (NOT midnight); `W_end` is 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.)
2. **Drop voided punches** (any carrying `voided_at`) before deriving — they are sealed as evidence, never counted.
3. **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 by `W_end`** (the week's own cutoff-anchored end) — the same clamp every session gets.
4. **Re-derive** `pay_block.output` and `resolved_alerts` over the clamped, voided-filtered set, supplying **`W_end` as the derivation clock (`nowMs`)** — **never a live clock, and never `captured_at`** — so the result is a **pure function of `(punches, W_start, W_end)`**, deterministic and reproducible at any later date. (`captured_at` remains 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`
