<!-- © TALOS Group Pty Ltd, used under licence by TALOS Defence Pty Ltd. -->
<!-- depth: 5 -->

# The job certificate format, version 1

Certificate format. A Hangar certificate is a signed file that shows what happened in a job, and anyone can check the file offline.

Use this page to build your own checker for Hangar certificates. The page gives every byte of the job certificate, the data certificate, the record, the tree head, the key attestation and the key set. The page also gives every byte of the application release documents that a job certificate includes (Section 12). A checker built from this page alone gives the same result as `hangar verify` on every test vector in `vectors/cert/`.

The `hangar_cert` crate is the reference implementation. If the crate and this page disagree, that is a defect. Report the defect to the address in `SECURITY.md`.

## 1. Conventions

- All integers are unsigned. Multi-byte numbers in hashes and signatures are big-endian, unless the text says otherwise.
- `||` means concatenation of bytes.
- `SHA-384` means FIPS 180-4 SHA-384. The output is 48 bytes.
- "Map `{1: a, 2: b}`" means a CBOR map with unsigned integer keys 1 and 2.
- An *optional* field is absent when the field has no value. The field is never present with an empty or null value.
- Times are seconds since 1970-01-01 00:00:00 UTC. A `time` value is the array `[seconds, nanoseconds]`, with nanoseconds below 1,000,000,000.

## 2. The encoding profile

Every document is CBOR (RFC 8949) in the core deterministic encoding of RFC 8949 Section 4.2.1, with the narrower rules below. A decoder must refuse any input that breaks a rule. A decoder must never repair the input.

| Rule | Detail |
|---|---|
| Integers | Major type 0 only, in the shortest form. Negative integers (major type 1) are refused |
| Strings | Byte strings (major type 2) and text strings (major type 3) with a definite length in the shortest form. Text is valid UTF-8 |
| Arrays | Major type 4, definite count in the shortest form, at most 65,536 items |
| Maps | Major type 5, definite count, at most 65,536 entries. Every key is an unsigned integer. Keys are in strictly increasing numeric order, which for shortest-form integers is the bytewise order RFC 8949 requires. A repeated key is refused |
| Simple values | `false` (0xf4) and `true` (0xf5) only. Floats, `null`, `undefined` and every other simple value are refused |
| Tags | Refused. The document type is field 0 of each body instead |
| Indefinite lengths | Refused, as is the break code 0xff and additional information 28, 29 and 30 |
| Nesting | At most 16 levels, the top-level item being level 1 |
| Size | At most 8,388,608 bytes (8 MiB) for one document |
| Trailing bytes | Refused. One document fills the input exactly |

Unknown fields. Every map on this page lists all of its keys. A decoder refuses any key that the page does not list. This rule stops an attacker adding a field to a signed document that one verifier ignores while another verifier acts on the field.

Re-encoding. After decoding, a verifier encodes the document again with these rules and compares the new bytes with the input. Any difference is a failure. A decoder that follows the rules above never sees a difference. The check catches a decoder that accepts more than this page allows.

## 3. Digests and domain separation

The digest of a signed document is

```
D = SHA-384( ASCII(context) || 0x00 || encode(body) )
```

Here `encode(body)` is the canonical encoding of the body map of the document, not the signature bundle. The `context` is one of these strings:

| Document | Context string | Body field 0 (document type) |
|---|---|---|
| Record | `hangar-record-v1` | 1 |
| Tree head | `hangar-treehead-v1` | 2 |
| Key attestation | `hangar-keyattest-v1` | 3 |
| Certificate | `hangar-certificate-v1` | 4 |
| Key set | `hangar-keyset-v1` | 5 |
| Published tree head list (unsigned) | none | 6 |
| Application release manifest (Section 12.2) | `hangar-app-release-v1` | 7 |
| Acceptance of a release (Section 12.3) | `hangar-app-acceptance-v1` | 8 |
| Compiled envelope (Section 12.1) | `hangar-envelope-v1` | 9 |
| Envelope request (Section 12.1) | `hangar-envelope-request-v1` | 10 (the request has no field 0; it sits inside a manifest) |
| Vendor release key | `hangar-vendor-key-v1` | 11 (over the ML-DSA-87 key then the P-256 key, not CBOR) |
| Licence (Section 12.6) | `hangar-entitlement-v1` | 12 |
| Release revocation list (Section 12.4) | `hangar-release-revocations-v1` | 13 |
| Package manifest | `hangar-package-v1` | 14 (over the exact `hangar_package.json` bytes, not CBOR) |
| Site snapshot (Section 12.5, unsigned) | `hangar-site-snapshot-v1` | 15 |
| Release bundle (Section 12.5, unsigned) | `hangar-release-bundle-v1` | 16 |
| Configuration version | `hangar-config-v1` | 17 (over the exact `config.json` bytes: compact JSON, keys sorted) |
| Policy version | `hangar-policy-v1` | 18 (over CBOR `{1: destinations sorted, 2: release limits map}`) |
| Narrowing | `hangar-narrowing-v1` | 19 |
| Input values | `hangar-input-values-v1` | 20 (over the exact `values.json` bytes) |

No context contains a zero byte, so the hashed bytes can be read in only one way. The record digest is also the chain hash and the log leaf data. The certificate digest is the fingerprint of the certificate, printed on the certificate page. The release id is the digest of the exact bytes of a release manifest under `hangar-app-release-v1`. A document type number marks a domain even where the domain hashes JSON. No CBOR document can take such a number.

The log uses the hashes of RFC 9162 Section 2.1, with SHA-384:

```
leaf hash = SHA-384( 0x00 || leaf data )        leaf data = the 48-byte record digest
node hash = SHA-384( 0x01 || left || right )
empty tree root = SHA-384( "" )
```

Key id. A key pair has an ML-DSA-87 public key `pk_ml` (2,592 bytes) and a P-256 public key `pk_ec` (65 bytes). The key id is:

```
key id = first 16 bytes of SHA-384( pk_ml || pk_ec )
```

The file `vectors/cert/digests.txt` gives test values for each formula. The values were computed without the crate.

## 4. Signatures

Every signed document has the form

```
{1: body, 2: signature bundle}
```

Both signatures cover the 48-byte digest `D` of the body (Section 3).

| Algorithm | What is signed and how |
|---|---|
| ML-DSA-87 | FIPS 204 ML-DSA.Sign, pure mode, empty context string, message `D`. Hedged: 32 random bytes per signature. Signature 4,627 bytes. Public key 2,592 bytes (pkEncode) |
| ECDSA P-256 | FIPS 186-5 ECDSA on P-256, message `D`, hash SHA-384: the value signed is the leftmost 256 bits of `SHA-384(D)`. Deterministic nonce per RFC 6979 as the issuer implements it (a verifier does not depend on the nonce). Signature `r || s`, 32 bytes each, big-endian, 64 bytes, with `s` at most `(n - 1) / 2` (low form). Public key SEC 1 uncompressed, `0x04 || x || y`, 65 bytes |

A WebCrypto or OpenSSL verifier checks the P-256 signature with message `D`, hash SHA-384, and the IEEE P1363 (`r || s`) format.

**The signature bundle** is the map

| Key | Field | Type |
|---|---|---|
| 1 | key id of the signing key pair | bytes, 16 |
| 2 | ML-DSA-87 signature, *optional in the encoding* | bytes, 4,627 |
| 3 | ECDSA P-256 signature, *optional in the encoding* | bytes, 64 |

The issuer always writes both signatures. A damaged document may lack key 2 or key 3, so the verifier can report which signature is missing. **A bundle verifies only when both signatures are present and both are valid.** A P-256 signature with a high `s` value fails.

