HushSpec/Docs
HUSHED · MMXXVI
Documentation v1.0
v1.0.0specificationstable

HushSpec Canonical Form Specification#

Version: 1.0.0 Status: Stable Date: 2026-09-15 Companion to: HushSpec Core 1.0.0 (Section 2.3), Decision Receipts 0.2, Policy Signing 0.2


1. Introduction#

Compliance evidence is only as good as the identity of the policy it refers to. A decision receipt says "this policy produced this decision"; a signature says "this policy was approved". Both need a policy identity that is the same in every SDK, on every platform, regardless of how the document was written, which keys were omitted, or which language serialized it.

This specification defines that identity: the canonical form of a HushSpec document and the content hash derived from it. It has three parts:

  1. Canonical projection (Section 3): a deterministic transformation of a resolved document into a JSON value with every schema default made explicit and every resolution-only field removed.
  2. Canonical serialization (Section 4): RFC 8785 (JSON Canonicalization Scheme) applied to the projected value.
  3. Content hash (Section 5): SHA-256 over the canonical bytes, in a self-describing wire form.

Two conformant implementations given the same resolved document MUST produce byte-identical canonical output and the same content hash. The vectors in fixtures/core/hash/ are normative (Section 7).

1.1 Terminology#

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" are to be interpreted as described in RFC 2119.

Resolved document. The output of resolving a document's extends chain (Core Section 2.3). It contains neither extends nor merge_strategy.

Schema. The JSON Schema documents under schemas/: hushspec-core.v1.schema.json and the three extension schemas (posture, origins, detection).

Canonical projection. The JSON value produced by Section 3.

Canonical form. The UTF-8 byte sequence produced by Section 4 from the canonical projection.

Content hash. The string produced by Section 5 from the canonical form.

1.2 Design Principles#

  1. Schema-derived. Every rule in Section 3 is derivable from the published schemas. A third party needs the schemas and this document, nothing else.
  2. Semantics-preserving. Two documents with the same canonical form MUST evaluate identically under Core Section 3. The projection may only add information the schema already implies (defaults) or drop information that has no semantic effect (Section 3.3).
  3. Language-neutral. Numbers, strings, and key order are defined by RFC 8785, not by any language's serializer.
  4. Self-describing hashes. The content hash names its algorithm so a future algorithm can be introduced without ambiguity.

2. Scope and Inputs#

2.1 What is canonicalized#

The canonical form is defined for resolved HushSpec documents only. Implementations MUST resolve the extends chain before canonicalizing. Canonicalizing an unresolved document is a conformance failure because the resulting hash would identify a fragment, not the policy that is enforced.

2.2 From YAML to the JSON data model#

A HushSpec document is authored in YAML (Core Section 2.4). Before projection it is converted to the JSON data model:

  • YAML mappings become JSON objects. Keys are strings (the YAML profile forbids non-string keys).
  • YAML sequences become JSON arrays.
  • YAML strings become JSON strings, byte-for-byte after YAML escape processing. No Unicode normalization is applied to values. (Path and host normalization in Core Sections 3.1 and 3.3 happen at evaluation time, on action inputs, never on the document.)
  • YAML integers become JSON numbers. YAML floats become JSON numbers. The distinction between 10 and 10.0 does not survive canonicalization (Section 4.3).
  • YAML booleans true/false become JSON booleans. Under the YAML 1.2 Core schema required by Core Section 2.4, yes, no, on, and off are strings.
  • YAML null becomes JSON null. HushSpec schemas define no nullable fields; a null written anywhere the schema types a value (a declared property, an array element, or a schema-map entry) is a validation error and the document MUST NOT be canonicalized. Inside the free-form values the schema does not describe (when.context and everything below it) null is an ordinary JSON value and is canonicalized as one.

A document supplied as JSON is already in the data model.

2.3 Validity precondition#

Only valid documents (Core Section 7) have a canonical form. Implementations MUST validate before canonicalizing and MUST NOT emit a canonical form or content hash for an invalid document. In particular, unknown fields at any level are a validation error, so the projection never has to decide what to do with them.


