Merge Semantics
HushSpec supports policy inheritance via the extends field. When a child policy extends a base, the merge_strategy declared in the child controls how the two documents combine. The merge result is a fully resolved document — the extends field is consumed during resolution and MUST NOT be present in the merged output.
Merge Strategies
HushSpec defines three merge strategies. The merge_strategy field must be one of "deep_merge", "merge", or "replace".
deep_merge (default)
The default strategy. For core rules, deep_merge operates at the rule-block level:
- If the child defines a rule block (e.g.,
rules.egress), that entire child rule block replaces the base rule block. - Rule blocks present in the base but absent in the child are preserved from the base.
- Arrays within rule blocks are never appended — the child's array replaces the base's array entirely.
- Scalar top-level fields (
name,description) follow simple replacement: the child's value overrides the base's.
For extensions, deep_merge delegates to the companion extension specifications, which define field-level merge behavior within each extension block (see Extension-Specific Merge Rules below).
Example
# base.yaml
hushspec: "0.1.0"
name: "org-base"
rules:
egress:
allow: ["api.internal.com"]
default: "block"
forbidden_paths:
patterns: ["**/.ssh/**", "**/.env"]
shell_commands:
forbidden_patterns: ["rm\\s+-rf\\s+/"]
# child.yaml
hushspec: "0.1.0"
name: "team-policy"
extends: "base.yaml"
# merge_strategy defaults to "deep_merge"
rules:
egress:
allow: ["api.openai.com", "api.internal.com"]
default: "block"
# Resolved result:
hushspec: "0.1.0"
name: "team-policy" # child overrides scalar
rules:
egress: # child's block replaces base's block
allow: ["api.openai.com", "api.internal.com"]
default: "block"
forbidden_paths: # preserved from base (not in child)
patterns: ["**/.ssh/**", "**/.env"]
shell_commands: # preserved from base (not in child)
forbidden_patterns: ["rm\\s+-rf\\s+/"]
Note that the child must re-include api.internal.com in its egress allow list if it wants to keep it — the child's egress block completely replaces the base's, arrays are not concatenated.
merge
Shallow merge at the rules level. For core rules, the behavior is the same as deep_merge in HushSpec v0: child rule blocks replace base rule blocks entirely, and rule blocks absent in the child are preserved from the base.
The difference is in extensions: under merge, if the child defines an extension block (e.g., extensions.posture), that entire extension block replaces the base extension block. There is no field-level merging within extensions. Extension blocks not defined in the child are still preserved from the base.
Example
# base.yaml
hushspec: "0.1.0"
rules:
egress:
allow: ["a.com"]
default: "block"
extensions:
posture:
initial: "standard"
states:
- name: "standard"
budgets: { tool_calls: 100 }
- name: "restricted"
budgets: { tool_calls: 10 }
# child.yaml (merge strategy)
hushspec: "0.1.0"
extends: "base.yaml"
merge_strategy: "merge"
extensions:
posture:
initial: "locked"
states:
- name: "locked"
budgets: { tool_calls: 0 }
Result: The child's posture block entirely replaces the base's. The resolved posture has only the "locked" state — the base's "standard" and "restricted" states are gone. The base's egress rule is preserved since the child did not define rules.
replace
The child document entirely replaces the base document. The base is loaded only to validate that the reference is resolvable; its content is completely discarded. No fields from the base are preserved.
Example
# child.yaml (replace strategy)
hushspec: "0.1.0"
extends: "base.yaml"
merge_strategy: "replace"
rules:
egress:
allow: ["b.com"]
default: "allow"
Result: The resolved document is exactly what the child declares. Nothing from base.yaml survives — no forbidden_paths, no shell_commands, nothing. Use this strategy when a child needs a completely fresh policy that just happens to be in the same inheritance chain for organizational purposes.
Strategy Comparison
| Behavior | deep_merge |
merge |
replace |
|---|---|---|---|
| Base rule blocks preserved? | Yes, if child omits them | Yes, if child omits them | No |
| Child rule block overrides base? | Yes, whole block | Yes, whole block | N/A (base discarded) |
| Arrays concatenated? | No | No | N/A |
| Extension field-level merge? | Yes (per companion spec) | No (whole block replaces) | N/A |
| Base extension blocks preserved? | Yes, if child omits them | Yes, if child omits them | No |
Merge Resolution Order
When an extends chain involves multiple levels of inheritance, merge is performed pairwise from the root to the leaf:
- Resolve the chain. Follow
extendsreferences to produce an ordered list:[root, ..., parent, child]. - Start with root. The root document (the one with no
extends) is the initial resolved document. - Apply each child. Each subsequent document is merged into the current result using the
merge_strategydeclared in that document.
This means each document in the chain can declare its own merge strategy. A middle layer might use deep_merge while the leaf uses replace.
Three-Level Chain Example
# org-root.yaml (no extends)
hushspec: "0.1.0"
name: "org-root"
rules:
forbidden_paths:
patterns: ["**/.ssh/**", "**/.env"]
egress:
allow: ["**.internal.com"]
default: "block"
shell_commands:
forbidden_patterns: ["rm\\s+-rf\\s+/"]
# team-layer.yaml
hushspec: "0.1.0"
name: "team-layer"
extends: "org-root.yaml"
# merge_strategy defaults to "deep_merge"
rules:
egress:
allow: ["**.internal.com", "api.openai.com"]
default: "block"
tool_access:
require_confirmation: ["deploy"]
default: "allow"
# project.yaml
hushspec: "0.1.0"
name: "project-policy"
extends: "team-layer.yaml"
# merge_strategy defaults to "deep_merge"
rules:
tool_access:
block: ["dangerous_tool"]
require_confirmation: ["deploy", "database_write"]
default: "allow"
Resolution steps:
- Chain:
[org-root, team-layer, project] - Start with
org-root: hasforbidden_paths,egress,shell_commands - Apply
team-layer(deep_merge):egressreplaced with team's version,tool_accessadded,forbidden_pathsandshell_commandspreserved from root - Apply
project(deep_merge):tool_accessreplaced with project's version,egresspreserved from step 3,forbidden_pathsandshell_commandspreserved from root
# Final resolved document:
hushspec: "0.1.0"
name: "project-policy"
rules:
forbidden_paths: # from org-root
patterns: ["**/.ssh/**", "**/.env"]
egress: # from team-layer
allow: ["**.internal.com", "api.openai.com"]
default: "block"
shell_commands: # from org-root
forbidden_patterns: ["rm\\s+-rf\\s+/"]
tool_access: # from project
block: ["dangerous_tool"]
require_confirmation: ["deploy", "database_write"]
default: "allow"
Circular Reference Detection
Engines MUST detect and reject circular inheritance. If document A extends B, and B extends A (or any longer cycle such as A extends B extends C extends A), the engine MUST reject the document with a clear error. Circular reference detection MUST be performed before any merge operations begin.
Engines typically detect cycles by maintaining a set of visited references during chain resolution. If a reference is encountered that is already in the visited set, the chain is circular.
Extension-Specific Merge Rules
Under the deep_merge strategy, extension blocks follow their own companion-spec merge rules rather than simple block replacement. Under merge and replace, extension blocks are replaced entirely (no field-level merging).
Posture
Under deep_merge:
- States merge by name. If the child defines a state with the same name as a base state, the child's state object entirely replaces the base's state object. States in the base not redefined by the child are preserved.
- Transitions replace entirely. If the child defines a
transitionsarray, the base's transitions are discarded. If the child omits transitions, the base's are preserved. - Initial overrides. If the child defines
initial, it replaces the base's value. Otherwise the base's value is preserved.
Origins
Under deep_merge:
- Profiles merge by
id. If the child defines a profile with the sameidas a base profile, the child's profile replaces it. New child profiles (IDs not in the base) are appended. Base profiles not redefined by the child are preserved. default_behavioroverrides. If the child defines it, the base's value is replaced. Otherwise the base's value is preserved.
Detection
Under deep_merge:
- Subsections (
prompt_injection,jailbreak,threat_intel) merge independently. Within each subsection, child fields override base fields. Base fields not specified in the child are preserved. - Thresholds follow scalar replacement: the child's threshold value overrides the base's for any given field.
Merge Helpers Are Not Part of the Spec
HushSpec does not support additional_patterns, remove_patterns, or any other additive/subtractive merge helpers. These are engine-specific convenience features that are explicitly outside the scope of this specification.
Engines that support such features MUST document them clearly and MUST ensure that the result of applying any helper is expressible as a valid HushSpec document. If you need additive pattern management, consult your engine's documentation.