## 5. The key hierarchy

| Key | Holds | Signs | Validity |
|---|---|---|---|
| Root | ML-DSA-87 and P-256 pair, published in the key set | Key attestations and key sets only | The key set gives `not_before` and, after rotation, `not_after` |
| Day key | ML-DSA-87 and P-256 pair, in memory on the control plane | Records, tree heads and certificates | The attestation gives `not_before` and `not_after`, at most 93,600 seconds (26 hours) apart |

A root signs a key attestation that vouches for each day key. A certificate includes the attestation for every day key that signed anything in the certificate. A verifier therefore needs only the key set.

A signed object is valid only if its time falls inside the window of the day key that signed the object. The times are the `time` of a record, the `time` of a tree head and the `issued_at` of a certificate. A day key that has expired does not invalidate objects that the key signed while the window was open.

### 5.1 Root key custody

The record-signing root of Hangar is separate from the release-signing root. Neither root is held in AWS (engine paper Section 1).

- **P-256 root private key.** The YubiHSM 2 pair in Sydney generates the key as a non-exportable asymmetric key with the ECDSA signing capability only. The key never exists outside the HSM.
- **ML-DSA-87 root private key.** The D-02 step must confirm whether the YubiHSM 2 firmware supports ML-DSA [verify]. If the firmware supports ML-DSA, the HSM generates and holds the key, as for the P-256 key. If the firmware does not support ML-DSA, the root is held as follows:
  - Two people generate the 32-byte ML-DSA-87 seed on an offline machine in a ceremony. The seed is never written in plain form.
  - The seed is sealed with AES-256-GCM under a 256-bit wrap key. The YubiHSM generates the wrap key, and the key cannot be exported. If the team uses the data-wrap command of the HSM instead, the mode is the AES-CCM of the HSM [verify which, at D-02].
  - The sealed file is stored in the vault account and in the offline safe. The file is unsealed only into the memory of the signing process, in `SecretBox`, for a signing ceremony. The memory is wiped when the ceremony ends.
  - Recommended, for the Director's decision: each ceremony signs the day key attestations for a batch of days ahead (for example 35 days). The seed is then unsealed about once a month, not every day.

Together, the key set and this section state which of the two arrangements applies.

## 6. The record (document type 1)

A record is one state change. Signed form: `{1: record body, 2: bundle}`.

| Key | Field | Type | Rule |
|---|---|---|---|
| 0 | document type | uint | 1 |
| 1 | format version | uint | 1 |
| 2 | chain id | bytes, 16 | The organisation's chain, or the platform chain named in the key set |
| 3 | index | uint | 0 for the first record of a chain, then one more than the record before |
| 4 | previous record hash | bytes, 48 | The record digest of the record before it. All zero for index 0 |
| 5 | time | `[seconds, nanoseconds]` | The control plane's clock |
| 6 | clock error bound | uint | Milliseconds |
| 7 | actor | map `{1: kind, 2: id}` | Kind: 1 customer, 2 operator, 3 system, 4 retention rule. Id: text |
| 8 | record kind | uint | See the table below |
| 9 | subject ids | array of text | At most 32 |
| 10 | kind-specific body | map | See Section 6.2 |

### 6.1 Record kinds

| Code | Kind | Code | Kind |
|---|---|---|---|
| 1 | booking | 8 | credit |
| 2 | purchase | 9 | flag |
| 3 | movement | 10 | hold |
| 4 | job state | 11 | release |
| 5 | reset | 12 | advisory |
| 6 | wipe | 13 | operator action |
| 7 | attestation | 14 | key event |
| 15 | release job | 16 | application release |

Kind 15 records the events of one job of an application release: compiled, an attempt started or ended, an artefact checked, returned or cancelled. Kind 16 records a change to an application release: submitted, accepted, refused, revoked, a channel moved, a widening approved or declined. Kind 11 still means a release of Hangar itself.

### 6.2 The kind-specific body

The kind-specific body is a map with unsigned integer keys. Each type of value has a size limit, so a record cannot hold customer data:

- byte strings at most 64 bytes;
- text at most 1,024 bytes, with no control characters (Unicode general category Cc);
- arrays and maps at most 256 items;
- nesting at most 4 levels, the body map being level 1;
- integers and booleans as in Section 2.

The verifier reads the body without knowing the keys. The only exception is the movement record, below.

**Movement records (kind 3).**

| Key | Field | Type |
|---|---|---|
| 1 | movement type | uint: 1 upload received, 2 staged for a slot, 3 mounted on the instance, 4 written by the job, 5 downloaded, 6 deleted, 7 never replicated elsewhere |
| 2 | digests of the objects moved | array of bytes, 48 each |
| 3 | total size in bytes | uint, *optional* |
| 4 | location (bucket and region, or instance and zone) | text, *optional* |
| 5 | slot id | text, *optional* |
| 6 | the account that sent, fetched or asked | text, *optional* |
| 7 | method (deletion method, or the request origin seen) | text, *optional* |
| 8 | the record digest of the wipe before a mount | bytes, 48, *optional* |
| 9 | the uploads an output derives from | array of bytes, 48 each, *optional* |

The control plane (D-02) defines the bodies of the other kinds and lists the bodies there. The test vectors use these bodies: job state `{1: job id, 2: state (1 started, 2 finished), 3: image name, 4: image digest text, 5: resolution time, 6: command, 7: environment names, 8: exit code}`; reset `{1: instance id, 2: GPU UUIDs, 3: serials, 4: method}`; wipe `{1: instance id, 2: method, 3: result}`; attestation `{1: subject, 2: digest}`; release `{1: version, 2: manifest digest}`.

A record from the slot egress proxy (D-03) has key 97. Key 97 is an array of text with one line for each decision of the proxy. Each line has the form `<outcome> <destination> <bytes_up> <bytes_down> <at> <reason>`. The outcome is `refused` or `connected`. The destination is the name that the job asked for, or `-` when the job gave no name. The byte counts are decimal. The time is RFC 3339. The reason is empty for a connection. Refusals go on a flag record, and connections go on a job state record. A record holds at most 256 lines, and each record links to the slot. The lines hold destinations and byte counts only, never content.

**Application release and release job records (kinds 14, 15 and 16, and the movement and operator records of a job).** This page fixes the keys from 100 up (`hangar_cert::release::body`). Keys 1 (action text), 2 (status text), 19 (reason text) and 39 (a 48-byte digest) are general keys of the control plane. The release check also reads these four keys.

| Key | Field | Type |
|---|---|---|
| 100 | release id | bytes, 48 |
| 101 | compiled envelope digest | bytes, 48 |
| 102 | pool id | text, a UUID |
| 103 | tenant | text, `tnt_` and 32 hex digits |
| 104 | configuration version digest | bytes, 48 |
| 105 | attempt number | uint, from 1 |
| 106 | outcome | text: `succeeded`, `failed`, `cancelled`, `interrupted` or `outcome_unknown` |
| 107 | vendor key id | bytes, 16 |
| 108 | vendor keys digest (`hangar-vendor-key-v1`) | bytes, 48 |
| 109 | acceptance digest | bytes, 48 |
| 110 | channel | text |
| 111 | output name | text |
| 112 | descriptor result | text: `passed` or `failed` |
| 113 | digest of the job's client reference | bytes, 48 |
| 114 | release sequence | uint |
| 115 | package name | text |
| 116 | package version | text |
| 117 | job state | text |
| 118 | widening items | array of text |
| 119 | return digest | bytes, 48 |
| 120 | licence serial | uint |
| 121 | key set digest | bytes, 48 |
| 122 | entitlement digest | bytes, 48 |
| 123 | deployment | text, `aws:<account>` |
| 124 | revocation list sequence | uint |
| 125 | baseline release id: the release a job's widening is measured against (the release of the package the tenant last ran, or the package's first accepted release) | bytes, 48 |
| 126 | advisory snapshot digest: the release vulnerability watch's mirror snapshot the release was matched against when the job started (D18.6) | bytes, 48 |
| 127 | advisory snapshot time | uint, seconds |
| 128 | advisory counts: six `name=count` words in the order `critical high medium low unknown exploited` | text |
| 129 | advisory decision: `recorded`, `within_policy` or `released` | text |
| 130 | request key id | text, `hgk_` and 24 hex digits |
| 131 | request key capabilities | array of text |
| 132 | digest of a tenant's reference | bytes, 48 |
| 133 | expiry | uint, seconds |

