{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://actum.run/spec/namespace.schema.json",
  "title": "Namespace Resource",
  "description": "A namespace: an environment that other resources reference (compositions run in it via publications; connections and policy are scoped to it). A namespace carries a name, an optional assigned compliance profile, a kill-switch state (active|frozen, 0.8.0 -- see $defs/unfreezeProposal), and its own revision for concurrency-guarded assignment/state changes. 0.x ships exactly two built-ins, 'dev' and 'prod'; POST /v1/namespaces (user-defined namespaces) is reserved. See spec/README.md and access-control.md.",
  "type": "object",
  "required": [
    "spec_version",
    "name"
  ],
  "properties": {
    "spec_version": {
      "type": "string",
      "description": "The spec version this document conforms to."
    },
    "name": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_-]{0,31}$",
      "not": {
        "enum": [
          "server",
          "meta",
          "compositions",
          "profiles",
          "publications",
          "executions",
          "audit",
          "namespaces"
        ]
      },
      "description": "The namespace name and its identity. Constrained so a future user-created namespace can never collide with a top-level collection route or an audit-chain label: it must match ^[a-z][a-z0-9_-]{0,31}$ and must not be a reserved name (server, meta, compositions, profiles, publications, executions, audit, namespaces). The two built-ins (dev, prod) trivially comply.",
      "examples": ["dev", "prod"]
    },
    "profile": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9_-]*@.+$",
      "description": "The assigned compliance profile as name@version (a semantic key into profile.schema.json; the URL is rendered under links.profile). Optional: an unassigned namespace has no compliance controls. Changing the assignment via PUT is machine-diffed against the control partial order the profile defines: a tightening change applies immediately; a loosening change is refused the immediate PUT and must instead open a profile-changes proposal (see $defs/profileChange).",
      "examples": ["fedramp-moderate@2.1.0"]
    },
    "state": {
      "enum": ["active", "frozen"],
      "default": "active",
      "description": "The namespace's kill-switch state (0.8.0). active is normal operation. frozen is an emergency stop, ratcheted the same way as the profile assignment above: freeze (active -> frozen) is a TIGHTENING -- it takes effect immediately on POST /v1/namespaces/{ns}/freeze, is executable by ROOT ONLY (an emergency stop's authority is deliberately kept narrower than the profile-change/unfreeze delegation below; freeze delegation remains a future option, not implemented), and is ALWAYS available regardless of policy (a kill-switch must never itself be gated -- there is no sign-off path that can block a freeze). unfreeze (frozen -> active) is a LOOSENING: when the namespace's assigned profile (or its own policy) requires sign-off, POST /v1/namespaces/{ns}/unfreeze does not apply the change immediately but instead opens a proposal reusing the existing profile-change decision pattern -- see $defs/unfreezeProposal, which mirrors $defs/profileChange's shape (proposed -> approved | rejected | withdrawn, four-eyes-capable, revision-guarded) rather than inventing a new one; when no sign-off is required, unfreeze applies immediately like a tightening. See 'Frozen semantics' below for what freezing does to executions and publications in the namespace.\n\nFrozen semantics (normative): while a namespace is frozen, (1) NEW EXECUTIONS are refused: POST /v1/executions targeting this namespace returns 409 {\"code\": \"namespace_frozen\"}; (2) IN-FLIGHT executions (pending, running, awaiting_approval) PAUSE at the next step boundary -- the executor's freeze check runs at the same seam as the suspend check and the approval-gate check, so a running execution finishes its current in-flight step call and then stops before starting the next one, without losing partial progress; (3) RESUME OR UNWIND OF AN ALREADY-FAILED EXECUTION STAYS ALLOWED even while frozen -- this is the surprising, deliberate stance: an emergency stop must not strand cleanup, so failed -> running (resume) and failed -> unwinding (unwind) are both permitted under a freeze, only NEW work is blocked. A SUSPENDED GATE KEEPS ITS TIMEOUT CLOCK RUNNING: freezing (or suspending) an execution paused at an awaiting_approval gate does not pause the approval-timeout evaluation -- the timeout is evaluated by timestamp (see execution.schema.json), and freezing the namespace changes nothing about when that timestamp is reached. Unfreezing does not retroactively resume paused executions; each must be resumed (or unwound) explicitly once the namespace is active again."
    },
    "revision": {
      "type": "string",
      "description": "Opaque concurrency token (ETag). A PUT that changes the profile assignment must supply the current revision (If-Match) to guard against lost updates."
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    },
    "links": {
      "type": "object",
      "description": "Server-rendered navigation URLs (path-absolute, opaque to clients). NOT part of the persisted document: derived on read from the semantic keys. Typically carries self, profile, policy, profile-changes, and (0.8.0) unfreeze-proposals.",
      "additionalProperties": {
        "type": "string",
        "format": "uri-reference"
      }
    }
  },
  "$defs": {
    "profileChange": {
      "type": "object",
      "title": "Profile-Change Proposal",
      "description": "A pending or decided proposal to LOOSEN a namespace's profile assignment. Tightening applies immediately on PUT and never creates one of these; only a loosening change (per the profile control partial order) opens a proposal, which reuses the same decision pattern as a publication (proposed -> approved | rejected | withdrawn, four-eyes-capable, revision-guarded). Lives at /v1/namespaces/{ns}/profile-changes[/{id}].",
      "required": [
        "spec_version",
        "id",
        "namespace",
        "from_profile",
        "to_profile",
        "state",
        "created_at"
      ],
      "properties": {
        "spec_version": {
          "type": "string",
          "description": "The spec version this document conforms to."
        },
        "id": {
          "type": "string"
        },
        "namespace": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_-]{0,31}$",
          "description": "The namespace whose assignment this proposal would change (a semantic key)."
        },
        "from_profile": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_-]*@.+$",
          "description": "The currently-assigned profile being loosened from, as name@version. Always present and non-null: a proposal is created only for a loosening change, and you can only loosen from an existing assignment. (Assigning a first profile to an unassigned namespace, or any tightening, applies immediately via PUT and never creates a proposal; unassigning a profile entirely is out of scope for 0.7.0.)"
        },
        "to_profile": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_-]*@.+$",
          "description": "The proposed profile as name@version. It is a loosening of from_profile under the control partial order the profile defines; that is why a proposal is required rather than an immediate PUT."
        },
        "state": {
          "enum": ["proposed", "approved", "rejected", "withdrawn"],
          "description": "proposed -> approved (the loosening assignment applies) | rejected (declined) | withdrawn (proposer cancelled). approved, rejected, and withdrawn are terminal."
        },
        "proposed_by": {
          "type": "string",
          "description": "The principal that proposed the loosening. Present when the implementation enforces access control."
        },
        "approved_by": {
          "type": "string",
          "description": "The principal that approved the loosening. Present (and, under four-eyes, distinct from proposed_by) once state is approved."
        },
        "decided_at": {
          "type": "string",
          "format": "date-time",
          "description": "When the approve/reject decision was recorded."
        },
        "reason": {
          "type": "string",
          "description": "The decider's note. Required on a reject decision."
        },
        "revision": {
          "type": "string",
          "description": "Opaque concurrency token (ETag). A decision or withdraw must supply the current revision (If-Match)."
        },
        "content_hash": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$",
          "description": "Lowercase hex SHA-256 over the JCS canonical JSON (RFC 8785) of this profile-change record at decision time, excluding signature and links. Computed once so the signature commits to the WHOLE record (not just to_profile): a forged record with the same to_profile but a different proposed_by would produce a different content_hash. Present once the record is decided and signing is in force."
        },
        "created_at": {
          "type": "string",
          "format": "date-time"
        },
        "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 approve/reject decision (meaning 'profile_change.approve'/'profile_change.reject'). Its binds.content_hash equals this record's content_hash field, so the signature commits to the entire decided record. One envelope shape shared with publication decisions and chain checkpoints."
        },
        "links": {
          "type": "object",
          "description": "Server-rendered navigation URLs (self, namespace, and the from- and to-profile versions). Not persisted.",
          "additionalProperties": {
            "type": "string",
            "format": "uri-reference"
          }
        }
      },
      "if": {
        "properties": { "state": { "const": "rejected" } },
        "required": ["state"]
      },
      "then": {
        "required": ["reason"]
      }
    },
    "unfreezeProposal": {
      "type": "object",
      "title": "Unfreeze Proposal",
      "description": "A pending or decided proposal to UNFREEZE a namespace (frozen -> active). Mirrors $defs/profileChange's shape and pattern exactly, because unfreeze is a LOOSENING on the same tightening/loosening ratchet the profile assignment uses: freeze (active -> frozen) is a tightening and always applies immediately via POST /v1/namespaces/{ns}/freeze -- it is a kill-switch and must never be gated, so it never creates one of these records. unfreeze (frozen -> active) is a loosening: when policy requires sign-off, POST /v1/namespaces/{ns}/unfreeze creates this record instead of applying immediately (proposed -> approved | rejected | withdrawn, four-eyes-capable, revision-guarded -- identical decision pattern to profileChange and to a publication decision); when no sign-off is required, unfreeze applies immediately and no proposal is created (mirrors profileChange's 'no sign-off required' immediate path). Lives at /v1/namespaces/{ns}/unfreeze-proposals[/{id}].",
      "required": [
        "spec_version",
        "id",
        "namespace",
        "state",
        "created_at"
      ],
      "properties": {
        "spec_version": {
          "type": "string",
          "description": "The spec version this document conforms to."
        },
        "id": {
          "type": "string"
        },
        "namespace": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_-]{0,31}$",
          "description": "The namespace this proposal would unfreeze (a semantic key). Must be frozen at proposal time."
        },
        "state": {
          "enum": ["proposed", "approved", "rejected", "withdrawn"],
          "description": "proposed -> approved (the namespace transitions frozen -> active) | rejected (declined; namespace stays frozen) | withdrawn (proposer cancelled; namespace stays frozen). approved, rejected, and withdrawn are terminal."
        },
        "proposed_by": {
          "type": "string",
          "description": "The principal that proposed the unfreeze. Present when the implementation enforces access control."
        },
        "approved_by": {
          "type": "string",
          "description": "The principal that approved the unfreeze. Present (and, under four-eyes, distinct from proposed_by) once state is approved."
        },
        "decided_at": {
          "type": "string",
          "format": "date-time",
          "description": "When the approve/reject decision was recorded."
        },
        "reason": {
          "type": "string",
          "description": "The decider's note. Required on a reject decision."
        },
        "revision": {
          "type": "string",
          "description": "Opaque concurrency token (ETag). A decision or withdraw must supply the current revision (If-Match)."
        },
        "content_hash": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$",
          "description": "Lowercase hex SHA-256 over the JCS canonical JSON (RFC 8785) of this unfreeze-proposal record at decision time, excluding signature and links. Computed once so the signature commits to the WHOLE decided record, the same pattern as profileChange.content_hash and a decided publication record. Present once the record is decided and signing is in force."
        },
        "created_at": {
          "type": "string",
          "format": "date-time"
        },
        "signature": {
          "$ref": "https://actum.run/spec/audit-event.schema.json#/$defs/signatureEnvelope",
          "description": "Present when the namespace's (about-to-be-restored) profile enforces signing: a non-repudiable signature over the approve/reject decision (meaning 'namespace.unfreeze.approve'/'namespace.unfreeze.reject'). Its binds.content_hash equals this record's content_hash field. One envelope shape shared with profile-change and publication decisions and chain checkpoints."
        },
        "links": {
          "type": "object",
          "description": "Server-rendered navigation URLs (self, namespace). Not persisted.",
          "additionalProperties": {
            "type": "string",
            "format": "uri-reference"
          }
        }
      },
      "if": {
        "properties": { "state": { "const": "rejected" } },
        "required": ["state"]
      },
      "then": {
        "required": ["reason"]
      }
    },
    "yankInFlight": {
      "type": "object",
      "title": "Yank In-Flight Mode",
      "description": "NORMATIVE spec of the optional request body for POST /v1/publications/{id}/yank (0.8.0 addition). The yank action and its resulting publication state transition (active -> yanked) are specced on publication.schema.json/access-control.md; this $def carries only the additive in_flight body, documented here (rather than on publication.schema.json, which this story does not touch) because it is part of the kill-switch grain of the suspend/freeze feature set. A yank request with an EMPTY body (or in_flight omitted) is byte-for-byte the 0.7.0 request and preserves EXACT 0.7.0 behavior: the publication moves active -> yanked, no new executions are accepted against it, and in-flight executions already running against it are entirely unaffected (they continue to resume/unwind normally, exactly as before this field existed) -- this is the 'drain' default. The two new non-default modes fan out the yank over every execution currently in-flight (pending, running, or awaiting_approval) against that specific publication, each transition emitted as its own audit event (execution.suspend for suspend mode; execution.unwind-triggering event for unwind mode) distinct from the publication.yank event itself: 'suspend' additionally suspends every in-flight execution of that publication (equivalent to calling POST .../suspend on each, taking effect at each execution's next step boundary, exactly like an explicit per-execution suspend); 'unwind' additionally triggers unwind on every in-flight execution of that publication (equivalent to calling POST .../unwind on each). Yanking never touches executions already in a terminal state (succeeded, failed, unwound, abandoned, compensation_incomplete) or already unwinding.",
      "properties": {
        "in_flight": {
          "enum": ["drain", "suspend", "unwind"],
          "default": "drain",
          "description": "drain (default, 0.7.0 behavior unchanged): only new executions are blocked; in-flight executions of this publication are left running to resume/unwind on their own. suspend: additionally suspend every in-flight execution of this publication (pauses each at its next step boundary, per execution.schema.json's suspended state). unwind: additionally trigger unwind on every in-flight execution of this publication. Each per-execution transition triggered by a non-drain mode is its own audit event, correlated to (but distinct from) the publication.yank event."
        }
      },
      "examples": [
        {},
        { "in_flight": "drain" },
        { "in_flight": "suspend" },
        { "in_flight": "unwind" }
      ]
    }
  }
}