3. Canonical Projection#

The canonical projection is computed by walking the resolved document alongside its schema. It is defined recursively over schema objects (JSON Schema nodes with a properties map), schema arrays (type: array with items), schema maps (type: object with an object-valued additionalProperties, e.g. posture states), and leaf values.

3.1 Top level#

  1. Start from the resolved document as a JSON object.
  2. Remove extends and merge_strategy if present. These are resolution fields (Core Section 2.3). A null written for either is refused before the removal, as Section 3.2 rule 2 refuses a null written for any declared property: removal takes out a value the document wrote, and is not a licence to accept one no valid document carries. Their schema defaults are not materialized: merge_strategy has the default deep_merge in the schema, but it MUST NOT appear in the canonical projection.
  3. If metadata.signature is present, remove it (Signing Section 7). This field is reserved for an inline signature and MUST NOT be covered by the hash it signs.
  4. Project the remaining object against the core schema's top-level properties per Section 3.2, except that the extensions value is projected per Section 3.4.

hushspec, name, description, rules, and metadata are therefore included exactly as written (after projection of their contents). name and description have no defaults and are absent when not written.

3.2 Objects: defaults materialized#

For a JSON object V projected against a schema object S with property map P:

  1. Every key of V MUST be in P (Section 2.3).
  2. No value in V may be null (Section 2.2). An implementation MUST refuse the document rather than emit null for a property the schema declares.
  3. For each property k in P, in any order (order is irrelevant; serialization sorts keys):
    • If k is present in V, its value is projected against P[k] (following any $ref). The projected value is included unless Section 3.3 says to omit it.
    • If k is absent in V and P[k] declares a default, the default value is included verbatim. Defaults are leaf values in every HushSpec schema (booleans, strings, numbers, or empty arrays); they are never themselves projected further.
    • If k is absent and P[k] declares no default, it stays absent.

Defaults are materialized only inside objects that are present in the document. An absent optional object (for example a rule block that the policy never mentions, or the whole rules object) is not invented. This keeps "the policy says nothing about egress" distinct from "the policy declares egress with default settings", which evaluate differently (Core Section 3: an absent block is inert; a present block with defaults is active).

A schema map (additionalProperties as a schema, used by posture states) projects each entry's value against that schema and keeps every key as written.

A schema array projects each element against items. Element order is preserved; arrays are never sorted.

Leaf values, and free-form objects the schema does not describe (when.context, whose entries are arbitrary), are kept exactly as written.

3.3 Empty containers#

Some fields have no schema default and mean the same thing whether they are absent or written as an empty array or object: when: {} is a condition that is always true, days: [] is every day, context: {} constrains nothing, an empty metadata: {} says nothing. Because implementations model these fields differently (some cannot distinguish "absent" from "empty"), the projection normalizes them:

After projecting a property value, if the property has no schema default, is not in the schema's required list, and the projected value is an empty array or an empty object, the property is omitted.

This rule applies at every level, including the top level (rules: {}, extensions: {}, metadata: {} are omitted) and inside extensions (extensions.detection: {} projects to {} and is omitted; extensions.origins: {} projects to {"default_behavior":"deny"} and is kept).

Properties whose default is an empty array (patterns, allow, block, exceptions, and so on) are unaffected: the empty array is the default and is materialized anyway.

Presence-significant exception. One field's empty value changes meaning and MUST be preserved:

SchemaPropertyWhy presence matters
OriginProfilematchmatch: {} is the explicit default profile; an absent match never matches (Origins Section 3).

The origins profile overlay lists -- ToolAccessRule.allow, block, require_confirmation and EgressRule.allow, block -- are not presence-significant: Origins Section 4 gives an absent overlay list ("inherit the base") and a present-but-empty one ("contributes nothing") the same evaluation result, so a written-empty overlay list has no meaning for the canonical form to carry.

No other field in the current schemas is presence-significant. A future schema revision that adds one MUST extend this table in the same change.