A certificate of kind 4 (the return of one job) includes these records, listed by action text (key 1):

| Record | Kind | Body |
|---|---|---|
| vendor release key registered | 14 | 107, 108 |
| release submitted | 16 | 100, 114, 115, 116, 107 |
| release accepted | 16 (platform chain, operator) | 100, 109 |
| release refused, release revoked | 16 | 100, 19 |
| channel moved | 16 | 115, 110, 100, 118 |
| widening approval requested | 16 | 100, 125, 110, 103, 118, and 39: the proposal digest |
| widening approved, widening declined | 16 (customer actor) | 100, 125, 110, 103, 118, and 39: the proposal digest |
| configuration set | 13 (customer actor) | 115, 102, 103, 104 |
| compiled | 15 | 100, 101, 102, 103 (with a tenant), 104, 113, 110, 125, 118 (only when the widening is not empty), 126 to 129 (all four or none: the advisory stamp), and 2: `run` or `self_test` |
| attempt started, attempt ended | 15 (subject the session) | 105, 106, 19 |
| written (movement type 4) | 3 | one per output, the job id in key 5 |
| artefact checked | 15 | 111, 112, 39 |
| returned | 15 | 106, 117 |
| cancelled | 15 | |

The certificate includes the D-08 session records of each attempt (start, steps, reset steps with provider responses, connections and approvals) in the same form as on a sandbox session certificate.

## 7. The log

### 7.1 Inclusion proof

An inclusion proof is the map `{1: leaf index (uint), 2: path (array of bytes, 48 each, at most 64)}`. The path is the RFC 9162 `PATH(m, D[n])`, with the sibling nearest the leaf first. Verify the proof with RFC 9162 Section 2.1.3.2. Take the tree size and the root **from the signed tree head**, never from the proof.

### 7.2 Tree head (document type 2)

Signed form: `{1: tree head body, 2: bundle}`.

| Key | Field | Type |
|---|---|---|
| 0 | document type | uint, 2 |
| 1 | version | uint, 1 |
| 2 | log id | bytes, 16, equal to the key set's log id |
| 3 | tree size | uint |
| 4 | root hash | bytes, 48 |
| 5 | time | `[seconds, nanoseconds]` |
| 6 | clock error bound | uint, milliseconds |

### 7.3 Published tree head list (document type 6)

The list is an unsigned map. Each head in the list is signed.

| Key | Field | Type |
|---|---|---|
| 0 | document type | uint, 6 |
| 1 | version | uint, 1 |
| 2 | heads | array of signed tree heads, sizes strictly increasing, one log id |
| 3 | consistency proofs | array of arrays of bytes (48 each). Entry i links head i to head i + 1 (RFC 9162 Section 2.1.4) |
| 4 | attestations | array of signed key attestations, one for each day key that signed a head |

## 8. Keys

### 8.1 Key set (document type 5)

The key set is the published trust anchor. Signed form: `{1: key set body, 2: bundle}`.

| Key | Field | Type |
|---|---|---|
| 0 | document type | uint, 5 |
| 1 | version | uint, 1 |
| 2 | issuer name | text, "TALOS Defence Pty Ltd" |
| 3 | issuer ABN | text, "24 696 691 906" |
| 4 | not before | uint, seconds |
| 5 | roots | array of root entries, 1 to 8 |
| 6 | platform chain id | bytes, 16 |
| 7 | log id | bytes, 16 |
| 8 | previous root key id | bytes, 16, *optional*, present on rotation |

Root entry: `{1: key id (bytes, 16), 2: ML-DSA-87 public key (bytes, 2,592), 3: P-256 public key (bytes, 65), 4: not before (uint), 5: not after (uint, optional)}`. The key id must equal the key id computed from keys 2 and 3.

A root listed in the key set signs the bundle. On rotation, key 8 names the previous root, and the previous root signs the bundle. The key set then lists both the old root (with `not_after`) and the new root. A verifier that holds the old key set accepts the new key set when all of these are true: the new key set checks on its own; the previous root of the new key set is a root of the old key set; the issuer, platform chain id and log id are unchanged; and the `not_before` of the new key set is not earlier than the old one.

### 8.2 Key attestation (document type 3)

Signed form: `{1: attestation body, 2: bundle}`. A root signs the bundle.

| Key | Field | Type |
|---|---|---|
| 0 | document type | uint, 3 |
| 1 | version | uint, 1 |
| 2 | day key id | bytes, 16, equal to the id computed from keys 3 and 4 |
| 3 | day key ML-DSA-87 public key | bytes, 2,592 |
| 4 | day key P-256 public key | bytes, 65 |
| 5 | not before | uint, seconds |
| 6 | not after | uint, seconds, greater than `not before` and at most 93,600 later |
| 7 | root key id | bytes, 16, equal to the bundle's key id |

A verifier refuses an attestation if any of these is true: the key id of the bundle differs from key 7; the day key id equals the id of any root in the key set; key 7 is not a root in the key set; the window is longer than 26 hours; the validity of the root does not cover `not before`; or the bundle does not verify under the root.

## 9. The certificate (document type 4)

Signed form: `{1: certificate body, 2: bundle}`. A day key signs the bundle.

| Key | Field | Type |
|---|---|---|
| 0 | document type | uint, 4 |
| 1 | version | uint, 1 |
| 2 | certificate id | bytes, 16 |
| 3 | kind | uint: 1 job certificate for a GPU slot, 2 job certificate for a sandbox session, 3 data certificate, 4 job certificate of an application release (the return of a job, API kind `release_run`). Any other number fails `decode` with the sentence "This certificate is of kind N, which this verifier does not know. Update the verifier and check it again." |
| 4 | issuer | map `{1: name (text), 2: ABN (text)}` |
| 5 | organisation | map `{1: legal name (text), 2: ABN or ACN (text), 3: chain id (bytes, 16)}` |
| 6 | facts | map, Section 9.1 |
| 7 | records | array of signed records, at most 4,096, in log order |
| 8 | tree head | signed tree head |
| 9 | inclusion proofs | array, one per record, same order |
| 10 | attestations | array of signed key attestations, at most 64 |
| 11 | issued at | `[seconds, nanoseconds]` |

### 9.1 Facts

