{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://projectfile.org/schema/org.projectfile.events.v1.json",
  "title": "org.projectfile.events extension (v1)",
  "description": "Standalone contract for the value at the org.projectfile.events subtree — a project's OUTBOUND lifecycle-event surface, the dual of org.projectfile.ci's inbound triggers. Declared ONCE and lowered to BOTH the local make plane (m6e → a desktop notification) and a forge CI plane (the pf-ci → an outbound webhook step). Both planes emit the SAME neutral JSON envelope ({event, project, goal, status, url, ts, payload}); that envelope is the contract, so the transport is pure configuration — a future Apprise / Node-RED / Kafka sink is a different URL, not new code. Phase 1 is intentionally minimal (convention over configuration): the MERE PRESENCE of this subtree opts a project into lifecycle emission, with two built-in events (goal.succeeded, goal.failed) fanned to two built-in sinks (a desktop notification locally, a webhook in CI). The webhook sink is keyed off ONE forge variable NAME; its URL VALUE lives in the forge (a repo/org variable or secret), NEVER in the projectfile — so the secret is never committed and never rendered into a workflow, and an opted-in repo with no variable configured emits nothing (secure default). The richer sinks{} / on{} subscription surface (named sinks, per-event routing, custom event names) is RESERVED for a later revision; a phase-1 consumer treats an absent block as 'no emission' and a present block as 'the built-in defaults'.",

  "type": "object",
  "additionalProperties": false,
  "properties": {
    "webhook": {
      "type": "object",
      "additionalProperties": false,
      "description": "The built-in webhook sink. Absent (an empty events block) still opts the project in, with the sink keyed off the default variable name EVENTS_WEBHOOK_URL. The sink only fires when that variable is non-empty at run time, so declaring the block never forces a network call on its own.",
      "properties": {
        "url": {
          "type": "object",
          "additionalProperties": false,
          "required": ["env"],
          "description": "An explicit RUNTIME-ENV binding naming the forge variable/secret whose VALUE is the POST target — the OUT-of-document analogue of a build-arg `{var: …}`. Structured (not a bare `${VAR}` string) so a runtime-env reference is never confused with a generation-time `${<pf-path>}` projectfile reference: a pf reference is an IN-document lookup resolved at generation time, a secret env is an OUT-of-document forge value resolved at run time, so the invariant (in-document => `${…}`, out-of-document => a structured binding) keeps them in separate spellings. Object form (over a flat `url-env:` sibling) composes with future adapter fields. Omit the whole `webhook` block to key off the default variable EVENTS_WEBHOOK_URL.",
          "properties": {
            "env": {
              "type": "string",
              "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
              "description": "The forge variable/secret NAME whose VALUE is the POST endpoint — e.g. `EVENTS_WEBHOOK_URL` (the default when the block is omitted) or `MY_CHAT_HOOK`. Names the variable, never the URL: the endpoint (which may embed a token) is a secret that belongs in the forge's variable store and the shell environment, NEVER in the committed projectfile. A generator substitutes its own spelling (m6e reads the env var of that NAME; GitHub/Forgejo render `${{ vars.NAME }}`). The sink only fires when that variable is non-empty at run time, so an opted-in repo with no variable configured emits nothing (secure default)."
            }
          }
        }
      }
    }
  }
}
