{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://skill-map.ai/spec/v1/api/rest-envelope.schema.json",
  "title": "RestEnvelope",
  "description": "Wrapper shape for REST responses under `/api/*` (Step 14.2). Five variants distinguished by the `kind` discriminator and which payload field is present (`items` for list kinds AND `'annotations.registered'`, `item` for single-resource kinds, `value` for `kind: 'config'` and the action-result kinds). The `/api/scan` and `/api/health` responses are exempt, they carry the underlying `ScanResult` / `IHealthResponse` shape directly. The `/api/graph` response is also exempt, it returns the formatter's native textual output (text/plain or text/markdown). Step 14.5.d adds the required `kindRegistry` field on every payload-bearing list / single / config variant so the UI can render Provider-declared kinds (label, color, icon) without hardcoding visuals; the sibling `providerRegistry` field carries the registered Providers' own identity (label, color, chip visibility) so the UI renders the active-lens dropdown and the per-node provider chip from the real Provider set instead of a hardcoded list. Sentinel kinds (`health`, `scan`, `graph`) stay exempt because they don't carry an envelope payload. Step 9.6 closes the action-result (R7) and `'annotations.registered'` (R7) gaps, both are payload-bearing but carry their own variant shapes (action-result: `value` + `elapsedMs`, no `filters`/`counts`/`kindRegistry`; annotations.registered: `items` + `counts.total`, no `filters`/`kindRegistry`) because they project read-only kernel surfaces orthogonal to the kindRegistry. Step 16 piece 1 (the findings workbench, inspector half) adds three kinds: `findings` rides the list shape with a REQUIRED `counts.dismissedExcluded` / `counts.fixedExcluded` honesty pair (stale rows ride `items` inline with their derived `stale` flag) and a locked per-item finding-row shape, `node.prob-extensions` rides the single shape (`item` = the finders / fixers / standalone launcher catalog), and `job.submitted` rides the action-result shape (`value` + `elapsedMs`, no registries). The change keeps `schemaVersion` at `'1'`, the BFF is greenfield (no released consumers depend on the prior shape), so a versioned migration buys nothing.",
  "type": "object",
  "required": [
    "schemaVersion",
    "kind"
  ],
  "properties": {
    "schemaVersion": {
      "type": "string",
      "const": "1",
      "description": "Envelope shape version. Bumped only on breaking changes. The Step 14.5.d addition of `kindRegistry` keeps the version at `'1'` because there are no released consumers in the wild."
    },
    "kind": {
      "type": "string",
      "enum": [
        "nodes",
        "links",
        "issues",
        "plugins",
        "config",
        "config.resolution",
        "graph",
        "node",
        "health",
        "scan",
        "action.applied",
        "annotations.registered",
        "contributions.registered",
        "findings",
        "node.prob-extensions",
        "job.submitted",
        "jobs"
      ],
      "description": "Discriminator. List kinds (`nodes`, `links`, `issues`, `plugins`) carry `items` + `filters` + `counts` + `kindRegistry`. The `node` kind carries `item` + `kindRegistry`. The `config` kind carries `value` + `kindRegistry`. The `action.applied` kind (Step 17, `POST /api/actions/:pluginId/:actionId`) carries `value` + `elapsedMs` (no `filters` / `counts` / `kindRegistry`, it's an action-result projection orthogonal to the kindRegistry surface). The `annotations.registered` kind (Step 9.6.6, BFF half) carries `items` + `counts.total` (no `filters` / `kindRegistry`, pure read-only catalog projection). Step 16 piece 1 kinds: `findings` (list shape, `GET /api/nodes/:pathB64/findings`, `counts` additionally requires `dismissedExcluded` / `fixedExcluded`), `node.prob-extensions` (single shape, `GET /api/nodes/:pathB64/prob-extensions`), `job.submitted` (action-result shape, `POST /api/nodes/:pathB64/jobs`). The `jobs` kind (`GET /api/jobs`, the cross-corpus job list) rides a registry-less list shape (`items` + `filters` + `counts`, no `kindRegistry` / `providerRegistry` / `contributionsRegistry`). The `health` / `scan` / `graph` values are reserved for documentation parity with the routes that DON'T use this envelope."
    },
    "items": {
      "type": "array",
      "description": "Present when `kind` is one of the list kinds (`nodes`, `links`, `issues`, `plugins`). Empty array is valid and means the filter matched zero rows."
    },
    "item": {
      "type": "object",
      "description": "Present when `kind` is `'node'` or `'node.prob-extensions'`. Single-resource envelope payload; each variant below constrains the shape (the `node` variant `$ref`s `node.schema.json`, so the decorated node validates in full rather than as a bare object)."
    },
    "links": {
      "type": "object",
      "required": ["incoming", "outgoing"],
      "additionalProperties": false,
      "properties": {
        "incoming": {
          "type": "array",
          "items": { "$ref": "../link.schema.json" },
          "description": "Links whose `target` resolves to this node."
        },
        "outgoing": {
          "type": "array",
          "items": { "$ref": "../link.schema.json" },
          "description": "Links whose `source` is this node."
        }
      },
      "description": "Present ONLY when `kind` is `'node'`, a sibling of `item` rather than a field inside it. The split is deliberate: `item` IS the node and validates against `node.schema.json`, so folding the relations in would turn it into a bundle and forfeit that validation. Both arrays are always present; empty means the node has no link in that direction."
    },
    "issues": {
      "type": "array",
      "items": { "$ref": "../issue.schema.json" },
      "description": "Present ONLY when `kind` is `'node'`, sibling of `item` for the same reason as `links`. Every persisted issue whose `nodeIds` include this node. Empty array is valid and means the node is clean."
    },
    "value": {
      "type": "object",
      "description": "Present when `kind` is `'config'`, `'action.applied'`, or `'job.submitted'`. For `'config'`, carries the merged effective config object. For `'action.applied'`, carries `{ actionId, nodePath, report }` (the generic Action-result payload from `POST /api/actions/:pluginId/:actionId`, `core/node-bump` included). For `'job.submitted'`, carries `{ jobId, nodePath, extensionId, supersededIds }` (the submit result from `POST /api/nodes/:pathB64/jobs`)."
    },
    "elapsedMs": {
      "type": "integer",
      "minimum": 0,
      "description": "Wall-clock milliseconds the BFF spent serving the request. Present on action-result envelopes (`kind: 'action.applied'`, `'job.submitted'`); absent elsewhere."
    },
    "filters": {
      "type": "object",
      "description": "Echo of the URL filters the server applied, normalized into a JSON-friendly shape (arrays for multi-value filters, `null` for absent ones). Helps the client correlate the response with the request."
    },
    "kindRegistry": {
      "type": "object",
      "description": "Catalog of node kinds active in the current scope, keyed by kind name. Built once per server boot from every enabled Provider's `kinds` map and embedded into every payload-bearing envelope so the UI can render kind tags / palette swatches / graph nodes against Provider-declared visuals (label, color, icon) without ever hardcoding a closed kind enum. Sentinel envelopes (`health`, `scan`, `graph`) are exempt. Each entry MAY carry contributions from multiple Providers when several declare the same kind name (e.g. Claude `agent` and OpenAI `agent`); the `providers` map keeps every contribution and `primaryProviderId` points at the one whose visuals drive the kind's primary CSS var. The kernel separately surfaces `provider-ambiguous` issues for files matched by more than one Provider; the UI may still receive the merged registry during the conflict window.",
      "additionalProperties": {
        "type": "object",
        "required": [
          "primaryProviderId",
          "providers"
        ],
        "additionalProperties": false,
        "properties": {
          "primaryProviderId": {
            "type": "string",
            "minLength": 1,
            "description": "Id of the Provider whose visuals drive the kind's primary CSS var (`--sm-kind-<kind>`). Set on first registration (first Provider in iteration order); subsequent contributors append to `providers` without overwriting the primary."
          },
          "providers": {
            "type": "object",
            "minProperties": 1,
            "description": "Per-provider visuals for this kind name. Keyed by Provider id (e.g. `'claude'`, `'codex'`). Lets the UI render a node painted with its own Provider's color via `entry.providers[node.provider]` when the kind name is shared across Providers.",
            "additionalProperties": {
              "type": "object",
              "required": [
                "label",
                "color"
              ],
              "additionalProperties": false,
              "properties": {
                "label": {
                  "type": "string",
                  "minLength": 1
                },
                "color": {
                  "type": "string",
                  "pattern": "^#[0-9a-fA-F]{6}$"
                },
                "colorDark": {
                  "type": "string",
                  "pattern": "^#[0-9a-fA-F]{6}$"
                },
                "emoji": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 8
                },
                "icon": {
                  "oneOf": [
                    {
                      "type": "object",
                      "required": [
                        "kind",
                        "id"
                      ],
                      "additionalProperties": false,
                      "properties": {
                        "kind": {
                          "const": "pi"
                        },
                        "id": {
                          "type": "string",
                          "pattern": "^pi-[a-z0-9]+(-[a-z0-9]+)*$"
                        }
                      }
                    },
                    {
                      "type": "object",
                      "required": [
                        "kind",
                        "path"
                      ],
                      "additionalProperties": false,
                      "properties": {
                        "kind": {
                          "const": "svg"
                        },
                        "path": {
                          "type": "string",
                          "minLength": 1
                        }
                      }
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "contributionsRegistry": {
      "type": "object",
      "description": "Catalog of registered view contributions active in the current scope, keyed by qualified contribution id `<pluginId>/<extensionId>/<contributionId>`. Built once per server boot from every enabled extension's `viewContributions` map and embedded into every payload-bearing envelope so the UI can fetch the catalog without a separate request. Sentinel envelopes (`health`, `scan`, `graph`), action-result envelopes (`action.applied`, `job.submitted`), and the catalog envelopes themselves (`annotations.registered`, `contributions.registered`) are exempt. Mirror of `kindRegistry`, parallel surface. Each entry references a slot by name from the closed catalog at `view-slots.schema.json#/$defs/SlotName`; the slot fixes both the renderer and the payload shape. Per-node payloads are delivered separately on `node` envelopes (single via `item.contributions`) or list envelopes (`items[].contributions`, only when `limit \u2264 bff.maxBulkContributions`, default 200).",
      "additionalProperties": {
        "type": "object",
        "required": [
          "pluginId",
          "extensionId",
          "contributionId",
          "slot"
        ],
        "additionalProperties": false,
        "properties": {
          "pluginId": {
            "type": "string",
            "minLength": 1
          },
          "extensionId": {
            "type": "string",
            "minLength": 1
          },
          "contributionId": {
            "type": "string",
            "minLength": 1
          },
          "slot": {
            "$ref": "../view-slots.schema.json#/$defs/SlotName"
          },
          "label": {
            "type": "string",
            "maxLength": 64
          },
          "tooltip": {
            "type": "string",
            "maxLength": 256
          },
          "icon": {
            "type": "string",
            "maxLength": 64
          },
          "emptyText": {
            "type": "string",
            "maxLength": 128
          },
          "emitWhenEmpty": {
            "type": "boolean",
            "default": false
          },
          "priority": {
            "type": "number",
            "description": "Optional ordering hint (default 100 when omitted). Slots whose `order` is `'priority'` sort contributions ASC by this value with alphabetical tie-break by qualified id. Mirror of `IViewContribution.priority` in `view-slots.schema.json#/$defs/ViewContribution`; propagated so the UI can apply the manifest-declared order without a second round-trip."
          },
          "pluginOrder": {
            "type": "number",
            "description": "Optional inspector-only ordering hint, denormalised from the owning plugin's `plugin.json` `order` field (default 100). The inspector groups `inspector.body.panel.*` contributions into one collapsible section per plugin and sorts those sections ASC by this value, tie-break by plugin id. Every contribution of a given plugin carries the same value."
          },
          "extensionOrder": {
            "type": "number",
            "description": "Optional inspector-only ordering hint, denormalised from the owning extension's `order` manifest field (default 100). Inside a plugin's inspector section, bricks are sorted ASC by this value, tie-break by `priority` then qualified id. Every contribution of a given extension carries the same value."
          }
        }
      }
    },
    "providerRegistry": {
      "type": "object",
      "description": "Catalog of Providers registered in the current scope, keyed by Provider id. Built once per server boot from every registered Provider's `ui` block and embedded into every payload-bearing envelope so the UI renders the active-lens dropdown, the topbar lens chip, and the per-node provider chip from the real Provider set instead of a hardcoded list. Sentinel envelopes (`health`, `scan`, `graph`), action-result envelopes (`action.applied`, `job.submitted`), and the catalog envelopes (`annotations.registered`, `contributions.registered`) are exempt. Mirror of `kindRegistry`, parallel surface. The active lens itself (current value + filesystem-detected candidates + the enabled `selectable` set) is NOT here; it is served by `GET /api/active-provider`, a per-project dynamic surface orthogonal to this static boot catalog.",
      "additionalProperties": {
        "type": "object",
        "required": [
          "label",
          "color",
          "isLens"
        ],
        "additionalProperties": false,
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1
          },
          "color": {
            "type": "string",
            "pattern": "^#[0-9a-fA-F]{6}$"
          },
          "colorDark": {
            "type": "string",
            "pattern": "^#[0-9a-fA-F]{6}$"
          },
          "emoji": {
            "type": "string",
            "minLength": 1,
            "maxLength": 8
          },
          "icon": {
            "oneOf": [
              {
                "type": "object",
                "required": [
                  "kind",
                  "id"
                ],
                "additionalProperties": false,
                "properties": {
                  "kind": {
                    "const": "pi"
                  },
                  "id": {
                    "type": "string",
                    "pattern": "^pi-[a-z0-9]+(-[a-z0-9]+)*$"
                  }
                }
              },
              {
                "type": "object",
                "required": [
                  "kind",
                  "path"
                ],
                "additionalProperties": false,
                "properties": {
                  "kind": {
                    "const": "svg"
                  },
                  "path": {
                    "type": "string",
                    "minLength": 1
                  }
                }
              }
            ]
          },
          "isLens": {
            "type": "boolean",
            "description": "True when this Provider is a selectable lens, projected from its `gatedByActiveLens` flag. The active-lens dropdown lists only `isLens: true` entries; the non-gated `core/markdown` base is `false` and never appears there. Independent of `selectable` (which marks which lenses are ENABLED right now): a disabled lens stays `isLens: true` and renders greyed."
          },
          "hideChip": {
            "type": "boolean",
            "description": "When `true`, the UI suppresses this Provider's per-card chip (reserved for the universal `markdown` base). Combined with `isLens: false` the base appears on no lens surface at all."
          },
          "bodyField": {
            "type": "string",
            "minLength": 1,
            "description": "Name of the parsed-frontmatter field that carries this Provider's node body, projected from its `read.bodyField` (see `provider.schema.json`). Present only for Providers whose prompt lives inside structured frontmatter rather than after a fence (OpenAI Codex sub-agents are pure TOML whose markdown prompt is `developer_instructions`). The UI uses it to render that field as the node body and to omit it from the metadata dump; absent for ordinary frontmatter-fence Providers."
          },
          "invocationSigil": {
            "type": "string",
            "minLength": 1,
            "maxLength": 1,
            "description": "Single glyph this lens's runtime uses to invoke a skill / command, projected from its `presentation.invocationSigil` (see `provider.schema.json`). The UI joins it against the active lens to paint the `invokes` edge-kind glyph (and tooltip example) in the link-kind palette: `/` for `claude` / `antigravity`, `$` for `codex`. Absent for lenses with no invocation channel (`agent-skills`, `markdown`), under which no `invokes` edge arises."
          },
          "mcpRegister": {
            "type": "object",
            "description": "How an operator registers skill-map's MCP server with this lens's runtime, projected VERBATIM from the Provider's `mcpRegister` block (see `provider.schema.json` for the two flavours and the `{{url}}` placeholder). Travels on the wire so the Copy affordance is driven by the registered Provider set instead of a client-side catalog, which is what let an external Provider's lens fall back to a bare URL while the built-ins had a real command. Absent for a Provider that declares no recipe; the UI then copies the endpoint URL alone."
          }
        }
      }
    },
    "counts": {
      "type": "object",
      "required": [
        "total"
      ],
      "properties": {
        "total": {
          "type": "integer",
          "minimum": 0,
          "description": "Total rows after filtering, before pagination is applied."
        },
        "returned": {
          "type": "integer",
          "minimum": 0,
          "description": "Rows actually carried in `items` (\u2264 `limit`). Present on list-kind envelopes (`nodes`, `links`, `issues`, `plugins`, `findings`); absent on `'annotations.registered'` (no pagination, no filters, the catalog ships in its entirety in every response)."
        },
        "dismissedExcluded": {
          "type": "integer",
          "minimum": 0,
          "description": "Findings the default view held back because their (extension, type) class matches an ACTIVE sidecar suppression (`sm findings dismiss`, the read-time dismissal lens; top precedence over fixed and stale). REQUIRED on `kind: 'findings'` envelopes, absent elsewhere; 0 under an explicit `?dismissed=1` / `?fixed=1` / `?stale=1` bucket filter, mirroring `sm findings --json`."
        },
        "fixedExcluded": {
          "type": "integer",
          "minimum": 0,
          "description": "Findings the default view held back because their `resolution` is `'fixed'` (already handled; a row that is BOTH fixed and stale counts here, state precedence; a suppressed row counts as dismissed instead). REQUIRED on `kind: 'findings'` envelopes, absent elsewhere. The default-view honesty device mirrored from `sm findings --json`: an empty `items` with a non-zero excluded count never claims a clean node. Always 0 when an explicit bucket filter is active (an explicit bucket view holds nothing back to report)."
        },
        "page": {
          "type": "object",
          "required": [
            "offset",
            "limit"
          ],
          "properties": {
            "offset": {
              "type": "integer",
              "minimum": 0,
              "description": "Pagination window offset (zero-based)."
            },
            "limit": {
              "type": "integer",
              "minimum": 0,
              "description": "Maximum items the page may carry."
            }
          },
          "additionalProperties": false,
          "description": "Pagination window. Present when the endpoint paginates (today: `/api/nodes` only)."
        }
      },
      "additionalProperties": false,
      "description": "Tally + paging info. Present on every list / single envelope; absent on `health` / `scan` / `graph` responses (which don't use this envelope). The list variants additionally require `returned`; the `'annotations.registered'` variant only requires `total`; the `'findings'` variant requires `total` + `returned` + the `dismissedExcluded` / `fixedExcluded` honesty pair."
    }
  },
  "oneOf": [
    {
      "description": "List envelope, `items` payload + `filters` + `counts` (with `returned`) + `kindRegistry` + `providerRegistry` + `contributionsRegistry`. Used by `/api/nodes`, `/api/links`, `/api/issues`, `/api/plugins`.",
      "required": [
        "items",
        "counts",
        "filters",
        "kindRegistry",
        "providerRegistry",
        "contributionsRegistry"
      ],
      "properties": {
        "kind": {
          "enum": [
            "nodes",
            "links",
            "issues",
            "plugins"
          ]
        },
        "counts": {
          "required": [
            "total",
            "returned"
          ]
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "item"
            ]
          },
          {
            "required": [
              "value"
            ]
          },
          {
            "required": [
              "elapsedMs"
            ]
          }
        ]
      }
    },
    {
      "description": "Findings list envelope, the per-node judgment tray served by `GET /api/nodes/:pathB64/findings` (Step 16 piece 1). Rides the list shape (`items` + `filters` + `counts` + the three registries) with two deltas locked here: `counts` additionally REQUIRES the default-view honesty pair `dismissedExcluded` / `fixedExcluded` (what the default view held back under the same filters, both 0 under an explicit `?dismissed=1` / `?fixed=1` / `?stale=1` bucket filter, mirroring `sm findings`; dismissed = the class matches an active sidecar suppression, the read-time lens, top precedence; stale rows ride `items` inline since 2026-07-20, flagged per row, never held back), and each item is one `state_findings` row projection (camelCase, the `sm findings --json` row shape plus the derived `stale` boolean; the internal `bodyHashAtGeneration` is NOT exposed).",
      "required": [
        "items",
        "counts",
        "filters",
        "kindRegistry",
        "providerRegistry",
        "contributionsRegistry"
      ],
      "properties": {
        "kind": {
          "const": "findings"
        },
        "counts": {
          "required": [
            "total",
            "returned",
            "dismissedExcluded",
            "fixedExcluded"
          ]
        },
        "items": {
          "items": {
            "type": "object",
            "required": [
              "id",
              "nodeId",
              "extensionId",
              "extensionVersion",
              "origin",
              "type",
              "severity",
              "message",
              "detail",
              "confidence",
              "model",
              "resolution",
              "resolutionActor",
              "resolutionNote",
              "resolutionBy",
              "resolutionAt",
              "stale",
              "generatedAt",
              "jobId"
            ],
            "additionalProperties": false,
            "properties": {
              "id": {
                "type": "integer",
                "minimum": 1,
                "description": "`state_findings.id`, the handle for `sm findings resolve <id>` / `sm findings dismiss <id>`."
              },
              "nodeId": {
                "type": "string",
                "minLength": 1
              },
              "extensionId": {
                "type": "string",
                "minLength": 1
              },
              "extensionVersion": {
                "type": "string",
                "minLength": 1
              },
              "origin": {
                "enum": [
                  "extension",
                  "kernel"
                ],
                "description": "`extension` = finder lane (the judgment a probabilistic finder Analyzer recorded); `kernel` = safety lane (kernel-synthesized reserved slugs)."
              },
              "type": {
                "type": "string"
              },
              "severity": {
                "enum": [
                  "info",
                  "warn",
                  "error"
                ]
              },
              "message": {
                "type": "string"
              },
              "detail": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "confidence": {
                "type": "number",
                "minimum": 0,
                "maximum": 1
              },
              "model": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The recording agent's self-reported model id; `null` when undeclared."
              },
              "resolution": {
                "enum": [
                  "fixed",
                  "human-decision",
                  null
                ],
                "description": "Lifecycle state; `null` = open (see `db-schema.md` \u00a7state_findings)."
              },
              "resolutionActor": {
                "enum": [
                  "human",
                  "fixer",
                  null
                ],
                "description": "Who decided a `fixed` row; `null` for `human-decision` / open rows."
              },
              "resolutionNote": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "resolutionBy": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The fixer's qualified extension id; `null` for a purely human resolution."
              },
              "resolutionAt": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "stale": {
                "type": "boolean",
                "description": "Derived: the node body changed since the judgment (or the node left the scan)."
              },
              "generatedAt": {
                "type": "integer",
                "minimum": 0
              },
              "jobId": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          }
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "item"
            ]
          },
          {
            "required": [
              "value"
            ]
          },
          {
            "required": [
              "elapsedMs"
            ]
          }
        ]
      }
    },
    {
      "description": "Cross-corpus job-queue list envelope served by `GET /api/jobs` (the read side of the UI queue inspector; HTTP face of `sm jobs list`). A REGISTRY-LESS list shape: `items` (each a public `Job` projection: every `job.schema.json` field EXCEPT the `nonce` record credential, which `job-lifecycle.md` \u00a7Atomic claim \u00b7 Nonce exposure forbids on read surfaces) + `filters` (echo of the applied `status` / `extension` / `node`, `null` when absent) + `counts` (`total` = `returned` = `items.length`; the endpoint does not paginate). The kind / provider / contribution registries are intentionally absent, the queue projection is orthogonal to those catalogs (same rationale as the action-result and annotation / contribution-catalog variants), the SPA already holds them cached from a prior list call, and dropping them keeps the route on a narrow read-only deps bag. Rows are newest-first (`createdAt DESC, id DESC`).",
      "required": [
        "items",
        "counts",
        "filters"
      ],
      "properties": {
        "kind": {
          "const": "jobs"
        },
        "counts": {
          "required": [
            "total",
            "returned"
          ]
        },
        "items": {
          "description": "Each item is the nonce-less public projection. Was an inlined COPY of the job shape, kept in sync by hand with `job.schema.json`; now a `$ref`, so the credential-omission rule has exactly one definition and cannot drift between the two files.",
          "items": {
            "$ref": "../job.schema.json#/$defs/PublicJob"
          }
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "item"
            ]
          },
          {
            "required": [
              "value"
            ]
          },
          {
            "required": [
              "elapsedMs"
            ]
          },
          {
            "required": [
              "kindRegistry"
            ]
          },
          {
            "required": [
              "providerRegistry"
            ]
          },
          {
            "required": [
              "contributionsRegistry"
            ]
          }
        ]
      }
    },
    {
      "description": "Single-resource envelope, `item` payload + its `links` / `issues` siblings + the three registries, no `counts` / `filters`. Used by `/api/nodes/:pathB64`. The relations ride BESIDE `item`, not inside it, so `item` stays a Node and validates against `node.schema.json` (the shape was flipped to this at Step 14.5.a; the schema caught up later).",
      "required": [
        "item",
        "links",
        "issues",
        "kindRegistry",
        "providerRegistry",
        "contributionsRegistry"
      ],
      "properties": {
        "kind": {
          "const": "node"
        },
        "item": {
          "$ref": "../node.schema.json"
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "items"
            ]
          },
          {
            "required": [
              "value"
            ]
          },
          {
            "required": [
              "elapsedMs"
            ]
          }
        ]
      }
    },
    {
      "description": "Probabilistic-launcher envelope served by `GET /api/nodes/:pathB64/prob-extensions` (Step 16). Rides the single shape (`item` + the three registries, no `counts` / `filters`); the `item` is the node's launcher catalog for the inspector's finder buttons (ROADMAP \u00a7Step 16): `finders` (probabilistic Analyzers whose precondition matches the node AND that have at least one matching fixer, i.e. `fixerIds` non-empty; the UI renders each as a Detect button, Detect+Fix when the automatic toggle is on, DISABLED while `hasOpenFindings` is true since 2026-07-20, the fix lives on each finding row), `standalone` (probabilistic Analyzers matching the node with NO fixer, PLUS probabilistic Actions without `analyzerIds`; single-action buttons, `fixerIds` empty), and `issueFixers` (probabilistic Actions whose `analyzerIds` resolve to a DETERMINISTIC analyzer, such as `core/ai-reference-action` over `core/reference-broken`, listed ONLY while the node carries at least one matching open Issue; the UI renders each as a fix button ON the matching deterministic issue rows, never as a launcher button, user decision 2026-07-22 replacing the former standalone placement). A fixer paired with a probabilistic finder is never listed on its own: it surfaces through its finder's `fixerIds` and the tray's per-finding fix button submits it; the former per-finder-fixer split and the `fixers` bucket stay retired. The OPTIONAL fourth bucket `skills` carries the skill-action catalog (`skill-actions.md`): one entry per skill installed under `.skill-map/.agents/skills/`, on every node, deterministically; ABSENT is not empty (an implementation predating skill actions omits the field and stays conforming, an empty catalog emits `[]`, same rule as `findingsMaxSeverity`). Entry shapes in `#/$defs/ProbExtensionEntry` (finders / standalone), `#/$defs/IssueFixerEntry` (issueFixers) and `#/$defs/SkillActionEntry` (skills).",
      "required": [
        "item",
        "kindRegistry",
        "providerRegistry",
        "contributionsRegistry"
      ],
      "properties": {
        "kind": {
          "const": "node.prob-extensions"
        },
        "item": {
          "required": [
            "finders",
            "standalone",
            "issueFixers"
          ],
          "additionalProperties": false,
          "properties": {
            "finders": {
              "type": "array",
              "items": {
                "$ref": "#/$defs/ProbExtensionEntry"
              }
            },
            "standalone": {
              "type": "array",
              "items": {
                "$ref": "#/$defs/ProbExtensionEntry"
              }
            },
            "issueFixers": {
              "type": "array",
              "items": {
                "$ref": "#/$defs/IssueFixerEntry"
              }
            },
            "skills": {
              "type": "array",
              "items": {
                "$ref": "#/$defs/SkillActionEntry"
              },
              "description": "OPTIONAL skill-action bucket (`skill-actions.md`). Absent is NOT empty: absent means the implementation does not report skill actions at all, `[]` means an empty catalog."
            }
          }
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "items"
            ]
          },
          {
            "required": [
              "value"
            ]
          },
          {
            "required": [
              "elapsedMs"
            ]
          }
        ]
      }
    },
    {
      "description": "Value envelope, `value` payload + `kindRegistry` + `providerRegistry` + `contributionsRegistry`, no `counts` / `filters`. Used by `/api/config` and by `GET /api/config/resolution` (kind `config.resolution`), whose `value.rows[]` is the flattened effective config: one entry per leaf key as `{ key, value, layer, secret }`, `layer` naming the winning config layer (`defaults` / `project` / `project-local` / `override`, the loader's per-key provenance) and `secret: true` meaning the value was MASKED server-side (a plugin-extension setting declared `type: 'secret'` never reaches the wire in clear).",
      "required": [
        "value",
        "kindRegistry",
        "providerRegistry",
        "contributionsRegistry"
      ],
      "properties": {
        "kind": {
          "enum": [
            "config",
            "config.resolution"
          ]
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "items"
            ]
          },
          {
            "required": [
              "item"
            ]
          },
          {
            "required": [
              "elapsedMs"
            ]
          }
        ]
      }
    },
    {
      "description": "Action-result envelope, `value` + `elapsedMs` siblings, no `filters` / `counts` / `kindRegistry` / `providerRegistry` / `contributionsRegistry`. Used by `POST /api/actions/:pluginId/:actionId` (`action.applied`, carries `{ actionId, nodePath, report }` plus the wall-clock duration). The registries are intentionally absent, the action result is orthogonal to every catalog and the SPA already has them cached from a prior list call.",
      "required": [
        "value",
        "elapsedMs"
      ],
      "properties": {
        "kind": {
          "enum": [
            "action.applied"
          ]
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "items"
            ]
          },
          {
            "required": [
              "item"
            ]
          },
          {
            "required": [
              "filters"
            ]
          },
          {
            "required": [
              "counts"
            ]
          },
          {
            "required": [
              "kindRegistry"
            ]
          },
          {
            "required": [
              "providerRegistry"
            ]
          },
          {
            "required": [
              "contributionsRegistry"
            ]
          }
        ]
      }
    },
    {
      "description": "Job-submitted action-result envelope from `POST /api/nodes/:pathB64/jobs` (Step 16 piece 1), shaped like the `action.applied` variant: `value` + `elapsedMs`, no `filters` / `counts` / registries. `value` locks to `{ jobId, nodePath, extensionId, supersededIds }`; a non-empty `supersededIds` reports the fixer-supersede cancellations applied in the same transaction. The `nonce` record credential NEVER travels here, the processing agent claims it via `sm jobs claim --json` (`job-lifecycle.md` \u00a7Atomic claim \u00b7 Nonce exposure).",
      "required": [
        "value",
        "elapsedMs"
      ],
      "properties": {
        "kind": {
          "const": "job.submitted"
        },
        "value": {
          "required": [
            "jobId",
            "nodePath",
            "extensionId",
            "supersededIds"
          ],
          "additionalProperties": false,
          "properties": {
            "jobId": {
              "type": "string",
              "minLength": 1
            },
            "nodePath": {
              "type": "string",
              "minLength": 1
            },
            "extensionId": {
              "type": "string",
              "minLength": 1,
              "description": "The qualified id the submit resolved to, which may differ from the (possibly bare) id the request body carried."
            },
            "supersededIds": {
              "type": "array",
              "items": {
                "type": "string",
                "minLength": 1
              },
              "description": "Stale queued sibling ids a FIXER submit cancelled in the same transaction (`job-lifecycle.md` \u00a7Findings injection for fixers \u00b7 Supersede). Empty for non-fixer submits and for fixers with nothing to supersede."
            }
          }
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "items"
            ]
          },
          {
            "required": [
              "item"
            ]
          },
          {
            "required": [
              "filters"
            ]
          },
          {
            "required": [
              "counts"
            ]
          },
          {
            "required": [
              "kindRegistry"
            ]
          },
          {
            "required": [
              "providerRegistry"
            ]
          },
          {
            "required": [
              "contributionsRegistry"
            ]
          }
        ]
      }
    },
    {
      "description": "Annotation-catalog envelope, `items` + `counts.total` only, no `filters` / `kindRegistry` / `providerRegistry` / `contributionsRegistry` / `returned`. Used by `GET /api/annotations/registered` (Step 9.6.6, BFF half). The catalog is small (typically 0\u201350 entries), ships in its entirety on every response, and does not paginate; `counts.total` doubles as `items.length`.",
      "required": [
        "items",
        "counts"
      ],
      "properties": {
        "kind": {
          "const": "annotations.registered"
        },
        "counts": {
          "not": {
            "required": [
              "returned"
            ]
          }
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "item"
            ]
          },
          {
            "required": [
              "value"
            ]
          },
          {
            "required": [
              "filters"
            ]
          },
          {
            "required": [
              "kindRegistry"
            ]
          },
          {
            "required": [
              "providerRegistry"
            ]
          },
          {
            "required": [
              "contributionsRegistry"
            ]
          },
          {
            "required": [
              "elapsedMs"
            ]
          }
        ]
      }
    },
    {
      "description": "View-contributions-catalog envelope, `items` + `counts.total` only, no `filters` / `kindRegistry` / `providerRegistry` / `contributionsRegistry` / `returned`. Used by `GET /api/contributions/registered`. Mirror of `annotations.registered`. The catalog ships in entirety; `counts.total` doubles as `items.length`. Each item is an `IRegisteredViewContribution` shape: `{ pluginId, extensionId, contributionId, slot, label?, tooltip?, icon?, emptyText?, emitWhenEmpty? }`.",
      "required": [
        "items",
        "counts"
      ],
      "properties": {
        "kind": {
          "const": "contributions.registered"
        },
        "counts": {
          "not": {
            "required": [
              "returned"
            ]
          }
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "item"
            ]
          },
          {
            "required": [
              "value"
            ]
          },
          {
            "required": [
              "filters"
            ]
          },
          {
            "required": [
              "kindRegistry"
            ]
          },
          {
            "required": [
              "providerRegistry"
            ]
          },
          {
            "required": [
              "contributionsRegistry"
            ]
          },
          {
            "required": [
              "elapsedMs"
            ]
          }
        ]
      }
    },
    {
      "description": "Sentinel kinds, reserved for routes that do NOT carry an envelope payload at the wire level (`health`, `scan`, `graph`). They do not carry `kindRegistry`, `providerRegistry`, or `contributionsRegistry` either; clients that need any of them must call a payload-bearing endpoint at boot.",
      "properties": {
        "kind": {
          "enum": [
            "health",
            "scan",
            "graph"
          ]
        }
      },
      "not": {
        "anyOf": [
          {
            "required": [
              "items"
            ]
          },
          {
            "required": [
              "item"
            ]
          },
          {
            "required": [
              "value"
            ]
          },
          {
            "required": [
              "kindRegistry"
            ]
          },
          {
            "required": [
              "providerRegistry"
            ]
          },
          {
            "required": [
              "contributionsRegistry"
            ]
          },
          {
            "required": [
              "elapsedMs"
            ]
          }
        ]
      }
    }
  ],
  "allOf": [
    {
      "description": "`links` / `issues` are exclusive to the single-node envelope. Expressed as ONE conditional rather than ten repetitions of `not: { anyOf: [...] }` (the idiom the sibling payload fields use per variant), because the same negation copied into every other variant is exactly the shape that goes stale when a variant is added: a new `kind` would silently be allowed to carry relations. The `if` matches the node envelope and adds nothing; every other kind takes the `else` and must omit both.",
      "if": {
        "required": ["kind"],
        "properties": {
          "kind": { "const": "node" }
        }
      },
      "else": {
        "not": {
          "anyOf": [
            { "required": ["links"] },
            { "required": ["issues"] }
          ]
        }
      }
    }
  ],
  "$defs": {
    "ProbExtensionEntry": {
      "type": "object",
      "required": [
        "id",
        "description",
        "state",
        "lastJudged",
        "jobId",
        "fixerIds",
        "hasOpenFindings",
        "fixerBusy"
      ],
      "additionalProperties": false,
      "description": "One launcher entry of the `node.prob-extensions` catalog (the inspector's finder buttons). `state` is the live queue state from `state_jobs` for the BUTTON's active job (`queued` / `running` when one exists, else `idle`). Because the tray's per-finding fix button submits the finder's fixers, the state is computed over the union of `{id} \u222a fixerIds` on this node, so a queued/running FIXER lights the finder's button (running wins over queued); `jobId` is that active job's id. `lastJudged` is the latest recorded execution for the finder itself (`null` when never judged). `fixerIds` is the finder's matching fixers (empty for standalone entries and for finders with none); `hasOpenFindings` DISABLES the finder button (open findings are handled from their rows; the button re-enables once none is open). `findingsMaxSeverity` drives the launcher's verdict mark: the highest severity among the extension's stored `state_findings` rows for this node. See the launcher-envelope variant for the `finders` / `standalone` classification.",
      "properties": {
        "id": {
          "type": "string",
          "minLength": 1,
          "description": "Qualified extension id (`<plugin>/<extension>`), the value `POST /api/nodes/:pathB64/jobs` accepts as `extension`."
        },
        "description": {
          "type": "string",
          "minLength": 1,
          "description": "The extension manifest's required `description`, rendered as the launcher tooltip / subtitle."
        },
        "state": {
          "enum": [
            "idle",
            "queued",
            "running"
          ]
        },
        "lastJudged": {
          "oneOf": [
            {
              "type": "null"
            },
            {
              "type": "object",
              "required": [
                "at",
                "model"
              ],
              "additionalProperties": false,
              "properties": {
                "at": {
                  "type": "integer",
                  "minimum": 0,
                  "description": "Epoch milliseconds of the latest recorded execution for this (node, extension) pair."
                },
                "model": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The recording agent's self-reported model id at that execution; `null` when undeclared."
                }
              }
            }
          ]
        },
        "fixerIds": {
          "type": "array",
          "items": {
            "type": "string",
            "minLength": 1
          },
          "description": "Qualified ids of the fixer Actions whose `precondition.analyzerIds` name this finder (the inverse Modelo B lookup). Non-empty ONLY on `finders`-bucket entries; empty for standalone entries (finders with no fixer, and Actions without `analyzerIds`). In manual mode the button's Fix state submits each of these; in automatic mode the finder is submitted with `autoFix: true` and the kernel chains them."
        },
        "hasOpenFindings": {
          "type": "boolean",
          "description": "True when the node currently carries at least one UNRESOLVED (not `fixed`), non-stale finding emitted by THIS finder's extension id. Drives the two-state button: `false` \u2192 Detect state (submit the finder), `true` \u2192 Fix state (submit the fixerIds). Always `false` for standalone entries."
        },
        "findingsMaxSeverity": {
          "oneOf": [
            {
              "type": "null"
            },
            {
              "enum": [
                "info",
                "warn",
                "error"
              ]
            }
          ],
          "description": "OPTIONAL. Highest severity among the findings a read surface LISTS for this extension on the node, both origins: unresolved (neither `fixed` nor `dismissed`, so a `human-decision` row still counts) and not hidden by the class-suppression lens. STALE rows count, because they are listed (marked inline, `db-schema.md` §state_findings Stale rule); this differs from `hasOpenFindings`, which additionally excludes them because a stale row awaits a re-run rather than a fix. `null` when nothing is listed: every finding resolved, the last run found nothing, or the pair was never judged (`lastJudged` disambiguates). Drives the launcher's verdict mark (severity glyph while rows are listed, clean check when `null` with a `lastJudged`), so the mark always agrees with what the operator sees in the findings surface, and resolving the last row flips it to the check on the next read. ABSENT means the server does not report the verdict at all, which a client MUST distinguish from `null`: it renders no mark rather than a clean one. Optional so an implementation predating the field stays conforming; the reference implementation always emits it."
        },
        "jobId": {
          "type": [
            "string",
            "null"
          ],
          "description": "The ACTIVE queued/running job's id for this button (the finder OR any of its `fixerIds` on this node, see `state`), the handle the UI's stop affordance cancels via POST /api/jobs/:jobId/cancel. `null` when idle."
        },
        "fixerBusy": {
          "type": [
            "object",
            "null"
          ],
          "additionalProperties": false,
          "required": [
            "all",
            "findingIds"
          ],
          "properties": {
            "all": {
              "type": "boolean",
              "description": "True when a whole-node fixer job (no frozen findingIds) is active: every row's fix affordance is busy."
            },
            "findingIds": {
              "type": "array",
              "items": {
                "type": "integer"
              },
              "description": "Union of the ACTIVE subset fixer jobs' frozen finding ids (sorted). A row whose finding id appears here is busy."
            }
          },
          "description": "Frozen finding targets of the ACTIVE fixer jobs for this finder (cli-contract.md, GET /api/nodes/:pathB64/prob-extensions): the tray derives each row's fix-button busy state from it so fixing one finding no longer spins every row. `null` when no fixer job is active."
        }
      }
    },
    "SkillActionEntry": {
      "type": "object",
      "required": [
        "id",
        "name",
        "description",
        "version",
        "state",
        "jobId",
        "lastJudged"
      ],
      "additionalProperties": false,
      "description": "One `skills` entry of the `node.prob-extensions` catalog: an operator-installed skill from the private `.skill-map/.agents/skills/` catalog (`skill-actions.md`), offered on every node. `state` / `jobId` mirror `ProbExtensionEntry` over the SKILL's own active jobs only (no fixerIds union; running wins over queued); `lastJudged` is its latest recorded execution for this node. Submitting posts the entry's `id` verbatim as the `extension` body field of `POST /api/nodes/:pathB64/jobs`.",
      "properties": {
        "id": {
          "type": "string",
          "pattern": "^skill:.+$",
          "description": "The `skill:<name>` submit target, `<name>` being the catalog subdirectory name verbatim."
        },
        "name": {
          "type": "string",
          "minLength": 1,
          "description": "The skill's frontmatter `name`, the launcher label (skill ids carry no `<plugin>/` segment to shorten)."
        },
        "description": {
          "type": "string",
          "minLength": 1,
          "description": "The skill's frontmatter `description`, rendered as the launcher tooltip / subtitle."
        },
        "version": {
          "type": "string",
          "minLength": 1,
          "description": "Resolved catalog version: frontmatter `version` when a string, else `metadata.version` when a string, else `0.0.0`. Informational; dedup correctness rides the body hashing into `promptTemplateHash`."
        },
        "state": {
          "enum": [
            "idle",
            "queued",
            "running"
          ]
        },
        "lastJudged": {
          "oneOf": [
            {
              "type": "null"
            },
            {
              "type": "object",
              "required": [
                "at",
                "model"
              ],
              "additionalProperties": false,
              "properties": {
                "at": {
                  "type": "integer",
                  "minimum": 0,
                  "description": "Epoch milliseconds of the latest recorded execution for this (node, skill) pair."
                },
                "model": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The recording agent's self-reported model id at that execution; `null` when undeclared."
                }
              }
            }
          ]
        },
        "jobId": {
          "type": [
            "string",
            "null"
          ],
          "description": "The ACTIVE queued/running job's id for this skill on this node, the handle the UI's stop affordance cancels via POST /api/jobs/:jobId/cancel. `null` when idle."
        }
      }
    },
    "IssueFixerEntry": {
      "type": "object",
      "required": [
        "id",
        "description",
        "state",
        "lastJudged",
        "jobId",
        "analyzerIds"
      ],
      "additionalProperties": false,
      "description": "One `issueFixers` entry of the `node.prob-extensions` catalog: a probabilistic Action whose `precondition.analyzerIds` resolve to a DETERMINISTIC analyzer (e.g. `core/ai-reference-action` over `core/reference-broken`), listed only while the node carries at least one matching open Issue. The UI renders it as a fix button ON each matching deterministic issue row (matched via `analyzerIds`), never as a launcher button (user decision 2026-07-22). `state` / `jobId` mirror `ProbExtensionEntry` but cover the ACTION's own jobs only (no fixerIds union); submitting fixes EVERY matching issue of the node in one job, so all matching rows share the busy state.",
      "properties": {
        "id": {
          "type": "string",
          "minLength": 1,
          "description": "Qualified action id (`<plugin>/<extension>`), the value `POST /api/nodes/:pathB64/jobs` accepts as `extension`."
        },
        "description": {
          "type": "string",
          "minLength": 1,
          "description": "The action manifest's required `description`, rendered as the fix button's tooltip."
        },
        "state": {
          "enum": [
            "idle",
            "queued",
            "running"
          ]
        },
        "lastJudged": {
          "oneOf": [
            {
              "type": "null"
            },
            {
              "type": "object",
              "required": [
                "at",
                "model"
              ],
              "additionalProperties": false,
              "properties": {
                "at": {
                  "type": "integer",
                  "minimum": 0,
                  "description": "Epoch milliseconds of the latest recorded execution for this (node, action) pair."
                },
                "model": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The recording agent's self-reported model id at that execution; `null` when undeclared."
                }
              }
            }
          ]
        },
        "jobId": {
          "type": [
            "string",
            "null"
          ],
          "description": "The ACTIVE queued/running job's id for this action on this node, the handle the UI's stop affordance cancels via POST /api/jobs/:jobId/cancel. `null` when idle."
        },
        "analyzerIds": {
          "type": "array",
          "minItems": 1,
          "items": {
            "type": "string",
            "minLength": 1
          },
          "description": "SHORT analyzer ids (as persisted on `scan_issues.analyzerId`, plugin prefix and `:sub-id` stripped from the action's declared `precondition.analyzerIds`), the row-match key: the UI shows this entry's fix button on every deterministic issue row whose `analyzerId` appears here."
        }
      }
    }
  },
  "additionalProperties": false
}