| Key | Field | Type |
|---|---|---|
| 1 | booked by | text |
| 2 | jobs | array of job maps, at most 256 |
| 3 | hardware | map, *optional* (absent on a data certificate) |
| 4 | when | map, *optional* (absent on a data certificate) |
| 5 | data in | array of data items |
| 6 | data out | array of data items |
| 7 | movement records | array of record digests (bytes, 48) |
| 8 | reset and wipe records | array of record digests (bytes, 48) |
| 9 | instance attestation digest | fact digest, *optional* |
| 10 | release manifest digest | fact digest, *optional* |
| 11 | daily audit log digest | fact digest, *optional* |
| 12 | clock error bound | uint, milliseconds |
| 13 | operated by | text |
| 14 | location statement | text |
| 15 | subject upload digest | bytes, 48, *optional*, present on a data certificate |
| 16 | attestation kind | uint, *optional*, present on a job certificate: 1 AWS Nitro attestation, 2 test attestation from the Hangar test pool, 3 AWS instance identity document only, for an instance type that has no hardware attestation (the Trainium2 pool). The verifier prints a notice on every report of a kind 3 certificate; 4 no attestation of the instance arrived (the worker never reached the control plane, or the job closed before it attested), and the verifier prints a notice on every report of a kind 4 certificate |
| 17 | reset path | uint, *optional*, present on a job certificate: 1 fresh instance, 2 reboot in place (the same instance, reset and rebooted between jobs) |
| 18 | sandbox facts | map, Section 9.4. Present on a sandbox session certificate (kind 2) and absent on every other kind |
| 19 | release job facts | map, Section 9.5. Present on a job certificate of an application release (kind 4) and absent on every other kind |
| 20 | platform version | text, *optional*: the Hangar platform version in force when the certificate was issued, as `major.minor.patch` with no leading zeros, such as `4.0.1`. A certificate of any kind states it once the control plane has a published platform version. A certificate issued before then omits key 20, and its bytes are what they were. Any other text at key 20 fails the facts check. The facts page prints a row "Hangar platform version" |
| 22 | places | array of place maps, at most 256, Section 9.6. *Optional*; required on a job certificate of the hosted service (kinds 1 and 2, and kind 4 from a hosted pool) whose key 20 is 4.17.0 or later |
| 23 | Region policy | map, Section 9.6. *Optional*; required wherever key 22 is required, and refused without key 22 |

A verifier that does not know key 20 fails `decode` with the unknown-key sentence (Section 2, unknown fields), so an older verifier never reads a certificate that states a platform version as if the certificate stated none. The certificate version stays 1: key 20 is an additive field, and the rule that a decoder refuses a key the page does not list is what makes the addition safe.

The issuer (certificate field 4) is the issuer that the record key set names (key set fields 2 and 3). Certificates from Hangar name TALOS Defence Pty Ltd as issuer. A job in a customer-controlled pool is signed under the customer's own root. The return from that job names the customer as issuer, and a verifier checks the return against the key set of the customer.

Job map: `{1: job id (text), 2: image name (text, optional), 3: image digest (text), 4: resolved at (time), 5: command (array of text, at most 256), 6: environment names (array of text, at most 256), 7: started (time), 8: ended (time), 9: outcome (uint), 10: outcome reason (text, optional)}`. Outcome: 1 completed (the command ran and exited), 2 ended abnormally (the job stopped before its command exited: a crash, an instance fault or the end of the slot), 3 cancelled (by the customer or an operator), 4 not started (booked and never run). The reason is a plain sentence from the worker, for example the exit status.

Hardware: `{1: instance type, 2: instance id, 3: accelerator count (uint), 4: accelerators (array of {1: UUID or device identity, 2: serial}, at most 64), 5: region, 6: zone, 7: capacity block id (optional), 8: accelerator family (optional)}`, text unless stated. Key 8 is `nvidia-h100` or `aws-trainium2`. When key 8 is absent, the family is `nvidia-h100`, so certificates issued before the Trainium pool keep their meaning. A certificate from the pool hosted-trn-mel has `aws-trainium2` at key 8, and each key 4 entry is `{1: "neuron<N>@<bdf>", 2: the driver serial}`. Any other text at key 8 fails `decode`. An entry that does not match the family also fails `decode`: a `neuron<N>@<bdf>` entry under any family but `aws-trainium2`, or any other entry under `aws-trainium2`. A verifier that does not know key 8 fails `decode` with the unknown-key sentence, so the verifier cannot misread the certificate. The facts page prints "with 1 Trainium2 accelerator" and one "Accelerator N" line for each device.

When: `{1: zone name (text, "Australia/Melbourne"), 2: start (local time), 3: end (local time)}`. Local time: `{1: UTC time, 2: offset in minutes east of UTC (uint), 3: local time text}`.

Data item: `{1: digest (fact digest), 2: size in bytes (uint), 3: file name (text, optional)}`.

### 9.2 Value rules on the certificate

| Value | Rule |
|---|---|
| Text | At most 1,024 bytes, no control characters |
| Fact digest | Bytes, exactly 32 (SHA-256, as AWS and OCI publish) or 48 (SHA-384) |
| Image digest | Text `sha256:` and 64 lower-case hexadecimal digits, or `sha512:` and 128 |
| Environment name | Text matching `[A-Za-z_][A-Za-z0-9_]*`, at most 128 characters. A name only: the grammar has no `=` |

No certificate field holds free bytes. A certificate cannot show file contents, secret values or environment values.

### 9.3 Melbourne time

Victoria observes AEST (UTC+10, offset 600) and AEDT (UTC+11, offset 660). Daylight time starts at 02:00 AEST on the first Sunday in October and ends at 03:00 AEDT on the first Sunday in April; both moments are 16:00 UTC on the Saturday before that Sunday. For a UTC time `t` in UTC year `Y`, the offset is 660 if `t` is at or after the October change of `Y` or before the April change of `Y`, and 600 otherwise.

The local time text is `YYYY-MM-DD HH:MM:SS AEDT` or `YYYY-MM-DD HH:MM:SS AEST`. The text shows the UTC time plus the offset, zero-padded. Example: UTC 1793829600 (2026-11-04 22:00:00 UTC) is `2026-11-05 09:00:00 AEDT`.

### 9.4 Sandbox facts (kind 2 only)

| Key | Field | Type |
|---|---|---|
| 1 | instance type | text, a Graviton type such as `m8g.xlarge` |
| 2 | instance id | text |
| 3 | image release digest | fact digest: the sandbox image release the session ran |
| 4 | shape | uint: 1 `c` (2 GiB per vCPU), 2 `m` (4 GiB per vCPU), 3 `r` (8 GiB per vCPU) |
| 5 | vCPUs | uint: 2, 4, 8 or 16 |
| 6 | companions | array, at most 16, of `{1: name (text), 2: image digest (text, Section 9.2)}` |
| 7 | allowlist digest | bytes, 48: SHA-384 of the canonical encoding (Section 2) of the destination allowlist as an array of text, in request order |
| 8 | gateway connections | uint: connections the gateway handled |
| 9 | gateway connections refused | uint, at most key 8 |
| 10 | credential attachments | array of `{1: secret name (text), 2: destination (text)}`. The secret's value never appears |
| 11 | approvals | array of `{1: proposal digest (bytes, 48), 2: decision (uint: 1 approved, 2 declined), 3: approver kind (uint: 1 a named person, 2 the account's Lock), 4: time}` |
| 12 | reset steps | array, at most 32, of `{1: step name (text), 2: provider response digest (bytes, 48), 3: time}`. The response digest is SHA-384 of the provider's response text as the step record carries it |
| 13 | tenant | text, optional: the tenant the session served, a vendor's downstream customer (D8.12). Absent for a session with no tenant |

The published sandbox reset has five steps. These are the names that the worker uses: `instance_terminated`, `egress_group_checked`, `credentials_revoked`, `gateway_ca_destroyed`, `secrets_detached`. A sandbox session certificate lists each step exactly once and lists no other step. The step `egress_group_checked` replaces the earlier step `security_group_deleted` (D-20 N1). A verifier refuses a certificate that lists `security_group_deleted`.

