{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://actum.run/spec/execution.schema.json",
  "title": "Execution Resource",
  "description": "A run of a composition. Durable: state persists per-step so resume and unwind survive crashes.",
  "type": "object",
  "required": [
    "spec_version",
    "id",
    "namespace",
    "composition",
    "state",
    "created_at"
  ],
  "properties": {
    "spec_version": {
      "type": "string"
    },
    "id": {
      "type": "string"
    },
    "namespace": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_-]{0,31}$",
      "description": "The namespace this execution runs in (a semantic key; the URL is rendered under links.namespace). In 0.x this is 'dev' or 'prod'. Connection names resolve against this namespace's credentials; resume and unwind always run here with the same bindings \u2014 an undo must hit the same system the do hit.",
      "examples": ["dev", "prod"]
    },
    "composition": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_]*@.+$",
      "description": "The pinned composition this executes, as name@version. Pinned at creation; resume and unwind always run against this exact version, regardless of later publishes or yanks.",
      "examples": [
        "create_issue_with_labels@1.2.0"
      ]
    },
    "state": {
      "enum": [
        "pending",
        "running",
        "succeeded",
        "failed",
        "unwinding",
        "unwound",
        "unwind_incomplete",
        "abandoned",
        "compensation_incomplete",
        "awaiting_approval",
        "suspended",
        "partially_compensated"
      ],
      "description": "pending -> running -> succeeded | failed. failed -> running (via resume) | unwinding (via unwind) -> unwound | unwind_incomplete | partially_compensated. failed may be marked abandoned without unwinding (caller accepts the orphans). compensation_incomplete is terminal and only ever reached by an ephemeral execution whose in-session unwind could not finish (see ephemeral-credentials.md). unwind_incomplete is NON-terminal (curmudgeon remediation A2) and is the vaulted analog of compensation_incomplete: reached when unwind() could not confirm every undo it attempted (a conclusive failure or an indeterminate transport error/timeout on at least one undo, classified confirmed/unknown/pending in the compensation field, same partition shape compensation_incomplete uses) -- unwinding never returns to the caller as a terminal response (no request ever observes state unwinding after it returns); a second unwind() retries only the still-unconfirmed (unknown/pending) undos, since already-confirmed ones are marked undo_done and are skipped, guaranteeing single delivery. partially_compensated is TERMINAL (0.10.0, preflight+unwind-policy epic, plan D6): reached only under a composition-declared unwind_mode of best_effort (see composition.schema.json's unwind_mode -- absent means strict, in which unwind_incomplete/unwound are the only outcomes) when a best_effort unwind pass completes with at least one undo recorded in compensation.skipped (its dependency was unavailable at connect time) and nothing left unknown/pending -- never silently unwound or compensated. A later explicit unwind() on a partially_compensated execution is permitted and retries exactly the skipped bucket, reaching unwound if every skipped undo now confirms (or unwind_incomplete/partially_compensated again if some remain unconfirmed/skipped). awaiting_approval is non-terminal: run() reached a type:approval step and is paused pending a decision on the open approvals[] entry (see $defs/approvalRecord) -- transitions to running (approve, re-driving run() and skipping done steps), failed (reject or an on_timeout:reject expiry, via the ordinary failure contract), or unwinding (caller abandons while waiting; the gate fired nothing so it contributes no unwind entries); resume on awaiting_approval is refused (409) except that an on_timeout:expire expiry re-arms the gate on resume. suspended is non-terminal: the execution is paused at a step boundary by an explicit suspend (see namespace.schema.json's kill-switch semantics); resume continues it. All four (awaiting_approval, suspended at 0.8.0; unwind_incomplete at the curmudgeon remediation; partially_compensated at 0.10.0) rely on spec/README.md's execution-state forward-compat rule (D2): clients MUST treat any state value they do not recognize as opaque and non-terminal, the same discipline compensation_incomplete established as precedent at 0.3.0."
    },
    "dry_run": {
      "type": "boolean",
      "default": false,
      "description": "When true, no step fires; the execution's plan field holds the resolved requests that would have been made."
    },
    "input": {
      "type": "object",
      "description": "The validated input this execution ran with."
    },
    "output": {
      "type": "object",
      "description": "The composition's output projection. Present only when state is succeeded."
    },
    "failure": {
      "$ref": "https://actum.run/spec/failure-contract.schema.json",
      "description": "Present only when state is failed."
    },
    "plan": {
      "type": "array",
      "description": "Dry-run only: the fully resolved requests that would fire, in order. Structured (breaking change at 0.7.0 — was array[string]): each entry is either a resolvedStep (method, resolved URL, connection, headers/body digests, undo — the same shape the audit event records for a fired step minus the outcome) or, for a type:approval composition step, a gateStep (the step cannot be a resolvedStep: it declares no method/connection, so it cannot satisfy resolvedStep's required [method, connection]). A step whose request depends on prior step output that dry-run cannot resolve is marked symbolic (see resolvedStep.symbolic).",
      "items": {
        "anyOf": [
          { "$ref": "#/$defs/resolvedStep" },
          { "$ref": "#/$defs/gateStep" }
        ]
      }
    },
    "links": {
      "type": "object",
      "description": "Server-rendered navigation URLs (path-absolute, opaque to clients). NOT part of the persisted record: derived on read from the semantic keys (composition, namespace). Typically carries self, composition, namespace, and events (the per-execution audit projection).",
      "additionalProperties": {
        "type": "string",
        "format": "uri-reference"
      }
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    },
    "finished_at": {
      "type": "string",
      "format": "date-time"
    },
    "warnings": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Implementation warnings the caller should see \u2014 e.g. the execution ran over a personal access token connection (actions attributed to its owner; expiry/revocation breaks resume/unwind)."
    },
    "created_by": {
      "type": "string",
      "description": "The principal that created this execution. Present when the implementation enforces access control; readable by the creator and the composition's executors/admins (see access-control.md)."
    },
    "approvals": {
      "type": "array",
      "description": "One entry per type:approval composition step this execution has reached, in step order. An entry is created (state open) the moment run() pauses at the gate (state becomes awaiting_approval) and is decided in place (approved | rejected | expired) — never removed, so the array is the durable record of every gate this execution's plan carried, decided or not.",
      "items": { "$ref": "#/$defs/approvalRecord" }
    },
    "provenance": {
      "$ref": "#/$defs/provenance",
      "description": "Optional actor/delegation chain supplied at execution create (see $defs/provenance). created_by (the attested principal) is independent of this field and is never derived from it."
    },
    "compensation": {
      "type": "object",
      "additionalProperties": false,
      "description": "The unwind outcome, partitioned by bucket in reverse-step order (confirmed precede unknown precede pending precede skipped). Two producers share this exact shape: (1) an ephemeral execution's in-session unwind (see _inline_unwind) -- present when it ran; on state failed only confirmed is populated (every attempted undo succeeded), on state compensation_incomplete unknown and/or pending are also present (skipped is never populated by this producer -- ephemeral unwind is out of scope for unwind_mode, see composition.schema.json's unwind_mode). (2) a vaulted execution's unwind() (A2, curmudgeon remediation; skipped bucket + partially_compensated added 0.10.0, plan D6) -- present whenever unwind() has attempted at least one undo. Under the composition's declared unwind_mode: strict (absent means strict) populates only confirmed/unknown/pending -- on state unwound only confirmed is populated, on state unwind_incomplete unknown and/or pending are also present, and the pass halts at the first non-confirmed outcome (no skipped entries are ever produced under strict). best_effort additionally populates skipped -- an undo whose dependency was unavailable at connect time (see D5) is recorded there with its reason and dependency and the pass continues past it; on state partially_compensated, skipped holds every remaining undo and unknown/pending are both empty; if the pass also leaves unknown/pending entries the state is unwind_incomplete instead (skipped may still be non-empty there too), never partially_compensated. A second unwind() call after unwind_incomplete or partially_compensated retries only the still-unconfirmed buckets (unknown/pending/skipped); confirmed entries are never re-driven (double-compensation risk) and persist across calls.",
      "properties": {
        "confirmed": {
          "type": "array",
          "description": "The undo was sent and the provider returned a success response in-session, OR (0.14.0, undo absent-is-success classification, plan D1) the step's undo declares missing_ok: true (composition.schema.json) and an attempt at it received HTTP 404 or 410 -- the resource is verifiably gone, so this is treated as confirmed too. The entry's outcome ($defs/confirmedUndoEntry) distinguishes the two: \"success\" (or absent) for an observed success response, \"absent\" for the missing_ok-inferred case. The caller MUST NOT re-drive these (double-compensation risk) regardless of which outcome produced the entry.",
          "items": { "$ref": "#/$defs/confirmedUndoEntry" }
        },
        "unknown": {
          "type": "array",
          "description": "The undo was sent but its outcome could not be determined (dropped connection or timeout). Reconcile against the provider before retrying.",
          "items": { "$ref": "#/$defs/undoEntry" }
        },
        "pending": {
          "type": "array",
          "description": "The undo was never attempted (or a conclusive error response proved it did not happen); safe to re-drive. Exception (0.14.0, plan D1): when the step's undo declares missing_ok: true, an HTTP 404/410 response is not \"a conclusive error response that proved it did not happen\" -- it is absent-is-success and lands in confirmed instead (see $defs/confirmedUndoEntry's outcome field).",
          "items": { "$ref": "#/$defs/undoEntry" }
        },
        "skipped": {
          "type": "array",
          "description": "0.10.0 (plan D6): populated only under a best_effort-declared unwind_mode. The undo's dependency was provably unavailable at connect time (see composition.schema.json's unwind_mode and D5's connect-phase-only classification) so the engine recorded it here -- with why and against what -- and continued the reverse-order pass rather than halting (the strict behavior). Each entry is audited (execution.unwind.skip). Safe to re-drive on a later unwind() once the dependency returns; never re-driven automatically.",
          "items": { "$ref": "#/$defs/skippedUndoEntry" }
        }
      }
    }
  },
  "allOf": [
    {
      "if": {
        "properties": { "state": { "const": "compensation_incomplete" } }
      },
      "then": {
        "required": ["compensation"]
      }
    },
    {
      "$comment": "A2 (curmudgeon remediation): unwind_incomplete is the vaulted analog of compensation_incomplete -- reached only when unwind() attempted at least one undo it could not confirm, so the same compensation partition (confirmed/unknown/pending) is required.",
      "if": {
        "properties": { "state": { "const": "unwind_incomplete" } }
      },
      "then": {
        "required": ["compensation"]
      }
    },
    {
      "$comment": "0.10.0 (plan D6): partially_compensated is reached only when a best_effort unwind pass recorded at least one skipped undo, so compensation (with a non-empty skipped bucket) is required.",
      "if": {
        "properties": { "state": { "const": "partially_compensated" } }
      },
      "then": {
        "required": ["compensation"],
        "properties": {
          "compensation": {
            "required": ["skipped"],
            "properties": {
              "skipped": { "minItems": 1 },
              "unknown": { "maxItems": 0 },
              "pending": { "maxItems": 0 }
            }
          }
        }
      }
    },
    {
      "$comment": "plan is dry-run-only: a live execution (dry_run absent or false) MUST NOT carry a plan. The structured plan is authoritative-looking, so leaking it onto a real run would mislead consumers.",
      "if": {
        "properties": { "dry_run": { "const": true } },
        "required": ["dry_run"]
      },
      "else": {
        "not": { "required": ["plan"] }
      }
    }
  ],
  "$defs": {
    "digest": {
      "type": "object",
      "additionalProperties": false,
      "required": ["sha256", "bytes"],
      "description": "A content digest standing in for a value that is redaction-class digest-only (request/response bodies, resolved template values): the SHA-256 and the byte count, never the value itself. This is the digests-not-bodies construction — the retained trail carries no erasable PII, so WORM retention and GDPR/CCPA erasure coexist.",
      "properties": {
        "sha256": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$",
          "redaction": "cleartext",
          "description": "Lowercase hex SHA-256 of the raw bytes (a one-way fingerprint, not the value)."
        },
        "bytes": {
          "type": "integer",
          "minimum": 0,
          "redaction": "cleartext",
          "description": "Length of the raw bytes."
        }
      }
    },
    "resolvedStep": {
      "type": "object",
      "additionalProperties": false,
      "required": ["method", "connection"],
      "description": "One fully-resolved primitive request: what a step would fire (dry-run plan) or did fire (audit event), templates resolved. Bodies and headers appear only as digests (redaction-class digest-only); vault-injected credentials never enter this shape at all (redaction-class never), including secret-referencing URL query parameters (see url). Shared shape: execution.plan is an array of these; audit-event.schema.json $refs this definition for step events. Each sub-field carries a redaction annotation.",
      "properties": {
        "method": {
          "type": "string",
          "redaction": "cleartext",
          "description": "HTTP method of the resolved request.",
          "examples": ["POST", "DELETE"]
        },
        "url": {
          "type": "string",
          "redaction": "cleartext",
          "description": "The resolved absolute URL (connection base_url + resolved path), including non-secret query parameters. A query parameter or path segment whose value references a vault secret (`secrets.*`) is redaction-class NEVER: it is never recorded resolved — in a dry-run plan it appears only as its unresolved `{{ secrets.* }}` placeholder, and in a live audit event the secret-referencing parameter is omitted from the URL entirely (mirroring the headers_digest exclusion of vault-injected headers). Present when the step could be fully resolved; may be partial when symbolic is true.",
          "examples": ["https://tracker.example.com/projects/p1/issues"]
        },
        "connection": {
          "type": "string",
          "redaction": "cleartext",
          "description": "The connection name whose namespace-scoped credential this request resolves against."
        },
        "headers_digest": {
          "$ref": "#/$defs/digest",
          "redaction": "digest-only",
          "description": "Digest of the resolved non-secret request headers. Authorization and other vault-injected headers are excluded before digesting (redaction-class never)."
        },
        "body_digest": {
          "$ref": "#/$defs/digest",
          "redaction": "digest-only",
          "description": "Digest of the resolved request body. Absent for bodyless methods and when symbolic is true."
        },
        "undo": {
          "type": "string",
          "redaction": "cleartext",
          "description": "The primitive that undoes this step, or the literal 'none' when the action cannot be compensated. NORMATIVE presence rule (why this field is optional, not required): in a live audit step event it MUST be present, so the audit record is self-sufficient for a manual unwind — the resolved undo call (e.g. 'DELETE /issues/8841'). In a dry-run plan entry it MAY be absent when the undo template itself depends on prior-step output that dry-run cannot resolve (the same reason symbolic exists); when present at plan time it carries that same resolved undo call shape (or the literal 'none'), never an unresolved template.",
          "examples": ["DELETE /issues/8841", "none"]
        },
        "symbolic": {
          "type": "boolean",
          "default": false,
          "redaction": "cleartext",
          "description": "True when the request could not be fully resolved at plan time (it depends on prior step output, or its connection is unresolved). url may then be partial (the known path with template placeholders still visible) and body_digest is omitted; the entry documents the dependency rather than an exact request."
        }
      },
      "if": {
        "required": ["symbolic"],
        "properties": { "symbolic": { "const": true } }
      },
      "else": {
        "required": ["url"]
      }
    },
    "undoEntryBase": {
      "type": "object",
      "required": ["resource", "undo"],
      "properties": {
        "resource": {
          "type": "string",
          "description": "Human-readable identification of the resource/state, including its identifier.",
          "examples": ["issue 8841"]
        },
        "undo": {
          "type": "string",
          "description": "The primitive call that undoes it, executable by the caller with the same connections. The literal string \"none\" declares the action cannot be compensated.",
          "examples": ["DELETE /issues/8841", "none"]
        }
      }
    },
    "undoEntry": {
      "description": "An unknown/pending bucket entry (resource, undo). The outcome discriminator (0.14.0, plan D1) is confined to compensation.confirmed[] -- see $defs/confirmedUndoEntry; an unknown/pending entry carrying outcome is structurally invalid (only a confirmed entry records how it was confirmed).",
      "allOf": [{ "$ref": "#/$defs/undoEntryBase" }],
      "not": { "required": ["outcome"] }
    },
    "confirmedUndoEntry": {
      "description": "A compensation.confirmed[] entry: $defs/undoEntryBase plus the additive-optional outcome discriminator (0.14.0, plan D1).",
      "allOf": [{ "$ref": "#/$defs/undoEntryBase" }],
      "properties": {
        "outcome": {
          "enum": ["success", "absent"],
          "description": "0.14.0 (undo absent-is-success classification, plan D1): additive-optional wire discriminator. MUST be \"absent\" when this confirmation was inferred from an undo attempt (first attempt or a later re-drive) that received HTTP 404 or 410 under the step's missing_ok: true declaration (composition.schema.json) -- the resource was verifiably gone rather than observed to succeed. \"success\", or the field's absence, means an ordinary observed success response -- existing 0.13.0 producers that never emit this field remain valid. Structurally excluded from unknown/pending entries ($defs/undoEntry rejects it) and from skipped entries ($defs/skippedUndoEntry has no such field)."
        }
      }
    },
    "skippedUndoEntry": {
      "type": "object",
      "required": ["resource", "undo", "reason", "dependency"],
      "description": "0.10.0 (plan D6): an undo entry ($defs/undoEntryBase: resource, undo) plus why it was skipped and against which dependency, recorded in compensation.skipped by a best_effort unwind pass. Shares the same dependency shape preflight-failure.schema.json uses for its refusal, so a caller correlates a preflight 503 and a later unwind skip against the same kind/name vocabulary.",
      "properties": {
        "resource": {
          "type": "string",
          "description": "Human-readable identification of the resource/state, including its identifier.",
          "examples": ["issue 8841"]
        },
        "undo": {
          "type": "string",
          "description": "The primitive call that would undo it, executable by the caller with the same connections once the dependency returns.",
          "examples": ["DELETE /issues/8841"]
        },
        "reason": {
          "type": "string",
          "description": "Why this undo was skipped rather than attempted or halted on (e.g. a connect-phase failure against the named dependency).",
          "examples": ["connection refused: tracker"]
        },
        "dependency": {
          "type": "object",
          "additionalProperties": false,
          "required": ["kind", "name"],
          "description": "The dependency that was unavailable when this undo was attempted.",
          "properties": {
            "kind": {
              "enum": ["store", "vault", "provider"],
              "description": "Same vocabulary as preflight-failure.schema.json's dependency.kind. An unwind-time connect failure against the remote API is kind provider (name = the binding connection's name) -- the same value a Tier-2 preflight probe failure against that connection uses, so the correlation holds."
            },
            "name": {
              "type": "string",
              "description": "Identifies the specific dependency instance (e.g. the connection name)."
            }
          }
        }
      }
    },
    "gateStep": {
      "type": "object",
      "additionalProperties": false,
      "required": ["type", "step", "role", "timeout"],
      "description": "The dry-run plan entry for a type:approval composition step. Sibling of resolvedStep, not a variant of it: an approval step declares no method/connection/request/undo (composition.schema.json's undo-required allOf forbids them), so it cannot satisfy resolvedStep's required [method, connection] and needs its own $def. plan.items is anyOf [resolvedStep, gateStep].",
      "properties": {
        "type": {
          "const": "approval",
          "redaction": "cleartext",
          "description": "Discriminates this plan entry as a gate, mirroring the composition step's own type field."
        },
        "step": {
          "type": "string",
          "redaction": "cleartext",
          "description": "The name of the composition step this gate corresponds to."
        },
        "role": {
          "type": "string",
          "redaction": "cleartext",
          "description": "The composition-level role (see access-control.md Roles) that may decide this gate, copied from the composition step's approval.role."
        },
        "timeout": {
          "type": "string",
          "pattern": "^P(?!$)(\\d+Y)?(\\d+M)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+S)?)?$",
          "redaction": "cleartext",
          "description": "ISO-8601 duration, copied from the composition step's approval.timeout. A dry-run plan entry documents the gate's declared timeout; it does not start the clock (only a live execution reaching awaiting_approval does that).",
          "examples": ["P3D", "PT4H"]
        }
      }
    },
    "approvalRecord": {
      "type": "object",
      "title": "Approval Record",
      "description": "One decided-or-open entry in an execution's approvals array, for a single type:approval composition step. Mirrors namespace.schema.json's $defs/profileChange (proposed/decided record with a whole-record content_hash a signature can bind). Created (state open) the instant run() pauses at the gate; decided in place, never removed.",
      "required": ["step", "state", "requested_at"],
      "properties": {
        "step": {
          "type": "string",
          "description": "The name of the composition step this record gates."
        },
        "state": {
          "enum": ["open", "approved", "rejected", "expired"],
          "description": "open -> approved | rejected (an explicit decision) | expired (on_timeout:expire elapsed with no decision; the gate re-arms — a fresh open record is created — on resume). approved, rejected, and expired are terminal for this record (a re-armed gate after expired is a new record, not a reopened one)."
        },
        "requested_at": {
          "type": "string",
          "format": "date-time",
          "description": "When the gate opened (the execution entered awaiting_approval for this step)."
        },
        "decided_by": {
          "type": "string",
          "description": "The principal that decided the gate. Present once state is approved or rejected. Null/absent under local single-trusted-caller mode (no principals exist), the same stance the audit actor field takes — the gate still pauses even though no principal is recorded."
        },
        "decided_at": {
          "type": "string",
          "format": "date-time",
          "description": "When the approve/reject decision was recorded. Absent while open or expired."
        },
        "reason": {
          "type": "string",
          "description": "The decider's note. Required on a reject decision."
        },
        "expires_at": {
          "type": "string",
          "format": "date-time",
          "description": "requested_at plus the composition step's approval.timeout duration — the timestamp timeout evaluation compares against (lazily, on any read or decision after expiry)."
        },
        "content_hash": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$",
          "description": "Lowercase hex SHA-256 over the JCS canonical JSON (RFC 8785) of this approval record at decision time, excluding signature and links — the profileChange.content_hash pattern. Computed once so a signature commits to the whole decided record (step, state, decided_by, reason, …), not just the decision outcome. Present once the record is decided (approved, rejected, or expired) and signing is in force."
        },
        "signature": {
          "$ref": "https://actum.run/spec/audit-event.schema.json#/$defs/signatureEnvelope",
          "description": "Present when the namespace's profile enforces signing: a non-repudiable signature over the decision (meaning 'execution.approval.approve'/'execution.approval.reject'), binds.content_hash equal to this record's content_hash. Signing itself is implemented by a later story (F4/P4) — this field only reserves the shape."
        }
      },
      "if": {
        "properties": { "state": { "const": "rejected" } },
        "required": ["state"]
      },
      "then": {
        "required": ["reason"]
      }
    },
    "provenance": {
      "type": "object",
      "title": "Actor/Delegation Provenance",
      "additionalProperties": false,
      "required": ["attested"],
      "description": "Optional actor/delegation chain for an execution: who/what is actually behind the call, beyond the authenticated principal recorded in created_by. Trust annotation (normative): only the authenticated principal is server-attested; on_behalf_of/agent/model are caller-asserted, and verifiers MUST treat them as such — that is what the attested array records. Connections are deliberately not duplicated here: the strongest link is already recorded per-step in resolvedStep.connection.",
      "properties": {
        "on_behalf_of": {
          "type": "string",
          "description": "Caller-asserted: a human principal this execution was performed on behalf of (e.g. a delegated or impersonated action). Not verified by the server.",
          "examples": ["jane.doe@corp"]
        },
        "agent": {
          "type": "object",
          "additionalProperties": false,
          "description": "Caller-asserted: the software agent (bot, automation, MCP client) that initiated this execution.",
          "properties": {
            "id": {
              "type": "string",
              "description": "Stable identifier for the agent."
            },
            "name": {
              "type": "string",
              "description": "Human-readable agent name."
            }
          }
        },
        "model": {
          "type": "object",
          "additionalProperties": false,
          "description": "Caller-asserted: the AI model, if any, driving the agent that initiated this execution.",
          "properties": {
            "provider": {
              "type": "string",
              "examples": ["anthropic"]
            },
            "name": {
              "type": "string",
              "examples": ["claude-fable-5"]
            },
            "version": {
              "type": "string"
            }
          }
        },
        "attested": {
          "type": "array",
          "description": "Which of this record's parts the server itself vouches for, as opposed to merely recording what the caller asserted. In 0.8.0 only the authenticated principal can be server-attested; on_behalf_of/agent/model are always caller-asserted regardless of this array's contents — a verifier must not treat their presence as server-verified.",
          "items": {
            "enum": ["principal"]
          }
        }
      }
    }
  }
}