{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://finhero.asia/schemas/finsys-core/adapter-manifest.schema.json",
  "title": "Adapter Manifest",
  "description": "Declarative descriptor every source adapter ships alongside its implementation. Validated by the host app's discovery mechanism at boot. Mirrors the AdapterManifest TypeScript type in adapter-manifest.ts; KEEP IN SYNC.",
  "type": "object",
  "required": [
    "manifestVersion",
    "id",
    "displayName",
    "category",
    "version",
    "produces",
    "implementation",
    "cardinality"
  ],
  "additionalProperties": false,
  "properties": {
    "manifestVersion": {
      "const": 1,
      "description": "Manifest format version. The schema file's $id should track this."
    },
    "id": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9-]*-v[0-9]+$",
      "minLength": 3,
      "maxLength": 100,
      "description": "Globally unique adapter id. Convention: <vendor>-<category-short>-v<n>"
    },
    "displayName": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200,
      "description": "Human-readable label for operator UIs."
    },
    "category": {
      "type": "string",
      "minLength": 1,
      "description": "Generic category this adapter implements. SYS-2500: the schema validates STRUCTURE only (non-empty string) — category MEMBERSHIP is enforced at runtime against finsys-core's registry (assertAdapterCategory / categorySchemaOf), so adding a category is a data-file edit with no schema change. The host rejects manifests whose category is not in the loaded catalogue."
    },
    "version": {
      "type": "integer",
      "minimum": 1,
      "description": "Monotonic version per adapter. Bump on any change to payload shape, field-mapping logic, or output semantics."
    },
    "produces": {
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1
      },
      "minItems": 1,
      "uniqueItems": true,
      "description": "Subset of the category's canonical field names that this adapter promises to produce. Validated against categoryFieldsOf(category) at registration."
    },
    "implementation": {
      "oneOf": [
        {
          "type": "object",
          "required": [
            "type",
            "fieldMap"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "declarative"
            },
            "fieldMap": {
              "type": "array",
              "minItems": 1,
              "items": {
                "type": "object",
                "required": [
                  "source",
                  "canonical"
                ],
                "additionalProperties": false,
                "properties": {
                  "source": {
                    "type": "string",
                    "pattern": "^\\$",
                    "description": "JSONPath into the raw payload, starting with $."
                  },
                  "canonical": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Canonical field name (must be in the category's field set)."
                  },
                  "transform": {
                    "type": "string",
                    "enum": [
                      "identity",
                      "pct_to_ratio01",
                      "to_boolean",
                      "to_integer"
                    ],
                    "default": "identity"
                  }
                }
              }
            }
          }
        },
        {
          "type": "object",
          "required": [
            "type",
            "entryPoint"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "typescript"
            },
            "entryPoint": {
              "type": "string",
              "pattern": "^(?!.*(?:^|/)\\.\\.(?:/|$))[^/].*\\.(ts|js|mjs|cjs)$",
              "description": "Relative path (from the adapter directory) to the module exporting the extract function. Must not start with '/' and must not contain '..' path segments — the host will reject manifests that try to escape the adapter directory. The host also runs a defence-in-depth path-traversal check at load time."
            }
          }
        },
        {
          "type": "object",
          "required": [
            "type",
            "fieldMap"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "form-intake"
            },
            "fieldMap": {
              "type": "array",
              "minItems": 1,
              "items": {
                "type": "object",
                "required": [
                  "formFieldId",
                  "canonical"
                ],
                "additionalProperties": false,
                "properties": {
                  "formFieldId": {
                    "type": "string",
                    "minLength": 1,
                    "description": "The form spec's field name this mapping consumes."
                  },
                  "canonical": {
                    "type": "string",
                    "minLength": 1,
                    "description": "Canonical field name (must be in the category's field set)."
                  },
                  "instanceKey": {
                    "type": "string",
                    "minLength": 1,
                    "description": "SYS-3358 — which instance of a multi-cardinality category this form field feeds. Omitted means the single instance (\"\"). Explicitly empty is refused: an absent key and a key that is the empty string would be indistinguishable in the row, so only one spelling of \"the single instance\" is permitted. A manifest declaring cardinality \"single\" must not carry this on any entry."
                  }
                }
              },
              "description": "SYS-2501 — form-field-id → canonical-field mappings. Data-only: the host's form submission handler is the runtime; no code is loaded. No transform slot — values arrive typed from the form layer."
            }
          }
        },
        {
          "type": "object",
          "required": [
            "type"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "manual-override"
            }
          },
          "description": "SYS-2501 — declares that this adapter's `produces` list is operator-overridable post-extraction. `produces` IS the override surface; the shape is intentionally empty beyond the discriminator."
        },
        {
          "type": "object",
          "required": [
            "type"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "extraction-pipeline"
            }
          },
          "description": "SYS-2998 — declaration-only: the adapter's implementation IS the host application's own document-extraction pipeline. No code is loaded and neither fetch() nor extract() ever runs; the host's pipeline writes the canonical rows and records the runs. The manifest exists purely as the declaration plane (produces, cardinality, fieldAuthorizations) for data the host was already producing."
        },
        {
          "type": "object",
          "required": [
            "type"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "external-assertion"
            }
          },
          "description": "SYS-3036 — declaration-only: the adapter is executed entirely OUTSIDE the host. An externally-orchestrated process completes its own ceremony/extraction and PUSHES the result to the host's assertion-ingest surface. No code is loaded and neither fetch() nor extract() ever runs — same declaration-only shape as form-intake/manual-override/extraction-pipeline, but data arrives via an external push rather than the host's own pipeline, form submission, or extract() call."
        },
        {
          "type": "object",
          "required": [
            "type"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "document-intake"
            }
          },
          "description": "SYS-3174 — declaration-only: the host's own document UPLOAD path. The host receives a file, stores it, and records the pointer; the manifest is how that write becomes declared, provenance-carrying data rather than an untracked write to a wide column. No code is loaded and neither fetch() nor extract() ever runs. Deliberately NOT filed under extraction-pipeline: an upload happens BEFORE extraction and often without any extraction following, so sharing that discriminator would leave provenance unable to distinguish 'this file arrived' from 'this file was read' by type alone — which is the question the document-intake category exists to answer."
        }
      ]
    },
    "cardinality": {
      "type": "string",
      "enum": [
        "single",
        "multi"
      ],
      "description": "SYS-2502 / SYS-3171 — explicit instance cardinality. REQUIRED as of 5.0.0. Previously optional, with the host inferring from the instanceKey convention (empty string -> single, non-empty -> multi); inference could not distinguish a declared-single adapter emitting a multi-keyed instance from a legitimately multi one, so mismatches were stored silently rather than rejected."
    },
    "singletonFields": {
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1
      },
      "uniqueItems": true,
      "description": "SYS-2502 — per-applicant singleton fields on a multi-instance category (e.g. accountHolderName on bank statements). Every entry must also appear in `produces`; host validates at registration."
    },
    "requiredIdentityFields": {
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1,
        "maxLength": 100,
        "not": {
          "enum": [
            "ihsId",
            "ic",
            "fullName"
          ]
        }
      },
      "uniqueItems": true,
      "description": "v2.7.0 — partner-specific identity field names required by fetch(). Host validates per-applicant before invoking fetch() and skips the adapter when missing. Omit (or empty) for adapters that don't implement fetch(). Must NOT include core fields ('ihsId', 'ic', 'fullName') — those are always populated, and declaring 'ic' as required would cause the host to skip every non-MY applicant since 'ic' can legitimately be empty."
    },
    "fieldAuthorizations": {
      "type": "object",
      "minProperties": 1,
      "propertyNames": {
        "minLength": 1
      },
      "additionalProperties": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "lenderRoles": {
            "type": "array",
            "minItems": 1,
            "uniqueItems": true,
            "items": {
              "type": "string",
              "minLength": 1
            }
          },
          "programIds": {
            "type": "array",
            "minItems": 1,
            "uniqueItems": true,
            "items": {
              "type": "string",
              "minLength": 1
            }
          }
        }
      },
      "description": "SYS-2503 — declarative per-field authorization gating, keyed by canonical field name (every key must also appear in `produces`; host validates at registration). No entry → field visible to every reader. An entry restricts: reader must satisfy EVERY declared dimension (AND across dimensions), matching ANY value within a list (OR within). Dimensions are host-interpreted opaque strings. Each entry must declare at least one non-empty dimension — an empty list would read as deny-all, better expressed by not producing the field."
    },
    "periods": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": [
          "name"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "description": "Human role label for the position (e.g. 'Current fiscal year'). Rendered in operator UIs; carries no identity semantics."
          },
          "description": {
            "type": "string",
            "maxLength": 2000,
            "description": "Optional longer description of what this position holds."
          }
        }
      },
      "description": "SYS-3002 — ordered period declarations: the contract for the category's period axis. Positional identity, 1-BASED: period1 is the FIRST array entry; there is no period0. Declared positions may overlap, nest, vary in length, or be staggered (e.g. period1 = an annual table, periods 2-5 = the four quarters overlapping it) — dates never identify a period. Absent means the single-period convention (exactly one implicit period; single-period categories never need to declare), so a declared list must be non-empty. Any implementation type may declare — the axis is contract-level, not implementation-level."
    },
    "enumValues": {
      "type": "object",
      "additionalProperties": {
        "type": "array",
        "minItems": 1,
        "uniqueItems": true,
        "items": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200,
          "pattern": "^\\S(.*\\S)?$"
        }
      },
      "description": "Vendor-specific value sets for the enum-kind fields this adapter produces. The category declares only THAT a field is enumerated (kind 'enum' — no values, no ordering); this map declares WHICH labels this vendor emits. Host-validated at registration: every key must appear in `produces` and be declared kind 'enum' by the category, and every enum-kind field in `produces` must have an entry. Labels are string-normalized (non-empty, no leading/trailing whitespace) and unique per set; the adapter emits them VERBATIM and the host refuses out-of-set values at ingest. Ordering and scoring interpretation belong to the consumer's per-value mapping, never here. Matching is exact-string (case and whitespace variations from a declared label are distinct, hence out-of-set, deliberately) — `uniqueItems` is likewise exact-match, so e.g. [\"High\", \"high\"] is a legal two-label set; vendors own their exact label byte-for-byte, hosts never fold case or reconcile near-miss spelling."
    },
    "notes": {
      "type": "string",
      "maxLength": 2000,
      "description": "Optional free-form notes; not consumed by the runtime."
    }
  }
}