The field names follow the D-08 control plane (`hangar_api` sandboxes): `shape`, `vcpus`, `secret_name` and `destination`, decisions `approved` and `declined`, and the reset step names. In D-08, the start record holds the allowlist as an array of text at body key 71, and each reset step record holds the response text of the provider at body key 76.

### 9.5 Release job facts (kind 4 only)

A job certificate of an application release is the return of one job (D15.19). Facts key 2 (jobs) may be empty, and key 3 (hardware) may be absent, because each attempt below lists its own instance. Keys 4 (when), 16 (attestation kind) and 17 (reset path) are present, as on every job certificate.

| Key | Field | Type |
|---|---|---|
| 1 | job id | text |
| 2 | tenant | text, *optional*: `tnt_` and 32 hex digits |
| 3 | release | signed release, Section 12.2 |
| 4 | vendor key record | bytes, 48, *optional* (required from a hosted pool): the digest of the record that registered the vendor's release key |
| 5 | widening approval record | bytes, 48, *optional*: the digest of the record that approved the release's widening for this tenant |
| 6 | configuration digest | bytes, 48 (`hangar-config-v1`) |
| 7 | envelope | the compiled envelope, Section 12.1 |
| 8 | pool | `{1: id (text), 2: name (text), 3: kind (uint: 1 hosted, 2 customer-controlled), 4: provider, 5: site, 6: owner nationality, 7: jurisdiction, 8: assurance}`, text unless stated |
| 9 | attempts | array, at most 4, of `{1: number (uint), 2: instance type, 3: instance id, 4: zone, 5: purchase (spot or on_demand), 6: started (time), 7: ended (time), 8: outcome (uint), 9: reason (text, optional), 10: reset and wipe record digests (array of bytes, 48), 11: attestation (fact digest, optional)}` |
| 10 | inputs | array of `{1: name, 2: digest (bytes, 48), 3: size (uint), 4: media type}` |
| 11 | values digest | bytes, 48, *optional* (`hangar-input-values-v1`) |
| 12 | outputs | array of `{1: name, 2: digest (bytes, 48), 3: size (uint), 4: media type, 5: descriptor (uint: 1 passed, 2 failed), 6: attempt (uint)}` |
| 13 | connections | array of `{1: destination (text), 2: connected (uint), 3: refused (uint)}` |
| 14 | model routes used | array of `{1: name, 2: destination, 3: fallback (bool)}` |
| 15 | approvals | array, as sandbox facts key 11 |
| 16 | runtime version | text |
| 17 | outcome | uint: 1 succeeded, 2 failed, 3 cancelled, 4 interrupted, 5 outcome unknown |
| 18 | self-test | bool: true for the acceptance self-test of a release not yet accepted |
| 19 | licence | uint, *optional*: 1 valid, 2 lapsed (a customer-controlled pool) |
| 20 | revocation list age | uint, *optional*: whole days, when the job started (a customer-controlled pool) |
| 21 | compiled record | bytes, 48: the digest of the `compiled` record |
| 22 | release id | bytes, 48 |
| 23 | envelope digest | bytes, 48 |
| 24 | baseline release | signed release, Section 12.2, *optional*: the release the widening is measured against, present when the compiled record's key 125 names another release than key 22; absent on a self-test |

Keys 19 and 20 are both present on a return from a customer-controlled pool (pool kind 2) and both absent on a return from a hosted pool.

The return holds no input bytes, no output bytes, no configuration value and no secret value. The return holds only the digests, names and sizes of these items.

### 9.6 Places and the Region policy (facts keys 22 and 23)

Key 22 states each place where customer bytes were stored, processed or staged, and where the records about them are, by AWS Region. Key 23 states the Region policy the job ran under (D-20 WP19). Both are additive under certificate version 1, as key 20 is: a verifier that does not know them fails `decode` with the unknown-key sentence, so it never reads a certificate that states them as if they were absent. A data certificate states neither. A return from a customer-controlled pool states neither: a customer runtime keeps no Region policy.

Place: `{1: role (uint), 2: region (text), 3: zone (text, optional), 4: what (text), 5: movement records (array of record digests, bytes 48, at most 4,096)}`.

| Role | Name | What it is |
|---|---|---|
| 1 | stored | Customer bytes at rest. *What* is the bucket name, `control plane database` for sealed sandbox logs, or the id of a sandbox session volume. |
| 2 | processed | Customer bytes in use. *What* is the instance id. |
| 3 | staged | Customer bytes copied to the instance before the job. *What* is the instance id. |
| 4 | record | Digests, sizes and names only. *What* is `control plane`, the cell that keeps the records, or `control plane (home cell)` for the organisation's home cell when another cell ran the job. |

A movement record of type 2 (staged for a slot) or 3 (mounted on the instance) belongs to a staged place. A movement record of any other type belongs to a stored place.

Region policy: `{1: setting (text), 2: source (uint), 3: policy record (bytes, 48), 4: cell (text), 5: home cell (text), 6: reason (text)}`.

| Key | Field | Values |
|---|---|---|
| 1 | setting | `melbourne_only`, `melbourne_preferred`, `no_preference`, `sydney_only` or `sydney_preferred`. Any other text fails `decode` |
| 2 | source | 1 the organisation setting, 2 the job's own setting. Any other number fails `decode` |
| 3 | policy record | the digest of the chain record that set the setting in force: for the organisation setting, its newest Region policy change, or the sign-up record when it never changed; for a job setting, the record "region policy set for a job", which states the setting and the source and is written with the job's submit record (for a visiting job, by the home cell before it sends the job). The record is on the home cell's chain, so a certificate of a visiting job names a record that the certificate does not include |
| 4 | cell | the cell that the issuer names as the signer: `sydney` or `melbourne`. The certificate signature is checked against the key set given to the verifier (check 4); the facts check reads this name for rules 1 and 3 |
| 5 | home cell | the organisation's home cell: `sydney` or `melbourne` |
| 6 | reason | `preferred_region`, `chip_not_in_preferred_region`, `no_capacity_in_start_window`, `first_to_start` or `chosen_block` (a booked job on a block the person chose in the alternative Region, which the setting allows). Any other text fails `decode` |

**The location statement is derived.** Key 14 equals the statement built from key 22 and key 23 by `hangar_cert::places::location_statement`, byte for byte. The issuer and the verifier call the same function. The Regions of processed and staged places are "processed"; the Regions of stored places are "stored"; each Region prints with its city, such as `ap-southeast-4 (Melbourne)`:

- Processed and stored in the same Regions: "Staged, processed and stored in R only."
- No stored place: "Processed in R only.", or "Staged and processed in R only." when a staged place exists.
- Otherwise: "Staged and processed in R. Stored in S."
- Then, when a record place exists: " Records in T."
- Then, when key 23 is present: " Region policy: W, set by the organisation." or "..., set by the job.", where W is the setting in the Director's words (`melbourne only`, `melbourne preferred, sydney as the alternative`, `no preference`, `sydney only`, `sydney preferred, melbourne as the alternative`).
- Last: " Keys held in Australia."

For example: "Staged, processed and stored in ap-southeast-4 (Melbourne) only. Records in ap-southeast-4 (Melbourne). Region policy: melbourne only, set by the organisation. Keys held in Australia."

The facts page prints one row "Place" for each place, and the rows "Region policy", "Placement" (the sentence of the reason), "Cells" (`signer named: sydney; home cell: melbourne`) and "Policy record".

## 10. Verification procedure

Inputs: the certificate bytes, the key set bytes, the current time `now` in seconds, and optionally the published tree head list. The checks run in this order. The first failure stops the procedure. The report shows every later check as not run, never as passed.

