The exact pack_manifest.json schema and SHA-256 hashing rule behind every Protokol Compliance Evidence Pack — published here so any third-party tool, not just our own /verify page, can independently check that a pack hasn't been tampered with.
Every pack exported today declares "format_version": "1.1" in its manifest, plus a spec_url pointing at this page — both the single-meeting pack_manifest.json AND the whole-client client_manifest.json carry the same stamp. A manifest with no format_version field predates this spec (any pack exported before this page existed) — it is the legacy case, still fully verifiable, because the hashing rule below has never changed since the pack format was introduced.
An unrecognized/future format_version (e.g. a pack made by a newer Protokol than your verifier knows about) should be reported as "unrecognized format version" rather than silently compared against a rule it doesn't actually implement.
pack_manifest.json schemaEvery field in a single-meeting evidence/audit pack's manifest, in the order they appear at export time.
| Field | Type | Description |
|---|---|---|
pack_version | string | On-disk pack SHAPE version. Unchanged at "1.0" since the pack format was introduced — describes the ZIP layout, not this spec. |
pack_kind | "evidence" | "audit" | "evidence" = curated regulator deliverable (selected questions only). "audit" = the complete bundle. |
generated_at | string (ISO 8601) | UTC timestamp the pack was generated at export time. |
meeting_id | string | The Protokol meeting id this pack was exported from. |
form_id | string | null | The compliance form attached to the meeting, if any. |
form_version | string | null | The version of that form at export time. |
ai_model | string | The AI model used to produce the dossier (e.g. "gemini-2.5-flash"). |
reviewed_at | string | null | When an advisor reviewed/approved the dossier, if at all. |
reviewed_by | string | null | The reviewing user’s id. |
acknowledged_incomplete | boolean | True if the advisor explicitly acknowledged missing-critical items before export. |
redacted | boolean (optional) | Present and true only when the pack was exported with export-time redaction applied. |
sha256 | string (64-char hex) | The integrity hash of every OTHER file in the pack. See "The hashing rule" below. |
format_version | string (optional) | The version of THIS published spec the pack was built against. Absent = legacy, pre-spec pack (still verifiable — see below). |
spec_url | string (optional) | The URL of this page at export time. |
warning | string (optional) | Present only when the dossier had not been reviewed by an advisor at export time. |
sha256 is computed over every OTHER file in the pack — pack_manifest.json itself is excluded (it is only added to the ZIP after this hash is taken) — in ascending filename order, feeding each file's name then its UTF-8 content bytes into one running SHA-256 digest:
const sha256 = createHash('sha256')
for (const name of Object.keys(fileContents).sort()) {
sha256.update(name)
sha256.update(fileContents[name])
}
const packHash = sha256.digest('hex')To verify a pack independently: unzip it, read every file except pack_manifest.json, sort the remaining filenames ascending, and for each one (in that order) feed its filename bytes then its content bytes into a single SHA-256 digest. Compare the resulting hex digest to the sha256 field declared in the pack's own pack_manifest.json — a match proves the pack has not been altered since export (tamper-evidence only; it does not by itself prove Protokol issued it — for that, pair it with /verify's provenance check against our audit log).
client_manifest.json schemaA whole-CLIENT evidence bundle (every "done" meeting for one client, exported as one client-pack-*.zip for FCA SYSC 9.1 "show me everything on client X" requests) declares its own root-level manifest, client_manifest.json, alongside one pack_manifest.json per included meeting subfolder (schema above, unchanged).
| Field | Type | Description |
|---|---|---|
client_id | string | The Protokol client id this whole-client bundle covers. |
client_name | string | The client’s display name at export time. |
exported_at | string (ISO 8601) | UTC timestamp the bundle was generated at export time. |
exported_by | string | null | The exporting advisor’s email. |
meeting_count | number | Count of "done" meetings actually included in the bundle. |
included_meeting_ids | string[] | The meeting ids included (status = "done") — one subfolder per id. |
excluded_meetings | Array<{ id, reason }> | Meetings for this client NOT included (not yet "done"), each with the reason. |
sha256_per_meeting | Record<meeting_id, string> | Each included meeting’s own subfolder hash, keyed by meeting_id — computed the exact same way as that meeting’s own pack_manifest.json sha256 field (see "The hashing rule" above). |
root_sha256 | string (64-char hex) | Hash-of-hashes over every value in sha256_per_meeting. See "The client (root) hashing rule" below. |
format_version | string (optional) | The SAME published spec version as pack_manifest.json’s format_version. Absent = legacy, pre-slice-0194 bundle (still verifiable). |
spec_url | string (optional) | The URL of this page at export time. |
root_sha256 is a hash-of-hashes, one level up from a single meeting's sha256: each included meeting's subfolder is hashed exactly like a single-meeting pack (the same rule above, excluding that folder's own pack_manifest.json), keyed into sha256_per_meeting by meeting_id. Those per-meeting hash STRINGS (not raw file bytes) are then sorted by meeting_id and concatenated into one more SHA-256 digest:
const sortedHashes = Object.keys(sha256PerMeeting).sort().map(k => sha256PerMeeting[k])
const rootSha256Hash = createHash('sha256').update(sortedHashes.join('')).digest('hex')To verify a client bundle independently: recompute each meeting subfolder's hash the same way as a single-meeting pack, sort those hashes by meeting_id, concatenate the hash strings (in that sorted order — no separator), and SHA-256 the concatenation. Compare the resulting hex digest to the root_sha256 field declared in the bundle's own client_manifest.json — same tamper-evidence guarantee as a single-meeting pack's sha256, one level up.
format_version and spec_url fields to the manifest and publishes this page. Slice 0194 extends the same stamp to the whole-client client_manifest.json. The hashing rules are unchanged from the original pack format.format_version field; verified using the same hashing rules documented above.