3.4 Extensions#

A present extensions MUST be a JSON object, and every one of its keys MUST name a published extension schema. An implementation that finds anything else refuses the document rather than passing the value through unprojected (Section 2.3).

The core schema declares extensions.posture, extensions.origins, and extensions.detection as opaque objects. For projection, each present extension block is projected against the root of its own schema document (hushspec-posture.v1.schema.json, hushspec-origins.v1.schema.json, hushspec-detection.v1.schema.json) using the same rules as Section 3.2, then subjected to Section 3.3. If every extension block projects to an omitted value, extensions itself is omitted.

3.5 Worked example#

Document (resolved):

yaml
hushspec: "0.1.0"
rules:
  egress:
    allow: ["api.example.com"]
  forbidden_paths:
    patterns: []
    when: {}
metadata: {}

Canonical projection (shown pretty-printed; the canonical form has no whitespace):

json
{
  "hushspec": "0.1.0",
  "rules": {
    "egress": {"allow": ["api.example.com"], "block": [], "default": "block", "enabled": true},
    "forbidden_paths": {"enabled": true, "exceptions": [], "patterns": []}
  }
}

enabled, block, default, and exceptions were materialized from schema defaults; when: {} and metadata: {} were omitted; patterns: [] stays because its default is [].

3.6 Schema fields that needed a decision#

The following points are ambiguous from the schemas alone. This section resolves them; the reference implementation and vectors follow these resolutions.

FieldAmbiguityResolution
merge_strategyHas a schema default but is a resolution field.Never materialized (Section 3.1).
TimeWindow.daysNo default; absent and [] both mean "all days".Empty is omitted (Section 3.3).
PostureState.capabilities, PostureState.budgetsNo default; absent and empty both mean "none".Empty is omitted.
Condition.all_of, Condition.any_of, Condition.contextNo default; empty is vacuous.Empty is omitted.
OriginProfile.match{} and absent differ.Preserved (Section 3.3 table).
Origin overlays allow/block/require_confirmationAbsent inherits the base; written empty contributes nothing.Both evaluate alike (Origins Section 4), so empty is omitted.
origins.profilesNo default; [] is the same as absent.Empty is omitted.
Posture root states, transitionsRequired.Required properties are never omitted, even when empty.
metadata sub-fieldsNo defaults.Written values only. metadata.signature is stripped (Section 3.1).
Numeric defaults written as floats (max_imbalance_ratio: 10.0)Float vs. integer.Indistinguishable after Section 4.3; the default 10.0 serializes as 10.

4. Canonical Serialization#

The canonical form is the RFC 8785 (JSON Canonicalization Scheme, JCS) serialization of the canonical projection, encoded as UTF-8. This section restates the parts of RFC 8785 that matter and adds HushSpec-specific constraints. Where this section and RFC 8785 disagree, RFC 8785 governs.

4.1 Objects#

  • Members are emitted in ascending order of their keys, compared as sequences of UTF-16 code units (RFC 8785 Section 3.2.3). This is not code-point order for characters outside the Basic Multilingual Plane: "€" (U+20AC) sorts before "😀" (U+1F600, encoded as the surrogate pair D83D DE00).
  • No whitespace anywhere. Members are separated by , and keys from values by :.
  • Duplicate keys cannot occur (they are rejected by the YAML profile).

4.2 Strings#

Strings are emitted between double quotes with exactly these escapes (RFC 8785 Section 3.2.2.2):

CharacterEmitted as
U+0022 quotation mark\"
U+005C reverse solidus\\
U+0008 backspace\b
U+0009 tab\t
U+000A line feed\n
U+000C form feed\f
U+000D carriage return\r
other U+0000..U+001F\u followed by four lowercase hex digits
everything elsethe character itself, UTF-8 encoded

Non-ASCII characters, U+007F, U+2028, U+2029, and astral characters are not escaped. Implementations that inherit a serializer which escapes /, non-ASCII, or line separators MUST override it.

4.3 Numbers#