1. **decode.** The certificate decodes under Section 2 with only the fields of Section 9, every nested document included, and re-encodes to identical bytes.
2. **key_set.** The key set decodes and re-encodes identically, lists distinct roots, names an issuer, and its bundle verifies under a root it lists (the previous root, on rotation).
3. **day_keys.** The certificate carries at least one attestation, no day key is attested twice, and each attestation passes Section 8.2.
4. **certificate_signature.** The bundle's key id is an attested day key, `issued_at` is inside its window, and the bundle verifies over the certificate digest.
5. **record_signatures.** Every included record passes the same test, using the `time` of the record.
6. **chain.** Every record's chain id is the organisation's chain id (field 5.3) or the key set's platform chain id. Within each chain, indices strictly increase in the order given. Where two included records are adjacent (index n and n + 1), the later one's previous record hash equals the earlier one's digest, and its time is not earlier by more than the larger of the two clock error bounds. Gaps are allowed: the log, not the chain, proves membership.
7. **inclusion.** There is at least one record and exactly one proof per record. No two proofs name one leaf index and no record appears twice. Each record digest verifies at its leaf index against the tree head's size and root.
8. **tree_head.** The tree head's log id equals the key set's log id, its bundle key id is an attested day key, its time is inside that key's window, and the bundle verifies.
9. **time.** The key set's `not_before` is not after `now`. No record time, tree head time, `issued_at` or attestation `not_before` is later than `now` plus the clock error bound. No record is later than the tree head, and the tree head is not later than `issued_at`, each allowing the clock error bound.
10. **facts.** The facts agree with the signed records. Check each of these:
    - The issuer equals the issuer of the key set.
    - Every data digest, the instance attestation digest, the release manifest digest, the daily audit log digest and the image digest of every job (as text) appears as a leaf value in the body of some included record.
    - Each listed movement record digest is the digest of an included movement record. Each reset and wipe record digest is the digest of an included reset or wipe record.
    - The accelerator count equals the number of accelerators listed. Each entry has the form of the accelerator family (Section 9.1).
    - Each local time equals the time that Section 9.3 gives for the UTC time. The zone is `Australia/Melbourne`.
    - A job ends no earlier than it starts. A job starts no earlier than its image name resolved.
    - A job certificate states hardware, when, the attestation kind and the reset path.
    - A sandbox session certificate has sandbox facts. Every other kind has none.
    - For sandbox facts, all of these hold. The vCPUs are 2, 4, 8 or 16. Refused connections do not exceed connections. The instance type and id equal those in hardware. The allowlist digest is the SHA-384 of the canonical encoding of an array that an included record holds. An included record holds the proposal digest of each approval, as bytes. Each published reset step appears exactly once, and no other step appears. The response digest of each step is the SHA-384 of a text value that an included record holds. A tenant, when present, is a text value that an included record holds.
    - A data certificate names its subject upload. Every included record is a movement record whose key 2 or key 9 contains the digest of that upload. The records are in time order. The first record shows the upload being received.
    - Places and the Region policy (Section 9.6). A job certificate of the hosted service whose platform version is 4.17.0 or later states key 22 and key 23. Key 23 is not stated without key 22. When key 22 is stated, all of these hold. Each listed movement record is named by exactly one place, of the role its type gives. A place names no movement record that the facts do not list. The instance of the hardware, and of each attempt of a release job, is a processed place, in the Region the hardware states. The location statement is the derived one.
    - When key 23 is stated, all of these hold, in this order. The cell and the home cell are cells of the hosted service. For `melbourne_only` and `sydney_only`, every place of every role is in that Region, the cell is the cell of that Region, and the home cell is the cell of that Region. Every place of every role is in the Region of a hosted cell that the setting allows: `melbourne` for `melbourne_only`, `sydney` for `sydney_only`, and `sydney` and `melbourne` for the three other settings. For `melbourne_preferred` and `sydney_preferred`, a processed place outside the preferred Region has the reason `chip_not_in_preferred_region`, `no_capacity_in_start_window` or `chosen_block`. Every processed place is in the Region of the cell that key 23 names.
11. **release** (kind 4 only, listed straight after `facts`; Section 10.1). The vendor signed the release, TALOS Defence accepted the release, the compiled envelope lies within the request of the release, and every connection and output stays within the envelope.
12. **published_heads** (only when the list is supplied). The list's heads have one log id and strictly increasing sizes; each consistency proof verifies; each head is signed by a day key the list's attestations vouch for, within its window; and the certificate's tree head (log id, size and root) is one of the heads. If the list does not contain the tree head of the certificate, the certificate comes from a log that was never published. The test therefore detects a fork of the log.

For kind 4, check 10 also requires: release job facts present (and absent on every other kind); when, the attestation kind and the reset path stated.

The certificate is **valid** when every check but `published_heads` passes and `published_heads` did not fail. For kinds 1 to 3, the checks are 1 to 10, and the reports list eleven checks exactly as before. For kind 4, the checks also include `release`. The report says whether `published_heads` ran.

### 10.1 The release check

Inputs: the certificate, the record key set, and TALOS Defence's published release key set (`hangar verify --release-keys`). The parts run in order. The first part that fails stops the check and gives the failure sentence.

1. The release id (Section 12.2) equals release job facts key 22. The record whose digest is at key 21 is included and is a release job record (kind 15) with action `compiled`. The body of that record has the release id at key 100 and the pool id at key 102. The body has the tenant at key 103 (absent when the job has no tenant). The body has `self_test` at key 2 for a self-test, or `run` otherwise.
2. The vendor signature bundle names the vendor keys in the release (key id as in Section 3). Both signatures verify over the release id. A return from a hosted pool carries facts key 4. A customer-controlled runtime cannot include a record of the Hangar chain, so there the acceptance binds the key. When facts key 4 is present, the record that key 4 names is included. That record is a key event (kind 14) on the vendor chain (the vendor chain of the manifest) with action `vendor release key registered`. The record has the vendor key id at key 107 and the vendor keys digest at key 108. A self-test runs on a hosted pool with no tenant. A return with facts key 18 true fails if the pool is customer-controlled, if the return names a tenant, if the return carries an acceptance, or if the return carries facts key 5.
3. Unless the job is a self-test, all of these are true:
    - The release carries an acceptance.
    - The verifier has the release key set. Otherwise the check fails with "This return needs TALOS Defence's published release key set to check the release. Give it with hangar verify --release-keys."
    - The acceptance names this release, this vendor key and this vendor keys digest.
    - Every published acceptance check appears exactly once and passed. The exception is `advisory_match`, which may be not run.
    - The key id of the acceptance bundle is a root of the release key set that is valid at the acceptance time. A day key is never a root.
    - That root is not a root of the record key set, because release roots and record roots are separate keys.
    - Then the widening rules apply. The compiled record has a baseline release id at key 125. When the baseline release id equals the release id, facts key 24 is absent and the widening is empty. Otherwise facts key 24 is present, and all of these are true: the release id in key 24 equals key 125; key 24 names the same package and vendor chain as the release; the vendor signature of key 24 verifies; and the acceptance of key 24 verifies against the release key set, by a root that is not a root of the record key set. The widening is the difference `diff` (Section 12.1) from the envelope request of the baseline to the envelope request of the release. Each item is printed as `describe` prints it. Key 118 of the compiled record (absent when empty) lists exactly those items, in that order. A widening that is not empty needs facts key 5. The widening list inside the acceptance is information only and decides nothing. A self-test carries no facts key 24. When facts key 5 is present, the record that key 5 names is included. That record is an application release record (kind 16) with action `widening approved`. The record has the release id at key 100 and the tenant of the job at key 103. For a job with no tenant, key 103 is absent or the empty text.
