R3 — Phase-Locked Cipher + HZC Archiver
R3 sits one layer above the entangled state. It gives devices a way to encrypt
payloads with a deterministic nonce — no random IV stored alongside the
ciphertext — and gives the framework a way to roll cold rows into hash-
chained .hzc archives that can be replayed standalone, years later,
without DB access.
The cipher and the archiver are independent primitives that compose: hot
rows live in audit_log and can be encrypted in flight with the
phase-locked variant; cold rows roll into HZC files that bundle the same
phase-locked encryption with the canonical chain continuity proof.
Phase-locked AES-256-GCM
Standard zeqEncrypt(plaintext) uses a random 12-byte IV per call. That's
the right primitive when you don't have a unique per-record identifier the
server can recompute from. Audit rows do — every row has a unique
(origin_id, zeqond_number) tuple, enforced by R1's UNIQUE INDEX. So
the IV doesn't have to be stored.
Nonce derivation:
nonce = sha256("phase-lock|" + zeqond + "|" + originId)[0:12]
Domain-separated from the random-IV cipher via the HMAC label
ZeqField/PhaseLockedNonce/v1, so a phase-locked ciphertext can never
accidentally decrypt against the random-IV cipher (or vice-versa).
API:
import {
zeqEncryptPhaseLocked,
zeqDecryptPhaseLocked,
} from "@workspace/api-core/lib/zeqField";
// Encrypt — returns hex (no IV component, recoverable from zeqond+origin)
const ct = zeqEncryptPhaseLocked(plaintext, 2287513900n, "zeq-dev:my-slug");
// Decrypt — pass the same (zeqond, originId) tuple the encryptor used
const back = zeqDecryptPhaseLocked(ct, 2287513900n, "zeq-dev:my-slug");
Six properties verified live:
| Property | Result |
|---|---|
| Round-trip | zeqDecryptPhaseLocked(zeqEncryptPhaseLocked(p, z, o), z, o) === p |
| Deterministic | Same (plaintext, zeqond, origin) always produces the same ciphertext |
| No collision | Different zeqond → different ciphertext |
| Tamper-z | Wrong zeqond at decrypt → GCM auth tag fails |
| Tamper-origin | Wrong origin at decrypt → GCM auth tag fails |
| IV-omitted | Ciphertext length = plaintext_len + 16 (just the GCM tag) |
The two tamper properties are the load-bearing security claim: if
anyone alters the row's zeqond_number or origin_id after the fact,
the recomputed nonce diverges and decryption fails. Combined with R1's
UNIQUE INDEX, no two rows can ever share the same phase-locked nonce
on the same origin.
HZC cold-row archiver
archiveOrigin({ originId, slug, parentOrigin, options }) selects rows
older than retentionDays (default 60), JSON-serialises each (with id
stripped — recoverable from zeqond), encrypts with the phase-locked
nonce, brotli-compresses via zeqCompress, and writes a
<slug>-<period>-z<from>-z<to>.hzc file with magic HZC1\n + JSON
manifest + base64 compressed body.
Continuity proof:
The manifest carries:
{
"version": "HZC1",
"slug": "zeq07792026349",
"parent_origin": "zeq-dev",
"origin_id": "zeq-dev:zeq07792026349",
"from_z": "2287489484",
"to_z": "2287489583",
"row_count": 100,
"last_archived_curr_hash": "70c9a73566a71f84...",
"first_hot_prev_hash": "70c9a73566a71f84...",
"continuity_holds": true,
"sealed_at_unix": 1745869800,
"domain": "audit-log/phase-locked"
}
last_archived_curr_hash is the canonical rowHash of the last row
folded into this archive. first_hot_prev_hash is the prev_hash of
the first row that REMAINS in audit_log after the rollover. They MUST
be equal — that's the continuity proof. The archive refuses to write if
they don't match.
Default non-destructive:
await archiveOrigin({ originId, slug, parentOrigin });
// → writes the .hzc file. rowsDeleted: false. Audit rows still in DB.
Pass { deleteAfterArchive: true } only after verifying the file out-of-band.
Read-back:
import { readArchive } from "@workspace/api-core/lib/archiver";
const { manifest, rows } = await readArchive("path/to/file.hzc");
// rows are decrypted and parsed; every emission's zeqond is recovered.
Verified end-to-end
A 100-row archive against a live chain (zeq07792026349, retentionDays=0,
maxRows=100) produced a 37,969-byte .hzc file. Read-back decrypted all
100 rows in canonical chain order; first/last zeqonds (2287489484 …
2287489583) matched the manifest exactly.
File map
shared/api-core/src/lib/zeqField.ts—zeqEncryptPhaseLocked/zeqDecryptPhaseLockedshared/api-core/src/lib/archiver.ts—archiveOrigin/readArchiveshared/api-core/src/lib/zeqCompress.ts— brotli compression wrapper
Deferred — R3.1
The destructive schema swap (replace state_hash, prev_hash columns
with payload_ct BYTEA + per-row nonce) is gated behind a dual-write
soak phase: the atomic writer must emit both plaintext and encrypted
payload for ≥30 days before reader migration. Doing the swap in a single
step would break R1 + chain-repair + the detail render simultaneously.
The framework's ≤0.1% tolerance forbids that kind of coupled change
without a verified rollback path.