Numbers are emitted using the ECMAScript Number::toString algorithm (RFC 8785 Section 3.2.2.3), which yields the shortest decimal that round-trips through an IEEE 754 double:

  • Whole values print without a decimal point or exponent: 10.0 → 10, 1e16 → 10000000000000000.
  • Fractions print in positional notation between 1e-6 and 1e21: 0.35 → 0.35, 0.000001 → 0.000001.
  • Outside that range, exponent notation with a signed exponent and no leading zeros: 1e21 → 1e+21, 1e-7 → 1e-7, 2.5e-8 → 2.5e-8.
  • Negative zero prints as 0.
  • NaN and infinities have no JSON representation; a document containing one is invalid.

Consequently an integer 10 and a float 10.0 have the same canonical form. This is deliberate: it removes the most common cross-language divergence (serializers that print 10 versus 10.0).

The safe-integer bound#

A number written with integer syntax -- digits with an optional sign, no fraction and no exponent -- MUST have an absolute value of at most 2^53 − 1, the largest integer an IEEE 754 double represents exactly. An implementation that encounters a larger integer literal MUST refuse to canonicalize rather than round it.

A number written with float syntax -- a fraction, an exponent, or both -- carries no such bound. It denotes a double, and the algorithm above emits it at any magnitude: 1.0e+16 → 10000000000000000, 1.0e+21 → 1e+21, 1.5e+300 → 1.5e+300.

This float-syntax exception applies to number properties and free-form condition context. A policy property declared as integer has the additional portable field bound of 2^53 - 1 in absolute value, regardless of source spelling (Core specification, Section 2.4). Integral float spellings within that bound, such as 10.0 and 1e1, are accepted and normalize to the same integer value; 9007199254740992.0 is refused in an integer property but remains a valid double in condition context. Tighter schema bounds still apply.

The bound is syntactic because the syntax is the only thing that distinguishes the two cases: 10000000000000000 and 1.0e+16 denote the same double, and only the way each was written says whether the author meant an exact integer, which 2^53 − 1 bounds, or a double, which it does not. An implementation therefore applies the bound where the distinction still exists -- in its parser, which sees the literal -- rather than in its serializer, which sees a number.

A value handed to an implementation directly, as a number of the host language rather than as a document to parse, carries no syntax to read. Section 2.3 already requires such a value to have come from a valid document, so the bound has been applied by the parser that read it.

A host language that types integers apart from doubles keeps the distinction the syntax carried, and an implementation written in one MAY apply the bound to such a value as well; an implementation whose only numeric type is a double cannot tell the two apart and emits the value. Neither choice changes the canonical form of any document: no valid document carries an integer past the bound, so the two agree wherever a document is what is being hashed.

4.4 Other values#

true, false, and null are emitted as those literals. Arrays are [, elements separated by ,, ], with element order preserved.

4.5 Encoding#

The canonical form is the UTF-8 encoding of the serialized text, with no byte order mark and no trailing newline.


5. Content Hash#

The content hash is:

text
"sha256:" || lowercase-hex(SHA-256(canonical form))

That is, the literal prefix sha256: followed by 64 lowercase hexadecimal characters. The prefix is part of the wire value everywhere a content hash appears: receipts (policy.content_hash, policy.extends_chain[].content_hash, action.content_hash), signature envelopes (content_hash), and log entries.

Rationale for the prefix: HushSpec 0.1 receipts carried a bare 64-hex digest whose algorithm was implied. A self-describing value lets a verifier reject an unknown algorithm instead of guessing, and lets a future minor version add an algorithm without changing field names. Only sha256 is defined in 0.2; verifiers MUST reject any other prefix.

Migration. Bare 64-hex hashes emitted by 0.1 SDKs are not valid 0.2 content hashes. A consumer that must accept both MAY treat a bare 64-hex string as sha256: for hashes computed by the same 0.1 code path, but MUST NOT compare a 0.1 hash to a 0.2 hash for equality: 0.1 hashes were computed over non-canonical serializations and differ per SDK.