4. The envelope digest (`hangar-envelope-v1` over the canonical bytes of the envelope) equals facts key 23 and key 101 of the compiled record. The envelope lies within the request of the release (Section 12.1, the rule `within`). The first source of the envelope is the envelope request digest of the release. The envelope states the release id, the id, name and kind of the pool, the tenant, the configuration digest, the inputs and the values digest, and each agrees with the facts. The configuration digest also equals facts key 6 and key 104 of the compiled record. Every input is in data in, with its size. A return from a customer-controlled pool states facts keys 19 and 20. A return from a hosted pool states neither.
5. Every destination with a connection made is in the destinations of the envelope. A destination with only refusals may have any name. Every model route used is a route of the envelope, to the destination of that route, or to the declared fallback when the entry is marked as a fallback. An included record holds the proposal digest of every approval, as bytes.
6. There are at most retries plus one attempts, numbered from 1 in order. Each attempt starts no earlier than the not-before time of the envelope and no later than the not-after time, allowing for the clock error bound. Each attempt ends no earlier than the attempt starts. The reset and wipe records of each attempt are included reset or wipe records. Every output names a listed attempt and is in data out with its size. Every output also has an included release job record with action `artefact checked`, the output name at key 111, the descriptor result at key 112 and the output digest at key 39. A job that states success has a last attempt that succeeded, and every output of that attempt passed.

The compiled record may hold the advisory stamp of the release vulnerability watch (Section 6.2, keys 126 to 129). Part 1 reads the stamp for every job. The record has all four keys or none. The digest is 48 bytes, the time is a uint, the counts are text in the stated form, and the decision is one of the three words. A record with no stamp is valid, so a return from before the watch verifies as before. The stamp shows what the control plane knew when the job started. The stamp does not show that the release has no vulnerabilities.

The browser verifier has no release key set. For a return that is not a self-test, the browser verifier therefore fails part 3 with the sentence above. The browser verifier never passes a release that it could not check.

**Notices.** Every report, valid or not, prints these facts as notices beside the verdict. The JSON field `notices` holds the notices, and the terminal shows them as `NOTICE:` lines. Notices are not checks, so a report never shows a notice as passed:

- attestation kind 2: "Test attestation: this certificate comes from the Hangar test pool and carries a test attestation of the instance."
- attestation kind 4: "Not attested: no attestation of the instance arrived for this job, so the certificate states none."
- reset path 2: "Reset by reboot in place: the instance was reset and rebooted between jobs on the same hardware."
- on a sandbox session certificate, one line for the tenant when there is one ("This session served the tenant NAME."), one line per companion ("Companion container NAME ran inside this session, image DIGEST."), one line for the gateway ("The gateway handled N connections and refused R."), and one line per reset step ("Reset step NAME confirmed at TIME, provider response digest HEX.").
- on a job certificate of an application release whose compiled record carries the advisory stamp: "When this job started the release was matched against the advisory snapshot sha384:HEX taken TIME. Matches: COUNTS. The advisory policy decided: DECISION. The match is only as complete as the advisory sources and the bill of materials."
- on a job certificate of an application release: for a self-test, "This return is a self-test of a release before TALOS Defence accepted it."; for a lapsed licence, "The runtime's licence had lapsed when this job returned."; and when the revocation list's age is stated, "The release revocation list was N days old when this job started."

The facts page of a kind 4 return adds these rows after the sandbox rows: Release (`<package> <version>, sha384:<12 hex>`), Tenant, Pool (`<name>, <site>`), Envelope (`sha384:<12 hex>, <n> destinations, <v> vCPU, <m> min`), Attempts, Outcome, and Self-test (`yes`, for a self-test only).

## 11. A worked start

The test vector for a slot certificate begins like this:

```
a2                         map, 2 entries (signed form)
  01                       key 1: body
  ac                       map, 12 entries
    00 04                  document type 4
    01 01                  version 1
    02 50 <16 bytes>       certificate id
    03 01                  kind 1, GPU slot
    04 a2                  issuer, map of 2
       01 75 "TALOS Defence Pty Ltd"     21-byte text
       02 6e "24 696 691 906"           14-byte text
    05 a3 ...              organisation
    06 ...                 facts
    ...
  02 a3                    key 2: bundle, map of 3
    01 50 <key id>
    02 59 12 13 <4,627 bytes>
    03 58 40 <64 bytes>
```

Every record inside the certificate begins `a2 01 ab 00 01 01 01 02 50 <chain id> 03 <index>`. The bytes show a signed form, then a record body of 11 entries, with type 1 and version 1.

## 12. The application release documents

These documents describe an application release and let a customer-controlled pool check the release offline. The release check (Section 10.1) reads them. Every document follows the encoding rules of Section 2 and lists all of its keys. A verifier refuses a document when a list that the page calls "sorted" is out of order or repeats an item. The version is 1 in every document.

### 12.1 The envelope request and the compiled envelope (types 10 and 9)

**The envelope request** sits inside the release manifest. The request has no field 0. The digest of the request is `hangar-envelope-request-v1` over the canonical bytes of the request.

| Key | Field | Type |
|---|---|---|
| 1 | destinations | array, at most 64, sorted by name, of `{1: name, 2: purpose (text), 3: required (bool)}` |
| 2 | data scopes | array, sorted, of uint: 1 the job's inputs, 2 none |
| 3 | secrets | array, sorted by name, of `{1: name, 2: destination, 3: header, 4: scheme (optional), 5: required (bool)}` |
| 4 | companions | array, at most 8, sorted by name, of `{1: name, 2: image digest (text)}` |
| 5 | model routes | array, sorted by name, of `{1: name, 2: destination, 3: fallback (optional)}` |
| 6 | person-present steps | array, sorted by action then target, of `{1: action (uint: 1 destination first use, 2 credential use, 3 agent action), 2: target, 3: sentence}` |
| 7 | compute | `{1: vCPUs, 2: memory MiB, 3: minutes per attempt}`, each at least 1 |
| 8 | failure policy | `{1: retries (0 to 3), 2: resume from a checkpoint (bool)}` |

Names: a destination is a lower-case DNS name of two or more labels. A destination is not an address, does not end in `.hangar.internal` and has no trailing dot. A secret is a capital letter then up to 63 capitals, digits or underscores. A header is lower-case letters, digits and hyphens, and is not a header that the gateway refuses. A companion is a lower-case letter then up to 30 lower-case letters, digits or hyphens, and is never `agent`. A secret is bound to a requested destination. The destination and the fallback of a model route are requested destinations, and the two differ. A destination first use step names a requested destination. A credential use step names a requested secret.

**The compiled envelope** (type 9). The envelope digest is `hangar-envelope-v1` over the canonical bytes of the compiled envelope.

| Key | Field | Type |
|---|---|---|
| 0 | document type | uint, 9 |
| 1 | version | uint, 1 |
| 2 | release id | bytes, 48 |
| 3 | pool | `{1: id (text), 2: name (text), 3: kind (uint: 1 hosted, 2 customer-controlled)}` |
| 4 | tenant | text, *optional* |
| 5 | image | `{1: digest (text), 2: platform (uint: 1 linux/arm64, 2 linux/amd64)}` |
| 6 | command | array of text, 1 to 256 |
| 7 | companions | as request key 4 |
| 8 | destinations | array of text, sorted: the gateway's allow list, and no other |
| 9 | dropped | array, sorted by item, of `{1: item (a requested destination, secret or route name), 2: reason}` |
| 10 to 13 | data scopes, secrets, model routes, person-present steps | as request keys 2, 3, 5 and 6 |
| 14, 15 | compute, failure policy | as request keys 7 and 8 |
| 16, 17 | not before, not after | `[seconds, nanoseconds]` |
| 18 | configuration digest | bytes, 48 |
| 19 | sources | `{1: request digest, 2: account policy digest, 3: tenant policy digest (optional), 4: narrowing digest}`, bytes, 48 each |
| 20 | inputs | array, sorted by name, of `{1: name, 2: digest (bytes, 48), 3: size, 4: media type}` |
| 21 | values digest | bytes, 48, *optional* |

