{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://skill-map.ai/spec/v1/node.schema.json",
  "title": "Node",
  "description": "A single entity in the graph. Typically a file on disk (a markdown skill, an agent, a TOML sub-agent definition, a plain-markdown note), but MAY also be a **virtual / derived** entity that lives only in memory and is reconstructed from one or more source files on every scan (e.g. an MCP server node derived from `settings.json` / `mcp.json` / `config.toml`). Virtual nodes carry `virtual: true` and use a synthetic `path` scheme (`mcp://<name>`, etc.). The `kind` is whatever the classifying Provider declares, open by design; the **built-in Claude Provider** emits `skill` / `agent` / `command` / `markdown` today, but external Providers (Cursor, Obsidian, …) MAY emit their own. Format-named kinds (`markdown`, future `toml`, future `json`) are reserved for the generic fallback only, when a file matches a specific role (agent / command / skill) that classification prevails over format naming.",
  "type": "object",
  "required": ["path", "kind", "provider", "bodyHash", "frontmatterHash", "bytes", "linksOutCount", "linksInCount", "externalRefsCount"],
  "additionalProperties": false,
  "properties": {
    "path": {
      "type": "string",
      "description": "Relative path from the scope root. Canonical node identifier in v0. Survives frontmatter edits; breaks on file moves (rare enough). FUTURE: a sibling `id` field (UUID stored in frontmatter) lands with write-back to decouple identity from location; `path` will remain as display metadata at that point. Stability: stable."
    },
    "kind": {
      "type": "string",
      "minLength": 1,
      "pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$",
      "description": "Category assigned by the Provider. Open-by-design, any non-empty string an enabled Provider declares is valid (built-in Claude Provider catalog: `skill` / `agent` / `command` / `markdown`; external Providers MAY declare their own). The pattern restricts kind names to ASCII letters, digits, underscore, and hyphen, starting with a letter, up to 64 chars. The restriction is a security boundary: the UI uses the kind name as a fragment of CSS custom-property identifiers (`--sm-kind-<name>`) injected into a `<style>` tag, so values that would break out of the declaration context (semicolons, braces, whitespace) MUST be rejected at the kernel boundary. Per-kind frontmatter schemas live with the Provider that emits the kind. Stability: stable."
    },
    "provider": {
      "type": "string",
      "description": "Identifier of the Provider extension that classified this node (e.g. `claude`)."
    },
    "frontmatter": {
      "type": "object",
      "description": "Full parsed frontmatter. See `frontmatter/base.schema.json` and `frontmatter/<kind>.schema.json`.",
      "additionalProperties": true
    },
    "bodyHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$",
      "description": "sha256 of the body content (post-frontmatter), hex-encoded lowercase. Used for rename heuristic (high confidence) and stale detection."
    },
    "frontmatterHash": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$",
      "description": "sha256 of the raw frontmatter block, hex-encoded lowercase. Used for rename heuristic (medium confidence)."
    },
    "bytes": {
      "$ref": "#/$defs/tripleSplit",
      "description": "Size breakdown in bytes: frontmatter, body, total."
    },
    "tokens": {
      "$ref": "#/$defs/tripleSplit",
      "description": "Size breakdown in tokens (computed with the configured encoding, `cl100k_base` or `o200k_base`): frontmatter, body, total. Optional; MAY be absent if tokenization was disabled."
    },
    "modifiedAtMs": {
      "type": ["integer", "null"],
      "minimum": 0,
      "description": "File modification time (`mtime`) in Unix milliseconds, captured at scan time from the on-disk `lstat` that already guards the read. Optional and nullable: virtual / derived nodes (`virtual: true`) have no backing file and omit it. Surfaced via `/api/nodes` and `/api/scan` so the UI can show and sort a 'last modified' column; sorting uses this raw value, the display format is a UI concern. NOT content: it never participates in `bodyHash` / `frontmatterHash`."
    },
    "linksOutCount": {
      "type": "integer",
      "minimum": 0,
      "description": "Outgoing links from this node to other graph nodes."
    },
    "linksInCount": {
      "type": "integer",
      "minimum": 0,
      "description": "Incoming links from other graph nodes to this one."
    },
    "externalRefsCount": {
      "type": "integer",
      "minimum": 0,
      "description": "http/https URLs in the body after normalization and exact-match dedup."
    },
    "externalRefs": {
      "type": "array",
      "description": "Distinct external URLs (http/https) the body references, in extractor-order (first-seen wins, dedup is by normalised URL). The denormalised `externalRefsCount` rides alongside and MUST equal `externalRefs.length` when both are present. Surfaced via `/api/nodes` so the inspector can list every external URL without a second round-trip.",
      "items": {
        "type": "object",
        "required": ["url"],
        "additionalProperties": false,
        "properties": {
          "url": {
            "type": "string",
            "description": "Normalised URL (lowercased host, fragment stripped)."
          },
          "line": {
            "type": "integer",
            "minimum": 1,
            "description": "1-indexed line of the occurrence in the source body, when known."
          },
          "originalTrigger": {
            "type": "string",
            "description": "Author substring (almost always equals `url`)."
          }
        }
      }
    },
    "sidecar": {
      "$ref": "#/$defs/sidecarOverlay",
      "description": "Step 9.6.2, co-located `.sm` sidecar overlay. Carries presence flag, drift status (null when no sidecar), and the parsed `annotations:` block (null when absent or empty). The kernel re-derives `status` on every scan from the live hashes; clients should treat it as authoritative for the snapshot but never persist it across scans."
    },
    "isFavorite": {
      "type": "boolean",
      "description": "Per-node favorite flag set by the local user from the UI. Sourced from `state_node_favorites` (zone `state_`, persistent across scans). Decorated by the BFF on every `/api/nodes` response via in-memory Set lookup against the favorites table, no SQL JOIN against `scan_nodes`. Absent on emissions that don't carry per-user state (e.g. `sm export --json`); consumers that don't recognise the field MUST ignore it."
    },
    "contributions": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["pluginId", "extensionId", "nodePath", "contributionId", "slot", "payload", "emittedAt"],
        "additionalProperties": false,
        "properties": {
          "pluginId": { "type": "string", "minLength": 1 },
          "extensionId": { "type": "string", "minLength": 1 },
          "nodePath": { "type": "string", "minLength": 1 },
          "contributionId": { "type": "string", "minLength": 1 },
          "slot": {
            "type": "string",
            "minLength": 1,
            "description": "Slot the payload targets; the slot fixes both the renderer and the payload shape. Deliberately NOT `$ref`-ed to `view-slots.schema.json#/$defs/SlotName`: this is the kernel's core Node schema, and pointing it at the view catalog would make every consumer that compiles a ScanResult (which embeds Node) drag the UI slot catalog in too. The closed enum is enforced where it belongs, on the envelope's `contributionsRegistry` and at extension load time."
          },
          "payload": {
            "description": "Shape declared by `slot`, open at this layer. The renderer narrows it."
          },
          "emittedAt": {
            "type": "integer",
            "minimum": 0,
            "description": "Unix ms at which the scan that produced this contribution emitted it."
          }
        }
      },
      "description": "BFF decoration, the node's view contributions as a LIST of rows (mirror of the kernel's `IPersistedContribution`), NOT a map: the keyed catalog is the envelope's separate `contributionsRegistry`, which carries the label / icon per slot. Delivered on the single-node envelope (`item.contributions`) and, up to `bff.maxBulkContributions`, on list envelopes. Same decoration posture as `isFavorite`: absent on emissions that carry no view state (`sm export --json`, a plain `sm scan` ScanResult), and consumers that don't recognise the field MUST ignore it."
    },
    "tags": {
      "type": "array",
      "items": { "type": "string" },
      "description": "BFF decoration, the node's curated tags lifted from its `.sm` sidecar (`annotations.tags`) and surfaced flat so the UI renders chips without re-walking the overlay. Human curation, so the sidecar stays the source of truth (see `architecture.md` §Storage rule) and this copy is a read projection. Absent on emissions that carry no sidecar projection; consumers that don't recognise the field MUST ignore it."
    },
    "body": {
      "type": ["string", "null"],
      "description": "BFF decoration, present ONLY on `GET /api/nodes/:pathB64?include=body`. The post-frontmatter file content as UTF-8, read from disk on demand because the kernel persists `bodyHash` only; `null` when the file is missing or unreadable. Never present without the opt-in flag, and never on a list envelope."
    },
    "virtual": {
      "type": "boolean",
      "description": "When `true`, this node is synthetic / derived: it does not correspond to a single file on disk. Reconstructed on every scan from the file(s) listed in `derivedFrom`. Use a synthetic `path` scheme (e.g. `mcp://github`) so the identifier is stable and visibly non-filesystem. Examples: MCP server nodes derived from `settings.json` / `mcp.json` / `config.toml`. When absent or `false`, the node is a normal filesystem-backed entity. Stability: experimental."
    },
    "derivedFrom": {
      "type": "array",
      "items": { "type": "string" },
      "description": "Paths of the source files from which this node was derived. Required (and only meaningful) when `virtual: true`. Drives invalidation: if any listed source changes between scans, the virtual node's hashes change and the rename / drift machinery surfaces it. Empty / absent when the node is a regular filesystem-backed entity (the `path` itself is the source)."
    }
  },
  "$defs": {
    "tripleSplit": {
      "type": "object",
      "required": ["frontmatter", "body", "total"],
      "additionalProperties": false,
      "properties": {
        "frontmatter": { "type": "integer", "minimum": 0 },
        "body": { "type": "integer", "minimum": 0 },
        "total": { "type": "integer", "minimum": 0 }
      }
    },
    "sidecarOverlay": {
      "type": "object",
      "required": ["present"],
      "additionalProperties": false,
      "properties": {
        "present": {
          "type": "boolean",
          "description": "True when a `<basename>.sm` file accompanies the node on disk; false otherwise."
        },
        "status": {
          "type": ["string", "null"],
          "enum": ["fresh", "stale-body", "stale-frontmatter", "stale-both", null],
          "description": "Drift status. NULL when no sidecar is present, or when the sidecar exists but failed to parse / validate (the row still records `present: true` so clients can distinguish absent from broken)."
        },
        "annotations": {
          "type": ["object", "null"],
          "additionalProperties": true,
          "description": "Parsed `annotations:` block from the sidecar. NULL when no sidecar is present, when the block is absent / empty, or when the sidecar failed to parse / validate."
        },
        "root": {
          "type": ["object", "null"],
          "additionalProperties": true,
          "description": "Parsed YAML root of the matching `.sm` sidecar. Mirrors the shape of `sidecar.schema.json` (top-level reserved blocks `identity` / `annotations` / `settings` / `audit` plus opt-in `<plugin-id>:` namespaces). Surfaced for the UI inspector's audit panel, plugin-contributions panel, and debug panel; NULL when the sidecar is absent or failed to parse. Note the duplication with `annotations` at this overlay level, existing consumers read from `annotations`, new consumers read structured sub-fields off `root` (`root.identity.*`, `root.audit.*`, etc.). The duplication is intentional and documented; do NOT remove the top-level `annotations` field."
        }
      }
    }
  }
}