6. Conformance#

An implementation conforms to this specification if, for every vector in fixtures/core/hash/, it produces exactly the vector's canonical text and content_hash from the vector's policy.

Implementations SHOULD canonicalize from the generic JSON data model of the resolved document (a tree of maps, arrays, and scalars) rather than from typed language structures, because typed structures commonly lose the distinction between absent and empty (Section 3.3) or between integer and float. When an implementation canonicalizes from typed structures, it MUST still reproduce the vectors byte-for-byte.

The reference implementation is scripts/canonical_json.py, a standard-library-only Python program that performs projection, serialization, and hashing and that generated the vectors. Its documented limits: it does not resolve extends (resolve first, for example with h2h resolve --format json), and it assumes the input has already been validated.


7. Test Vectors#

Vectors are YAML files under fixtures/core/hash/ conforming to schemas/hushspec-hash-vector.v1.schema.json:

FieldMeaning
hushspec_hash_vectorVector format version, "0.1.0".
descriptionWhat the vector exercises, with a section reference.
sourceOptional, informational: the unresolved document policy came from.
policyThe resolved document to canonicalize.
canonicalThe exact canonical text.
content_hashThe content hash.
VectorExercises
minimal.yamlSection 3.1: nothing to materialize at the top level.
all-rule-blocks-defaults.yamlSection 3.2: every rule block present and empty; full default materialization.
defaults-partial.yamlSection 3.2: defaults fill only absent fields; absent blocks are not invented.
numbers.yamlSection 4.3: whole floats, fractions, integers.
numbers-large.yamlSection 4.3: float syntax beyond the safe-integer bound; negative zero.
strings-escapes.yamlSection 4.2: every escape class, non-ASCII, U+2028, astral, DEL, NBSP.
key-order-utf16.yamlSection 4.1: UTF-16 key order including a surrogate-pair key.
empty-containers.yamlSection 3.3: omission of empty no-default containers.
metadata-governance.yamlSection 3.1: metadata is covered by the hash.
when-conditions.yamlSections 3.2 and 3.3 applied to when; timezone default.
extension-posture.yamlSection 3.4: schema maps, required-but-empty arrays.
extension-origins.yamlSection 3.3: the presence-significant match: {}; origins defaults.
origins-overlay-empties.yamlSection 3.3: overlay lists written empty are omitted; match: {} is kept.
extension-detection.yamlSection 3.4: detector defaults.
extends-resolved.yamlSection 2.1: canonicalized after resolution; source shows the unresolved child.
empty-strings.yamlSections 3.2 and 3.3: an optional string written "" survives; only empty containers are omitted.

Verify with:

Terminal
python3 scripts/canonical_json.py --check fixtures/core/hash

All four SDKs walk fixtures/core/hash/, as does the conformance testkit's hash fixture category. hushspec-difftest additionally asserts that every SDK reports the same content hash for every generated policy.


8. Security Considerations#

The security considerations for the whole specification family, including the shared threats this section relies on, are collected in hushspec-security.md.

  • Hash identity, not authenticity. A content hash proves that two parties hold the same policy; it does not prove who wrote it. Authenticity comes from the signature specification.
  • Canonicalization must precede validation of nothing. Validate first (Section 2.3). A canonicalizer that tolerates unknown fields could be made to hash a document that no conformant engine would accept.
  • Resolution is part of identity. Because the hash covers the resolved document, changing a base policy changes the hash of every child, even if the child file is untouched. This is the intended behavior: the enforced policy changed.
  • Resource limits. The canonical form of a document is bounded by the YAML profile's size limits (Core Section 2.4); canonicalizers need no additional limits.

Appendix A. Changes from 0.1#

HushSpec 0.1 defined no canonical form. Each SDK hashed its own serialization of the parsed document, so identical policies produced four different content_hash values. This specification replaces all of those with one definition and moves the wire form from bare hex to sha256:-prefixed hex.

Loading documentation index…

↑↓ navigate↵ openesc close