A compiled envelope has these properties without reference to a request. Every secret, model route and fallback is bound to a granted destination. The envelope grants the data scope of the inputs of the job whenever the envelope binds an input. The window is positive and at most `(minutes + 10) * (retries + 1) * 60` seconds.

**Within.** A compiled envelope lies within the request of a release when all of these are true:

- The request is the request inside the manifest, and the envelope names the digest of the request as the request source.
- The image and the command are those of the manifest.
- Every companion, data scope and secret binding is requested exactly.
- Every destination is requested. Every required destination and secret is present.
- Every dropped item names a requested item that the envelope does not grant.
- Every model route is a requested route to the same destination, with the requested fallback or none.
- Every requested person-present step is present.
- Compute, retries and resume are no wider than requested.

**Widening.** Between two requests, the widenings are the following, in this order and by name within each: a new destination; a new or rebound secret; a new data scope; a new companion or one on another image; a new model route or one to another destination; a fallback gained or changed; more vCPUs, memory or minutes; more retries or resume newly allowed; a person-present step removed. A request with no widening needs no approval.

### 12.2 The release manifest and the signed release (type 7)

| Key | Field | Type |
|---|---|---|
| 0 | document type | uint, 7 |
| 1 | version | uint, 1 |
| 2 | vendor | `{1: chain id (bytes, 16), 2: legal name (text)}` |
| 3 | package | text: a lower-case letter then 1 to 62 lower-case letters, digits or underscores |
| 4 | version | text, `MAJOR.MINOR.PATCH`, digits only, no leading zeros |
| 5 | package manifest digest | bytes, 48 (`hangar-package-v1`) |
| 6 | image | as envelope key 5 |
| 7 | command | array of text, 1 to 256 |
| 8 | resources | `{1: vCPUs, 2: memory MiB, 3: disk GiB, 4: accelerator (uint: 0 none, 1 H100, 2 Trainium2), 5: minutes per attempt, 6: further accelerators the release also runs on (array of uint, optional, one or two codes in ascending order, none equal to key 4 and none repeated)}` |
| 9 | companions | equal to the request's companions |
| 10 | SBOM digest | bytes, 48 |
| 11 | envelope request | Section 12.1 |
| 12 | envelope request digest | bytes, 48, equal to the request's digest |
| 13 | configuration schema digest | bytes, 48 |
| 14 | runtime range | `{1: minimum, 2: below}`, each `MAJOR.MINOR.PATCH`, minimum below below |
| 15 | determinism | uint: 1 deterministic, 2 within the envelope |
| 16 | sequence | uint, from 1 |
| 17 | created at | `[seconds, nanoseconds]` |

The compute of the request lies inside the resources. The **release id** is `hangar-app-release-v1` over the exact canonical bytes of the manifest. No field names an image by tag, and no field holds a configuration value.

**The signed release**: `{1: manifest bytes, 2: vendor signature bundle, 3: {1: ML-DSA-87 public key, 2: P-256 public key}, 4: acceptance (optional), 5: acceptance signature bundle (optional)}`. Key 1 holds the exact bytes of the manifest, decoded strictly. Keys 4 and 5 are both present or both absent. The vendor bundle signs the release id (Section 4). The vendor keys digest is `hangar-vendor-key-v1` over the ML-DSA-87 key followed by the P-256 key.

### 12.3 The acceptance (type 8)

| Key | Field | Type |
|---|---|---|
| 0 | document type | uint, 8 |
| 1 | version | uint, 1 |
| 2 | release id | bytes, 48 |
| 3 | vendor key id | bytes, 16 |
| 4 | vendor keys digest | bytes, 48 |
| 5 | checks | array of `{1: name, 2: result (uint: 1 passed, 2 failed, 3 not run), 3: detail digest (optional)}` |
| 6 | widening | array of text |
| 7 | self-test returns | array of `{1: pool name, 2: certificate digest (bytes, 48)}` |
| 8 | accepted at | `[seconds, nanoseconds]` |

The published checks, each listed once, are: `vendor_signature`, `image_present`, `sbom_present`, `advisory_match`, `envelope_request`, `configuration_schema`, `runtime_range`, `envelope_difference`, `self_test`. A root of the release key set signs the acceptance digest, which is `hangar-app-acceptance-v1` over the canonical bytes of the acceptance.

### 12.4 The release revocation list (type 13)

The list is the map `{0: 13, 1: 1, 2: sequence (uint, from 1), 3: issued at, 4: revoked}`, where revoked is an array sorted by release id of `{1: release id (bytes, 48), 2: at, 3: reason (text)}`. Each list holds every revocation made so far. Signed form `{1: list, 2: bundle}`. A root of the release key set that is valid at the issue time signs the bundle, over `hangar-release-revocations-v1` of the list.

### 12.5 The release bundle and the site snapshot (types 16 and 15, unsigned)

A root signs each item inside, so neither document is signed as a whole. A key set inside is stored as the exact published bytes of the key set.

- **Release bundle**: `{0: 16, 1: 1, 2: signed release, 3: signed revocation list, 4: release key set bytes}`. The bundle checks when all of these are true: the release key set checks; the vendor signature and the acceptance hold against the release key set; the release key set signed the list; and the release is not on the list. A reader compares the key set with the key set that the reader trusts, or checks the key set as a rotation of the trusted key set.
- **Site snapshot**: `{0: 15, 1: 1, 2: pool id (text), 3: created at, 4: licence file (optional), 5: signed revocation list, 6: release key set bytes, 7: entitlement key set bytes}`. The snapshot checks when all of these are true: both key sets check and share no root; the release key set signed the list; and the entitlement key set signed the licence, which names the pool in key 2.

Neither document holds image layers. The vendor ships a separate OCI layout, and the importer checks the layout by digest against the manifest.

### 12.6 The licence (type 12)

| Key | Field | Type |
|---|---|---|
| 0 | document type | uint, 12 |
| 1 | version | uint, 1 |
| 2 | serial | uint, from 1 |
| 3 | customer | `{1: legal name, 2: ABN, 3: chain id (bytes, 16)}` |
| 4 | pool id | text |
| 5 | deployments | array, 1 to 16, of `{1: provider (uint: 1 AWS, 2 Azure), 2: id (an AWS account of 12 digits, or a lower-case Azure subscription UUID)}` |
| 6 | capability | `{1: profiles (array of text), 2: packages (uint), 3: tenants (uint)}` |
| 7 | support | uint: 1 standard, 2 extended |
| 8 | update channel | text |
| 9 | not before | uint, seconds |
| 10 | not after | uint, seconds, after not before |
| 11 | issued at | `[seconds, nanoseconds]` |

The licence file is `{1: licence, 2: bundle}`. A root of the entitlement key set that is valid at the issue time signs the bundle, over `hangar-entitlement-v1` of the licence. The offline check reads the file, the signature, the deployment and the term, in that order. The check refuses a file that does not decode, a bad signature, another deployment, or a term that has not started. Inside the term, the licence is valid. After the term, the licence is lapsed. A lapsed licence still runs installed releases and refuses new release imports. A lapsed licence admits new tenants for 90 days. The check reports nothing to TALOS Defence.
