{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://skill-map.ai/spec/v1/job.schema.json",
  "title": "Job",
  "description": "Row in `state_jobs`. Non-terminal state until it reaches `completed` or `failed`, at which point an `ExecutionRecord` is also written. The queue is kind-agnostic: `extensionId` names a probabilistic Action OR a probabilistic Analyzer (the columns were renamed from `action_id` / `action_version` when Analyzers joined the queue; `state_executions.extension_id` set the naming precedent).",
  "type": "object",
  "required": ["id", "extensionId", "extensionVersion", "extensionKind", "nodeId", "contentHash", "status", "ttlSeconds", "createdAt"],
  "additionalProperties": false,
  "properties": {
    "id": {
      "type": "string",
      "pattern": "^d-\\d{8}-\\d{6}-[0-9a-f]{4}$",
      "description": "Job id: `d-YYYYMMDD-HHMMSS-XXXX`. Human-readable, sortable."
    },
    "extensionId": {
      "type": "string",
      "description": "Qualified id of the submitted probabilistic extension (Action or Analyzer), e.g. `core/ai-summarizer-action`."
    },
    "extensionVersion": {
      "type": "string",
      "description": "Semver of the extension implementation at submit time. Frozen for this job."
    },
    "extensionKind": {
      "type": "string",
      "enum": ["action", "analyzer"],
      "description": "Extension kind resolved at submit time, frozen like the version. `sm record` routes on this value (analyzer report → findings write-through; action report → summaries/enrichments conventions), keeping a plugin that ships both kinds under one extension id unambiguous end-to-end."
    },
    "nodeId": {
      "type": "string",
      "description": "Target `node.path`. `--all` submissions fan out into one job per node."
    },
    "contentHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$",
      "description": "sha256 over the NUL-joined (0x00) tuple (extensionId, extensionVersion, node.path, bodyHash, frontmatterHash, promptTemplateHash). Duplicate detection key."
    },
    "nonce": {
      "type": "string",
      "description": "Unique per-job token, the SOLE record credential: `sm record` requires it to close the job, and a mismatch rejects with exit code 4. NOT required by this root schema, because the shape most consumers see is the nonce-less projection (`#/$defs/PublicJob`); only the two credentialed surfaces carry it (`#/$defs/CredentialedJob`). Validate against the `$defs` that matches your surface rather than this root, which is deliberately permissive about the field so both projections can extend it."
    },
    "priority": {
      "type": "integer",
      "description": "Higher = claimed first. Default 0.",
      "default": 0
    },
    "findingIds": {
      "type": ["array", "null"],
      "items": { "type": "integer" },
      "description": "Finding-subset targeting for FIXER jobs, frozen at submit (`job-lifecycle.md` \u00a7Findings injection for fixers \u00b7 Finding-subset targeting): the `state_findings` ids this job resolves; the findings injection narrows to them and the supersede/duplicate/running gates apply per set OVERLAP. `null` (or absent) = whole-node targeting, every open finding of the fixer's analyzers injects. Meaningless on non-fixer jobs.",
      "default": null
    },
    "autoFix": {
      "type": "boolean",
      "description": "Per-job auto-fix opt-in, frozen at submit like `extensionKind`. When `true` on a finder job (`extensionKind = analyzer`), `sm record` chains the finder's fixers on completion: it resolves the Actions whose `precondition.analyzerIds` name this finder and submits each (see `job-lifecycle.md` §Auto-fix chain). Independent of the global `core/auto-fix` hook (they coexist; a double submit is a harmless supersede/duplicate). Ignored for Action jobs and when the finder recorded no findings. Default `false`.",
      "default": false
    },
    "status": {
      "type": "string",
      "enum": ["queued", "running", "completed", "failed", "cancelled"],
      "description": "Lifecycle state. Three terminal states: `completed`, `failed`, `cancelled`. `cancelled` (via `sm jobs cancel`) is a distinct terminal state, NOT a `failed` sub-reason. See `job-lifecycle.md` for allowed transitions."
    },
    "failureReason": {
      "type": ["string", "null"],
      "enum": ["runner-error", "report-invalid", "timeout", "abandoned", "job-file-missing", "user-failed", null],
      "description": "Populated ONLY when `status = failed`; `null` for `completed`, `queued`, `running`, and `cancelled` (a cancellation is self-explanatory and carries no reason). `user-failed` marks a job the operator forced to `failed` via `sm jobs fail` (symmetric to `sm jobs cancel`, which instead moves the job to the `cancelled` state). `job-file-missing` (legacy name preserved across the disk-to-DB shift) covers DB corruption where `state_jobs.content_hash` no longer resolves in `state_job_contents`, the runtime invariant should keep this state unreachable; the enum value exists as a defensive failure-mode label."
    },
    "runner": {
      "type": ["string", "null"],
      "enum": ["agent", "in-process", null]
    },
    "ttlSeconds": {
      "type": ["integer", "null"],
      "minimum": 1,
      "description": "Optional TTL resolved at submit time from explicit operator sources only (`--ttl` flag, `jobs.perExtensionTtl`, `jobs.ttlSeconds`; see `job-lifecycle.md` §TTL resolution). NULL (the default) = the job never expires; the reaper skips it and `sm doctor`'s `jobs-overdue` check advises instead. Frozen."
    },
    "createdAt": { "type": "integer", "description": "Unix ms. Submit time." },
    "claimedAt": { "type": ["integer", "null"], "description": "Unix ms. Null while queued." },
    "finishedAt": { "type": ["integer", "null"], "description": "Unix ms. Null while not terminal." },
    "expiresAt": {
      "type": ["integer", "null"],
      "description": "Derived = claimedAt + ttlSeconds × 1000 for TTL-armed jobs. Used by auto-reap. Null while queued, and null for the life of a TTL-less job (never reaped)."
    },
    "submittedBy": {
      "type": ["string", "null"],
      "description": "Free-form: username, CI job id, or `skill:<skill-id>`. Optional provenance."
    }
  },
  "$defs": {
    "PublicJob": {
      "title": "PublicJob",
      "description": "The nonce-less projection, and the shape EVERY read surface MUST emit: `sm jobs list --json`, `sm jobs show --json`, `GET /api/jobs`, and the MCP job tools (`job-lifecycle.md` §Nonce exposure). The nonce is the sole record credential, so a passive reader of the queue must not be able to forge callbacks for jobs it never claimed. This is the shape those four surfaces already called \"the public-job shape\" in prose before it existed as a definition; the root schema used to REQUIRE the nonce, which made every one of them unsatisfiable. Absence is asserted, not merely permitted: a projection that leaks the credential fails here.",
      "$ref": "#",
      "not": { "required": ["nonce"] }
    },
    "CredentialedJob": {
      "title": "CredentialedJob",
      "description": "The projection that DOES carry the record credential, emitted by exactly two surfaces: `sm jobs submit --json` (the creator's envelope) and the stored `state_jobs` row itself. `sm jobs claim --json` hands the credential over too, but in its own `{ id, nonce, content }` handover shape rather than as a Job. Requiring the nonce here (instead of on the root) keeps the guarantee that a credentialed surface cannot silently drop it while the public projections stay valid.",
      "$ref": "#",
      "required": ["nonce"]
    }
  }
}
