This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git


The following commit(s) were added to refs/heads/main by this push:
     new 70a95c5  feat(security): config schema + adapter contracts (PR1/5) 
(#381)
70a95c5 is described below

commit 70a95c553df3500007c76bad47cf44bc69f5b723
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sat May 30 19:00:25 2026 +0200

    feat(security): config schema + adapter contracts (PR1/5) (#381)
    
    * feat(security): config schema + adapter contracts (PR1/5)
    
    First of 5 PRs converting the security skill family from
    Airflow/ASF-coupled to a generic framework with ASF as the
    default-configured option.
    
    This PR is pure additions — zero behaviour change. Every existing
    ASF assumption gets a config knob with the current behaviour as
    the default, so the airflow-s reference adopter is byte-equivalent.
    
    Additions:
    
    - projects/_template/project.md — new "Security workflow
      configuration" section with 11 YAML blocks covering every
      ASF-coupling dimension surfaced by the discovery audit
      (179 findings across 18 files): cve_authority, governance,
      security_inbox, forwarders, mail_provider, archive_system,
      tracker, scope_detection, release_process, roster, product.
      Every field carries a comment naming what it controls, the
      ASF default, when a non-ASF adopter would override it, and the
      1-3 skills that consume it.
    
    - tools/cve-tool/README.md — adapter contract for CNA backends.
      Defines the interface every CVE-authority adapter must
      implement (allocate, fetch, push, publish, retract) plus a
      generic state-verb mapping (allocated -> review-ready ->
      publish-ready -> public). ASF-default adapter: tools/vulnogram/
      (renamed to tools/cve-tool-vulnogram/ in PR4).
    
    - tools/mail-archive/README.md — adapter contract for public
      mail-archive backends. Defines search_thread_url,
      fetch_thread_by_url, list_recent_threads, publication_signal_url.
      ASF-default adapter: tools/ponymail/ (renamed in PR3).
    
    - tools/forwarder-relay/README.md — adapter contract for
      forwarder-relay inbound paths. Defines detect, extract_credit,
      contact_handle, preamble_match, reporter_addressing_block.
      ASF-default adapter: the ASF Security forwarder shape in
      tools/gmail/asf-relay.md (renamed in PR3).
    
    - docs/labels-and-capabilities.md — 3 new rows for the new
      tool stubs (all capability:setup, pure interface specs).
    
    No skill bodies touched. No tool implementations renamed. No
    ASF-default adapter changes. Skills will be lifted to read these
    knobs in PR2-PR5.
    
    Generated-by: Claude Code (Opus 4.7)
    
    * fix(security/pr1): broken links — contract.md -> README.md, 
bot-credits-policy
    
    The drafted files referenced `<tool>/contract.md` but were written
    as `<tool>/README.md` (matching the validator's required filename).
    Update project.md cross-references accordingly.
    
    Also fix one hallucinated link in tools/forwarder-relay/README.md
    that pointed at `docs/security/reporter-credit-policy.md` (file does
    not exist) — repoint to the actual bot/AI credit policy doc at
    `tools/vulnogram/bot-credits-policy.md`.
    
    Generated-by: Claude Code (Opus 4.7)
---
 docs/labels-and-capabilities.md |   3 +
 projects/_template/project.md   | 612 ++++++++++++++++++++++++++++++++++++++++
 tools/cve-tool/README.md        | 410 +++++++++++++++++++++++++++
 tools/forwarder-relay/README.md | 404 ++++++++++++++++++++++++++
 tools/mail-archive/README.md    | 343 ++++++++++++++++++++++
 5 files changed, 1772 insertions(+)

diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 1792142..2746260 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -171,11 +171,14 @@ Tools under [`tools/`](../tools/). Tools with two values 
(separated by
 |---|---|---|
 | [`tools/agent-isolation`](../tools/agent-isolation/) | `capability:setup` | 
Secure-agent sandbox helpers |
 | [`tools/cve-org`](../tools/cve-org/) | `capability:resolve` + 
`capability:intake` | Publishes to CVE.org *(resolve)* and records the 
resulting CVE state back into the tracker *(intake)* |
+| [`tools/cve-tool`](../tools/cve-tool/) | `capability:setup` | Adapter 
contract for CNA backends (Vulnogram, MITRE form, CVE.org direct, GHSA). Pure 
interface spec; no executable code — adapters under sibling `tools/cve-tool-*/` 
directories implement it. |
 | [`tools/dashboard-generator`](../tools/dashboard-generator/) | 
`capability:stats` | Self-contained HTML dashboard generator |
 | [`tools/dev`](../tools/dev/) | `capability:setup` | Framework dev-loop 
helpers |
+| [`tools/forwarder-relay`](../tools/forwarder-relay/) | `capability:setup` | 
Adapter contract for inbound-relay backends (ASF Security relay, huntr.com, 
HackerOne triagers). Pure interface spec; adapters declare detection + 
credit-extraction + reporter-addressing rules. |
 | [`tools/github`](../tools/github/) | `capability:setup` | GitHub REST / 
GraphQL substrate (called by every lifecycle phase — pure substrate, no single 
phase) |
 | [`tools/gmail`](../tools/gmail/) | `capability:setup` | Gmail API substrate |
 | [`tools/jira`](../tools/jira/) | `capability:setup` | JIRA REST substrate 
(read-only today; write subcommands tracked in 
[#301](https://github.com/apache/airflow-steward/issues/301)) |
+| [`tools/mail-archive`](../tools/mail-archive/) | `capability:setup` | 
Adapter contract for public mail-archive backends (PonyMail, Hyperkitty, 
Discourse, Google Groups, GitHub Discussions). Pure interface spec. |
 | [`tools/mail-source`](../tools/mail-source/) | `capability:setup` + 
`capability:intake` | Mail-source backend abstraction (mbox / IMAP / Mailman 
3); the abstraction is setup, every concrete read is part of the intake 
pipeline |
 | [`tools/ponymail`](../tools/ponymail/) | `capability:setup` + 
`capability:intake` | PonyMail archive substrate; same dual role as 
`mail-source` — substrate plus an intake-pipeline component |
 | [`tools/pr-management-stats`](../tools/pr-management-stats/) | 
`capability:stats` | PR-backlog analytics engine |
diff --git a/projects/_template/project.md b/projects/_template/project.md
index 87cbb3b..d29d76d 100644
--- a/projects/_template/project.md
+++ b/projects/_template/project.md
@@ -13,6 +13,18 @@
     - [Backend declaration](#backend-declaration)
     - [Per-backend config](#per-backend-config)
   - [Issue-template fields](#issue-template-fields)
+  - [Security workflow configuration](#security-workflow-configuration)
+    - [CVE authority](#cve-authority)
+    - [Governance](#governance)
+    - [Security inbox](#security-inbox)
+    - [Forwarders](#forwarders)
+    - [Mail provider](#mail-provider)
+    - [Archive system](#archive-system)
+    - [Tracker](#tracker)
+    - [Scope detection](#scope-detection)
+    - [Release process](#release-process)
+    - [Roster](#roster)
+    - [Product](#product)
   - [Pointers to sibling files](#pointers-to-sibling-files)
 
 <!-- END doctoc generated TOC please keep comment here to allow auto update -->
@@ -218,6 +230,606 @@ project.
 | `severity` | TODO | `dropdown` | TODO |
 | `cve-tool-link` | TODO | `input` | TODO |
 
+## Security workflow configuration
+
+This block declares the **plug-points** that drive every ASF-coupled
+assumption in the skills. The defaults shipped here reproduce the
+current Apache Airflow security-team behaviour byte-for-byte: an
+adopter who copies `projects/_template/` into a fresh `<project-config>/`
+and changes nothing in this section ends up with the same workflow
+that runs in `airflow-s/airflow-s` today. Non-ASF adopters override
+individual fields (CNA tool, mail backend, archive system, governance
+gate, etc.) without touching skill bodies — skills resolve these knobs
+at run time. Each field carries a `#` comment stating *what it
+controls*, the *ASF default*, *when a non-ASF adopter would override
+it*, and the *consuming skills* (1-3 most relevant names).
+
+The adapter contracts these blocks reference live under:
+
+- [`../../tools/cve-tool/README.md`](../../tools/cve-tool/README.md) — CNA 
tool interface (ASF default adapter: `tools/vulnogram/`)
+- [`../../tools/mail-archive/README.md`](../../tools/mail-archive/README.md) — 
public-archive interface (ASF default adapter: `tools/ponymail/`)
+- 
[`../../tools/forwarder-relay/README.md`](../../tools/forwarder-relay/README.md)
 — inbound-relay interface (ASF default adapter: the ASF-security forwarder 
shape in `tools/gmail/asf-relay.md`)
+
+### CVE authority
+
+```yaml
+cve_authority:
+  # Which CNA tool the project uses to allocate, edit, and publish CVE
+  # records. Selects the adapter under tools/cve-tool/.
+  # ASF default: vulnogram (ASF-hosted Vulnogram instance at
+  # cveprocess.apache.org). Non-ASF adopters running their own MITRE
+  # CNA pick `mitre-form` or `cve-org-direct`; GHSA-only projects pick
+  # `ghsa`; pre-CNA projects pick `none`.
+  # Consumed by: security-cve-allocate, security-issue-sync,
+  # generate-cve-json.
+  tool: vulnogram
+
+  # Front-door allocation URL. Skill prints this and waits for the
+  # operator to paste the allocated ID back.
+  # ASF default: Vulnogram's ASF allocation endpoint.
+  # Override when: pointing at a non-ASF Vulnogram tenant, or any
+  # other CNA tool's allocation UI.
+  # Consumed by: security-cve-allocate.
+  allocate_url: https://cveprocess.apache.org/allocatecve
+
+  # Template for the per-record edit/view URL. `<CVE-ID>` is the
+  # placeholder the skill substitutes.
+  # ASF default: Vulnogram's cve5 record view.
+  # Override when: any non-Vulnogram CNA tool.
+  # Consumed by: security-cve-allocate, security-issue-sync.
+  record_url_template: https://cveprocess.apache.org/cve5/<CVE-ID>
+
+  # Template for the "Source" tab inside the CNA tool — used when the
+  # skill needs to inspect raw CNA_private state.
+  # ASF default: Vulnogram cve5 source tab.
+  # Override when: the adapter exposes raw record state via a
+  # different URL (or leave null if the adapter has no equivalent).
+  # Consumed by: security-issue-sync, generate-cve-json.
+  source_tab_url_template: 
https://cveprocess.apache.org/cve5/<CVE-ID>?tab=source
+
+  # Template for the "preview the allocation email" tab in the CNA
+  # tool. The Vulnogram default emits an allocation email visible
+  # under this URL; null for adapters that don't preview email.
+  # ASF default: Vulnogram email preview tab.
+  # Consumed by: security-cve-allocate.
+  email_preview_url_template: 
https://cveprocess.apache.org/cve5/<CVE-ID>?tab=email
+
+  # Generic state machine the adapter exposes. Adapters map their
+  # tool-native states to this 4-stop sequence; the rest of the
+  # workflow speaks only in these terms.
+  # ASF default mapping: Vulnogram's DRAFT -> allocated,
+  # REVIEW -> review-ready, READY -> publish-ready, PUBLIC -> public.
+  # Override when: the adapter has a different state machine — the
+  # adapter declares its own mapping in its README.md.
+  # Consumed by: security-issue-sync, security-cve-allocate,
+  # generate-cve-json.
+  states: [allocated, review-ready, publish-ready, public]
+
+  # How "record is now PUBLIC" propagates back to the workflow.
+  # `poll` = skill re-fetches the record on a sweep; `webhook` =
+  # adapter pushes; `manual` = operator flips a label by hand.
+  # ASF default: poll (Vulnogram has no webhook).
+  # Override when: a CNA tool offers a webhook (`webhook`) or only a
+  # human-driven publication signal (`manual`).
+  # Consumed by: security-issue-sync.
+  publication_propagation: poll
+
+  # Whether the CNA tool emits an allocation email of its own that
+  # the skills should expect to see on the security mailing list.
+  # ASF default: true (Vulnogram auto-emails the assigner list).
+  # Override when: the adapter is silent on allocation — skills then
+  # skip the "wait for Vulnogram email" step.
+  # Consumed by: security-cve-allocate.
+  emits_allocation_email: true
+
+  # Where the human review of the draft CVE record happens before
+  # publication. `mailing-list` = an off-system thread; `github-pr`
+  # = a PR on the tracker repo; `none` = no formal review gate.
+  # ASF default: mailing-list (PMC reviews on private@).
+  # Override when: an adopter wires review into a tracker-repo PR.
+  # Consumed by: security-cve-allocate, security-issue-sync.
+  reviewer_channel: mailing-list
+```
+
+### Governance
+
+```yaml
+governance:
+  # Who has authority to allocate a CVE on behalf of the project.
+  # `pmc-member` = an ASF-style governance committee membership gate;
+  # `security-team-member` = looser, anyone on the security team;
+  # `maintainer` = any committer; `none` = no formal gate.
+  # ASF default: pmc-member (ASF PMC membership via OAuth into
+  # Vulnogram).
+  # Override when: non-ASF projects with their own authority model.
+  # Consumed by: security-cve-allocate, security-issue-sync.
+  cve_allocation_gate: pmc-member
+
+  # Label the tracker applies to mark "this account is governance-
+  # authorised" — distinct from "security-team member". Skills use
+  # this to gate the allocation step.
+  # ASF default: "PMC" (matches the existing airflow-s label).
+  # Override when: a non-ASF adopter uses a different label name.
+  # Consumed by: security-cve-allocate, pr-management-triage.
+  gate_label: "PMC"
+
+  # Whether release votes block on outstanding security work. When
+  # true, release-manager skills check the security tracker for
+  # un-fixed-but-public CVEs before greenlighting a vote.
+  # ASF default: true (ASF release process gates on this).
+  # Override when: projects with no formal release-vote gate.
+  # Consumed by: security-issue-sync, generate-cve-json.
+  release_vote_gating: true
+
+  # Private mailing list the governance body uses for escalation,
+  # PMC discussions, and "this is bigger than security@" routing.
+  # ASF default: private@<project>.apache.org.
+  # Override when: non-ASF — point at the equivalent private list
+  # or leave null if no such list exists.
+  # Consumed by: security-issue-sync, security-issue-invalidate.
+  private_governance_list: private@<project>.apache.org
+
+  # GitHub handle (or external contact) the skills cc / @-mention
+  # when escalating beyond the security team.
+  # ASF default: the PMC chair or designated escalation contact —
+  # filled in per-project. Use the `@<handle>` form for GitHub
+  # surfaces; an email is acceptable for off-GitHub escalation.
+  # Override when: non-ASF — point at the equivalent role-holder.
+  # Consumed by: security-issue-sync, pr-management-triage.
+  escalation_contact: "@<escalation-contact>"
+
+  # URL of the public committee roster, used for "is this person
+  # authorised" checks in the allocation flow.
+  # ASF default: ASF committee URL (whimsy/projects/.../committee).
+  # Override when: non-ASF — link to the equivalent roster page.
+  # Consumed by: security-cve-allocate.
+  roster_url: https://projects.apache.org/committee.html?<project>
+```
+
+### Security inbox
+
+```yaml
+security_inbox:
+  # The inbound channel reports land on. `mailing-list` = an SMTP
+  # address; `ghsa-inbox` = GitHub Security Advisories private
+  # reports; `hackerone` = a HackerOne program inbox;
+  # `chat-channel` = e.g. a private Slack; `intake-form` = a
+  # web form posting into a tracker.
+  # ASF default: mailing-list.
+  # Override when: non-ASF projects on GHSA / HackerOne / etc.
+  # Consumed by: security-issue-import, security-issue-sync.
+  kind: mailing-list
+
+  # The concrete address / channel ID / form URL the inbound channel
+  # uses. For `mailing-list`, this is the SMTP address.
+  # ASF default: security@<project>.apache.org.
+  # Override when: non-ASF — replace with the adopter's inbox.
+  # Consumed by: security-issue-import, security-issue-sync,
+  # canned-responses templating.
+  address: <security-list>
+
+  # The foundation-wide security address that gates the
+  # "don't exclude this sender" rule in the inbound importer.
+  # Null for non-ASF adopters with no foundation-level inbox.
+  # ASF default: [email protected] (the ASF security team
+  # forwards reports here onto project security@ lists).
+  # Override when: non-ASF — set to null (or your foundation's
+  # equivalent address if one exists).
+  # Consumed by: security-issue-import.
+  foundation_security_address: [email protected]
+
+  # Whether reports arrive via a forwarder/relay (an upstream party
+  # that triages and re-sends, rather than the reporter mailing the
+  # project list directly). When true, the forwarders block below
+  # declares the enabled adapters.
+  # ASF default: true (the ASF security team relays many reports).
+  # Override when: non-ASF projects with no forwarder layer.
+  # Consumed by: security-issue-import, gmail/asf-relay (adapter).
+  has_forwarder_relay: true
+
+  # Optional Gmail/IMAP search filter used by the inbound importer to
+  # scope which threads count as "security inbox messages".
+  # ASF default: `list:<security-list-domain>` (Gmail's list-id
+  # filter for the project security list).
+  # Override when: the mail backend uses a different scoping
+  # mechanism (folder, label, etc.).
+  # Consumed by: security-issue-import.
+  list_filter_query: "list:<security-list-domain>"
+```
+
+### Forwarders
+
+```yaml
+forwarders:
+  # Enabled forwarder/relay adapters. Each name must match an
+  # adapter directory under tools/ that conforms to
+  # tools/forwarder-relay/README.md.
+  # ASF default: [asf-security] — the ASF security team relays
+  # reports onto project security@ lists with a known preamble and
+  # credit line.
+  # Override when: non-ASF — set to [] if no forwarder layer
+  # exists, or add the adopter's relay adapter name(s) here.
+  # Consumed by: security-issue-import, gmail/asf-relay (adapter).
+  enabled: [asf-security]
+
+  # Per-adapter configuration. Keys must match `enabled` entries.
+  asf-security:
+    # Handle / address the forwarder sends from. Skills use this to
+    # detect "this is a relayed message, not a direct report".
+    # ASF default: [email protected].
+    # Override when: a different upstream relay address.
+    # Consumed by: gmail/asf-relay, security-issue-import.
+    contact_handle: [email protected]
+
+    # Regex matched against the message body to confirm a message
+    # really is a relay (not just a CC'd address).
+    # ASF default: the ASF preamble — "Dear PMC, The security
+    # vulnerability report..."
+    # Override when: a different relay's preamble shape.
+    # Consumed by: gmail/asf-relay, security-issue-import.
+    preamble_match: "^Dear PMC,\\s+The security vulnerability report"
+
+    # Rule the adapter uses to lift the original reporter's credit
+    # line out of the relayed body. Adapters define their own
+    # extraction shape; see tools/forwarder-relay/README.md.
+    # ASF default: the existing ASF-security credit extraction (the
+    # "Reported by: <name> <<email>>" line near the top of the
+    # forwarded body).
+    # Override when: a different relay shape.
+    # Consumed by: gmail/asf-relay, security-issue-import.
+    credit_extraction_rule: "first-line-matching:^Reported by:\\s+(.+)$"
+```
+
+### Mail provider
+
+```yaml
+mail_provider:
+  # Primary mail backend the skills read inbound mail from and write
+  # drafts into. Adapters live under tools/<backend>/.
+  # ASF default: gmail-mcp (triager Gmail account via the Gmail MCP
+  # adapter at tools/gmail/).
+  # Override when: a non-ASF adopter uses an IMAP triager mailbox
+  # (`imap`), an Outlook inbox (`outlook`), or a forum-style
+  # inbound channel (`discourse`).
+  # Consumed by: security-issue-import, security-issue-sync,
+  # security-issue-invalidate (draft replies).
+  primary: gmail-mcp
+
+  # Read-only fallback backend used when the primary can't reach a
+  # thread (e.g. message older than the Gmail retention window).
+  # `none` means no fallback — operations that fail on the primary
+  # surface a hard error instead of trying a secondary.
+  # ASF default: ponymail (read-only ASF archive backstop).
+  # Override when: non-ASF adopters typically set this to `none`
+  # or to their own archive adapter (hyperkitty, mbox, ...).
+  # Consumed by: security-issue-sync, security-issue-import.
+  fallback: ponymail
+```
+
+### Archive system
+
+```yaml
+archive_system:
+  # Public mailing-list / forum archive the project's advisories
+  # eventually surface on. Adapter selection — drives URL shapes
+  # and thread-fetch verbs.
+  # ASF default: ponymail (lists.apache.org).
+  # Override when: non-ASF adopters on hyperkitty (Mailman 3),
+  # discourse, google-groups, github-discussions, or none.
+  # Consumed by: security-issue-sync, generate-cve-json.
+  kind: ponymail
+
+  # Domain the public lists live under — used to assemble per-list
+  # URLs (`<list>@<list_domain>`) when the archive search needs
+  # qualified addresses.
+  # ASF default: <project>.apache.org (e.g. airflow.apache.org).
+  # Override when: non-ASF — the adopter's public-list domain.
+  # Consumed by: security-issue-sync, generate-cve-json.
+  list_domain: <project>.apache.org
+
+  # Template the search-thread verb assembles. Placeholders:
+  # `{list}` (list short name), `{year}`, `{month}`, `{query}`.
+  # ASF default: ponymail's `list?` search endpoint.
+  # Override when: a different archive's search URL shape.
+  # Consumed by: security-issue-sync (search before announce),
+  # generate-cve-json (references[] assembly).
+  search_url_template: 
"https://lists.apache.org/list?{list}:{year}-{month}:{query}";
+
+  # Template for the archive's programmatic thread-fetch endpoint
+  # (used by the mail-archive adapter's fetch_thread_by_url).
+  # ASF default: ponymail's thread.lua API.
+  # Override when: a different archive — hyperkitty has a different
+  # API shape; discourse exposes JSON on `/t/<id>.json`; etc.
+  # Consumed by: tools/ponymail (adapter), security-issue-sync.
+  api_query_url_template: 
"https://lists.apache.org/api/thread.lua?list={list}&domain={list_domain}&id={thread_id}";
+
+  # The URL the skill polls to detect "advisory has been announced
+  # publicly" — i.e. the archive page where the announcement thread
+  # appears once published.
+  # ASF default: lists.apache.org `users` list page.
+  # Override when: the announcement surfaces on a different list /
+  # forum.
+  # Consumed by: security-issue-sync.
+  advisory_publication_signal_url: 
"https://lists.apache.org/list.html?<users-list>"
+```
+
+### Tracker
+
+```yaml
+tracker:
+  # Platform the tracker repo lives on. Selects the API adapter
+  # (gh CLI today; gitlab CLI / forgejo / jira REST in the future).
+  # ASF default: github (airflow-s/airflow-s).
+  # Override when: non-ASF adopters on gitlab, gitea, jira, forgejo.
+  # Consumed by: every skill that touches the tracker.
+  platform: github
+
+  # Project-board backend on the tracker platform. Drives the
+  # board-reconciliation step in the triage skills.
+  # ASF default: github-projects-v2.
+  # Override when: gitlab-board for a GitLab tracker, or `none`
+  # if the adopter doesn't run a board at all.
+  # Consumed by: pr-management-triage, security-issue-sync,
+  # security-issue-triage.
+  board: github-projects-v2
+
+  # Visibility of the tracker repo. Drives "may this URL leak
+  # publicly" guards in the canned-responses + CVE-JSON references.
+  # ASF default: private (tracker existence is itself secret per
+  # the AGENTS.md rules).
+  # Override when: a project that runs its security tracker openly.
+  # Consumed by: every skill that emits URLs to outside surfaces.
+  visibility: private
+
+  # Whether the reporter can see the tracker issue once opened.
+  # ASF default: false (private tracker — reporter never gets a
+  # link).
+  # Override when: a public tracker where the reporter is added as
+  # a collaborator.
+  # Consumed by: security-issue-import, canned-responses templating.
+  reporter_has_access: false
+
+  # Whether the tracker drives a board / kanban view. When false,
+  # skills skip column transitions entirely.
+  # ASF default: true.
+  # Override when: an adopter using only labels + milestones.
+  # Consumed by: pr-management-triage, security-issue-sync.
+  project_board_enabled: true
+
+  # Template the skills use to compose a "link back to the skill
+  # docs as seen in this repo" URL. `<skill>` is the slug under
+  # .claude/skills/.
+  # ASF default: tracker default branch on github.com.
+  # Override when: a non-GitHub tracker — replace with the platform's
+  # equivalent file-view URL shape.
+  # Consumed by: pr-management-mentor, canned-responses (footer link).
+  skill_url_template: 
"https://github.com/<tracker>/blob/main/.claude/skills/<skill>/SKILL.md"
+
+  # Tracker body-field heading names — the literal `###` headings
+  # the skills read and write under in the tracker's issue
+  # template. The skill code refers to these by *role*; this map
+  # binds role -> concrete heading.
+  # ASF default: the existing airflow-s headings.
+  # Override when: an adopter with a different issue-template shape
+  # — change the headings here, not the skills.
+  # Consumed by: every skill that reads/writes the issue body.
+  body_fields:
+    cve_link: "CVE tool link"
+    mailing_thread: "Mailing list thread URL"
+    affected_versions: "Affected versions"
+
+  # Tracker labels — role -> concrete label name. Skills speak in
+  # roles; this map binds role -> literal label.
+  # ASF default: the airflow-s label set.
+  # Override when: a tracker with a different label vocabulary.
+  # Consumed by: security-issue-triage, security-issue-sync,
+  # pr-management-triage.
+  labels:
+    security_marker: "security"
+    needs_triage: "needs triage"
+    pr_open: "pr created"
+    pr_merged: "pr merged"
+    cve_allocated: "cve allocated"
+    not_cve_worthy: "not cve worthy"
+```
+
+### Scope detection
+
+```yaml
+scope_detection:
+  # Whether the project distinguishes scope sub-products (e.g.
+  # airflow vs providers vs chart). When false, every issue maps
+  # to the single product declared in the `product` block.
+  # ASF/Airflow default: true.
+  # Override when: a single-artifact project — set to false and
+  # drop the labels map.
+  # Consumed by: security-issue-triage, generate-cve-json,
+  # security-issue-sync.
+  enabled: true
+
+  # Scope label -> sub-product mapping. Each entry binds a tracker
+  # label to the CVE `product` field value, the package-name shape
+  # the advisory will use, and the upstream path prefix the skill
+  # uses to confirm a PR really touches that scope.
+  # ASF/Airflow default: the three existing scope labels.
+  # Override when: a project with different scope axes — keep the
+  # `product`/`packageName`/`path_prefix` triad shape.
+  # Consumed by: security-issue-triage, generate-cve-json.
+  labels:
+    airflow:
+      product: "Apache Airflow"
+      packageName: "apache-airflow"
+      path_prefix: "^(airflow-core/|airflow/(?!providers/)|airflow-ctl/)"
+    providers:
+      product: "Apache Airflow"
+      packageName: "apache-airflow-providers-<provider>"
+      path_prefix: "^providers/"
+    chart:
+      product: "Apache Airflow Helm Chart"
+      packageName: "apache-airflow-helm-chart"
+      path_prefix: "^chart/"
+```
+
+### Release process
+
+```yaml
+release_process:
+  # Cascade of sources skills consult to resolve "who is RM for
+  # version X". First match wins; later entries are tried only if
+  # earlier entries fail to surface a handle.
+  # ASF default: the project's release-trains.md roster file, then
+  # the wiki release-managers page, then the dev@ mailing-list
+  # VOTE/RESULT threads.
+  # Override when: non-ASF — collapse to whatever roster source the
+  # project keeps. Drop entries that don't apply.
+  # Consumed by: security-issue-sync, security-issue-fix (PR
+  # reviewer assignment).
+  release_manager_lookup_cascade:
+    - kind: roster_file
+      path: "release-trains.md"
+    - kind: wiki_url
+      url: 
"https://cwiki.apache.org/confluence/display/<PROJECT>/Release+Managers"
+    - kind: mailing_list_vote_thread
+      list: "<dev-list>"
+
+  # Artifact registries where the project publishes releases — the
+  # skills cross-check that a fix has shipped here before flipping
+  # the issue to "fix released".
+  # ASF default: [pypi, artifacthub] (Python wheels + Helm chart).
+  # Override when: non-Python projects (`maven`, `npm`, ...) or
+  # projects that publish elsewhere.
+  # Consumed by: security-issue-sync, generate-cve-json
+  # (references[] population).
+  artifact_registries: [pypi, artifacthub]
+
+  # Milestones the skills treat as "stale" — i.e. anything still
+  # pinned to one of these is overdue for re-targeting. Listed as
+  # exact milestone-name matches.
+  # ASF/Airflow default: the current airflow-s stale-milestone list.
+  # Override when: replace with the adopter's stale-milestone names.
+  # Consumed by: security-issue-sync, pr-management-triage.
+  stale_milestones:
+    - "Airflow 2.x"
+    - "Airflow 2.10.x"
+    - "Airflow 3.0.x"
+
+  # Whether the upstream repo uses a newsfragments / changelog
+  # fragment tool, and which one. Skills hook this when proposing
+  # a fix — the fix PR must include a fragment.
+  # ASF/Airflow default: enabled with towncrier.
+  # Override when: projects without a fragment tool (`enabled:
+  # false`) or with a different tool (reno, changie, ...).
+  # Consumed by: security-issue-fix, issue-fix-workflow.
+  newsfragments:
+    enabled: true
+    tool: towncrier
+```
+
+### Roster
+
+```yaml
+roster:
+  # Source of truth for the security team membership and the
+  # bare-name -> handle mapping. Selects how the skills resolve
+  # "is X on the security team".
+  # `tracker-collaborators` = read the tracker repo's collaborator
+  # list; `roster-file:<path>` = read a checked-in file (path
+  # relative to <project-config>/); `inline:<list>` = a literal
+  # list spelled out below.
+  # ASF default: roster-file:release-trains.md (the canonical
+  # source for the Airflow security team).
+  # Override when: non-ASF — pick whichever shape matches the
+  # adopter's source of truth.
+  # Consumed by: security-issue-sync, security-cve-allocate,
+  # security-issue-triage.
+  source: roster-file:release-trains.md
+
+  # Bare-name -> @handle mapping. Mailing-list threads frequently
+  # reference contributors by first name only; this map binds
+  # those bare names to GitHub handles so the skills can produce
+  # @-mentions on the tracker.
+  # ASF/Airflow default: the existing airflow-s bare-name map.
+  # Override when: adapt to the adopter's roster — add/remove
+  # entries as needed.
+  # Consumed by: security-issue-sync, pr-management-mentor.
+  bare_name_handles:
+    Jarek: "@potiuk"
+    Ash: "@ashb"
+    Kaxil: "@kaxil"
+    Ephraim: "@ephraimbuddy"
+    Jed: "@jedcunningham"
+
+  # Release-manager handles, ordered by recency of train ownership.
+  # First handle is the current default RM; the rest are the
+  # historical RMs the skill falls back to when assigning legacy
+  # trains.
+  # ASF/Airflow default: the current RM order from release-trains.md.
+  # Override when: keep in sync with `release-trains.md`.
+  # Consumed by: security-issue-sync, security-issue-fix.
+  release_managers:
+    - "@ephraimbuddy"
+    - "@jedcunningham"
+    - "@potiuk"
+    - "@kaxil"
+```
+
+### Product
+
+```yaml
+product:
+  # Human-readable product name — what lands in the CVE record's
+  # `product` field and what canned responses address the product
+  # as.
+  # ASF/Airflow default: Airflow.
+  # Override when: any other project — replace with the canonical
+  # short name.
+  # Consumed by: generate-cve-json, canned-responses templating.
+  name: Airflow
+
+  # Package name shape for the primary artifact — used by the
+  # advisory templating and the CVE JSON `affected[].packageName`.
+  # ASF/Airflow default: apache-airflow (PyPI distribution).
+  # Override when: any other project — use the package-registry
+  # name (PyPI / npm / Maven / ...).
+  # Consumed by: generate-cve-json, canned-responses templating.
+  package_name: apache-airflow
+
+  # Regex matched against changed paths in an upstream PR to
+  # confirm "this PR really touches the product". Used as a
+  # backstop sanity check in the fix flow.
+  # ASF/Airflow default: starts with `airflow` (matches
+  # airflow/, airflow-core/, airflow-ctl/, etc.).
+  # Override when: any other repo layout.
+  # Consumed by: security-issue-fix, pr-management-triage.
+  code_pointer_path_prefix: "^airflow"
+
+  # Prefixes the title-normalization skill strips when normalising
+  # an inbound subject line into a CVE title. Matched at the start
+  # of the subject, case-insensitively, in order; the first match
+  # wins and is removed.
+  # ASF/Airflow default: the existing airflow-s strip cascade.
+  # Override when: any other project — replace with the adopter's
+  # subject-prefix conventions.
+  # Consumed by: title-normalization, generate-cve-json,
+  # canned-responses templating.
+  subject_prefix_strip:
+    - "[SECURITY]"
+    - "[Security Report]"
+    - "Re:"
+    - "Fwd:"
+    - "Airflow:"
+    - "Apache Airflow:"
+
+  # Prefix the affected-versions extractor looks for in mailing-list
+  # bodies — reporters typically write "Airflow 2.10.0 is affected".
+  # Skill strips this prefix to leave the bare version literal.
+  # ASF/Airflow default: "Airflow".
+  # Override when: any other product — the literal product token
+  # reporters use in version expressions.
+  # Consumed by: security-issue-sync, generate-cve-json.
+  affected_version_extract_prefix: "Airflow"
+```
+
 ## Pointers to sibling files
 
 - [`release-trains.md`](release-trains.md) — fast-moving release state, 
release-manager attribution, security-team roster.
diff --git a/tools/cve-tool/README.md b/tools/cve-tool/README.md
new file mode 100644
index 0000000..6c7df9f
--- /dev/null
+++ b/tools/cve-tool/README.md
@@ -0,0 +1,410 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update 
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents**  *generated with 
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [`tools/cve-tool/`](#toolscve-tool)
+  - [What this is](#what-this-is)
+  - [Today's adapters](#todays-adapters)
+  - [Interface](#interface)
+    - [`allocate(scope, fields) to cve_id`](#allocatescope-fields-to-cve_id)
+    - [`fetch_current_state(cve_id) to {state, 
fields}`](#fetch_current_statecve_id-to-state-fields)
+    - [`push_update(cve_id, fields, state_transition=None) to 
diff`](#push_updatecve_id-fields-state_transitionnone-to-diff)
+    - [`publish(cve_id) to ok`](#publishcve_id-to-ok)
+    - [`retract(cve_id, reason) to ok`](#retractcve_id-reason-to-ok)
+  - [Generic state verbs](#generic-state-verbs)
+  - [Skills that consume this contract](#skills-that-consume-this-contract)
+  - [ASF default — Vulnogram](#asf-default--vulnogram)
+  - [Configuration](#configuration)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# `tools/cve-tool/`
+
+**Capability:** capability:setup
+
+## What this is
+
+The framework's CVE-tooling adapter contract. Today every CVE-aware
+skill in this repository — `security-cve-allocate`,
+`security-issue-sync`, `security-issue-invalidate`,
+`security-issue-deduplicate` — speaks Vulnogram. They embed
+Vulnogram's URL prefix (`cveprocess.apache.org`), Vulnogram's
+`#source` / `#json` / `#email` tab shape, and Vulnogram's `DRAFT` →
+`REVIEW` to `READY` to `PUBLIC` state machine directly in the
+skill prose. That is fine for the ASF — the foundation runs a single
+CNA tool and the skills can afford to be Vulnogram-shaped — but it
+forecloses every other plausible adopter. A project running CVE.org's
+direct submission portal, the MITRE form for non-CNAs, GHSA's own
+CNA flow, or no CVE allocation at all has no seam to plug into.
+This contract is that seam. It defines the methods every CVE-tool
+backend must implement, the lifecycle moments at which the skills
+fire each method, and the generic state verbs the skills use to
+talk about a CVE record without baking in any one tool's vocabulary.
+Skills consume the contract; the adapter consumes the wire protocol
+of whichever CNA tool the adopter actually uses.
+
+The contract is read by the framework, not by humans during normal
+operation. New adopters declare `cve_authority.tool: <adapter>` in
+`projects/<project>/project.md`; the skills resolve that to a
+sibling directory under `tools/` and call the methods named here.
+Adopters who run Vulnogram inherit the ASF defaults verbatim.
+
+## Today's adapters
+
+Only one CVE-tool adapter ships today:
+
+| Adapter | Directory | Status | Notes |
+|---|---|---|---|
+| Vulnogram (ASF) | `tools/vulnogram/` | Shipping | The reference 
implementation. PR4 of the ASF-pluggable security flow renames the directory to 
`tools/cve-tool-vulnogram/` to match the contract's adapter-naming rule. **Not 
renamed in this PR.** |
+| CVE.org direct submission | `tools/cve-tool-cve-org-direct/` *(planned)* | 
Placeholder | For adopters who are themselves a CNA and submit records straight 
to `cve.org` via the CVE Services API rather than through an intermediate CNA 
tool. |
+| MITRE form | `tools/cve-tool-mitre-form/` *(planned)* | Placeholder | For 
adopters who are not a CNA and request CVE IDs via the [MITRE CVE Request 
form](https://cveform.mitre.org/). Allocation is asynchronous and 
operator-mediated; `fetch_current_state` will frequently return `unknown` until 
the form's email reply arrives. |
+| GHSA-as-CNA | `tools/cve-tool-ghsa/` *(planned)* | Placeholder | For 
adopters who already drive their advisory flow through GitHub Security 
Advisories (GHSA) and use GitHub as the CNA. `allocate` becomes a GHSA draft 
create; `publish` becomes a GHSA publish. |
+| None | `tools/cve-tool-none/` *(planned)* | Placeholder | The 
disable-CVE-allocation backend. All methods are no-ops returning a synthetic 
`not-applicable` result. For adopters who triage security issues but do not 
allocate CVEs (e.g. an internal product with no public distribution). |
+
+The four placeholder adapters do not exist on disk yet; they exist
+in this table so the contract can describe the surface they will fit
+into when an adopter actually needs one. PR4 of the ASF-pluggable
+security flow will create `tools/cve-tool-vulnogram/` (the rename
+target) but will not pre-create the other three — those land when an
+adopter implements them.
+
+## Interface
+
+Every CVE-tool adapter exposes five methods. The names here are the
+generic verbs the skills use; an adapter is free to name its internal
+CLIs whatever fits its tool's vocabulary, as long as the skill-facing
+surface uses these names.
+
+### `allocate(scope, fields) to cve_id`
+
+Reserve a CVE ID and create the initial record.
+
+- **Lifecycle moment.** Fires once per tracking issue, inside
+  `security-cve-allocate` after the security team has agreed the
+  report is valid and a CVE should be allocated. The pre-allocation
+  consensus discussion happens on the tracker; `allocate` is the
+  step that turns that consensus into a reserved ID.
+- **Inputs.**
+  - `scope` — a string identifying the project scope under which
+    the CVE belongs. Concretely for ASF: the scope label
+    (`affects: airflow` / `affects: providers` / `affects: chart`)
+    drives which `vendor` / `product` / `packageName` triad the
+    adapter writes into the initial record. For other adopters this
+    is whatever string their CVE tool needs to route the allocation
+    to the right CNA pool.
+  - `fields` — a dict of the tracker's body fields at the moment
+    allocation fires: `title`, `description`, `affected_versions`,
+    `cwe`, `severity`, `credits`, `references`. The adapter is free
+    to ignore fields its tool cannot accept at allocation time and
+    fill them in later via `push_update`. The Vulnogram adapter
+    accepts the full set at allocation; the MITRE-form adapter
+    only sends `title` + `affected_versions` + a short description
+    in the initial form submission.
+- **Output.** The allocated CVE ID as a string in canonical
+  `CVE-YYYY-NNNN+` form (`CVE-2026-12345`). If the adapter cannot
+  allocate synchronously (the MITRE-form case), it returns a
+  provisional placeholder string and the skill stores that on the
+  tracker as `cve allocation pending`; the next `fetch_current_state`
+  call upgrades it once the real ID arrives.
+- **No-op case.** The `none` adapter raises `NotApplicable` and
+  the skill skips allocation entirely. The `security-cve-allocate`
+  skill detects this and posts a "this adopter does not allocate
+  CVEs; closing without an ID" comment on the tracker instead.
+
+### `fetch_current_state(cve_id) to {state, fields}`
+
+Read the current record state and the fields the tool stores.
+
+- **Lifecycle moment.** Fires inside `security-issue-sync` at
+  Step 1e (the reviewer-comment sync) and Step 5b (the
+  state-progression gate before the release-manager hand-off).
+  Also fires from `security-issue-invalidate`'s Step 0 gate, to
+  refuse closure when a record exists and is past `review-ready`.
+- **Inputs.** The CVE ID. Nothing else — the adapter is responsible
+  for resolving the tool-specific URL or API call from the ID.
+- **Output.** A two-field dict:
+  - `state` — one of the generic state verbs defined below
+    (`allocated`, `review-ready`, `publish-ready`, `public`,
+    `retracted`, `unknown`). The adapter is responsible for
+    mapping its tool's native state vocabulary onto these verbs.
+  - `fields` — a dict of the fields the tool currently stores
+    for the record. Shape matches the `allocate` input dict; the
+    skills diff this against the tracker body to detect drift.
+- **No-op case.** The `none` adapter returns `{state: "unknown",
+  fields: {}}`. Skills treat `unknown` as "do not gate progression
+  on tool state"; they fall back to the tracker body's view.
+
+### `push_update(cve_id, fields, state_transition=None) to diff`
+
+Write field updates to the record, optionally with a state move.
+
+- **Lifecycle moment.** Fires inside `security-issue-sync` at
+  Step 5c, whenever the tracker body has drifted from what
+  `fetch_current_state` returned. Also fires from
+  `security-issue-deduplicate` when the kept tracker absorbs
+  credits or references from the merged-in tracker.
+- **Inputs.**
+  - `cve_id` — as for `fetch_current_state`.
+  - `fields` — the new desired fields. The adapter is responsible
+    for translating these into whatever wire format its tool
+    accepts (JSON record for Vulnogram, web-form fields for the
+    MITRE form, GraphQL mutation for GHSA).
+  - `state_transition` *(optional)* — one of the generic state
+    verbs. When supplied, the adapter performs the state move as
+    part of the same write; when omitted, the adapter only writes
+    fields and leaves state untouched. The Vulnogram adapter today
+    embeds the state inside the JSON body and so does both in one
+    PUT; other adapters may need a separate API call after the
+    field write. The contract does not constrain how — only that
+    the call appear atomic from the skill's point of view.
+- **Output.** A diff dict — `{added: [...], removed: [...],
+  changed: [...]}` — that the skill can show the user as part of
+  the sync confirmation. Empty diff means the record was already
+  up to date; the skill suppresses the confirmation prompt in
+  that case.
+- **No-op case.** The `none` adapter returns `{added: [], removed:
+  [], changed: []}` and writes nothing. The `unknown`-state
+  return from `fetch_current_state` also causes the skill to skip
+  `push_update` — the adapter has nowhere to push to.
+
+### `publish(cve_id) to ok`
+
+Move the record to its terminal public state.
+
+- **Lifecycle moment.** Fires inside `security-issue-sync` at
+  the publication step, **after** the public advisory archive URL
+  has been captured on the tracker. The captured archive URL is
+  the real-world signal that the advisory has actually shipped to
+  the users-list; before that point the adapter would be
+  publishing a record before the world has heard about the issue,
+  which is the opposite of the desired ordering.
+- **Inputs.** The CVE ID.
+- **Output.** `ok` (a sentinel; the skill does not inspect the
+  return body — failure raises). The skill follows up with
+  `fetch_current_state` to confirm the state landed at `public`
+  and posts a confirmation comment on the tracker.
+- **No-op case.** The `none` adapter raises `NotApplicable`. The
+  skill treats this as "this adopter does not publish CVEs; the
+  tracker close is the only terminal action" and proceeds to the
+  archive-from-board step directly. The `ghsa` adapter implements
+  this as a GraphQL `publishSecurityAdvisory` mutation.
+
+### `retract(cve_id, reason) to ok`
+
+Mark an allocated-but-not-public record as rejected.
+
+- **Lifecycle moment.** Fires inside `security-issue-invalidate`
+  for trackers that already carry a CVE ID. CVE retraction is
+  governance-sensitive; the skill requires explicit confirmation
+  from a release-vote-gating role (`governance.cve_allocation_gate`
+  from `project.md`) before invoking `retract`. The retracted
+  state is terminal — once a record has been retracted, no
+  subsequent `push_update` or `publish` is valid.
+- **Inputs.**
+  - `cve_id` — as above.
+  - `reason` — a short string captured from the tracker
+    discussion explaining the retraction. The adapter is
+    responsible for writing this into the appropriate field of
+    its tool's record (Vulnogram's `CNA_private.justification`,
+    CVE.org's `rejectedReason`, etc.).
+- **Output.** `ok` sentinel as for `publish`.
+- **No-op case.** The `none` adapter raises `NotApplicable`. The
+  skill falls back to "close the tracker as invalid; there is no
+  CVE record to retract." For adapters whose tool does not
+  support retraction of `public` records (Vulnogram refuses this;
+  CVE.org has a separate REJECT flow), the adapter must raise a
+  distinguishable `AlreadyPublic` error and the skill escalates
+  to the configured governance contact.
+
+## Generic state verbs
+
+The skills speak in generic verbs about a CVE record's lifecycle.
+The adapter is responsible for mapping its tool's native states
+onto these verbs. The verbs are:
+
+| Verb | Meaning | Vulnogram-native state |
+|---|---|---|
+| `allocated` | Record exists, ID is reserved, content is being filled in. Not 
visible publicly. | `DRAFT` |
+| `review-ready` | Record content is complete and ready for CNA review. 
Reviewer comments may arrive at this state. Not visible publicly. | `REVIEW` |
+| `publish-ready` | Record content is final, reviewer comments addressed, 
staged for the advisory-send step. The advisory emails are dispatched from the 
CVE tool while in this state. Not visible publicly. | `READY` |
+| `public` | Record pushed to `cve.org` and world-readable. Terminal in the 
success path. | `PUBLIC` |
+| `retracted` | Record marked rejected post-allocation. Terminal in the 
failure path. | `REJECTED` (in `CNA_private.state`) |
+| `unknown` | The adapter cannot determine the state (network failure, 
asynchronous tool that hasn't replied yet, `none` backend). Skills treat this 
as "fall back to the tracker body's view." | n/a |
+
+The map is **adapter-internal**. Skills never write `DRAFT` or
+`REVIEW` — they write `allocated` and `review-ready`. An adapter
+that needs a more granular internal model is free to introduce
+sub-states inside its `tool/` directory, as long as the
+contract-facing methods normalise on the verbs above.
+
+The `DRAFT` to `REVIEW` transition is sync-driven (the tracker body
+fields determine readiness) in the Vulnogram adapter; the
+`REVIEW` to `READY` transition is release-manager-driven because the
+adapter cannot tell from the tracker body alone whether reviewer
+comments are still pending. Other adapters may collapse these two
+transitions into one (the GHSA adapter has no separate
+review/publish-ready distinction) or split them further (a
+hypothetical multi-stage CNA tool with `REVIEW` to `ADDRESSING` →
+`READY`). The contract does not constrain how the adapter maps
+its internal states onto the four generic verbs — only that the
+verbs are what the skills see.
+
+## Skills that consume this contract
+
+The CVE-tool contract is consumed by four skills today:
+
+- **`security-cve-allocate`** — calls `allocate` to reserve the
+  CVE ID and create the initial record, then writes the ID back
+  onto the tracker's *CVE tool link* body field. The skill is
+  PMC-gated (or `governance.cve_allocation_gate`-gated for
+  non-ASF adopters); the adapter does not enforce that gate
+  itself — the skill does.
+- **`security-issue-sync`** — calls `fetch_current_state` at
+  Step 1e (to surface reviewer-comment signals) and at Step 5b
+  (to gate the release-manager hand-off on the post-push state
+  being `review-ready`). Calls `push_update` at Step 5c whenever
+  the tracker body has drifted from the tool's view. Calls
+  `publish` at the publication step after the public advisory
+  archive URL has been captured.
+- **`security-issue-invalidate`** — uses `fetch_current_state` at
+  its Step 0 gate to refuse closure when a CVE record exists and
+  is past `review-ready`. Calls `retract` when the tracker
+  carries a CVE ID and the consensus decision is "invalid after
+  allocation" (escalates first via `governance.escalation_contact`,
+  since CVE retraction has public consequences).
+- **`security-issue-deduplicate`** — calls `push_update` on the
+  kept tracker's CVE record after merging in credits and
+  references from the duplicate tracker. The duplicate tracker's
+  CVE record (if any) is the subject of a separate `retract`
+  call once the dedup decision has been confirmed.
+
+These four skills are the only consumers in the current shape of
+the framework. Future skills that need to touch a CVE record
+(e.g. a hypothetical `security-cve-rotate-credit` action) will
+extend the contract rather than reaching past it into a specific
+adapter.
+
+## ASF default — Vulnogram
+
+The ASF reference adapter lives at [`tools/vulnogram/`](../vulnogram/).
+It is the only adapter shipping today, and the only adapter the
+skills are tested against. Key properties of the ASF default:
+
+- **URL prefix:** `https://cveprocess.apache.org/cve5/<CVE-ID>`.
+  The record page, `#source` tab, `#json` tab, and `#email` tab
+  are all rooted there. See 
[`tools/vulnogram/record.md`](../vulnogram/record.md#record-urls)
+  for the canonical URL table.
+- **Email preview URL:** `https://cveprocess.apache.org/cve5/<CVE-ID>#email`.
+  Renders the advisory exactly as Vulnogram will dispatch it to
+  the users-list and announce-list — same subject, same body,
+  same recipient list. The release-manager checklist calls this
+  out as a load-bearing checkpoint before the advisory-send step.
+- **Source tab URL:** `https://cveprocess.apache.org/cve5/<CVE-ID>#source`.
+  The copy-paste fallback target for the JSON record. The default
+  write path is the OAuth-authenticated API (`vulnogram-api-record-update`);
+  copy-paste is the documented fallback when the OAuth session
+  has expired or the operator has opted out.
+- **State machine:** `DRAFT` to `REVIEW` to `READY` to `PUBLIC`,
+  carried inside `CNA_private.state` on the CVE 5.x record. The
+  adapter maps these onto the generic verbs as shown in the
+  state-verb table above.
+- **Reviewer-comment channel:** mailing-list (the ASF CNA
+  reviewers email the project's `security_list` rather than
+  surfacing comments on the tracking issue directly). This is
+  the `cve_authority.reviewer_channel: mailing-list` setting in
+  `project.md`; an adapter could equally well declare
+  `reviewer_channel: github-pr` (for a CNA tool that uses pull
+  requests as its review surface) or `reviewer_channel: none`
+  (for a CNA tool with no separate review step).
+- **Publication propagation:** poll (the skills check the public
+  advisory archive on every sync rather than waiting for a
+  webhook). This is the `cve_authority.publication_propagation:
+  poll` setting in `project.md`.
+
+**Rename pending.** PR4 of the ASF-pluggable security flow renames
+`tools/vulnogram/` to `tools/cve-tool-vulnogram/` to match the
+contract's adapter-naming rule. **This PR does not perform the
+rename** — the existing skill prose and the `cve_authority.tool:
+vulnogram` setting in `project.md` continue to point at
+`tools/vulnogram/` until PR4 lands.
+
+## Configuration
+
+Every adopter declares its CVE-tool choice in
+`projects/<project>/project.md` under the `cve_authority` block.
+The shape is:
+
+```yaml
+cve_authority:
+  tool: vulnogram              # vulnogram | cve-org-direct | mitre-form | 
ghsa | none
+  allocate_url: "https://cveprocess.apache.org/cve5/new";
+  record_url_template: "https://cveprocess.apache.org/cve5/{cve_id}";
+  source_tab_url_template: "https://cveprocess.apache.org/cve5/{cve_id}#source";
+  email_preview_url_template: 
"https://cveprocess.apache.org/cve5/{cve_id}#email";
+  states: [allocated, review-ready, publish-ready, public]
+  publication_propagation: poll        # poll | webhook | manual
+  emits_allocation_email: true
+  reviewer_channel: mailing-list       # mailing-list | github-pr | none
+```
+
+Field-by-field:
+
+- **`tool`** — names the adapter directory the skills resolve to.
+  The ASF default is `vulnogram` (resolves to `tools/vulnogram/`;
+  becomes `tools/cve-tool-vulnogram/` after PR4). Adopters using
+  a different CVE-tool backend pick one of the other four
+  enumerated values, each of which is expected to resolve to a
+  sibling `tools/cve-tool-<name>/` directory.
+- **`allocate_url`** — the URL the human operator opens to begin
+  the allocation flow (for tool-mediated allocation paths) or
+  the API endpoint the adapter POSTs to (for fully automated
+  paths). The Vulnogram default is the `/cve5/new` form; the
+  MITRE-form default would be `https://cveform.mitre.org/`.
+- **`record_url_template`** — the per-record URL pattern. The
+  `{cve_id}` placeholder is the only token the skills substitute.
+  Skills use this for the "open the CVE record" links they post
+  on the tracker.
+- **`source_tab_url_template`** — the copy-paste fallback target.
+  Optional for adapters that have no copy-paste fallback (e.g.
+  GHSA — there is no JSON form to paste into). When the field is
+  null, the skills suppress the copy-paste fallback proposal.
+- **`email_preview_url_template`** — the advisory-email preview
+  URL. Optional for adapters whose CVE tool does not dispatch
+  the advisory itself; when null, the release-manager checklist
+  omits the preview step. The ASF default points at the `#email`
+  tab.
+- **`states`** — the ordered list of generic state verbs the
+  adapter exposes. Adapters with fewer states (the GHSA adapter
+  collapses `review-ready` and `publish-ready` into a single
+  pre-publish state; the `none` adapter has only `allocated` and
+  `unknown`) declare a shorter list. Skills branch on the
+  declared list when deciding which lifecycle steps apply.
+- **`publication_propagation`** — how the skills learn that a
+  record has reached `public`. `poll` (the ASF default) means
+  the skills check the public archive on every sync; `webhook`
+  means an external hook updates the tracker directly; `manual`
+  means a human flips the tracker label and the skills trust it.
+- **`emits_allocation_email`** — whether the CVE tool sends an
+  allocation-confirmation email at `allocate` time. The ASF
+  default is `true` (Vulnogram emails the project's
+  `security_list` on every allocation); the MITRE-form default
+  is also `true` (the form replies with the allocated ID);
+  GHSA's default is `false` (no email — the allocation result is
+  the API response). Skills that wait on this email surface a
+  "do not close this tracker until the allocation email lands"
+  hint when the flag is `true`.
+- **`reviewer_channel`** — where reviewer comments arrive.
+  `mailing-list` (ASF default) means `security-issue-sync` reads
+  reviewer comments off the security mailing list; `github-pr`
+  means it reads them off a backing PR; `none` means there is
+  no separate review step.
+
+The contract does not constrain how the adapter implements any of
+these settings — only that the settings are present and that the
+adapter respects them. Adapters are free to add their own
+tool-specific sub-keys under `cve_authority.<adapter-name>:` (e.g.
+`cve_authority.vulnogram.asf_org_id`, 
`cve_authority.vulnogram.cna_private_owner`)
+for fields the contract does not surface.
diff --git a/tools/forwarder-relay/README.md b/tools/forwarder-relay/README.md
new file mode 100644
index 0000000..995156a
--- /dev/null
+++ b/tools/forwarder-relay/README.md
@@ -0,0 +1,404 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update 
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents**  *generated with 
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [tools/forwarder-relay/ — adapter 
contract](#toolsforwarder-relay--adapter-contract)
+  - [What "a relay message" means](#what-a-relay-message-means)
+  - [Today's adapters](#todays-adapters)
+  - [Interface](#interface)
+    - [`detect(message) -> adapter_name | 
null`](#detectmessage---adapter_name--null)
+    - [`extract_credit(body) -> {name, kind, raw_string} | 
null`](#extract_creditbody---name-kind-raw_string--null)
+    - [`contact_handle` (attribute)](#contact_handle-attribute)
+    - [`preamble_match` (attribute)](#preamble_match-attribute)
+    - [`reporter_addressing_block(...) -> 
string`](#reporter_addressing_block---string)
+    - [`via_forwarder_question_mode` 
(attribute)](#via_forwarder_question_mode-attribute)
+  - [Skills that consume this contract](#skills-that-consume-this-contract)
+  - [ASF default — ASF Security 
forwarder](#asf-default--asf-security-forwarder)
+    - [Why `@raboof` is the contact handle 
today](#why-raboof-is-the-contact-handle-today)
+  - [Configuration](#configuration)
+  - [Cross-references](#cross-references)
+  - [What this contract does NOT cover](#what-this-contract-does-not-cover)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# tools/forwarder-relay/ — adapter contract
+
+**Capability:** capability:setup
+
+A forwarder-relay adapter is a pluggable seam that teaches the
+security skills how to recognise an inbound report that arrived
+**through a relay** (someone else forwarded the original
+reporter's message to the project), how to extract the
+original-reporter credit from the relayed body, and how to route
+reporter-facing drafts back through the same relay channel. This
+file defines what the skills expect from an adapter, what the
+single shipping adapter (ASF Security relay) does today, and how
+adopters declare which adapters are enabled in
+[`<project-config>/project.md`](../../projects/_template/project.md).
+
+The framework default is the ASF Security relay adapter, which is
+the only one shipping in the tree today. The contract exists so
+that adopters whose security inbox sits behind huntr.com,
+HackerOne, GitHub Security Advisories, an internal SOC, or any
+other forwarding service can plug in an adapter without
+patching the skill bodies.
+
+## What "a relay message" means
+
+Many security reports never reach the project's security inbox
+directly. The original reporter files with a third-party broker —
+the ASF Security team at `[email protected]`, huntr.com,
+HackerOne, GHSA — and the broker forwards the report to the
+project. On the inbound thread, the broker is the visible
+correspondent; the actual reporter is one hop away, reachable
+only by asking the broker to relay messages back.
+
+This matters for three skill behaviours:
+
+1. **Credit extraction.** The `From:` header of a relay message
+   names the broker, not the reporter. Per the bot/AI credit
+   policy in
+   
[`tools/vulnogram/bot-credits-policy.md`](../vulnogram/bot-credits-policy.md)
+   the tracker's *Reporter credited as* field must name the
+   external reporter, so the skill has to pull the name from the
+   message body (the broker's preamble convention) instead of
+   from the header.
+2. **Reply routing.** Drafts intended for the reporter must go
+   through the broker — but addressed to the broker, with the
+   reporter-facing content folded inside as a paste-ready block
+   the broker can copy verbatim into their reply to the reporter.
+   See [`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) for
+   the paste-ready-block convention introduced in PR #375.
+3. **Question batching.** The project should not pester the
+   broker with every workflow chatter event. The
+   
[`docs/security/forwarder-routing-policy.md`](../../docs/security/forwarder-routing-policy.md)
+   policy doc — which consumes this contract — defines which
+   milestones get relayed and which stay on the project side.
+
+A forwarder-relay adapter is the seam that lets each skill ask
+the right adapter "is this a relay message? whose credit is in
+it? how do I draft back through it?" without hard-coding the
+ASF preamble or the `@apache.org` sender pattern.
+
+## Today's adapters
+
+| Adapter | Status | Inbound channel | Reference doc |
+|---|---|---|---|
+| `asf-security` | shipping | Mail from `[email protected]` or a personal 
`@apache.org` address with the ASF forwarding preamble | 
[`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) |
+| `huntr-relay` | placeholder | Mail from huntr.com's notification address 
with the huntr disclosure preamble | not implemented — contract slot only |
+| `hackerone-relay` | placeholder | Mail from HackerOne's notification address 
with the HackerOne handoff preamble | not implemented — contract slot only |
+| `ghsa-relay` | placeholder | Mail forwarded from a GHSA private report by 
the GitHub notification system | not implemented — contract slot only |
+| `custom` | escape hatch | Adopter-specific broker (internal SOC, third-party 
VRP, mailing-list relay) | adopter ships their own adapter dir |
+
+Only `asf-security` is wired in. The other rows are reserved
+contract slots: when an adopter needs huntr.com or HackerOne
+support, they implement an adapter directory under
+`tools/forwarder-relay/<name>/` that satisfies the interface
+below, and add `<name>` to the `forwarders.enabled` list in
+their `<project-config>/project.md`.
+
+## Interface
+
+A forwarder-relay adapter exposes the following operations. Skills
+dispatch through this interface; they do not import adapter
+internals directly.
+
+### `detect(message) -> adapter_name | null`
+
+Given a fetched mail-source message (`From`, `Subject`,
+`Body`, `Date`, `Message-ID`, headers), return the adapter's
+own name if the message is a relay message handled by this
+adapter, or `null` if not.
+
+Detection is the OR of two signals:
+
+* **Sender pattern.** A regex or set-membership check against
+  the `From:` address. ASF Security: `[email protected]` OR
+  any `*@apache.org` address. huntr: huntr.com's outbound
+  notification address. HackerOne: HackerOne's notification
+  address.
+* **Preamble match.** A regex against the first ~400 characters
+  of the message body, anchored to the broker's standard
+  forwarding header. ASF Security: *"Dear PMC, The security
+  vulnerability report has been received by the Apache Security
+  Team …"*.
+
+Both signals are evaluated; either one matching is sufficient,
+but the adapter MAY require both for higher-confidence cases.
+The skill calls each enabled adapter's `detect()` in the order
+listed under `forwarders.enabled`; first non-null wins.
+
+**Lifecycle:** called by `security-issue-import` Step 3
+(classification), and by `security-issue-sync` Step 2b when
+re-reading an existing tracker's inbound thread for routing
+decisions.
+
+### `extract_credit(body) -> {name, kind, raw_string} | null`
+
+Given the relay-message body, extract the original reporter's
+credit. Returns:
+
+* `name` — the human-readable reporter name as it appears in
+  the body. Used verbatim in the tracker's *Reporter credited
+  as* field unless the reporter later requests a different
+  rendering through a credit-preference exchange.
+* `kind` — one of `human` (named individual), `tool`
+  (automated scanner like `bugbunny.ai`, `protectai/modelscan`),
+  `service` (a broker / VRP / SOC operating on someone else's
+  behalf). Drives the bot-credit policy gate in
+  
[`tools/vulnogram/bot-credits-policy.md`](../vulnogram/bot-credits-policy.md).
+* `raw_string` — the exact substring lifted from the body
+  (e.g. *"This vulnerability was discovered and reported by
+  bugbunny.ai"*). Stored so a later sync can diff against the
+  current tracker field and detect that the reporter has been
+  manually overridden.
+
+Returns `null` when the body does not contain a credit line in
+the adapter's expected shape. The skill then surfaces a "credit
+unknown — please confirm before drafting the receipt" prompt
+rather than guessing.
+
+**Lifecycle:** called by `security-issue-import` Step 4 (field
+population for the new tracker body), and by
+`security-issue-sync` Step 2b when reconciling the tracker
+field against the latest read of the thread.
+
+### `contact_handle` (attribute)
+
+The GitHub-style handle (or back-channel identifier) of the
+relay contact the skills should `@mention` when proposing a
+draft. For ASF Security this is currently `@raboof` (Arnout
+Engelen, the on-duty ASF Security liaison); for huntr the
+handle would be huntr's program-issued contact. Lifted into
+config now so the skill body never hard-codes a name.
+
+The handle MAY be a list of fallbacks (`[@raboof,
+@securityasf-rota]`) for adapters whose contact rotates; the
+skill picks the first available one and surfaces the chosen
+handle in the proposal recap.
+
+### `preamble_match` (attribute)
+
+The regex used by `detect()`. Exposed as a read-only attribute
+so that:
+
+* `security-issue-import` can print the matched preamble in its
+  Step 3 classification proposal ("detected as ASF Security
+  relay because the body starts with `<matched snippet>`"),
+  giving the human reviewer a one-line "yes this looks right"
+  affordance;
+* the test harness in
+  [`tools/skill-and-tool-validator/`](../skill-and-tool-validator/)
+  can replay sample bodies through the adapter and assert the
+  detect outcome.
+
+### `reporter_addressing_block(...) -> string`
+
+Render the paste-ready block convention introduced in
+[`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) — the
+fenced block addressed to the reporter in the project's voice,
+signed *"<project> security team"*, inside a wrapper addressed
+to the forwarder asking them to forward it verbatim. The
+adapter owns the exact wrapper wording; the inner reporter
+block is built by the calling skill and passed in.
+
+Parameters:
+
+* `forwarder_first_name` — for the *"Hi <name>"* salutation
+  on the wrapper.
+* `reporter_first_name` — for the *"Hello <name>"* salutation
+  on the inner block, when known.
+* `links` — list of `(label, url)` pairs (GHSA URL, CVE
+  record URL, advisory URL) the wrapper prints near the top
+  so the forwarder can one-click context-switch on their side.
+* `inner_body` — the project's reporter-facing text. The
+  adapter wraps it in the paste-ready block; it does not
+  modify the inner content.
+
+The adapter's responsibility is the wrapper shape: where the
+links go, how the inner block is fenced, how the signature
+attaches. The reason this is in the adapter rather than in
+the skill is that different brokers have different forwarding
+conventions — huntr.com expects the inner block to be a
+markdown comment that pastes back into their UI; ASF Security
+expects a `---`-fenced plaintext block. One skill body, many
+adapter renderings.
+
+**Lifecycle:** called by `security-issue-import` Step 7
+(receipt-of-confirmation draft), by `security-cve-allocate`
+Step 4 (CVE-allocation notification draft), by
+`security-issue-sync` Step 2b (status-update drafts), and by
+`security-issue-invalidate` Step 5d (invalidation notice draft).
+
+### `via_forwarder_question_mode` (attribute)
+
+A boolean signalling how the adapter prefers credit-preference
+questions to be handled:
+
+* `true` — fold the credit-preference question into the
+  **same** receipt-of-confirmation draft, addressed to the
+  reporter via the paste-ready block. The forwarder makes one
+  forward-and-paste action total. This is the right default
+  for adapters where the broker prefers not to be a question
+  router (ASF Security: yes).
+* `false` — send the credit-preference question on a
+  **separate** draft, framed as a back-channel request to the
+  forwarder (*"please ask the reporter their credit
+  preference"*). This is appropriate for adapters where the
+  broker actively reviews each exchange (some HackerOne
+  programs).
+
+The skill body branches on this attribute in
+`security-issue-import` Step 7 and `security-cve-allocate`
+Step 4 instead of carrying an `if asf_relay:` check inline.
+
+## Skills that consume this contract
+
+| Skill | Step | What the skill calls |
+|---|---|---|
+| 
[`security-issue-import`](../../.claude/skills/security-issue-import/SKILL.md) 
| Step 3 — classification | `detect()` on every enabled adapter; the first 
non-null return classifies the candidate as a relay import |
+| `security-issue-import` | Step 4 — field population | `extract_credit()` for 
the *Reporter credited as* field |
+| `security-issue-import` | Step 7 — receipt-of-confirmation draft | 
`reporter_addressing_block()` + `via_forwarder_question_mode` to fold the 
credit-preference question |
+| [`security-issue-sync`](../../.claude/skills/security-issue-sync/SKILL.md) | 
Step 2b — draft routing | `contact_handle` + `reporter_addressing_block()` for 
any reporter-facing draft (CVE-allocated, fix-merged, advisory-shipped) on a 
relay tracker |
+| 
[`security-issue-invalidate`](../../.claude/skills/security-issue-invalidate/SKILL.md)
 | Step 5d — ASF-relay branch | `reporter_addressing_block()` for the 
polite-but-firm invalidation notice routed through the forwarder |
+| 
[`security-cve-allocate`](../../.claude/skills/security-cve-allocate/SKILL.md) 
| Step 4 — dual-mode draft | `via_forwarder_question_mode` to decide whether 
the CVE-allocation draft folds in the credit-preference ask or sends it 
separately |
+| [`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) | reference doc for the 
shipping adapter | this whole file is the formal contract that `asf-relay.md` 
documents prose-style |
+
+## ASF default — ASF Security forwarder
+
+The ASF Security adapter is the one shipping today. Its
+parameter values, lifted out of skill bodies and into
+`<project-config>/project.md`, are:
+
+```yaml
+forwarders:
+  enabled:
+    - asf-security
+  asf-security:
+    sender_pattern: '(security@apache\.org|.+@apache\.org)'
+    preamble_match: 'Dear PMC,\s+The security vulnerability report has been 
received by the Apache Security Team'
+    credit_extraction_rule:
+      # The ASF forwarding template ends with a credit line in one of these 
shapes.
+      patterns:
+        - 'This vulnerability was discovered and reported by (?P<credit>.+?)\.'
+        - 'Credit:\s+(?P<credit>.+?)$'
+        - 'Reported by:\s+(?P<credit>.+?)$'
+      kind_hints:
+        # Substring matches on the extracted name to classify kind.
+        tool: ['\.ai\b', 'bot\b', 'scanner\b']
+        service: ['security team\b', 'soc\b']
+        # default: human
+    contact_handle: '@raboof'   # ASF Security on-duty liaison; lift to a rota 
when one exists
+    via_forwarder_question_mode: true
+    reporter_addressing_block:
+      wrapper_salutation: 'Hi <forwarder-first-name>,'
+      links_section: true        # GHSA / CVE / advisory URLs on their own 
lines near the top
+      fence: '---'               # `---`-fenced plaintext block
+      inner_salutation: 'Hello <reporter-first-name>,'
+      inner_signature: '<project> security team'
+      wrapper_signoff: 'Thanks,\n<sender>'
+```
+
+The exact shape of the paste-ready block is defined in
+[`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) under the
+*"Reporter-facing content goes as a ready-to-paste block, not as
+a third-person ask"* rule, with the worked GHSA / CVE example.
+
+### Why `@raboof` is the contact handle today
+
+Arnout Engelen (`@raboof`, `[email protected]`) is the ASF
+Security team member who currently triages relayed reports for
+the projects this framework's reference adopter belongs to. The
+handle is lifted into config rather than hard-coded so that:
+
+* a future on-duty rota can declare a list (`[@raboof,
+  @next-on-duty, @asf-security-rota]`) without touching skill
+  bodies;
+* adopters whose ASF Security liaison is a different individual
+  declare their own handle locally;
+* the handle is reviewable in one place during a security-team
+  rotation hand-off instead of being scattered across draft
+  templates.
+
+## Configuration
+
+The adopter declares enabled adapters in
+[`<project-config>/project.md`](../../projects/_template/project.md)
+under the `forwarders` block:
+
+```yaml
+forwarders:
+  enabled:
+    - asf-security           # default; ships with framework
+    # - huntr-relay          # placeholder — uncomment when implemented
+    # - hackerone-relay      # placeholder — uncomment when implemented
+  asf-security:
+    contact_handle: '@raboof'
+    via_forwarder_question_mode: true
+    # sender_pattern / preamble_match / credit_extraction_rule
+    # inherit framework defaults unless the adopter overrides
+```
+
+The framework ships sensible defaults for every key under
+`asf-security`. An adopter typically only overrides
+`contact_handle` (their liaison) and possibly the
+`sender_pattern` (if they accept relays from a wider set of
+addresses than just `*@apache.org`).
+
+## Cross-references
+
+* **Policy** —
+  
[`docs/security/forwarder-routing-policy.md`](../../docs/security/forwarder-routing-policy.md)
+  defines *when* via-forwarder mode applies on a tracker and
+  *which* milestones get relayed. The adapter contract here is
+  the mechanism; that doc is the policy that drives it.
+* **Drafting convention** —
+  [`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) carries
+  the prose explanation of the paste-ready block, the clickable
+  external-reference URL rule, and the threading semantics for
+  relay drafts. The contract surfaces those rules as an
+  interface; the prose file remains the human-readable
+  reference for the shipping adapter.
+* **Bot-credit gate** —
+  [`tools/vulnogram/bot-credits-policy.md`](../vulnogram/bot-credits-policy.md)
+  reads the `kind` field returned by `extract_credit()` to
+  decide whether a CVE record should list the credit as a tool
+  / organisation rather than an individual.
+* **Mail-source layer** — this contract sits on top of the
+  abstract mail operations defined in
+  [`tools/mail-source/contract.md`](../mail-source/contract.md);
+  the forwarder-relay adapter consumes a message returned by
+  the mail-source layer and produces routing metadata. It does
+  not itself fetch or send mail.
+
+## What this contract does NOT cover
+
+* **Detection of GHSA / private-reporting trackers without an
+  inbound relay message.** Those are handled by the
+  `<!-- apache-steward: routing-mode via-forwarder -->` marker
+  documented in
+  
[`docs/security/forwarder-routing-policy.md`](../../docs/security/forwarder-routing-policy.md).
+  The contract is for adapters that recognise a message; the
+  marker is for trackers with no message at all.
+* **Send semantics.** Drafts produced via
+  `reporter_addressing_block()` are handed back to the
+  mail-source layer's `create_draft` operation; this contract
+  does not send mail. The framework rule remains *draft, never
+  send*.
+* **Tracker field schema.** The names of the tracker body
+  fields (*Reporter credited as*, *Security mailing list
+  thread*, etc.) are declared in
+  [`<project-config>/project.md`](../../projects/_template/project.md)
+  under the `tracker.body_fields` block. The adapter returns
+  values; the tracker decides where to write them.
+* **Multi-thread reconciliation.** When a tracker records both
+  a direct reporter thread and a separate relay thread, the
+  primary-vs-relay selection rule lives in
+  [`tools/gmail/threading.md`](../gmail/threading.md) —
+  *Selecting the inbound thread when multiple are recorded*.
+  The adapter contract assumes one inbound message at a time
+  and lets the threading layer decide which message to ask
+  about.
diff --git a/tools/mail-archive/README.md b/tools/mail-archive/README.md
new file mode 100644
index 0000000..0fb61b6
--- /dev/null
+++ b/tools/mail-archive/README.md
@@ -0,0 +1,343 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update 
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents**  *generated with 
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [tools/mail-archive/](#toolsmail-archive)
+  - [Today's adapters](#todays-adapters)
+  - [Interface](#interface)
+    - [`search_thread_url(list, year, month, query) to 
string`](#search_thread_urllist-year-month-query-to-string)
+    - [`fetch_thread_by_url(url) to thread_data | 
null`](#fetch_thread_by_urlurl-to-thread_data--null)
+    - [`list_recent_threads(list, since) to 
[thread_summary]`](#list_recent_threadslist-since-to-thread_summary)
+    - [`resolve_advisory_announcement_url(list, advisory_id) to string | 
null`](#resolve_advisory_announcement_urllist-advisory_id-to-string--null)
+    - [`publication_signal_url(list) to 
string`](#publication_signal_urllist-to-string)
+  - [Skills that consume this contract](#skills-that-consume-this-contract)
+  - [ASF default — PonyMail](#asf-default--ponymail)
+  - [Configuration](#configuration)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# tools/mail-archive/
+
+**Capability:** capability:setup
+
+This file defines the adapter contract for **public mail-archive
+backends** — the seam that lets adopting projects plug a non-ASF
+archive system (Hyperkitty, Discourse, Google Groups, GitHub
+Discussions, or none at all) into the same skills that today reach
+straight into `lists.apache.org` via the PonyMail MCP. The contract
+declares *verbs* that the generic skill bodies call, with input and
+output shapes that every backend must satisfy; the concrete URL
+construction, authentication model, and search syntax stay inside
+each adapter directory.
+
+The contract exists because the skills currently hard-code two
+ASF-specific assumptions in their step bodies:
+
+1. They call `mcp__ponymail__search_list`, `mcp__ponymail__get_thread`,
+   `mcp__ponymail__list_recent_threads`, and friends by name — the
+   `mcp__ponymail__*` prefix is woven into roughly two dozen step
+   bodies across `security-issue-import`, `security-issue-sync`, and
+   `security-issue-invalidate`.
+2. They construct `https://lists.apache.org/...` URLs inline (the
+   `list?<list>:YYYY-M:<query>` search form, the
+   `api/thread.lua?...` JSON form, the
+   `thread/<hash>?<list>` resolved form, and the
+   `list.html?<users-list>` advisory-published form) from
+   `<list>` / `<list-domain>` / `<month>` placeholders that come
+   from the project manifest.
+
+Both assumptions are documented today in
+[`tools/gmail/ponymail-archive.md`](../gmail/ponymail-archive.md) and
+in [`tools/ponymail/operations.md`](../ponymail/operations.md). PR1
+introduces this contract so that PR3 can rename `tools/ponymail/`
+to `tools/mail-archive-ponymail/` without disturbing the skills, and
+so that later PRs can refactor every `mcp__ponymail__*` call site
+and every `lists.apache.org` URL template to flow through the verbs
+declared here.
+
+ASF projects keep the existing PonyMail behaviour by default. A
+non-ASF adopter declares a different `archive_system.kind` in
+`projects/<project>/project.md` and the adapter for that kind is
+loaded in place of PonyMail. Skills do not change.
+
+## Today's adapters
+
+| Adapter | Status | Source | Notes |
+|---|---|---|---|
+| `ponymail` | shipping | [`tools/ponymail/`](../ponymail/) (will be renamed 
to `tools/mail-archive-ponymail/` in PR3) | ASF's `lists.apache.org` 
deployment; PMC LDAP OAuth for private lists; anonymous read for public lists; 
load-bearing for the `security-issue-import`, `security-issue-sync`, and 
`security-issue-invalidate` skills today. |
+| `hyperkitty` | placeholder | not implemented | Mailman 3's archive UI. Same 
conceptual surface as PonyMail (per-list archive, search by month + query, 
per-thread permalink) but a different URL grammar and a different JSON API. |
+| `discourse` | placeholder | not implemented | Topic-based forum, with 
mailing-list-mode bridging. The verbs map onto Discourse's `/search.json` and 
`/t/<slug>/<id>.json` endpoints. |
+| `google-groups` | placeholder | not implemented | `groups.google.com` 
archives. Lacks a stable JSON read API; an adapter likely degrades to 
user-paste for the per-thread URL, the same way PonyMail does for private 
lists. |
+| `github-discussions` | placeholder | not implemented | GitHub's discussions 
feature, accessed via the GraphQL API the `gh` CLI already wraps. Useful for 
projects whose announcement channel is a `discussions` category rather than a 
mailing list. |
+| `none` | placeholder | not implemented | Explicit *"no archive backend"* 
declaration. Every verb returns `null` / no-op; the consuming skill falls back 
to user-paste or to a *"not available"* note in the tracker body field. Useful 
for projects that do not host their security discussions on an archived list at 
all (private-only intake forms, chat-channel intake). |
+
+The ASF default adapter is the only one that ships today. Every
+placeholder above is named, with a one-paragraph justification, so
+that an adopter who needs that backend can author the adapter
+without re-inventing the contract.
+
+## Interface
+
+Every adapter exposes the verbs below. Each verb declares:
+
+- **When it fires** — the skill lifecycle point that calls the verb.
+- **Inputs** — the typed arguments.
+- **Output shape** — the return type, including the `null` / no-op
+  shape when the backend cannot resolve the request.
+
+The output shapes are documented in conceptual terms rather than as
+a strict JSON schema; an adapter is free to return a language-native
+object as long as the consuming skill can read the named fields.
+
+### `search_thread_url(list, year, month, query) to string`
+
+**When it fires.** Step 5 of `security-issue-import` — the skill
+needs a one-click URL the user can open in their authenticated
+browser to land on the inbound report's archive page. Also fires
+inside `security-issue-invalidate` when the relay-rewritten
+inbound thread needs to be located for the closing reply.
+
+**Inputs.**
+
+| Arg | Type | Notes |
+|---|---|---|
+| `list` | string | The fully-qualified mailing-list address. For ASF: 
`security@<project>.apache.org`. Adapters that don't use email addresses 
(Discourse, GitHub Discussions) interpret this as the channel identifier. |
+| `year` | integer | Four-digit Gregorian year, e.g. `2026`. |
+| `month` | integer | 1–12. The adapter is responsible for formatting 
(PonyMail wants `YYYY-M`, no leading zero; Hyperkitty wants `YYYY-MM`). |
+| `query` | string | The search query in the adapter's native dialect — 
typically the report subject for `security-issue-import`, the CVE ID for 
advisory scans. The adapter is free to URL-encode, escape, or transform as 
needed. |
+
+**Output.** A complete URL string that a human can open in a
+browser to land on the search results. The skill proposes this URL
+to the user at Step 5 of `security-issue-import` and waits for the
+user to paste back the resolved per-thread URL.
+
+**No-op case.** Adapters that cannot generate a search URL (the
+`none` adapter, or a backend that gates search behind an interactive
+session) return an empty string. The skill treats an empty return
+as *"no search URL available, fall back to user-paste with no
+prompt URL"*.
+
+### `fetch_thread_by_url(url) to thread_data | null`
+
+**When it fires.** Step 1c / 1e / 1h / 2b of `security-issue-sync`
+— when the tracker already carries an archive thread URL and the
+sync skill wants to re-read the discussion for new mailing-list
+activity since the last sync. Also fires inside `security-issue-import`
+when the user pastes a URL back and the skill wants to verify the
+URL resolves before recording it in the tracker body field.
+
+**Inputs.**
+
+| Arg | Type | Notes |
+|---|---|---|
+| `url` | string | A URL the adapter previously produced via 
`search_thread_url` or via `resolve_advisory_announcement_url`. The adapter is 
responsible for parsing its own URL grammar. |
+
+**Output.** A `thread_data` object with at least:
+
+- `thread_id` — the adapter's opaque per-thread identifier.
+- `list` — the list address (echoed from the URL for adapter consistency).
+- `subject` — the thread subject.
+- `messages[]` — an array of `{message_id, from, date, body, in_reply_to}` 
records, ordered by date.
+- `participant_handles[]` — every distinct sender, formatted as the adapter's 
native handle (email address for mailing-list adapters; `@user` for Discourse; 
`@user` for GitHub Discussions).
+
+**No-op case.** Returns `null` when:
+
+- The URL is well-formed but the thread no longer exists (deleted /
+  retracted / archive purged).
+- The URL is well-formed but requires authentication the adapter
+  doesn't have (the `ponymail` adapter against a private list when
+  the user has not run `mcp__ponymail__login`).
+- The URL is malformed (parsing failure).
+
+Skills handle a `null` return by surfacing the gap to the user at
+the next sync — they do not retry automatically.
+
+### `list_recent_threads(list, since) to [thread_summary]`
+
+**When it fires.** Periodic-sweep bodies (`security-issue-import`
+when it scans `security@` for unimported threads;
+`security-issue-sync` when it scans `users@` for `[RESULT][VOTE]`
+announcements). Also fires on the *"check for new activity on this
+list"* shortcut path used by triage sweeps.
+
+**Inputs.**
+
+| Arg | Type | Notes |
+|---|---|---|
+| `list` | string | The fully-qualified list address. |
+| `since` | ISO-8601 date or relative duration string | The lower bound for 
the scan window. Adapters that don't accept a free-form `since` are expected to 
translate (PonyMail takes a `d=lte=` / `d=gte=` syntax; Hyperkitty takes a 
`?date=` param; Discourse takes an `after:` operator). |
+
+**Output.** An array of `thread_summary` records, each at minimum:
+
+- `thread_id`
+- `subject`
+- `first_message_date`
+- `last_message_date`
+- `message_count`
+- `permalink` — the URL `fetch_thread_by_url` would accept.
+
+Ordering is *newest-first by `last_message_date`*. Adapters that
+return an unordered set internally must sort before returning.
+
+**No-op case.** Returns `[]` (empty array) when the list has no
+activity in the requested window, or when authentication is
+missing and the list is private. Empty `[]` and *"no access"* are
+indistinguishable from the skill's perspective by design — the
+skill surfaces the gap without distinguishing reason.
+
+### `resolve_advisory_announcement_url(list, advisory_id) to string | null`
+
+**When it fires.** Step 1h / Step 2b of `security-issue-sync` — the
+sync skill polls for the *"advisory archived on `<users-list>`"*
+signal that flips the tracker from `fix released` to `announced`.
+Today the ASF adapter resolves this by curling
+`https://lists.apache.org/list.html?<users-list>:YYYY:<CVE-ID>` and
+checking for a 200 response with a thread hit; other adapters
+implement the equivalent against their own search APIs.
+
+**Inputs.**
+
+| Arg | Type | Notes |
+|---|---|---|
+| `list` | string | The public announcement list address (`<users-list>` or 
`<announce-list>` for ASF). |
+| `advisory_id` | string | The advisory identifier the skill is scanning for — 
typically the CVE ID once `cve_authority.publish` has fired, but could equally 
be a GHSA ID for projects using GHSA as their `cve_authority.tool`. |
+
+**Output.** The resolved permalink for the advisory thread
+(equivalent to the return shape of `search_thread_url` but already
+narrowed to the single matched thread), or `null` when no thread
+matches.
+
+**No-op case.** Returns `null` when:
+
+- No thread mentions the advisory ID on the named list within the
+  adapter's default scan window.
+- The list is private and the adapter has no access (the
+  announcement list should always be public, so this is an
+  adapter-misconfiguration signal — the skill flags it but does
+  not retry).
+- The adapter is `none` (no archive backend declared).
+
+The skill treats `null` as *"not yet archived"* and re-checks on the
+next sync run. A non-null return is a load-bearing signal — it
+triggers the multi-step `fix released to announced` close-out flow
+in `security-issue-sync` Step 4 (label flips, CVE JSON
+regeneration, Vulnogram `REVIEW to PUBLIC` push, milestone close,
+board archival, RM hand-off comment).
+
+### `publication_signal_url(list) to string`
+
+**When it fires.** On every `security-issue-sync` run that has the
+*"public-advisory-url not yet populated"* condition — the skill
+needs the URL that *flips visible* when a release-announcement is
+archived, so it can present it to the user as a one-click verify
+URL alongside the `resolve_advisory_announcement_url` programmatic
+scan.
+
+**Inputs.**
+
+| Arg | Type | Notes |
+|---|---|---|
+| `list` | string | The announcement list address. |
+
+**Output.** A URL pointing at the list's *most-recent-activity*
+view. For PonyMail this is
+`https://lists.apache.org/list.html?<users-list>` (the unfiltered
+list-index page that updates as new messages arrive). For
+Hyperkitty this is `https://<archive-host>/archives/list/<list>/`.
+For Discourse this is the category permalink. The skill embeds the
+URL in informational comments and in the *"check for advisory
+archive"* sync prompt.
+
+**No-op case.** Adapters that have no concept of *"most-recent-
+activity page"* (the `none` adapter; some Discourse configurations)
+return an empty string. The skill omits the verify-URL line from
+its sync prompt when this happens.
+
+## Skills that consume this contract
+
+| Skill | Where the call lives today | Verb |
+|---|---|---|
+| `security-issue-import` | Step 5 — *"PonyMail URL construction"* — the skill 
builds the per-month search URL from the project manifest's `<security-list>` 
value plus the inbound message's received-month, proposes it to the user, and 
waits for the resolved per-thread URL to be pasted back. | 
`search_thread_url(list=<security-list>, year, month, query=<subject>)` for the 
prompt URL; `fetch_thread_by_url(url=<pasted-url>)` for the verification step. |
+| `security-issue-sync` | Step 1c — *"check the mailing-list thread for new 
activity since last sync"*. | 
`fetch_thread_by_url(url=<security-thread-field>)` re-read; the skill diffs 
participants and message dates against the previous sync. |
+| `security-issue-sync` | Step 1e — *"locate the `[RESULT][VOTE]` thread for 
the release that ships this CVE"*. | `list_recent_threads(list=<dev-list>, 
since=<vote-window-start>)` filtered for `[RESULT][VOTE]` subject prefix. |
+| `security-issue-sync` | Step 1h — *"has the advisory been archived on 
`<users-list>` yet?"*. | `resolve_advisory_announcement_url(list=<users-list>, 
advisory_id=<CVE-ID>)`; non-null return triggers the close-out flow. |
+| `security-issue-sync` | Step 2b — *"present the verify URL to the user 
alongside the programmatic scan result"*. | 
`publication_signal_url(list=<users-list>)`. |
+| `security-issue-invalidate` | Closing-reply step — *"locate the original 
relay-rewritten inbound thread so the polite-but-firm rejection lands on the 
right archive entry"*. | `search_thread_url(list=<security-list>, year, month, 
query=<subject>)`; the skill cross-checks against the tracker's 
*security-thread* body field. |
+
+Every call site listed above currently hard-codes `mcp__ponymail__*`
+or constructs a `https://lists.apache.org/...` URL inline. PR3
+refactors the call sites to flow through the verbs declared in this
+contract; PR1 (this PR) only declares the contract.
+
+## ASF default — PonyMail
+
+The ASF default adapter is documented today at
+[`tools/ponymail/`](../ponymail/) (read-side via the
+[`apache/comdev` 
`mcp/ponymail-mcp/`](https://github.com/apache/comdev/tree/main/mcp/ponymail-mcp)
+MCP server) and 
[`tools/gmail/ponymail-archive.md`](../gmail/ponymail-archive.md)
+(URL-template form used for in-tracker cross-links).
+
+URL-construction shape that the ASF adapter satisfies:
+
+| Verb | URL template |
+|---|---|
+| `search_thread_url` | 
`https://lists.apache.org/list?<list>:YYYY-M:<url-encoded query>` |
+| `fetch_thread_by_url` | 
`https://lists.apache.org/api/thread.lua?list=<list-local>&domain=<list-domain>&q=<search>`
 (JSON read), backed by `https://lists.apache.org/thread/<hash>?<list>` (the 
canonical per-thread permalink the skill stores in tracker body fields) |
+| `list_recent_threads` | 
`https://lists.apache.org/api/stats.lua?list=<list-local>&domain=<list-domain>&d=lte=<since>`
 |
+| `resolve_advisory_announcement_url` | 
`https://lists.apache.org/list.html?<users-list>:YYYY:<CVE-ID>` (text-mode 
existence check), resolving to 
`https://lists.apache.org/thread/<hash>?<users-list>` on a hit |
+| `publication_signal_url` | `https://lists.apache.org/list.html?<users-list>` 
|
+
+Month-token format note: the PonyMail search URL takes the month
+**without a leading zero** (`2026-4`, not `2026-04`). Adapters that
+front-end a backend with a different convention (Hyperkitty uses
+`2026-04`) must normalise at the boundary.
+
+Auth note: private-list reads (`security@<project>.apache.org`,
+`private@<project>.apache.org`) require an authenticated PonyMail
+MCP session (PMC LDAP OAuth). The first-login flow is documented
+in [`tools/ponymail/tool.md`](../ponymail/tool.md#setup) and is run
+once per workstation; the session cookie is cached at
+`~/.ponymail-mcp/session.json`.
+
+**Rename plan.** PR3 of this refactor renames `tools/ponymail/` to
+`tools/mail-archive-ponymail/` and updates every cross-link. The
+directory contents stay the same — only the path moves. PR1 (this
+PR) keeps the existing path so the diff stays minimal and reviewable.
+
+## Configuration
+
+The adapter selection lives in `projects/<project>/project.md`
+under the `archive_system` block:
+
+```yaml
+# archive_system — public mail-archive backend
+# ASF default: ponymail (lists.apache.org)
+archive_system:
+  kind: ponymail               # ASF default; override per-adopter for 
hyperkitty | discourse | google-groups | github-discussions | none
+  list_domain: <project>.apache.org   # ASF default; the list's domain 
component
+  search_url_template: 
https://lists.apache.org/list?{list}:{year}-{month}:{query}
+                                # ASF default; the URL `search_thread_url` 
returns
+  api_query_url_template: 
https://lists.apache.org/api/thread.lua?list={list_local}&domain={list_domain}&q={query}
+                                # ASF default; the URL `fetch_thread_by_url` 
reads
+  advisory_publication_signal_url: 
https://lists.apache.org/list.html?<users-list>
+                                # ASF default; the URL 
`publication_signal_url` returns
+```
+
+Adopters override per-field. A Hyperkitty deployment would set
+`kind: hyperkitty`, point `search_url_template` at
+`https://<archive-host>/hyperkitty/list/{list}/{year}/{month}/?q={query}`,
+and the rest follows. A project that has no archive backend at all
+declares `kind: none` and the skills degrade — `search_thread_url`
+returns empty, `fetch_thread_by_url` returns `null`, and the
+tracker body fields fall back to the *"not available, see Gmail
+thread `<threadId>`"* textual note.
+
+Adapter selection is *purely declarative*. The skill bodies do not
+branch on `kind` — they call the verbs, and the dispatch into the
+adapter happens at the contract boundary. This is the property that
+makes the contract a stable seam: adding `discourse` later is a
+new directory under `tools/mail-archive-<name>/`, not a change to
+the skills.


Reply via email to