You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by po...@apache.org on 2026/04/29 02:04:53 UTC

(airflow-steward) branch main updated: sanitize: drop Airflow remnants; synthesize fictional adopter for tests (#7)

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 b73e240  sanitize: drop Airflow remnants; synthesize fictional adopter for tests (#7)
b73e240 is described below

commit b73e24078beab2b632ff44fab2298bd1db02f180
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Wed Apr 29 04:04:48 2026 +0200

    sanitize: drop Airflow remnants; synthesize fictional adopter for tests (#7)
    
    * docs+code: sanitize Airflow remnants; synthesize fictional adopter for tests
    
    Comprehensive sanitization pass across the framework's documentation
    and the generate-cve-json Python package, plus a config-driven
    refactor of three previously-hardcoded Airflow values in cve_json.py.
    
    ## Documentation (Tier 1 + Tier 2)
    
    Top-level docs:
    
    - `README.md`: drop the broken `Current projects` table (no
      per-project subdirs in this repo); reframe the legacy
      `apache/airflow-steward` repo identity; replace hardcoded
      `airflow / providers / chart` scope-label list with a pointer to
      `<project-config>/scope-labels.md`; replace the `airflow-s` grep
      blocklist token with `<tracker>`; replace `airflow-s#XYZ` with
      `<tracker>#XYZ`; rewrite `Adopting the framework` section.
    - `CONTRIBUTING.md`: drop the per-project tree section that
      documented a no-longer-existing `projects/airflow/` subtree; rewrite
      the file index for the framework-only layout; retarget the base
      branch from `airflow-s` to `main`; replace per-project guidance
      with adopter-side guidance.
    - `new-members-onboarding.md`: replace 4 hardcoded
      `projects/airflow/` and `airflow-s/projects/2` URLs with
      `<project-config>` pointers and generic phrasing.
    - `projects/_template/{README,canned-responses,release-trains,
      title-normalization}.md`: drop cross-references to a
      no-longer-existing `../airflow/` template peer; inline the
      shapes/example wording previously hosted there.
    
    Tool docs:
    
    - `tools/gmail/asf-relay.md`, `tools/ponymail/tool.md`: replace
      Airflow-specific PMC LDAP / mailing-list values with
      `<project-config>` pointers and generic phrasing.
    - `tools/gmail/{operations,search-queries,draft-backends}.md`,
      `tools/github/{project-board,tool}.md`: replace hardcoded
      `airflow-s` / `airflow-s/projects/2` / `airflow-s@noreply.github.com`
      references with `<tracker>` placeholders / project-config
      pointers / a new `<tracker-noreply>` placeholder.
    
    Skill files (`.claude/skills/*/SKILL.md`):
    
    - Replace `airflow-s` standalone references with `<tracker>` /
      `<tracker> issue` / `<tracker> tracking issue` /
      `/blob/<tracker-default-branch>/` everywhere it isn't an
      `(example: airflow-s/airflow-s …)` framing line. Total: 7 skill
      files touched, ~30 substitutions.
    - Reframe Airflow-as-the-assumed-adopter language: "Apache Airflow
      PMC" → "the project's PMC", "the Airflow security team" → "the
      project security team", "the Airflow Security Model" → "the
      project's security model", "the Airflow Release Plan wiki" → "the
      project's release plan", etc.
    
    Skill files (the deeper Airflow-specific procedural content —
    release-train naming, scope-label sets, milestone formats, the
    title-normalization regex example — is intentionally left for a
    follow-up that moves it into per-project config under
    `<project-config>/`. Refactoring it changes skill behaviour and
    needs its own PR.)
    
    `AGENTS.md`: 3 prose tweaks to drop "Airflow's", "for all Airflow
    CVEs", "the Airflow process", "the Airflow release train" in
    contexts that treated Airflow as the assumed project.
    
    `tools/vulnogram/generate-cve-json/SKILL.md`: rewrite the preamble
    to flag concrete `apache-foo-providers-*` strings as illustrative
    examples rather than Airflow defaults; rewrite multi-product /
    provider-display-map / `< NEXT VERSION` sections to refer to
    adopter-supplied config rather than a built-in `AIRFLOW_PROVIDER_
    DISPLAY_MAP` (which the code no longer has — was stale doc).
    
    ## Code (Tier 3 — config-driven refactor)
    
    `tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py`
    had three hardcoded Airflow values that bypassed the otherwise-
    config-driven design:
    
    - `wrap_cve_record` hardcoded `CNA_private.projecturl =
      "https://airflow.apache.org/"`, `owner = "airflow"`,
      `userslist = "users@airflow.apache.org"` into every emitted CVE
      record.
    - `build_references` hardcoded the `"airflow-s" not in url` filter,
      so adopters' tracker URLs would leak into published CVE records
      while Airflow's wouldn't.
    - `resolve_title` hardcoded the `r"^\s*apache\s+airflow\s*[:\-...]?
      \s*"` strip regex, so the title-prefix scrub only worked for
      Airflow-titled issues.
    
    All three are now config-driven:
    
    - New `[cna_private]` config section with `project_url`, `owner`,
      `users_list` fields. Loaded into `CNA_PRIVATE_PROJECT_URL`,
      `CNA_PRIVATE_OWNER`, `CNA_PRIVATE_USERS_LIST` constants.
    - New `TRACKER_FILTER_TOKEN` constant derived from `meta.tracker_repo`
      (uses the org segment) — `build_references` filters with that.
    - New `TITLE_STRIP_RE` constant compiled at config-load time from the
      configured `top_level_product` — `resolve_title` uses it.
    
    Also removed an orphaned `SKILL_SOURCE_URL = "https://github.com/
    airflow-s/airflow-s/..."` assignment at module level that was
    overriding the config-loaded value (bug fix).
    
    ## Test fixture (Q1b — fictional adopter)
    
    `tests/fixtures/cve-json-config.toml` replaced its Airflow-shaped
    values with a fictional "Apache Example" project: `apache-example`
    top-level package, `apache-example-providers-<name>` provider
    layout, `apache-example-s/apache-example-s` tracker repo,
    `example.apache.org` project URL, etc. The provider display map is
    trimmed to the providers the tests actually exercise (5 entries:
    cncf-kubernetes, elasticsearch, opensearch, smtp, snowflake);
    unknown providers fall back to the title-cased dash-split path.
    
    `tests/test_generate_cve_json.py` (135 assertions touched via sed):
    all `Apache Airflow` → `Apache Example`, `apache-airflow` →
    `apache-example`, `apache-airflow-providers-*` →
    `apache-example-providers-*`, `airflow-s/airflow-s` →
    `apache-example-s/apache-example-s`, plus 1 manual update for the
    CNA_private envelope assertion (now `example` / `users@example.
    apache.org`).
    
    ## Other
    
    - `pyproject.toml`: rename root package `apache-airflow-steward` →
      `apache-steward` (the future canonical name; `name` field is
      internal-only, no PyPI publication). Rationale captured in a
      comment.
    - `uv.lock` regenerated.
    
    ## Test plan
    
    - 139 tests pass across both Python projects (134 generate-cve-json,
      58 oauth-draft).
    - `prek run --all-files` passes (all 16 hooks).
    - `zizmor` clean.
    
    Generated-by: Claude Code (Claude Opus 4.7)
    
    * sanitize: rename `provider` → `project`; switch test data to fictional names
    
    Follow-up to the previous sanitization commit. The earlier pass had
    left two adopter-shape leaks in the generate-cve-json package:
    
    1. The codebase used **"provider"** throughout (PROVIDER_DISPLAY_MAP,
       provider_product_template, provider_dir, etc.) — a term that comes
       straight from Apache Airflow's `apache-airflow-providers-*`
       sub-package layout. Renamed to **"project"** so the framework
       doesn't bake in any one adopter's terminology.
    2. The test fixture's `project_display_map` (was: provider map)
       contained the names of real ASF providers / external tools
       (`elasticsearch`, `snowflake`, `cncf-kubernetes`, `opensearch`,
       `smtp`). Replaced with clearly-fictional placeholders that
       exercise the same code paths.
    
    ## Renames in cve_json.py
    
    - Constants: `PROVIDER_DISPLAY_MAP` → `PROJECT_DISPLAY_MAP`,
      `PROVIDER_PRODUCT_TEMPLATE` → `PROJECT_PRODUCT_TEMPLATE`,
      `PROVIDER_PREFIX` → `PROJECT_PREFIX`.
    - Local vars: `provider_dir` → `project_dir`, etc.
    - Package-name infix: `-providers-` → `-project-` (so the regex now
      matches `apache-example-project-foo` instead of
      `apache-example-providers-foo`).
    - Comments / docstrings: every "provider" / "Provider" / "providers"
      → "project" / "Project" / "projects".
    - Re-export from `__init__.py` updated to match.
    - **Untouched:** the CVE 5.x schema field name `providerMetadata`
      (line 909, line 53 docstring). That's the upstream schema, not
      our terminology.
    
    ## Renames in the test fixture
    
    - TOML key: `[packages.provider_display_map]` → `[packages.project_display_map]`.
    - TOML key: `provider_product_template` → `project_product_template`.
    - Template value: `"Apache Example Providers {display}"` →
      `"Apache Example Project {display}"`.
    - Regex named group: `(?P<provider>...)` → `(?P<project>...)`.
    - Display map entries — replaced 5 real-software names with
      fictional placeholders that cover the same test cases:
    
      | Old (real software) | New (fictional) | Test purpose |
      |---|---|---|
      | `cncf-kubernetes` → `CNCF Kubernetes` | `acme-xyz` → `Acme XYZ` | multi-word + acronym |
      | `elasticsearch` → `Elasticsearch` | `foo` → `Foo` | single-word identity |
      | `opensearch` → `OpenSearch` | `kerfluffle` → `Kerfluffle` | single-word mixed-case |
      | `smtp` → `SMTP` | `xyz` → `XYZ` | single-word acronym |
      | `snowflake` → `Snowflake` | `bar` → `Bar` | single-word identity |
      |   (none) | `pop-corn` → `Pop Corn` | multi-word title-case |
    
      `brand-new` and `madeup-widget` (used by tests for the unmapped-
      fallback path) were already fictional and stay as-is.
    
    ## Test updates
    
    `test_generate_cve_json.py`: 60+ substitutions to follow the
    renames. All 139 tests pass against the new fixture.
    
    ## SKILL.md
    
    Same terminology pass: "provider directory" → "project directory",
    "providers trackers" → "project trackers", etc. The illustrative
    package examples (`apache-foo-providers-elasticsearch`) are now
    `apache-foo-project-alpha` / `apache-foo-project-beta` — fictional
    sub-project names instead of real software references.
    
    ## Test plan
    
    - ✅ 139 tests pass (134 generate-cve-json, 58 oauth-draft).
      No grepping for "provider" picks up anything except the upstream
      `providerMetadata` CVE schema field.
    - ✅ `prek run --all-files` passes (16 hooks).
    - ✅ `zizmor` clean.
    
    Generated-by: Claude Code (Claude Opus 4.7)
---
 .claude/skills/allocate-cve/SKILL.md               |  14 +-
 .claude/skills/deduplicate-security-issue/SKILL.md |   2 +-
 .claude/skills/fix-security-issue/SKILL.md         |  20 +--
 .../skills/import-security-issue-from-pr/SKILL.md  |   4 +-
 .claude/skills/import-security-issue/SKILL.md      |  12 +-
 .claude/skills/invalidate-security-issue/SKILL.md  |  10 +-
 .claude/skills/sync-security-issue/SKILL.md        |  18 +-
 AGENTS.md                                          |  20 +--
 CONTRIBUTING.md                                    |  56 +++---
 README.md                                          |  56 +++---
 new-members-onboarding.md                          |  52 +++---
 projects/_template/README.md                       |  21 +--
 projects/_template/canned-responses.md             |  10 +-
 projects/_template/release-trains.md               |   2 +-
 projects/_template/title-normalization.md          |  15 +-
 pyproject.toml                                     |   7 +-
 tools/github/project-board.md                      |   2 +-
 tools/github/tool.md                               |   2 +-
 tools/gmail/asf-relay.md                           |   6 +-
 tools/gmail/draft-backends.md                      |   2 +-
 tools/gmail/operations.md                          |   2 +-
 tools/gmail/search-queries.md                      |  10 +-
 tools/ponymail/tool.md                             |   8 +-
 tools/vulnogram/generate-cve-json/SKILL.md         | 101 +++++------
 .../src/generate_cve_json/__init__.py              |   4 +-
 .../src/generate_cve_json/cve_json.py              | 120 ++++++++-----
 .../tests/fixtures/cve-json-config.toml            | 188 +++++++--------------
 .../tests/test_generate_cve_json.py                | 173 +++++++++----------
 uv.lock                                            |   2 +-
 29 files changed, 448 insertions(+), 491 deletions(-)

diff --git a/.claude/skills/allocate-cve/SKILL.md b/.claude/skills/allocate-cve/SKILL.md
index 2fe5b39..b4b4c49 100644
--- a/.claude/skills/allocate-cve/SKILL.md
+++ b/.claude/skills/allocate-cve/SKILL.md
@@ -50,9 +50,9 @@ confirm. The only action the skill performs unilaterally is
 **reading** the tracker state and printing the allocation recipe for
 the user to click through.
 
-**Golden rule — only Apache Airflow PMC members can allocate CVEs.**
+**Golden rule — only members of the project's PMC can allocate CVEs.**
 The ASF Vulnogram form at `https://cveprocess.apache.org/allocatecve`
-requires ASF OAuth with PMC-level access on the Airflow project. The
+requires ASF OAuth with PMC-level access on the adopting project. The
 full allocation mechanics (form-fill recipe, PMC-gated access, form
 fields, fatal mis-allocation, after-allocation wire-back) live in
 [`tools/vulnogram/allocation.md`](../../../tools/vulnogram/allocation.md);
@@ -61,8 +61,8 @@ the per-project URL templates live in
 This is not something the skill can work around — a non-PMC user who
 clicks *Allocate* sees the button grey out.
 
-The current Airflow PMC roster lives on the ASF project page:
-<https://projects.apache.org/committee.html?airflow>. Authoritative
+The current PMC roster lives on the project's ASF committee page
+(`https://projects.apache.org/committee.html?<project>`). Authoritative
 GitHub handles for the subset of PMC members who also sit on the
 security team are listed in
 [`<project-config>/release-trains.md`](../../../<project-config>/release-trains.md)
@@ -113,7 +113,7 @@ anything else.
   required if the tracker carries a reporter thread that needs a
   status-update draft (Step 5).
 - **A PMC member on call** — the Vulnogram allocation form is
-  PMC-gated. If the user is not on the Airflow PMC, the skill
+  PMC-gated. If the user is not on the project's PMC, the skill
   still runs: it produces a relay message for a PMC member to
   click through instead of stopping.
 
@@ -130,7 +130,7 @@ Before touching the tracker, verify:
 
 1. **`gh` is authenticated** —
    `gh api repos/<tracker> --jq .name` must return
-   `airflow-s`. A 401/403/404 means the user needs `gh auth login`
+   `<tracker>`. A 401/403/404 means the user needs `gh auth login`
    or collaborator access; stop.
 2. **`uv` is on the PATH** — `uv --version`. Without it the Step 4
    CVE-JSON regeneration would fail silently mid-flow; better to
@@ -580,7 +580,7 @@ presenting.
   nothing more. Do not try to automate the form fill — the ASF CVE
   tool is ASF-OAuth-gated and agent automation of CNA allocation is
   explicitly out of scope.
-- **Only an Airflow PMC member can allocate.** The Vulnogram
+- **Only a project PMC member can allocate.** The Vulnogram
   allocation button is PMC-gated. If the user running this skill is
   not a PMC member, the recipe is a **relay message** they post for
   a PMC member to act on, not a form they can fill themselves.
diff --git a/.claude/skills/deduplicate-security-issue/SKILL.md b/.claude/skills/deduplicate-security-issue/SKILL.md
index 8fea886..809df45 100644
--- a/.claude/skills/deduplicate-security-issue/SKILL.md
+++ b/.claude/skills/deduplicate-security-issue/SKILL.md
@@ -102,7 +102,7 @@ in `README.md`.
 ## Step 0 — Pre-flight check
 
 1. `gh api repos/<tracker> --jq .name` returns
-   `airflow-s`.
+   `<tracker>`.
 2. Both issue numbers resolve —
    `gh issue view <kept> --repo <tracker> --json number`
    and the same for `<dropped>` — before any write.
diff --git a/.claude/skills/fix-security-issue/SKILL.md b/.claude/skills/fix-security-issue/SKILL.md
index 883c6fd..63f7291 100644
--- a/.claude/skills/fix-security-issue/SKILL.md
+++ b/.claude/skills/fix-security-issue/SKILL.md
@@ -9,7 +9,7 @@ description: |
   implementation plan, waits for explicit user confirmation, writes the
   change in the user's local <upstream> clone, runs the local checks
   and tests, opens a PR from the user's fork via `gh pr create --web`,
-  and updates the airflow-s tracking issue with the new PR link and any
+  and updates the <tracker> tracking issue with the new PR link and any
   relevant labels. Public PR content is checked to make sure it does
   **not** reveal the CVE, the security nature of the change, or any link
   back to <tracker>.
@@ -138,7 +138,7 @@ continue.
 1. **`gh` authenticated** —
    `gh api repos/<tracker> --jq .name` and
    `gh api repos/<upstream> --jq .name` both return. A 401/403
-   on the first means no airflow-s access; on the second it is a
+   on the first means no <tracker> access; on the second it is a
    quota/auth issue — both require user action, stop.
 2. **Fork exists and is pushable** —
    `gh repo view <your-login>/airflow --json name --jq .name`
@@ -256,7 +256,7 @@ If **easily fixable**, extract and write down:
 - the target branch (`main` almost always; a release branch only if
   the user explicitly says so),
 - any backport label that should be applied to the eventual PR, based
-  on the milestone on the `airflow-s` issue (the adopting project's
+  on the milestone on the `<tracker>` issue (the adopting project's
   backport-label policy and current release branches live in
   [`<project-config>/fix-workflow.md`](../../../<project-config>/fix-workflow.md#backport-labels)
   and
@@ -330,7 +330,7 @@ verbatim.**
   - **bad** (reveals security framing): `cve-2026-40690`,
     `security-fix-218`, `vulnerable-deserialize-fix`.
 
-  Tracker identifiers on their own (e.g. `airflow-s-216`) are not
+  Tracker identifiers on their own (e.g. `<tracker>-216`) are not
   flagged — they are public-safe identifiers per the
   [Confidentiality of the tracker repository](../../../AGENTS.md#confidentiality-of-the-tracker-repository)
   rule — but they also do not help anyone reading the branch URL
@@ -394,7 +394,7 @@ List:
 
 ### 4e. Backport label
 
-If the `airflow-s` issue's milestone indicates a release branch that
+If the `<tracker>` issue's milestone indicates a release branch that
 has not yet been cut (e.g. `3.1.9`, `3.2.1`), note which
 `backport-to-vX-Y-test` label the PR should carry so that the fix
 lands on the intended patch release. If no backport is needed (the
@@ -520,7 +520,7 @@ only fires on merge, not on label application, so there is no race
 with CI. Applying the label early ensures it is not forgotten.
 
 **Grep the PR body one more time for forbidden terms** (`CVE`,
-`airflow-s`, `vulnerability`, `security fix`, `advisory`, private
+`<tracker>`, `vulnerability`, `security fix`, `advisory`, private
 issue number, reporter name tied to a finding) before calling
 `gh pr create --web`. If anything matches, abort and tell the user.
 
@@ -530,7 +530,7 @@ After the user submits the PR in the browser, capture the PR URL
 
 ---
 
-## Step 9 — Update the airflow-s tracking issue
+## Step 9 — Update the <tracker> tracking issue
 
 Now that a public PR exists, update the private tracking issue:
 
@@ -565,7 +565,7 @@ Now that a public PR exists, update the private tracking issue:
    The public `<upstream>` PR description and any follow-up public
    comments must also obey the rule, but under the usual
    public-surface confidentiality constraints (no `CVE-`,
-   `airflow-s`, *"security fix"*, etc. alongside the mention).
+   `<tracker>`, *"security fix"*, etc. alongside the mention).
 
 2. **Update the issue body "PR with the fix" field** if it is empty
    or points to a stale PR. Use `gh issue view --json body`, patch
@@ -575,7 +575,7 @@ Now that a public PR exists, update the private tracking issue:
 
 3. **Maintain milestones and labels** — see the next section.
 
-4. **Status update to the reporter** — if the airflow-s issue has an
+4. **Status update to the reporter** — if the <tracker> issue has an
    identified external reporter and the reporter has not yet been
    told about the fix PR, delegate to the `sync-security-issue`
    skill's "Status update to the reporter" category by re-running
@@ -720,7 +720,7 @@ Print a short recap:
 - the branch name (in the user's fork),
 - the list of files changed,
 - the tests that were run and their results,
-- the comment posted on the `airflow-s` issue,
+- the comment posted on the `<tracker>` issue,
 - the backport label that was applied (or a note that none was needed),
 - the next step — typically *"wait for review; re-run
   sync-security-issue after the PR merges to transition the issue
diff --git a/.claude/skills/import-security-issue-from-pr/SKILL.md b/.claude/skills/import-security-issue-from-pr/SKILL.md
index 560e3f0..154f5ce 100644
--- a/.claude/skills/import-security-issue-from-pr/SKILL.md
+++ b/.claude/skills/import-security-issue-from-pr/SKILL.md
@@ -379,7 +379,7 @@ The first entry on the tracker's status rollup. Shape per
 [`tools/github/status-rollup.md`](../../../tools/github/status-rollup.md):
 
 ```markdown
-<!-- airflow-s status rollup v1 — all bot-authored status updates fold into this single comment. -->
+<!-- <tracker> status rollup v1 — all bot-authored status updates fold into this single comment. -->
 <details><summary><YYYY-MM-DD> · @<author-handle> · Import from PR (<scope>, <upstream>#<N>)</summary>
 
 **Imported from public PR `<upstream>#<N>` on <YYYY-MM-DD>** (scope: `<scope>`, PR state: `<state>`).
@@ -391,7 +391,7 @@ This tracker was deliberately opened by the security team for a public fix that
 Provenance: public PR <pr.url>, author `@<pr.author.login>`.
 Extracted fields: scope=`<scope>`, *PR with the fix*=<pr.url>, *Remediation developer*=<pr.author.name>, *Affected versions*=`<per-scope shape>`, Severity=`Unknown`.
 
-*Reporter credited as* intentionally left blank — public-PR imports do not credit the PR author as the CVE reporter (no responsible disclosure). See the [Reporter credit policy](https://github.com/<tracker>/blob/airflow-s/.claude/skills/import-security-issue-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports) section of the skill for the rationale.
+*Reporter credited as* intentionally left blank — public-PR imports do not credit the PR author as the CVE reporter (no responsible disclosure). See the [Reporter credit policy](https://github.com/<tracker>/blob/<tracker-default-branch>/.claude/skills/import-security-issue-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports) section of the skill for the rationale.
 ```
 
 Zero-whitespace rules from
diff --git a/.claude/skills/import-security-issue/SKILL.md b/.claude/skills/import-security-issue/SKILL.md
index 4708ba7..94b8d7e 100644
--- a/.claude/skills/import-security-issue/SKILL.md
+++ b/.claude/skills/import-security-issue/SKILL.md
@@ -162,7 +162,7 @@ candidate Gmail threads:
 
 | Selector | Resolves to |
 |---|---|
-| `import new` (default) | every security@ thread received in the last **14 days** that has not yet been imported as an airflow-s issue and has not already been answered-and-closed on-thread |
+| `import new` (default) | every security@ thread received in the last **14 days** that has not yet been imported as an <tracker> issue and has not already been answered-and-closed on-thread |
 | `import since:YYYY-MM-DD` | every security@ thread received since the given date that is not yet imported |
 | `import thread:<id>` | the single Gmail thread with that `threadId` — useful for re-importing after a manual discard, or for picking up a single message the automatic scan missed |
 | `import last 30d` / `import all` / `import last 90d` (explicit request only) | a wider sweep — use when the skill has not been run in a while or the user is doing a backlog catch-up. The `all` alias is 90 days. |
@@ -257,7 +257,7 @@ most threads will be filtered out at Step 2.
 
 ---
 
-## Step 2 — Deduplicate against existing airflow-s issues
+## Step 2 — Deduplicate against existing <tracker> issues
 
 For each candidate `threadId`, check whether that ID already appears in
 an `<tracker>` issue body. The sync skill records each thread
@@ -323,7 +323,7 @@ check:
      cannot review it"* / *"We do not consider this a
      vulnerability"* / *"We do not consider this a security
      issue"*
-   - *"Per the Airflow Security Model"* / *"documented in our
+   - *"Per the project's security model"* / *"documented in our
      Security Model"* / *"this is by design"* / *"this is
      expected behaviour"*
    - *"This is explicitly out of scope"* / *"is explicitly
@@ -544,8 +544,8 @@ mailbox. The per-candidate budget is ≤ 2 archive searches
 
    Hits whose author is on the security-team roster AND whose body
    opens with a canned-response cue (*"Thank you for reporting …
-   this isn't a security issue"*, *"Per the Airflow Security
-   Model"*, *"This is expected behaviour for a Dag author"*, etc.)
+   this isn't a security issue"*, *"Per the project's security
+   model"*, *"This is documented / expected behaviour"*, etc.)
    are prior rejections. Fetch each with
    `mcp__claude_ai_Gmail__get_thread` (MINIMAL is enough when you
    only need to confirm the canned-response shape; FULL_CONTENT is
@@ -1015,7 +1015,7 @@ For each confirmed `Report` / `ASF-security relay`:
    `gh issue comment <N> --repo <tracker> --body-file <tmpfile>`:
 
    ```markdown
-   <!-- airflow-s status rollup v1 — all bot-authored status updates fold into this single comment. -->
+   <!-- <tracker> status rollup v1 — all bot-authored status updates fold into this single comment. -->
    <details><summary><YYYY-MM-DD> · @<author-handle> · Import (<classification>, <reporter>)</summary>
 
    **Imported from Gmail thread `<threadId>` on <YYYY-MM-DD>** (class: `<classification>`, reporter: `<reporter>`).
diff --git a/.claude/skills/invalidate-security-issue/SKILL.md b/.claude/skills/invalidate-security-issue/SKILL.md
index 2fba5f6..598b81e 100644
--- a/.claude/skills/invalidate-security-issue/SKILL.md
+++ b/.claude/skills/invalidate-security-issue/SKILL.md
@@ -174,7 +174,7 @@ the close. Read the *Security mailing list thread* body field:
 For `security@`-imported trackers, locate the Gmail `threadId`:
 
 1. Read the rollup comment on the tracker (the first
-   `<details>` block with the `airflow-s status rollup v1`
+   `<details>` block with the `<tracker> status rollup v1`
    marker). Look for `threadId` references in the *Provenance:*
    line of the import entry.
 2. If the rollup is missing or thin, fall back to a Gmail subject
@@ -197,7 +197,7 @@ Scan `tracker.comments[]` for posts that argue **why** the report
 is not a security issue. Strong signals:
 
 - Citations of the
-  [Apache Airflow Security Model](<security-model-url>)
+  [the project's security model](<security-model-url>)
   (full URL, anchor links, paraphrases).
 - Phrases like *"this is by design"*, *"out of scope"*,
   *"documented behavior"*, *"requires X privileges already"*,
@@ -292,7 +292,7 @@ Reasoning summary in the [status rollup](#issuecomment-<rollup-id>); a draft rep
 For PR-imported trackers, replace *"a draft reply to the reporter
 is in Gmail awaiting review"* with *"no reporter notification
 (PR-imported tracker — see the import-from-pr skill's
-[Reporter credit policy](https://github.com/<tracker>/blob/airflow-s/.claude/skills/import-security-issue-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports))"*.
+[Reporter credit policy](https://github.com/<tracker>/blob/<tracker-default-branch>/.claude/skills/import-security-issue-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports))"*.
 
 The comment links must resolve once the rollup entry from Step 5e
 has been posted (capture its URL and substitute before posting
@@ -416,11 +416,11 @@ upsert recipe). Shape:
 - @<author>: > <quote 2> ([source](#issuecomment-<id>))
 - ...
 
-**Canned response selected:** *<canned section name>* in [`canned-responses.md`](https://github.com/<tracker>/blob/airflow-s/<project-config>/canned-responses.md#<anchor>).
+**Canned response selected:** *<canned section name>* in [`canned-responses.md`](https://github.com/<tracker>/blob/<tracker-default-branch>/<project-config>/canned-responses.md#<anchor>).
 
 **Reporter notification:** <one of:>
 - **`security@`-imported:** Gmail draft `<draftId>` created on thread `<threadId>` — awaiting user review.
-- **PR-imported:** none (no reporter; per [Reporter credit policy](https://github.com/<tracker>/blob/airflow-s/.claude/skills/import-security-issue-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports)).
+- **PR-imported:** none (no reporter; per [Reporter credit policy](https://github.com/<tracker>/blob/<tracker-default-branch>/.claude/skills/import-security-issue-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports)).
 - **Indeterminate import path:** none (flag from Step 2 surfaced; user explicitly chose silent close).
 
 **Project board:** archived (item `<item-id>`).
diff --git a/.claude/skills/sync-security-issue/SKILL.md b/.claude/skills/sync-security-issue/SKILL.md
index bad2e1d..f3e7425 100644
--- a/.claude/skills/sync-security-issue/SKILL.md
+++ b/.claude/skills/sync-security-issue/SKILL.md
@@ -302,7 +302,7 @@ Before reading any tracker state, verify:
    always a stop.
 2. **`gh` is authenticated** with access to `<tracker>` —
    `gh api repos/<tracker> --jq .name` must return
-   `airflow-s`. A 401/403/404 means the user needs
+   `<tracker>`. A 401/403/404 means the user needs
    `gh auth login` or collaborator access.
 3. **PonyMail MCP status** (opt-in; primary read path when
    enabled) — read `config/user.md` → `tools.ponymail`. If
@@ -405,7 +405,7 @@ state. `gh search prs --state` only accepts `open` or `closed`, so run two
 queries (or omit `--state` entirely for "any state"):
 
 ```bash
-gh search prs "airflow-s#<N>" --repo <upstream>         --json number,title,state,url,milestone,mergedAt
+gh search prs "<tracker>#<N>" --repo <upstream>         --json number,title,state,url,milestone,mergedAt
 gh search prs "#<N>"          --repo <tracker>    --json number,title,state,url,milestone,mergedAt
 ```
 
@@ -474,7 +474,7 @@ Process for finding the real reporter and the original thread:
 1. **Do not stop at the GitHub-notification mirror thread.** Searching Gmail
    for the issue title typically returns the GitHub-notification thread
    (`From: <user> via security <<security-list>>`,
-   `To: <tracker> <ai...@noreply.github.com>`) first. That is
+   `To: <tracker> <<tracker-noreply>>`) first. That is
    *not* the original report — it is a mirror of the GitHub issue and its
    comments. Filter it out and keep digging.
 
@@ -619,8 +619,8 @@ update, label change, or next-step recommendation in Step 2:
 | Reporter explicit opt-out of credit (*"do not credit me"*, *"anonymous"*) | Set the field to `anonymous` and flag the advisory to use that form. |
 | Release manager's `[RESULT][VOTE] Release Airflow <version>` on `<dev-list>` for a version that carries the fix | Record the release manager in the "Known release managers" subsection of [`AGENTS.md`](../../../AGENTS.md) if not already there; flag Step 13 (advisory) as assigned to that person. |
 | Advisory message sent to `announce@apache.org` / `<users-list>` for the CVE on the tracker | Propose adding the `announced - emails sent` label and removing `fix released`. **Do not propose closing the issue here** — closing is gated on the archived public advisory URL being captured (see the next row). |
-| Advisory archived on `<users-list>` (the announcement message is now visible in `lists.apache.org/list.html?<users-list>` — scan the archive with the CVE ID when `announced - emails sent` is set and the *"Public advisory URL"* body field is empty) | Propose populating the *"Public advisory URL"* body field with the archive URL, regenerating the CVE JSON attachment (the generator picks the URL up automatically and tags it `vendor-advisory`), adding the `announced` label, **and moving th [...]
-| Project-board column drifted from the issue's label-derived state (e.g. a tracker carries `pr merged` but is still in the `PR created` column on [Project 2](https://github.com/orgs/airflow-s/projects/2), or `announced` + *Public advisory URL* body field populated but the column is still `Fix released`) | Propose moving the project item to the correct column per the mapping table in Step 2b. The board is the primary security-team overview surface; a stale column hides ownership handoffs [...]
+| Advisory archived on `<users-list>` (the announcement message is now visible in `lists.apache.org/list.html?<users-list>` — scan the archive with the CVE ID when `announced - emails sent` is set and the *"Public advisory URL"* body field is empty) | Propose populating the *"Public advisory URL"* body field with the archive URL, regenerating the CVE JSON attachment (the generator picks the URL up automatically and tags it `vendor-advisory`), adding the `announced` label, **and moving th [...]
+| Project-board column drifted from the issue's label-derived state (e.g. a tracker carries `pr merged` but is still in the `PR created` column on [Project 2](<project-board-url>), or `announced` + *Public advisory URL* body field populated but the column is still `Fix released`) | Propose moving the project item to the correct column per the mapping table in Step 2b. The board is the primary security-team overview surface; a stale column hides ownership handoffs from the team at a glance. |
 | `announced` label set and CVE record on `cveprocess.apache.org` now reports state PUBLISHED (checked via `curl -s https://cveprocess.apache.org/cve5/<CVE-ID>.json` / the ASF CVE tool API, or an explicit release-manager comment on the issue stating the Vulnogram push is done) | Propose closing the issue. Do not update any labels. This is the terminal transition. |
 | CVE record has open **review comments / reviewer proposals** (detected via the Gmail-search path in Step 1e — reviewer-comment notifications from Vulnogram land on `<security-list>` with the CVE ID in the subject line; the `cveprocess.apache.org/cve5/<CVE-ID>.json` endpoint is behind ASF OAuth and is not readable from this skill's context, so Gmail is the load-bearing signal source). | Surface each open review comment in Step 2a with **clickable links** to the Gmail thread and to the C [...]
 | The referenced `<upstream>` PR has been opened but is still in `open` state | Propose `pr created` label; update the *"PR with the fix"* body field with the PR URL. |
@@ -982,7 +982,7 @@ will change and *why*. Group them by category:
 
 - **Assignees** — when a fix PR exists in `<upstream>` (found in
   Step 1b or named in the *"PR with the fix"* body field) **and the
-  PR author is a member of the Airflow security team** (their GitHub
+  PR author is a member of the project security team** (their GitHub
   handle appears in the security-team roster in
   [`<project-config>/release-trains.md`](../../../<project-config>/release-trains.md) — when in doubt,
   run `gh api repos/<tracker>/collaborators --jq '.[].login'`
@@ -1460,7 +1460,7 @@ the actual person, in this order:
    [`AGENTS.md`](../../../AGENTS.md) first** — if the release is already
    listed there, use that name. This is the cache; the next two sources
    are how the cache was populated and how you refresh it.
-2. **Check the Airflow Release Plan wiki** at
+2. **Check the project's release plan** at
    <https://cwiki.apache.org/confluence/display/AIRFLOW/Release+Plan>.
    This is the canonical forward-looking schedule for every release
    train (core Airflow, Providers, Airflow Ctl, Helm Chart, Airflow 2)
@@ -1523,7 +1523,7 @@ line so the handoff is unambiguous:
 > JSON embed).
 
 **Whenever a CVE ID is mentioned** — in the proposal, in the status-change
-comment on the `airflow-s` issue, in the draft email to the reporter, or in
+comment on the `<tracker>` issue, in the draft email to the reporter, or in
 the recap — render it as a clickable link per the "Linking CVEs" section of
 [`AGENTS.md`](../../../AGENTS.md). Concretely:
 
@@ -1811,7 +1811,7 @@ After the regeneration step finishes, print a short recap:
 the entire recap text: any mention of the tracking issue, any
 cross-referenced `<tracker>` issue, any PR, any specific
 comment anchor and any milestone must be a clickable markdown link.
-The user has to be able to click every `airflow-s` reference in the
+The user has to be able to click every `<tracker>` reference in the
 recap without manually pasting the number into the URL bar.
 
 Concrete minimum that every recap must include as clickable links:
diff --git a/AGENTS.md b/AGENTS.md
index 22c9b1c..f61665c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -510,7 +510,7 @@ This applies **even when**:
 **Why:** every ASF project operates its own CNA process under its
 own security team. Content about project X's in-flight or
 historical vulnerability is project X's private information, not
-Airflow's, and copying it into our tracker effectively re-publishes
+this project's, and copying it into our tracker effectively re-publishes
 it via screenshots, excerpts pasted into advisories, timeline
 clippings, or future scrapes. Cross-project correlations also
 reveal investigation patterns, reporter behaviour, and triage-team
@@ -527,7 +527,7 @@ channel they arrived on:
 - Reporter mentioned another project on the `<security-list>` thread
   → discuss it on that same thread if it helps triage; do not copy
   into the tracker.
-- Observation is load-bearing for Airflow's own fix or advisory
+- Observation is load-bearing for our own fix or advisory
   (e.g. the other project's fix shape informs ours) → summarise it
   **without naming the project**. *"The reporter has filed similar
   reports with other ASF projects"* is allowed and sometimes
@@ -570,13 +570,13 @@ informational background only.** Do not:
 
 The adopting project's security team scores every accepted vulnerability independently,
 as part of the CVE-allocation step, using the same CVSS version and vector
-conventions we use for all Airflow CVEs. The independent score is the **only**
+conventions for every CVE the project ships. The independent score is the **only**
 score that ends up in the CVE record and the public advisory. Reasons:
 
 - reporter scores are frequently inflated (*"High"* or *"Critical"* is the
   default for many report templates, regardless of actual exploitability in
-  an Airflow deployment);
-- reporters typically do not know the Airflow Security Model and therefore
+  the project's deployment);
+- reporters typically do not know the project's security model and therefore
   misjudge which capabilities are in-scope for a CVE in the first place;
 - propagating the reporter's score creates an implicit contract with them — if
   we later revise it downward, they feel the rug has been pulled, and the
@@ -721,16 +721,16 @@ handling process, no speculation about severity or timelines beyond
 the single forward-looking sentence in paragraph 2.
 
 **Emails to the ASF security team are even shorter.** The ASF CVE
-managers and the ASF security team already know the Airflow process,
-the Vulnogram tool, and the CVE-5 schema. A message to them is a
-**request or a fact**, not a briefing:
+managers and the ASF security team already know the project's
+process, the Vulnogram tool, and the CVE-5 schema. A message to
+them is a **request or a fact**, not a briefing:
 
 - Lead with the ask or the fact in one sentence (*"Please push the
   attached credit correction to cve.org for CVE-YYYY-NNNNN."*).
 - Include only the minimum artifact the recipient needs to act (the
   CVE ID, the corrected JSON, the archive URL) — one link, maybe two.
-- Do **not** restate the vulnerability, the Airflow release train, or
-  the history of the ticket.
+- Do **not** restate the vulnerability, the project's release train,
+  or the history of the ticket.
 - Do **not** explain why the ASF team's action is needed when their
   role in the process is already established (e.g. pushing to cve.org,
   allocating a CVE from a PMC-gated form).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 012c322..f01a6c4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -21,10 +21,10 @@
 
 Thanks for helping improve this repository. It is a reusable framework
 for running the ASF security-disclosure process as a set of agent-driven
-skills, and today drives Apache Airflow. The same tree can be extended
-to any ASF project — adding support is a matter of dropping a new
-subtree under [`projects/`](projects/) and pointing
-[`config/active-project.md`](config/active-project.md) at it.
+skills. Adopting projects ship a per-project configuration layer
+(`<project-config>/`) in their own tracker repo and consume this
+framework as a submodule — see [`projects/_template/`](projects/_template/)
+for the scaffold an adopter copies and fills in.
 
 Before sending a patch, please skim this file end-to-end: it lays out
 the layering the repository depends on, and a patch that ignores the
@@ -105,22 +105,16 @@ four — no hard-coded project assumptions anywhere.
 │   ├── user.md.template           # Bootstrap template with TODOs
 │   └── user.md.example            # Filled-in example
 │
-├── projects/                      # One subtree per supported ASF project
-│   ├── README.md                  # Current-projects index
-│   ├── airflow/                   # Active project — all Airflow-specific content
-│   │   ├── project.md             # Manifest: tracker repo, upstream repo, tools,
-│   │   │                          # board IDs, Gmail domains
-│   │   ├── security-model.md
-│   │   ├── canned-responses.md    # Reporter-facing reply templates
-│   │   ├── release-trains.md      # Current cuts + release-manager roster
-│   │   ├── milestones.md          # Scope → milestone-format mapping
-│   │   ├── scope-labels.md
-│   │   ├── naming-conventions.md
-│   │   ├── title-normalization.md
-│   │   ├── fix-workflow.md
-│   │   └── README.md
-│   └── _template/                 # Scaffold for bootstrapping a new project
-│       └── (same shape as airflow/, with TODO placeholders)
+├── projects/                      # Templates for adopting projects' configs
+│   └── _template/                 # Scaffold for bootstrapping a new project's
+│                                  # `<project-config>/` (the per-project layer
+│                                  # an adopter ships in their tracker repo).
+│                                  # Files: project.md (manifest), security-model.md,
+│                                  # canned-responses.md, release-trains.md,
+│                                  # milestones.md, scope-labels.md, naming-
+│                                  # conventions.md, title-normalization.md,
+│                                  # fix-workflow.md, README.md — all stubbed
+│                                  # with TODO placeholders.
 │
 ├── tools/                         # Project-agnostic adapters per external system
 │   ├── gmail/
@@ -171,7 +165,7 @@ First-time clone:
 
 ```bash
 git clone git@github.com:<tracker>.git
-cd airflow-s
+cd <tracker-repo-name>
 prek install                   # wire the hooks into .git/hooks
 prek run --all-files           # runs every hook on every file; does a
                                # one-time bootstrap of config/user.md
@@ -198,7 +192,7 @@ editing:
 |---|---|
 | A step of the disclosure process that applies to every project | [`README.md`](README.md) |
 | An editorial / confidentiality / style rule | [`AGENTS.md`](AGENTS.md) |
-| Anything Airflow-specific (canned reply, milestone convention, scope label, release-train state) | [`projects/airflow/`](projects/airflow/) |
+| Anything project-specific (canned reply, milestone convention, scope label, release-train state) | the adopter's own `<project-config>/` (lives in their tracker repo, not here) |
 | An adapter surface for an external system (a new Gmail search template, a new GraphQL recipe, a new `gh` invocation, a new CVE-tool endpoint) | the matching [`tools/<system>/`](tools/) subtree |
 | A skill's workflow | [`.claude/skills/<name>/SKILL.md`](.claude/skills/) |
 | Bootstrap scaffolding for a new project | [`projects/_template/`](projects/_template/) |
@@ -216,10 +210,11 @@ Rules of thumb for each layer:
   project (different Gmail domains, different GitHub org, different
   board node IDs), the adapter declares variables and the active
   project's [`project.md`](<project-config>/project.md) fills them.
-- **Project subtrees carry concrete names freely** — they exist for
-  exactly that. `projects/airflow/` can reference `<upstream>`
-  directly, can paste the Keycloak provider version without
-  apology, can name @jscheffl as RM in `release-trains.md`.
+- **An adopter's `<project-config>/` carries concrete names freely** —
+  it exists for exactly that. The adopter's own per-project files can
+  reference their `<upstream>` repo directly, paste concrete package
+  versions, name release managers, etc. — none of that lives in this
+  framework repo.
 - **Skills never mutate state without user confirmation.** If you add
   a new action, write the proposal/confirm/apply shape into the skill
   and the guardrails into `AGENTS.md`. See the existing skills for
@@ -259,14 +254,13 @@ any new behaviour has to stay compatible with.
 
 ## Opening a pull request
 
-- **Base branch:** `airflow-s`. Do not open PRs against any other
-  branch unless explicitly coordinated.
+- **Base branch:** `main`. Do not open PRs against any other branch
+  unless explicitly coordinated.
 - **Scope:** keep one concern per PR. A skill-behaviour change, a
-  tool-adapter addition, and a project-content update should land as
-  three separate PRs.
+  tool-adapter addition, and a doc update should land as separate PRs.
 - **Commit message shape:** imperative-present subject, ≤72 chars,
   plain prose body explaining *why*. Look at
-  [recent merged commits](https://github.com/<tracker>/commits/airflow-s)
+  [recent merged commits](https://github.com/apache/airflow-steward/commits/main)
   for the cadence.
 - **PR description:** one `## Summary` section with 1–3 bullets of
   *what changed and why*, and one `## Test plan` section listing how
diff --git a/README.md b/README.md
index 5b0ca10..e6f30d6 100644
--- a/README.md
+++ b/README.md
@@ -55,15 +55,16 @@
 - [Label lifecycle](#label-lifecycle)
   - [State diagram](#state-diagram)
   - [Label reference](#label-reference)
-- [Current projects](#current-projects)
+- [Adopting the framework](#adopting-the-framework)
 
 <!-- END doctoc generated TOC please keep comment here to allow auto update -->
 
 ## Overview
 
-This repository (`apache/airflow-steward`, future-renamed to
-`apache/steward`) hosts a reusable, project-agnostic framework for
-running an ASF project's security-issue handling process. The
+This repository hosts a reusable, project-agnostic framework for
+running an ASF project's security-issue handling process. (Currently
+served from `apache/airflow-steward` for legacy reasons; future-
+renamed to `apache/steward`.) The
 lifecycle and conventions below are framework-level; everything
 project-specific (identity, repositories, mailing lists, canned
 responses, release trains, security model, scope labels, milestone
@@ -118,9 +119,8 @@ Pick whichever applies to you now:
 
 - **I am new to the security team, or I mostly just want to comment on
   issues.** Read [Shared conventions](#shared-conventions) below. The
-  adopting project's security-issues board (for Airflow:
-  <https://github.com/orgs/airflow-s/projects/2>; in general, see
-  `<project-config>/project.md → GitHub project board`) is the main
+  adopting project's security-issues board — see
+  `<project-config>/project.md → GitHub project board` — is the main
   view. You do not need an agent for commenting.
 - **I am a rotational triager** — running `import new reports` and
   `sync all` a few times a week. Jump to
@@ -339,10 +339,12 @@ pulling at least one other security-team member into the discussion. Use the
 canned-response templates from [`<project-config>/canned-responses.md`](<project-config>/canned-responses.md)
 for negative assessments so the tone stays polite-but-firm.
 
-When the report is confirmed valid, apply exactly one scope label (`airflow`
-/ `providers` / `chart`). If a report affects more than one scope, split into
-per-scope trackers before allocation — the `sync-security-issue` skill
-surfaces this as a blocker. See
+When the report is confirmed valid, apply exactly one scope label from
+the project's scope set (declared in
+[`<project-config>/scope-labels.md`](<project-config>/scope-labels.md)).
+If a report affects more than one scope, split into per-scope trackers
+before allocation — the `sync-security-issue` skill surfaces this as
+a blocker. See
 [Step 5](#step-5--land-the-validinvalid-consensus).
 
 If discussion stalls for about 30 days, escalate to a broader audience per
@@ -422,8 +424,8 @@ skill:
 - opens the public PR from your fork via `gh pr create --web` with a
   scrubbed title and body — every public surface (commit message,
   branch name, PR title, PR body, newsfragment) is grep-checked for
-  `CVE-`, `airflow-s`, `vulnerability`, *"security fix"* and similar
-  leakage before being written or pushed;
+  `CVE-`, the `<tracker>` repo slug, `vulnerability`, *"security fix"*
+  and similar leakage before being written or pushed;
 - updates the `<tracker>` tracking issue with the new PR
   link and applies the `pr created` label, handing back off to
   `sync-security-issue`.
@@ -725,7 +727,7 @@ short-circuits the two best outcomes phase 1 is designed to produce:
   *will* miss novel approaches.
 - **An expert flagging that the report is invalid / out of scope.**
   Sometimes the right answer is "this is documented behaviour" or
-  "this duplicates `airflow-s#XYZ` from 2 years ago"; an AI analysis
+  "this duplicates `<tracker>#XYZ` from 2 years ago"; an AI analysis
   that already proposes a fix anchors the discussion towards
   *implementing* that fix rather than evaluating whether one is
   needed.
@@ -1058,19 +1060,17 @@ skill keeps these labels honest: on every run it detects the current state
 of the issue, the fix PR, and the release train, and proposes the label
 transitions the process requires.
 
-## Current projects
+## Adopting the framework
 
-One row per project configured under
-[`projects/`](projects/). The directory name is the resolution key
-for the `<PROJECT>` placeholder used throughout the framework (see
-[`AGENTS.md` — Placeholder convention](AGENTS.md#placeholder-convention-used-in-skill-files)).
+Projects don't live in this repository — adopters pull the framework
+into their own tracker repo as a git submodule (see *Repository
+purpose* in [`AGENTS.md`](AGENTS.md#repository-purpose)) and ship
+their per-project configuration alongside it under
+`<project-config>/` (which resolves to `.apache-steward/` in the
+adopter's tracker root).
 
-| Project | Directory | Index | Manifest |
-|---|---|---|---|
-| [Apache Airflow](<project-website>/) | [`projects/airflow/`](projects/airflow/) | [`<project-config>/README.md`](<project-config>/README.md) | [`<project-config>/project.md`](<project-config>/project.md) |
-
-Add a new project by copying
-[`projects/_template/`](projects/_template/) into
-`projects/<name>/`, filling in the TODO placeholders, and adding a
-row to the table above. The full bootstrap walk-through lives in
-[`projects/README.md`](projects/README.md#bootstrapping-a-new-project).
+To bootstrap a new adopter, copy [`projects/_template/`](projects/_template/)
+into `<project-config>/` in your tracker repo, fill in the TODO
+placeholders, and point the framework's skills at it via the path
+resolution documented in
+[`AGENTS.md` — Placeholder convention](AGENTS.md#placeholder-convention-used-in-skill-files).
diff --git a/new-members-onboarding.md b/new-members-onboarding.md
index 5265a57..095c2f6 100644
--- a/new-members-onboarding.md
+++ b/new-members-onboarding.md
@@ -16,9 +16,9 @@
 
 Hello, new member of the adopting project's security team. This
 repository hosts a project-agnostic framework for handling security
-issues; the adopting project (for this clone:
-[`<project-config>/`](projects/airflow/)) is declared in
-[`config/active-project.md`](config/active-project.md). This document
+issues; the adopting project's per-project layer lives in
+[`<project-config>/`](<project-config>/) (i.e. `.apache-steward/` at
+the root of the adopter's tracker repo). This document
 is the soft-landing guide — it tells you how the team works, where
 the action happens, and what is expected of you in the first few
 weeks.
@@ -69,23 +69,19 @@ for the lookup command and the latest snapshot.
 
 # Where things happen
 
-- **The project's `<security-list>`** (for Airflow:
-  `<security-list>`; in general see
-  `<project-config>/project.md → Mailing lists`). You are
-  subscribed automatically when you join the security team. This is
-  where external reporters land their reports and where we reply to
-  keep them informed.
-- **`<tracker>` GitHub repository** (for this clone:
-  `<tracker>`) — the private tracker. Every valid report
-  becomes a tracking issue here. Everything that happens on an
-  issue is automatically mirrored to the security mailing list so
-  people who prefer email stay in the loop.
+- **The project's `<security-list>`** — see
+  `<project-config>/project.md → Mailing lists` for the concrete
+  address. You are subscribed automatically when you join the
+  security team. This is where external reporters land their reports
+  and where we reply to keep them informed.
+- **`<tracker>` GitHub repository** — the private tracker. Every
+  valid report becomes a tracking issue here. Everything that
+  happens on an issue is automatically mirrored to the security
+  mailing list so people who prefer email stay in the loop.
 - **Security-issues board** — each project runs its own Projects V2
-  board. For Airflow:
-  <https://github.com/orgs/airflow-s/projects/2>. In general, see
-  `<project-config>/project.md → GitHub project board`. If you
-  want one URL to bookmark for the adopting project, bookmark that
-  board.
+  board. See `<project-config>/project.md → GitHub project board`
+  for the URL. If you want one thing to bookmark for the adopting
+  project, bookmark that board.
 - **Private PRs on the `<tracker>` `main` branch** — an exceptional
   path used for highly-critical fixes that need private code review
   before going public. See
@@ -105,9 +101,8 @@ week. A good starting routine:
 1. **Read [`README.md`](README.md) once, skim the role sections.** Pick
    the role you think you are most likely to take on first, and read it
    in full.
-2. **Open the adopting project's board** (for Airflow:
-   <https://github.com/orgs/airflow-s/projects/2>; in general, see
-   `<project-config>/project.md → GitHub project board`). Get a
+2. **Open the adopting project's board** — its URL is in
+   `<project-config>/project.md → GitHub project board`. Get a
    feel for what states issues sit in and how they flow across
    columns.
 3. **Subscribe to a few open issues** and read along. The mailing-list
@@ -222,10 +217,10 @@ The full list of skills, and what each one does, is in
 
 Beyond reacting to inbound reports, you are also welcome to
 proactively look for security improvements in the adopting project.
-Take a look at the tracker's Discussions tab (for Airflow:
-<https://github.com/<tracker>/discussions>) — we
-occasionally start a discussion there when we see we can improve
-something, process-wise or tooling-wise. Join those discussions,
+Take a look at the tracker's Discussions tab
+(`https://github.com/<tracker>/discussions`) — we occasionally start
+a discussion there when we see we can improve something, process-wise
+or tooling-wise. Join those discussions,
 share your perspective, or start new ones if you see something
 worth fixing.
 
@@ -238,9 +233,10 @@ shape the team are small enough to read in one sitting:
 - [`config/README.md`](config/README.md) — how the per-project and
   per-user configuration layers work, with a tutorial for your own
   `config/user.md`.
-- [`<project-config>/`](projects/airflow/) — project-specific content
+- [`<project-config>/`](<project-config>/) — project-specific content
   (roster, release trains, security model, scope labels, milestones,
-  canned responses, fix-workflow specifics).
+  canned responses, fix-workflow specifics) — lives in the adopter's
+  tracker repo.
 - [`how-to-fix-a-security-issue.md`](how-to-fix-a-security-issue.md) —
   high-level fix workflow.
 - [`new-members-onboarding.md`](new-members-onboarding.md) — this
diff --git a/projects/_template/README.md b/projects/_template/README.md
index 8c2f174..63c179b 100644
--- a/projects/_template/README.md
+++ b/projects/_template/README.md
@@ -38,11 +38,10 @@ so a directory that starts with `_` is never accidentally picked up).
 
 ## What each file is for
 
-Once you have copied the template and renamed the directory, update
-this `README.md` to be your project's **file index**. The template
-below mirrors the structure of
-[`../airflow/README.md`](../airflow/README.md); delete the sections
-your project does not need and fill in the rest.
+Once you have copied the template into `<project-config>/` in your
+tracker repo, update this `README.md` to be your project's **file
+index**. Delete the sections your project does not need and fill in
+the rest.
 
 ### Authoritative manifest (fill this in first)
 
@@ -102,10 +101,8 @@ your project does not need and fill in the rest.
 
 ## Cross-references
 
-- [`../README.md`](../README.md) — framework-level *"Current
-  projects"* view + bootstrap walk-through.
-- [`../../config/active-project.md`](../../config/active-project.md) —
-  the selector that picks which project under `projects/` the skills
-  load.
-- [`../airflow/`](../airflow/) — a fully-populated example to
-  reference while filling in your own project.
+- [`../README.md`](../README.md) — framework-level *"Adopting the
+  framework"* view + bootstrap walk-through.
+- [`../../AGENTS.md`](../../AGENTS.md#placeholder-convention-used-in-skill-files) —
+  the placeholder convention that lets skills resolve `<project-config>/`
+  to the adopter's path at agent runtime.
diff --git a/projects/_template/canned-responses.md b/projects/_template/canned-responses.md
index a56ffed..1c6d52b 100644
--- a/projects/_template/canned-responses.md
+++ b/projects/_template/canned-responses.md
@@ -48,9 +48,8 @@ canonicalise.
 ## TODO — minimum viable canned responses
 
 At a minimum, the following templates should exist before a tracker
-team goes live. Copy one of the shapes from
-[`../airflow/canned-responses.md`](../airflow/canned-responses.md)
-and adapt the wording to the project.
+team goes live. The shapes are stubbed below; adapt the wording to
+the adopting project's voice.
 
 ### Confirmation of receiving the report
 
@@ -116,9 +115,8 @@ Minimum set (one per lifecycle transition):
 | CVE published on cve.org | post-Step 15 | `sync-security-issue` skill (recently-closed scan) |
 | Credit correction | Step 16 | Release manager |
 
-See
-[`../airflow/canned-responses.md`](../airflow/canned-responses.md)
-for fully-populated worked examples to adapt.
+Each row above corresponds to a section below; flesh out the
+template body with project-specific wording.
 
 ### CVE allocated (Step 6)
 
diff --git a/projects/_template/release-trains.md b/projects/_template/release-trains.md
index 4e05533..0fbdec5 100644
--- a/projects/_template/release-trains.md
+++ b/projects/_template/release-trains.md
@@ -29,7 +29,7 @@ TODO: list the project's active release branches. For each branch:
 - whether new security fixes should default to this branch or a
   different one.
 
-Example shape (from `projects/airflow/release-trains.md`):
+Example shape:
 
 > - **`main`** — becomes the next minor release (X.Y+1.0 eventually).
 > - **`v1-2-test`** — patch branch for the `1.2.x` series. Next patch is `1.2.3`.
diff --git a/projects/_template/title-normalization.md b/projects/_template/title-normalization.md
index 886ffd2..722bac5 100644
--- a/projects/_template/title-normalization.md
+++ b/projects/_template/title-normalization.md
@@ -52,9 +52,18 @@ TODO: one rule per bullet, applied in order. Typical patterns:
 ## Implementation recipe
 
 TODO: keep the transform inline in the skill, do not create a
-separate Python project. See
-[`../airflow/title-normalization.md`](../airflow/title-normalization.md#implementation-recipe)
-for a worked example (Airflow's cascade).
+separate Python project. A typical cascade looks like:
+
+1. Strip a leading `[ Security Report ]` or similar harness prefix.
+2. Strip a leading `<vendor>: <product>:` (e.g. the project's own
+   "Apache Foo:" prefix that the CVE tool re-applies).
+3. Strip a trailing version-parenthetical like `(<= 1.2.3)`.
+4. Strip a leading `Re:` if the original report came in by email and
+   was retitled with the reply prefix.
+
+The result is the bare vulnerability description that goes into the
+CVE record's `title` field. Document the cascade your project uses
+in this file once you settle on it.
 
 ## Sanity check
 
diff --git a/pyproject.toml b/pyproject.toml
index 7974eb0..6b31922 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,7 +29,12 @@
 # own pyproject.toml + uv.lock.
 
 [project]
-name = "apache-airflow-steward"
+# Forward-looking name: the framework lives at `apache/airflow-steward`
+# today (legacy from when it was Airflow's private security tracker)
+# and is renaming to `apache/steward`. The package name here matches
+# the future canonical name; no PyPI publication, so the name divergence
+# from the GitHub slug is harmless.
+name = "apache-steward"
 version = "0.0.0"
 description = "Reusable framework for handling security vulnerabilities in Apache projects."
 requires-python = ">=3.11"
diff --git a/tools/github/project-board.md b/tools/github/project-board.md
index d90a3f7..dff1017 100644
--- a/tools/github/project-board.md
+++ b/tools/github/project-board.md
@@ -67,7 +67,7 @@ intended behaviour, since such issues are usually noise.
 neither `gh` nor a GraphQL mutation can set it. To change it:
 
 1. Open `<project_board_url>/workflows` (for Airflow:
-   <https://github.com/orgs/airflow-s/projects/2/workflows>).
+   <<project-board-url>/workflows>).
 2. Click **Auto-add to project**.
 3. Edit the **Filter** field to the expression above.
 4. Save.
diff --git a/tools/github/tool.md b/tools/github/tool.md
index 0bdbf67..83a5c40 100644
--- a/tools/github/tool.md
+++ b/tools/github/tool.md
@@ -64,7 +64,7 @@ when the tool changes.
 ## Confidentiality note
 
 Some of the recipes below operate on the **private** tracker repo
-(e.g. `<tracker>` for the Airflow project) and others on a
+(e.g. `<tracker>` for the adopting project) and others on a
 public repo (e.g. `<upstream>`). The confidentiality rules in
 [`../../AGENTS.md`](../../AGENTS.md) still bind regardless of which
 tool is in use: anything that lands on a public surface must be
diff --git a/tools/gmail/asf-relay.md b/tools/gmail/asf-relay.md
index 5abcc3f..d8fc2bf 100644
--- a/tools/gmail/asf-relay.md
+++ b/tools/gmail/asf-relay.md
@@ -30,9 +30,9 @@ the differences are in the headers and body shape.
 
 Placeholder convention:
 
-- `<security-list>` — the project's security list. For Airflow,
-  `<security-list>`; see
-  [`../../<project-config>/project.md`](../../<project-config>/project.md#mailing-lists).
+- `<security-list>` — the project's security list. The concrete
+  address is declared in
+  [`<project-config>/project.md → Mailing lists`](../../<project-config>/project.md#mailing-lists).
 
 ## Rules
 
diff --git a/tools/gmail/draft-backends.md b/tools/gmail/draft-backends.md
index dab4fd5..2923dcb 100644
--- a/tools/gmail/draft-backends.md
+++ b/tools/gmail/draft-backends.md
@@ -172,7 +172,7 @@ to the user before drafting a new one"*.
 
 ## Known issue — `oauth_curl` thread-attached drafts may not surface in the global Drafts folder
 
-Caught live on 2026-04-25 during the [`airflow-s#346`](https://github.com/<tracker>/issues/346)
+Caught live on 2026-04-25 during the [`<tracker>#346`](https://github.com/<tracker>/issues/346)
 fix-skill flow: when **multiple `oauth_curl`-backed drafts pile up on
 the same Gmail thread** within a single skill flow (typical sequence:
 allocate-cve drafts a CVE-allocated message → sync-security-issue
diff --git a/tools/gmail/operations.md b/tools/gmail/operations.md
index 44e43e3..db2e479 100644
--- a/tools/gmail/operations.md
+++ b/tools/gmail/operations.md
@@ -219,7 +219,7 @@ to that user until sent. Draft content may reference the private
 tracker's URL (reporter is on the private thread and is expected to
 keep it confidential), but anything destined for a public list must
 obey the confidentiality rules in
-[`../../AGENTS.md`](../../AGENTS.md) — no `airflow-s` URLs, no CVE
+[`../../AGENTS.md`](../../AGENTS.md) — no `<tracker>` URLs, no CVE
 IDs before publication, no *"security fix"* leakage.
 
 ## Error handling
diff --git a/tools/gmail/search-queries.md b/tools/gmail/search-queries.md
index b777ea4..e56ec62 100644
--- a/tools/gmail/search-queries.md
+++ b/tools/gmail/search-queries.md
@@ -60,7 +60,7 @@ scan excludes the mirror senders up front:
 ```text
 -from:notifications@github.com
 -from:noreply@github.com
--from:airflow-s@noreply.github.com
+-from:<tracker-noreply>
 -from:security-noreply@github.com
 ```
 
@@ -78,7 +78,7 @@ bots, within a time window:
 list:<security-list-domain>
   -from:notifications@github.com
   -from:noreply@github.com
-  -from:airflow-s@noreply.github.com
+  -from:<tracker-noreply>
   -from:security-noreply@github.com
   newer_than:30d
 ```
@@ -122,7 +122,7 @@ distinctive noun-phrase set — the same 3–5 tokens the skill's Step
 down to messages whose sender is on the security-team roster AND
 whose body opens with a canned-response preamble
 (*"Thank you for reporting … this isn't a security issue"*,
-*"Per the Airflow Security Model"*, *"This is expected behaviour
+*"Per the project's security model"*, *"This is expected behaviour
 for a Dag author"*, *"We treat this as out of scope of the Security
 Model"*). Those are the prior-rejection precedents.
 
@@ -180,14 +180,14 @@ The tool's JSON API is OAuth-gated and not readable from skill
 context, so Gmail is the load-bearing signal path.
 
 ```text
-<CVE-ID> -from:notifications@github.com -from:noreply@github.com -from:airflow-s@noreply.github.com list:<security-list-domain>
+<CVE-ID> -from:notifications@github.com -from:noreply@github.com -from:<tracker-noreply> list:<security-list-domain>
 ```
 
 A second search without the `list:` filter catches CNA-tooling
 emails that went to individual security-team members first:
 
 ```text
-<CVE-ID> -from:notifications@github.com -from:noreply@github.com -from:airflow-s@noreply.github.com
+<CVE-ID> -from:notifications@github.com -from:noreply@github.com -from:<tracker-noreply>
 ```
 
 Sync-style skills impose a hard per-issue Gmail-call budget. Keep
diff --git a/tools/ponymail/tool.md b/tools/ponymail/tool.md
index 0cec818..42893ab 100644
--- a/tools/ponymail/tool.md
+++ b/tools/ponymail/tool.md
@@ -95,8 +95,8 @@ Prerequisites:
 
 - Python 3.11+ (the MCP server is a Python package).
 - An ASF LDAP account with access to the lists the project needs.
-  For the Airflow security team, that is the `pmc-airflow` LDAP
-  group, which gates the `<security-list>` and
+  Typically that is the project's PMC LDAP group (e.g.
+  `pmc-<project>`), which gates the `<security-list>` and
   `<private-list>` archives.
 - A local browser that can complete the ASF OAuth redirect flow
   (the login tool opens a browser window for LDAP authentication).
@@ -181,8 +181,8 @@ lists:
 mcp__ponymail__list_lists()
 ```
 
-The result is a `{ domain → { list → message_count } }` map. For an
-Airflow security-team triager, you should see
+The result is a `{ domain → { list → message_count } }` map. For a
+PMC-LDAP-authenticated triager, you should see the project's
 `<security-list-domain>` → `{ security: <count> }` — proof that
 the session has PMC-level LDAP access. If you only see public lists
 (`dev`, `users`, `announce`), the LDAP group membership is not being
diff --git a/tools/vulnogram/generate-cve-json/SKILL.md b/tools/vulnogram/generate-cve-json/SKILL.md
index cbc8275..a854bcd 100644
--- a/tools/vulnogram/generate-cve-json/SKILL.md
+++ b/tools/vulnogram/generate-cve-json/SKILL.md
@@ -28,14 +28,14 @@ to eliminate the manual "copy each field from the issue into the right
 Vulnogram form input" step when you are preparing to publish an advisory.
 
 > **Project-agnostic by design.** All project-specific values
-> (vendor, top-level product / package name, provider display map,
+> (vendor, top-level product / package name, project display map,
 > CNA org id, generator tag, …) are loaded from a TOML config the
 > adopting project ships at `<project-config>/tools/vulnogram/cve-json-config.toml`.
-> Examples in this document use Apache Airflow's configuration as a
-> running illustration, since that's where the tool was originally
-> developed; any adopter writes their own config per the schema in
-> the package [README](README.md) and the tool emits CVE records
-> against their own product taxonomy.
+> Concrete `apache-foo-project-*` strings appearing in this
+> document are **illustrative examples** of how a project with a
+> project-style package layout would configure things; replace
+> them mentally with the adopter's own package taxonomy. The
+> schema is documented in the package [README](README.md).
 
 **Golden rule:** the script generates a *proposal* JSON document. It
 parses a handful of structured fields from the issue body, but it cannot
@@ -59,9 +59,10 @@ diff the two to see what the tool has added / what you changed by hand.
   - `--cve-id CVE-YYYY-NNNN+` — override the CVE ID if the issue body's
     `CVE tool link` field has not yet been filled in, or retarget the
     JSON to a different CVE ID.
-  - `--title "Apache Airflow: …"` — override the CVE title. Default is
-    the GitHub issue title with an `<vendor>: <product>:` (e.g. `Apache Airflow:`) prefix when it
-    does not already start with that phrase.
+  - `--title "<vendor>: <product>: …"` — override the CVE title.
+    Default is the GitHub issue title with the project's
+    `<vendor>: <product>:` prefix (sourced from the TOML config) when
+    it does not already start with that phrase.
   - `--version-start X.Y.Z` — override the start of the affected version
     range (the `affected[].versions[].version` field). Default is the
     lower bound parsed from the Affected versions field when it uses
@@ -75,18 +76,18 @@ diff the two to see what the tool has added / what you changed by hand.
     credit(s) from the *Reporter credited as* field are always
     emitted with `type: "finder"`.
   - `--vendor` / `--product` / `--package-name` / `--collection-url` —
-    override the product identity fields (defaults to
-    `"Apache Software Foundation"` / `"Apache Airflow"` /
-    `"apache-airflow"` / `"https://pypi.python.org"`). These are
-    used as the default identity for *Affected versions* lines that
-    don't start with a recognisable Airflow package directory name
+    override the product identity fields. The defaults come from the
+    project's TOML config (`product.vendor`, `product.default_product`,
+    `product.default_package_name`, `product.default_collection_url`).
+    They are used as the identity for *Affected versions* lines that
+    don't start with a recognisable per-package directory name
     (see the multi-product note below).
   - `--product-for PACKAGE=PRODUCT` — override the CVE product display
     name for a specific `packageName`. Repeat to override multiple
-    packages. Useful when a provider is not yet in the built-in
-    display-name map, or when an acronym needs different casing from
-    the title-cased fallback. Example:
-    `--product-for apache-airflow-providers-foo='Apache Airflow Providers Foo'`.
+    packages. Useful when a package is not in the project's
+    `project_display_map` config, or when an acronym needs different
+    casing from the title-cased fallback. Example:
+    `--product-for apache-foo-project-baz='Apache Foo Project Baz'`.
   - `--org-id <uuid>` — override the CNA assigner org id (defaults to
     the ASF org id).
   - `--discovery <word>` — override `source.discovery` (default
@@ -121,30 +122,31 @@ filled in through a prior `sync-security-issue` run. In particular:
   `>= 2.0.0, < 3.2.2`, `<= 3.2.1`, a bare version like `3.1.5`, and a
   bare lower bound like `>= 2.0.0`).
   **Multi-product CVEs are supported** — put one package per line,
-  prefixing each with the Airflow package directory name, and the
-  script emits one `affected[]` entry per line with the right
-  `product` / `packageName`. Example:
-
-      apache-airflow-providers-elasticsearch <=6.5.0
-      apache-airflow-providers-opensearch <=1.9.0
-
-  Known provider directory names (`elasticsearch`, `opensearch`,
-  `cncf-kubernetes`, `smtp`, …) are resolved to the vendor-preferred
-  display casing via the built-in `AIRFLOW_PROVIDER_DISPLAY_MAP`;
-  unknown providers fall back to title-cased dash-split and can be
-  overridden with `--product-for`. A line without a package prefix
-  (or a single-line field) falls back to the `--product` /
-  `--package-name` defaults, which preserves the historical
+  prefixing each with the package directory name as it appears in
+  the adopter's repo, and the script emits one `affected[]` entry
+  per line with the right `product` / `packageName`. Example
+  (illustrative — using a hypothetical `apache-foo` project's
+  sub-project layout):
+
+      apache-foo-project-alpha <=6.5.0
+      apache-foo-project-beta <=1.9.0
+
+  Known package directory names are resolved to the vendor-preferred
+  display casing via the project's `packages.project_display_map`
+  config table; unknown packages fall back to title-cased dash-split
+  and can be overridden with `--product-for`. A line without a
+  package prefix (or a single-line field) falls back to the
+  `--product` / `--package-name` defaults, which preserves the
   single-product behaviour.
 
-  **`< NEXT VERSION` placeholder** — providers trackers don't know which
-  package version will ship the fix until the wave's release manager
-  picks it during the `Providers YYYY-MM-DD` cut. Until then, the
+  **`< NEXT VERSION` placeholder** — multi-package trackers don't
+  know which package version will ship the fix until the wave's
+  release manager picks it during a release cut. Until then, the
   *Affected versions* lines use the literal token `NEXT VERSION` as
   the upper bound, e.g.:
 
-      apache-airflow-providers-elasticsearch < NEXT VERSION
-      apache-airflow-providers-opensearch < NEXT VERSION
+      apache-foo-project-alpha < NEXT VERSION
+      apache-foo-project-beta < NEXT VERSION
 
   The generator strips `< NEXT VERSION` before parsing each line and
   emits a `versions[]` entry without `lessThan` (open-ended upper
@@ -225,8 +227,8 @@ in `README.md`.
 
 Before reading the tracker:
 
-1. `gh api repos/<tracker> --jq .name` returns
-   `airflow-s`, **and**
+1. `gh api repos/<tracker> --jq .name` returns the adopter's
+   tracker repo name (per `<project-config>/project.md`), **and**
 2. `uv --version` returns.
 
 If either fails, stop and tell the user what to install or log
@@ -287,9 +289,10 @@ Additional flags, all optional:
 
 - `--cve-id CVE-YYYY-NNNN+` — override the CVE ID if the *CVE tool link*
   field is empty.
-- `--title "Apache Airflow: …"` — override the title.
+- `--title "<vendor>: <product>: …"` — override the title.
 - `--vendor` / `--product` / `--package-name` / `--collection-url` —
-  override product identity (defaults cover Apache Airflow on PyPI).
+  override product identity (defaults sourced from the project's TOML
+  config under `[product]`).
 - `--org-id <uuid>` — override the CNA assigner org id (defaults to the
   ASF org id).
 - `--discovery UNKNOWN|INTERNAL|EXTERNAL|USER` — override
@@ -469,17 +472,17 @@ tool-side difference is intentional and the reviewer will keep it.
 ## Guardrails
 
 - **Confidentiality.** The script deliberately **drops** any URL that
-  points at `cveprocess.apache.org` or `airflow-s` from the references
-  list before serialising. Those URLs are private ASF-internal tracker
-  links and should not appear in a published CVE record. See the
-  "Confidentiality of `<tracker>`" section of
+  points at `cveprocess.apache.org` or the project's `<tracker>` repo
+  from the references list before serialising. Those URLs are private
+  ASF-internal links and should not appear in a published CVE record.
+  See the "Confidentiality of `<tracker>`" section of
   [`AGENTS.md`](../../../AGENTS.md).
 - **CVE IDs are always linked** per the "Linking CVEs" rule in
   [`AGENTS.md`](../../../AGENTS.md). When the skill mentions the CVE in
-  proposals, recaps or comments on the airflow-s issue, it must render
-  the ID as a markdown link — before publication to the ASF CVE tool, and
-  additionally to `cve.org` after publication.
-- **Airflow-s references are always linked** per the "Linking
+  proposals, recaps or comments on the `<tracker>` issue, it must
+  render the ID as a markdown link — before publication to the ASF
+  CVE tool, and additionally to `cve.org` after publication.
+- **`<tracker>` references are always linked** per the "Linking
   `<tracker>` issues and PRs" rule in
   [`AGENTS.md`](../../../AGENTS.md). When the skill mentions the
   tracking issue in its own comments, render it as a markdown link.
diff --git a/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py
index f304b45..ea8d440 100644
--- a/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py
+++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py
@@ -27,7 +27,7 @@ from __future__ import annotations
 from generate_cve_json.cve_json import (
     COLLECTION_URL_TO_PROJECT_URL_TEMPLATE,
     NEXT_VERSION_TOKEN_RE,
-    PROVIDER_DISPLAY_MAP,
+    PROJECT_DISPLAY_MAP,
     _build_attachment_body,
     _has_vendor_advisory_reference,
     _is_cna_ready_for_review,
@@ -62,7 +62,7 @@ from generate_cve_json.cve_json import (
 __all__ = [
     "COLLECTION_URL_TO_PROJECT_URL_TEMPLATE",
     "NEXT_VERSION_TOKEN_RE",
-    "PROVIDER_DISPLAY_MAP",
+    "PROJECT_DISPLAY_MAP",
     "_build_attachment_body",
     "_has_vendor_advisory_reference",
     "_is_cna_ready_for_review",
diff --git a/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
index a5386c3..9675f34 100644
--- a/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
+++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
@@ -101,7 +101,7 @@ import tempfile
 # Configuration loading.
 # -----------------------------------------------------------------------------
 #
-# All project-specific values (vendor, top-level product/package, provider
+# All project-specific values (vendor, top-level product/package, project
 # display map, package-name regex, CNA org id, generator tag, …) are read
 # at module load time from a TOML config file the adopting project ships
 # in its tracker repo at:
@@ -174,9 +174,11 @@ def _populate_constants() -> None:
     global DEFAULT_REPO, DEFAULT_VENDOR, DEFAULT_PRODUCT
     global DEFAULT_PACKAGE_NAME, DEFAULT_COLLECTION_URL
     global DEFAULT_ASF_ORG_ID, GENERATOR_TAG, SKILL_SOURCE_URL
-    global PROVIDER_DISPLAY_MAP, PACKAGE_RE
-    global TOP_LEVEL_NAME, TOP_LEVEL_PRODUCT, PROVIDER_PRODUCT_TEMPLATE
-    global PROVIDER_PREFIX
+    global PROJECT_DISPLAY_MAP, PACKAGE_RE
+    global TOP_LEVEL_NAME, TOP_LEVEL_PRODUCT, PROJECT_PRODUCT_TEMPLATE
+    global PROJECT_PREFIX
+    global CNA_PRIVATE_PROJECT_URL, CNA_PRIVATE_OWNER, CNA_PRIVATE_USERS_LIST
+    global TITLE_STRIP_RE, TRACKER_FILTER_TOKEN
 
     DEFAULT_REPO = cfg["meta"]["tracker_repo"]
     DEFAULT_VENDOR = cfg["product"]["vendor"]
@@ -188,14 +190,40 @@ def _populate_constants() -> None:
     SKILL_SOURCE_URL = cfg["meta"].get(
         "skill_source_url", f"https://github.com/{cfg['meta']['tracker_repo']}"
     )
-    PROVIDER_DISPLAY_MAP = dict(cfg["packages"]["provider_display_map"])
+    PROJECT_DISPLAY_MAP = dict(cfg["packages"]["project_display_map"])
     PACKAGE_RE = re.compile(cfg["packages"]["package_pattern"])
     TOP_LEVEL_NAME = cfg["packages"]["top_level_name"]
     TOP_LEVEL_PRODUCT = cfg["packages"]["top_level_product"]
-    PROVIDER_PRODUCT_TEMPLATE = cfg["packages"]["provider_product_template"]
+    PROJECT_PRODUCT_TEMPLATE = cfg["packages"]["project_product_template"]
     # Convenient derived constant: the prefix used to detect
-    # "provider" subpackages (e.g. ``apache-airflow-providers-``).
-    PROVIDER_PREFIX = f"{TOP_LEVEL_NAME}-providers-"
+    # "project" subpackages (e.g. ``<top-level>-project-``).
+    PROJECT_PREFIX = f"{TOP_LEVEL_NAME}-project-"
+
+    # Per-project CVE 5.x `CNA_private` envelope fields. These end up
+    # in every CVE record this tool generates for the project, and
+    # used to be hardcoded to Airflow values — now config-driven so
+    # adopters can populate them with their project's URLs / lists.
+    cna_private_cfg = cfg.get("cna_private", {})
+    CNA_PRIVATE_PROJECT_URL = cna_private_cfg.get("project_url", "")
+    CNA_PRIVATE_OWNER = cna_private_cfg.get("owner", "")
+    CNA_PRIVATE_USERS_LIST = cna_private_cfg.get("users_list", "")
+
+    # Title-strip regex for `resolve_title` — built from the configured
+    # top-level product so the *"<vendor>: <product>:"* prefix is
+    # stripped without hardcoding a specific project's name.
+    _stripped_product = re.escape(TOP_LEVEL_PRODUCT.strip())
+    TITLE_STRIP_RE = re.compile(
+        rf"^\s*{_stripped_product}\s*[:\-–—]?\s*",  # noqa: RUF001 — en-/em-dash deliberate
+        re.IGNORECASE,
+    )
+
+    # Substring used by `build_references` to filter out tracker URLs
+    # before serialising. We use the tracker repo's owner segment
+    # (the part before `/`) — distinctive enough for any URL that
+    # points at the tracker (issues, PRs, raw blobs all carry it)
+    # without false positives on URLs that mention the project name
+    # in a path component.
+    TRACKER_FILTER_TOKEN = DEFAULT_REPO.split("/", 1)[0]
 
 
 # Module-level constants — populated by `_populate_constants()`. The
@@ -210,12 +238,17 @@ DEFAULT_COLLECTION_URL: str = ""
 DEFAULT_ASF_ORG_ID: str = ""
 GENERATOR_TAG: str = ""
 SKILL_SOURCE_URL: str = ""
-PROVIDER_DISPLAY_MAP: dict[str, str] = {}
+PROJECT_DISPLAY_MAP: dict[str, str] = {}
 PACKAGE_RE: re.Pattern[str] = re.compile("")
 TOP_LEVEL_NAME: str = ""
 TOP_LEVEL_PRODUCT: str = ""
-PROVIDER_PRODUCT_TEMPLATE: str = ""
-PROVIDER_PREFIX: str = ""
+PROJECT_PRODUCT_TEMPLATE: str = ""
+PROJECT_PREFIX: str = ""
+CNA_PRIVATE_PROJECT_URL: str = ""
+CNA_PRIVATE_OWNER: str = ""
+CNA_PRIVATE_USERS_LIST: str = ""
+TITLE_STRIP_RE: re.Pattern[str] = re.compile("")
+TRACKER_FILTER_TOKEN: str = ""
 
 # CVE 5.x convention values that are not project-specific.
 DEFAULT_CREDIT_TYPE = "finder"
@@ -231,9 +264,9 @@ with contextlib.suppress(FileNotFoundError):
 NO_RESPONSE = "_No response_"
 
 # Sentinel token for an as-yet-unreleased upper bound in
-# ``Affected versions`` entries. Used predominantly for providers
-# trackers where the wave milestone (date-based, e.g. ``Providers
-# 2026-04-21``) does not reveal which exact provider package version
+# ``Affected versions`` entries. Used predominantly for projects
+# trackers where the wave milestone (date-based, e.g. ``Projects
+# 2026-04-21``) does not reveal which exact project package version
 # will ship the fix. The token is stripped before parsing and the
 # resulting CVE 5.x ``versions[]`` entry omits ``lessThan`` —
 # downstream consumers (Vulnogram, cve.org) read that as
@@ -265,7 +298,6 @@ ATTACHMENT_MARKER_VERSION = "v1"
 # Back-compat alias — the `v1` comment-based attachments used a single
 # marker. New body-embedded attachments use a begin/end pair.
 ATTACHMENT_MARKER_PREFIX = ATTACHMENT_MARKER_BEGIN_PREFIX
-SKILL_SOURCE_URL = "https://github.com/airflow-s/airflow-s/tree/airflow-s/tools/vulnogram/generate-cve-json"
 
 
 # -----------------------------------------------------------------------------
@@ -441,7 +473,7 @@ def parse_affected_versions(value: str, version_start_override: str | None) -> l
       whose fix-shipped version is not yet known).
 
     The ``< NEXT VERSION`` sentinel signals "fix not yet released,
-    upper bound unknown" — used predominantly on providers trackers
+    upper bound unknown" — used predominantly on project trackers
     where the wave milestone is date-based and the package version
     that will carry the fix is decided by the release manager during
     the wave. The token is stripped before further parsing; the
@@ -613,7 +645,9 @@ def build_references(
     gathered: list[str] = list(parse_url_list(pr_field))
     if extra_urls:
         gathered.extend(extra_urls)
-    filtered = [url for url in gathered if "cveprocess.apache.org" not in url and "airflow-s" not in url]
+    filtered = [
+        url for url in gathered if "cveprocess.apache.org" not in url and TRACKER_FILTER_TOKEN not in url
+    ]
     # De-duplicate preserving order, then sort alphabetically.
     seen: set[str] = set()
     ordered: list[str] = []
@@ -649,16 +683,16 @@ def _product_for_package(
 
     * `package_name == TOP_LEVEL_NAME` → `TOP_LEVEL_PRODUCT` (e.g.
       `apache-airflow` → `Apache Airflow`).
-    * `package_name` matches `<TOP_LEVEL_NAME>-providers-<dir>` →
-      `PROVIDER_PRODUCT_TEMPLATE.format(display=...)`, where `display`
-      is `PROVIDER_DISPLAY_MAP[<dir>]` when the directory name is
+    * `package_name` matches `<TOP_LEVEL_NAME>-project-<dir>` →
+      `PROJECT_PRODUCT_TEMPLATE.format(display=...)`, where `display`
+      is `PROJECT_DISPLAY_MAP[<dir>]` when the directory name is
       known, or a title-cased dash-split fallback otherwise
       (`foo-bar` → `Foo Bar`).
     * `product_overrides` lets callers shadow either source by package
       name — used by the `--product-for` CLI flag for unknown
       subpackages or acronyms that don't round-trip through `title()`.
 
-    Unknown `package_name` (neither `TOP_LEVEL_NAME` nor a provider
+    Unknown `package_name` (neither `TOP_LEVEL_NAME` nor a project
     subpackage) is returned unchanged — callers that pass a
     non-project package name are opting into bring-your-own product
     naming.
@@ -670,13 +704,13 @@ def _product_for_package(
         return overrides[package_name]
     if package_name == TOP_LEVEL_NAME:
         return TOP_LEVEL_PRODUCT
-    if package_name.startswith(PROVIDER_PREFIX):
-        provider_dir = package_name[len(PROVIDER_PREFIX) :]
-        if provider_dir in PROVIDER_DISPLAY_MAP:
-            display = PROVIDER_DISPLAY_MAP[provider_dir]
+    if package_name.startswith(PROJECT_PREFIX):
+        project_dir = package_name[len(PROJECT_PREFIX) :]
+        if project_dir in PROJECT_DISPLAY_MAP:
+            display = PROJECT_DISPLAY_MAP[project_dir]
         else:
-            display = " ".join(part.title() for part in provider_dir.split("-") if part)
-        return PROVIDER_PRODUCT_TEMPLATE.format(display=display)
+            display = " ".join(part.title() for part in project_dir.split("-") if part)
+        return PROJECT_PRODUCT_TEMPLATE.format(display=display)
     return package_name
 
 
@@ -685,7 +719,7 @@ def _split_affected_lines(affected_versions_value: str) -> list[tuple[str | None
 
     Each non-empty line is inspected for a leading Airflow package
     directory name (``apache-airflow`` or
-    ``apache-airflow-providers-<dir>``); when detected, the line splits
+    ``apache-airflow-project-<dir>``); when detected, the line splits
     into ``(package_name, version_expression)``. Lines without a
     recognisable package prefix yield ``(None, <whole line>)`` so the
     caller can fall back to the default product/package. Bullets
@@ -734,14 +768,14 @@ def build_affected(
 
     The *Affected versions* body field is parsed line by line. Each line
     that starts with an Airflow package directory name
-    (``apache-airflow`` or ``apache-airflow-providers-<dir>``) creates
+    (``apache-airflow`` or ``apache-airflow-project-<dir>``) creates
     an entry with that package's derived product / packageName. Lines
     without a recognisable prefix — or an empty field — fall back to
     the explicit ``product`` / ``package_name`` arguments, which is the
     historical single-entry shape.
 
     ``product_overrides`` is consulted *before*
-    :data:`PROVIDER_DISPLAY_MAP` and lets callers shadow the
+    :data:`PROJECT_DISPLAY_MAP` and lets callers shadow the
     resolved product name for any specific package. It is wired to the
     ``--product-for PACKAGE=PRODUCT`` CLI flag.
     """
@@ -1066,9 +1100,9 @@ def wrap_cve_record(cna: dict, *, cve_id: str, org_id: str) -> dict:
     record: dict = {
         "CNA_private": {
             "emailed": None,
-            "projecturl": "https://airflow.apache.org/",
-            "owner": "airflow",
-            "userslist": "users@airflow.apache.org",
+            "projecturl": CNA_PRIVATE_PROJECT_URL,
+            "owner": CNA_PRIVATE_OWNER,
+            "userslist": CNA_PRIVATE_USERS_LIST,
             "state": compute_cna_private_state(cna, cve_id),
             "todo": [],
             "type": "unsure",
@@ -1475,8 +1509,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
         metavar="PACKAGE=PRODUCT",
         help=(
             "Override the CVE product name for a specific packageName, "
-            "e.g. `--product-for apache-airflow-providers-foo='Apache "
-            "Airflow Foo Provider'`. Useful when a provider is not yet "
+            "e.g. `--product-for apache-airflow-project-foo='Apache "
+            "Airflow Foo Project'`. Useful when a project is not yet "
             "in the built-in display-name map, or when the default "
             "title-cased fallback needs a different casing. Repeat the "
             "flag to override multiple packages."
@@ -1581,15 +1615,13 @@ def resolve_title(
         title = issue_title.strip()
         if not title:
             title = summary.split(".", 1)[0].strip() if summary else ""
-    # The CNA container is already scoped to Apache/Airflow, so the
-    # repeated "Apache Airflow:" prefix on the title is noise. Strip it
-    # (along with any trailing punctuation/whitespace) if present.
-    title = re.sub(
-        r"^\s*apache\s+airflow\s*[:\-–—]?\s*",  # noqa: RUF001 — en-dash/em-dash are deliberate regex matches
-        "",
-        title,
-        flags=re.IGNORECASE,
-    )
+    # The CNA container is already scoped to the project, so the
+    # repeated "<vendor>: <product>:" prefix on the title is noise.
+    # Strip it (along with any trailing punctuation/whitespace) if
+    # present. The strip pattern is built from the configured
+    # `top_level_product` at config-load time — see `TITLE_STRIP_RE`
+    # in `_populate_constants`.
+    title = TITLE_STRIP_RE.sub("", title)
     return title
 
 
diff --git a/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml b/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
index b804b94..ec9f83a 100644
--- a/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
+++ b/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
@@ -17,163 +17,97 @@
 
 # generate-cve-json — TEST FIXTURE config.
 #
-# This file is a fixture for the tool's own pytest suite, NOT the
-# framework's default configuration. It carries values that match the
-# Apache Airflow security team's adopter-side config because the
-# tests in `test_generate_cve_json.py` were written against that
-# specific configuration shape (Airflow-specific package names,
-# provider display map, etc.).
+# This file is a fixture for the tool's own pytest suite, NOT a
+# default that adopters should copy. It describes a fictional
+# **`apache-example`** project — top-level package `apache-example`
+# with a sub-project layout (`apache-example-project-<name>`). The
+# shape is realistic enough to exercise every code path in the tool
+# (top-level + sub-project package matching, project display-map
+# lookup, multi-product CVE handling, the `< NEXT VERSION`
+# placeholder, etc.) without tying the test suite to any real
+# adopter's taxonomy.
 #
-# Adopting projects MUST NOT copy this file. Write your own
+# Adopters MUST NOT copy this file. Write your own
 # `cve-json-config.toml` from scratch using the schema documented in
-# the package README. The values here are illustrative of one
-# specific adopter's configuration; nothing in the framework treats
-# them as defaults.
+# the package README. Nothing in the framework treats the values
+# below as defaults.
 
 [product]
-# CVE 5.x `affected[].vendor` — the CNA vendor name.
+# CVE 5.x `affected[].vendor` — the CNA vendor name. All Apache
+# project CVEs share this vendor regardless of which project ships
+# the affected code.
 vendor = "Apache Software Foundation"
 
 # Top-level product display name and package name. Used as the default
 # `product` / `packageName` for *Affected versions* lines that don't
 # match a more specific entry in `[packages]` below.
-default_product = "Apache Airflow"
-default_package_name = "apache-airflow"
+default_product = "Apache Example"
+default_package_name = "apache-example"
 
 # Where the package is distributed (`affected[].collectionURL`).
 default_collection_url = "https://pypi.python.org"
 
 [cna]
 # CNA assigner UUID (CVE 5.x `cveMetadata.assignerOrgId` /
-# `providerMetadata.orgId`). This is the ASF org id.
+# `providerMetadata.orgId`). This is the ASF org id (shared across
+# every Apache project's CVEs).
 org_id = "f0158376-9dc2-43b6-827c-5f631a4d8d09"
 
+[cna_private]
+# Per-project fields that go into the CVE 5.x `CNA_private`
+# envelope. These are project-scoped values that ASF Vulnogram
+# uses internally; adopters fill them with their own URLs.
+project_url = "https://example.apache.org/"
+owner = "example"
+users_list = "users@example.apache.org"
+
 [meta]
 # Tracker repo slug (org/name). Used for the `x_generator.engine`
 # tag in the CVE record and for the self-source-link below.
-tracker_repo = "airflow-s/airflow-s"
+tracker_repo = "apache-example-s/apache-example-s"
 
 # Stable identifier embedded in the CVE record's `x_generator.engine`
 # field so a reader can identify which tool produced the JSON.
-generator_tag = "airflow-s/generate_cve_json.py"
+generator_tag = "apache-example-s/generate_cve_json.py"
 
 # Self-source URL the script can self-document with (e.g. in error
 # messages, on the `_print_skill_link` line of the generated comment).
 # Optional: defaults to f"https://github.com/{tracker_repo}".
-skill_source_url = "https://github.com/airflow-s/airflow-s/tree/airflow-s/tools/vulnogram/generate-cve-json"
+skill_source_url = "https://github.com/apache-example-s/apache-example-s/tree/main/tools/vulnogram/generate-cve-json"
 
 [packages]
 # Regex matching this project's package names. Required named groups:
-#   `package`   — the full package name as shipped on PyPI (whatever
-#                 collection_url declares).
-#   `provider`  — optional; the subpackage / provider directory name
-#                 used to look up a display name in
-#                 `provider_display_map` below. May be empty when the
-#                 line names the top-level package only.
-#   `rest`      — optional; everything after the package name, used
-#                 by the script to consume the trailing version-range
-#                 expression.
-package_pattern = '^(?P<package>apache-airflow(?:-providers-(?P<provider>[a-z0-9][a-z0-9_-]*))?)(?:\s+(?P<rest>.*))?$'
+#   `package`  — the full package name as shipped on PyPI (whatever
+#                collection_url declares).
+#   `project`  — optional; the sub-project directory name used to look
+#                up a display name in `project_display_map` below. May
+#                be empty when the line names the top-level package
+#                only.
+#   `rest`     — optional; everything after the package name, used
+#                by the script to consume the trailing version-range
+#                expression.
+package_pattern = '^(?P<package>apache-example(?:-project-(?P<project>[a-z0-9][a-z0-9_-]*))?)(?:\s+(?P<rest>.*))?$'
 
 # Top-level package name + product. When the matched `package_pattern`
-# yields just the top-level name (no `provider` group), the product is
-# `top_level_product`. When `provider` is present, the product is
-# `provider_product_template` with `{display}` substituted from
-# `provider_display_map` (or a title-cased fallback).
-top_level_name = "apache-airflow"
-top_level_product = "Apache Airflow"
-provider_product_template = "Apache Airflow Providers {display}"
+# yields just the top-level name (no `project` group), the product is
+# `top_level_product`. When `project` is present, the product is
+# `project_product_template` with `{display}` substituted from
+# `project_display_map` (or a title-cased fallback).
+top_level_name = "apache-example"
+top_level_product = "Apache Example"
+project_product_template = "Apache Example Project {display}"
 
-# Provider directory-name → vendor-preferred display casing for the
-# CVE `product` field. Provider directory names are lowercase
-# (`elasticsearch`, `cncf-kubernetes`); CVE product names follow
-# vendor-preferred casing (`Elasticsearch`, `CNCF Kubernetes`).
-# Extend this table when a new provider appears in a CVE; unknown
-# providers fall back to a title-cased dash-split of the directory
-# name, which is correct for most single-word providers but may need
-# an entry here for acronyms.
-[packages.provider_display_map]
-"cncf-kubernetes" = "CNCF Kubernetes"
-"elasticsearch" = "Elasticsearch"
-"opensearch" = "OpenSearch"
-"smtp" = "SMTP"
-"ssh" = "SSH"
-"ftp" = "FTP"
-"http" = "HTTP"
-"imap" = "IMAP"
-"openfaas" = "OpenFaaS"
-"openlineage" = "OpenLineage"
-"mysql" = "MySQL"
-"postgres" = "PostgreSQL"
-"sqlite" = "SQLite"
-"odbc" = "ODBC"
-"jdbc" = "JDBC"
-"sftp" = "SFTP"
-"amazon" = "Amazon"
-"google" = "Google"
-"microsoft-azure" = "Microsoft Azure"
-"microsoft-mssql" = "Microsoft SQL Server"
-"microsoft-winrm" = "Microsoft WinRM"
-"apache-beam" = "Apache Beam"
-"apache-cassandra" = "Apache Cassandra"
-"apache-drill" = "Apache Drill"
-"apache-druid" = "Apache Druid"
-"apache-flink" = "Apache Flink"
-"apache-hdfs" = "Apache HDFS"
-"apache-hive" = "Apache Hive"
-"apache-impala" = "Apache Impala"
-"apache-kafka" = "Apache Kafka"
-"apache-kylin" = "Apache Kylin"
-"apache-livy" = "Apache Livy"
-"apache-pig" = "Apache Pig"
-"apache-pinot" = "Apache Pinot"
-"apache-spark" = "Apache Spark"
-"apache-tinkerpop" = "Apache TinkerPop"
-"celery" = "Celery"
-"cohere" = "Cohere"
-"common-compat" = "Common Compat"
-"common-io" = "Common IO"
-"common-messaging" = "Common Messaging"
-"common-sql" = "Common SQL"
-"databricks" = "Databricks"
-"datadog" = "Datadog"
-"dbt-cloud" = "dbt Cloud"
-"dingding" = "DingTalk"
-"discord" = "Discord"
-"docker" = "Docker"
-"edge3" = "Edge3"
-"exasol" = "Exasol"
-"fab" = "FAB"
-"facebook" = "Facebook"
-"git" = "Git"
-"github" = "GitHub"
-"grpc" = "gRPC"
-"hashicorp" = "HashiCorp"
-"jenkins" = "Jenkins"
-"mongo" = "MongoDB"
-"neo4j" = "Neo4j"
-"openai" = "OpenAI"
-"oracle" = "Oracle"
-"pagerduty" = "PagerDuty"
-"papermill" = "Papermill"
-"pgvector" = "pgvector"
-"pinecone" = "Pinecone"
-"qdrant" = "Qdrant"
-"redis" = "Redis"
-"salesforce" = "Salesforce"
-"samba" = "Samba"
-"segment" = "Segment"
-"sendgrid" = "SendGrid"
-"singularity" = "Singularity"
-"slack" = "Slack"
-"snowflake" = "Snowflake"
-"standard" = "Standard"
-"tableau" = "Tableau"
-"telegram" = "Telegram"
-"teradata" = "Teradata"
-"trino" = "Trino"
-"vertica" = "Vertica"
-"weaviate" = "Weaviate"
-"yandex" = "Yandex"
-"ydb" = "YDB"
-"zendesk" = "Zendesk"
+# Sub-project directory-name → vendor-preferred display casing for
+# the CVE `product` field. Directory names are lowercase
+# (`foo`, `acme-xyz`); CVE product names follow vendor-preferred
+# casing (`Foo`, `Acme XYZ`). Extend this table when a new sub-project
+# appears in a CVE; unknown names fall back to a title-cased
+# dash-split of the directory name, which is correct for most
+# single-word sub-projects but may need an entry here for acronyms.
+[packages.project_display_map]
+"foo" = "Foo"
+"bar" = "Bar"
+"kerfluffle" = "Kerfluffle"
+"pop-corn" = "Pop Corn"
+"xyz" = "XYZ"
+"acme-xyz" = "Acme XYZ"
diff --git a/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
index 5decd3c..55809ab 100644
--- a/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
+++ b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
@@ -53,8 +53,8 @@ from generate_cve_json import (
 
 DEFAULT_AFFECTED_ARGS: dict[str, Any] = {
     "vendor": "Apache Software Foundation",
-    "product": "Apache Airflow",
-    "package_name": "apache-airflow",
+    "product": "Apache Example",
+    "package_name": "apache-example",
     "collection_url": "https://pypi.python.org",
     "version_start": None,
 }
@@ -242,7 +242,7 @@ class TestParseAffectedVersionsNextVersionPlaceholder:
     """The `< NEXT VERSION` sentinel: fix not yet released, upper bound unknown.
 
     Stripped before further parsing; resulting entry has no `lessThan`.
-    Used predominantly for providers trackers where the wave milestone
+    Used predominantly for projects trackers where the wave milestone
     is date-based and the package version that ships the fix is decided
     by the release manager during the wave.
     """
@@ -288,39 +288,30 @@ class TestParseAffectedVersionsNextVersionPlaceholder:
 
 class TestProductForPackage:
     def test_core_package_resolves_to_apache_airflow(self):
-        assert _product_for_package("apache-airflow") == "Apache Airflow"
+        assert _product_for_package("apache-example") == "Apache Example"
 
     def test_known_provider_uses_display_map_casing(self):
+        assert _product_for_package("apache-example-project-foo") == "Apache Example Project Foo"
         assert (
-            _product_for_package("apache-airflow-providers-elasticsearch")
-            == "Apache Airflow Providers Elasticsearch"
-        )
-        assert (
-            _product_for_package("apache-airflow-providers-opensearch")
-            == "Apache Airflow Providers OpenSearch"
-        )
-        assert (
-            _product_for_package("apache-airflow-providers-cncf-kubernetes")
-            == "Apache Airflow Providers CNCF Kubernetes"
+            _product_for_package("apache-example-project-kerfluffle") == "Apache Example Project Kerfluffle"
         )
+        assert _product_for_package("apache-example-project-acme-xyz") == "Apache Example Project Acme XYZ"
         # Confirms the user-cited mapping example from the Vulnogram form:
-        #   apache-airflow-providers-snowflake → "Apache Airflow Providers Snowflake".
-        assert (
-            _product_for_package("apache-airflow-providers-snowflake") == "Apache Airflow Providers Snowflake"
-        )
+        #   apache-example-project-bar → "Apache Example Project Bar".
+        assert _product_for_package("apache-example-project-bar") == "Apache Example Project Bar"
 
     def test_unknown_provider_falls_back_to_title_case(self):
         assert (
-            _product_for_package("apache-airflow-providers-madeup-widget")
-            == "Apache Airflow Providers Madeup Widget"
+            _product_for_package("apache-example-project-madeup-widget")
+            == "Apache Example Project Madeup Widget"
         )
 
     def test_overrides_win_over_display_map(self):
         assert (
             _product_for_package(
-                "apache-airflow-providers-elasticsearch",
+                "apache-example-project-foo",
                 product_overrides={
-                    "apache-airflow-providers-elasticsearch": "Custom ES Display",
+                    "apache-example-project-foo": "Custom ES Display",
                 },
             )
             == "Custom ES Display"
@@ -329,12 +320,12 @@ class TestProductForPackage:
     def test_overrides_win_over_title_case_fallback(self):
         assert (
             _product_for_package(
-                "apache-airflow-providers-madeup-widget",
+                "apache-example-project-madeup-widget",
                 product_overrides={
-                    "apache-airflow-providers-madeup-widget": "Apache Airflow Providers WIDGET",
+                    "apache-example-project-madeup-widget": "Apache Example Project WIDGET",
                 },
             )
-            == "Apache Airflow Providers WIDGET"
+            == "Apache Example Project WIDGET"
         )
 
 
@@ -347,29 +338,29 @@ class TestBuildAffectedSingleProduct:
     def test_empty_field_emits_one_placeholder_entry(self):
         entries = build_affected("", **DEFAULT_AFFECTED_ARGS)
         assert len(entries) == 1
-        assert entries[0]["packageName"] == "apache-airflow"
-        assert entries[0]["product"] == "Apache Airflow"
+        assert entries[0]["packageName"] == "apache-example"
+        assert entries[0]["product"] == "Apache Example"
 
     def test_bare_version_range_uses_defaults(self):
         entries = build_affected("<3.2.2", **DEFAULT_AFFECTED_ARGS)
         assert len(entries) == 1
-        assert entries[0]["packageName"] == "apache-airflow"
-        assert entries[0]["product"] == "Apache Airflow"
+        assert entries[0]["packageName"] == "apache-example"
+        assert entries[0]["product"] == "Apache Example"
         assert entries[0]["versions"][0]["lessThan"] == "3.2.2"
 
     def test_single_line_with_package_prefix_detects_provider(self):
         entries = build_affected(
-            "apache-airflow-providers-elasticsearch <=6.5.0",
+            "apache-example-project-foo <=6.5.0",
             **DEFAULT_AFFECTED_ARGS,
         )
         assert len(entries) == 1
-        assert entries[0]["packageName"] == "apache-airflow-providers-elasticsearch"
-        assert entries[0]["product"] == "Apache Airflow Providers Elasticsearch"
+        assert entries[0]["packageName"] == "apache-example-project-foo"
+        assert entries[0]["product"] == "Apache Example Project Foo"
         assert entries[0]["versions"][0]["lessThanOrEqual"] == "6.5.0"
 
     def test_single_line_with_core_package_prefix_detects_core(self):
         entries = build_affected(
-            "apache-airflow <3.2.2",
+            "apache-example <3.2.2",
             vendor="Apache Software Foundation",
             product="IGNORED",
             package_name="ignored",
@@ -379,59 +370,59 @@ class TestBuildAffectedSingleProduct:
         assert len(entries) == 1
         # Package prefix wins over the default argument because the body
         # has said the package name explicitly.
-        assert entries[0]["packageName"] == "apache-airflow"
-        assert entries[0]["product"] == "Apache Airflow"
+        assert entries[0]["packageName"] == "apache-example"
+        assert entries[0]["product"] == "Apache Example"
 
 
 class TestBuildAffectedMultiProduct:
     def test_two_providers_produce_two_entries(self):
         entries = build_affected(
-            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0",
+            "apache-example-project-foo <=6.5.0\napache-example-project-kerfluffle <=1.9.0",
             **DEFAULT_AFFECTED_ARGS,
         )
         assert len(entries) == 2
         es, os_ = entries
-        assert es["packageName"] == "apache-airflow-providers-elasticsearch"
-        assert es["product"] == "Apache Airflow Providers Elasticsearch"
+        assert es["packageName"] == "apache-example-project-foo"
+        assert es["product"] == "Apache Example Project Foo"
         assert es["versions"][0]["lessThanOrEqual"] == "6.5.0"
-        assert os_["packageName"] == "apache-airflow-providers-opensearch"
-        assert os_["product"] == "Apache Airflow Providers OpenSearch"
+        assert os_["packageName"] == "apache-example-project-kerfluffle"
+        assert os_["product"] == "Apache Example Project Kerfluffle"
         assert os_["versions"][0]["lessThanOrEqual"] == "1.9.0"
 
     def test_bullet_prefixes_are_stripped(self):
         entries = build_affected(
-            "- apache-airflow-providers-elasticsearch <=6.5.0\n* apache-airflow-providers-opensearch <=1.9.0",
+            "- apache-example-project-foo <=6.5.0\n* apache-example-project-kerfluffle <=1.9.0",
             **DEFAULT_AFFECTED_ARGS,
         )
         assert [e["packageName"] for e in entries] == [
-            "apache-airflow-providers-elasticsearch",
-            "apache-airflow-providers-opensearch",
+            "apache-example-project-foo",
+            "apache-example-project-kerfluffle",
         ]
 
     def test_blank_lines_between_entries_are_ignored(self):
         entries = build_affected(
-            "apache-airflow-providers-elasticsearch <=6.5.0\n\napache-airflow-providers-opensearch <=1.9.0\n",
+            "apache-example-project-foo <=6.5.0\n\napache-example-project-kerfluffle <=1.9.0\n",
             **DEFAULT_AFFECTED_ARGS,
         )
         assert len(entries) == 2
 
     def test_mixed_known_and_unknown_provider(self):
         entries = build_affected(
-            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-brand-new <=0.1.0",
+            "apache-example-project-foo <=6.5.0\napache-example-project-brand-new <=0.1.0",
             **DEFAULT_AFFECTED_ARGS,
         )
-        assert entries[0]["product"] == "Apache Airflow Providers Elasticsearch"
-        assert entries[1]["product"] == "Apache Airflow Providers Brand New"
+        assert entries[0]["product"] == "Apache Example Project Foo"
+        assert entries[1]["product"] == "Apache Example Project Brand New"
 
     def test_product_overrides_applied_per_entry(self):
         entries = build_affected(
-            "apache-airflow-providers-brand-new <=0.1.0",
+            "apache-example-project-brand-new <=0.1.0",
             product_overrides={
-                "apache-airflow-providers-brand-new": "Apache Airflow Providers BRAND",
+                "apache-example-project-brand-new": "Apache Example Project BRAND",
             },
             **DEFAULT_AFFECTED_ARGS,
         )
-        assert entries[0]["product"] == "Apache Airflow Providers BRAND"
+        assert entries[0]["product"] == "Apache Example Project BRAND"
 
     def test_line_without_prefix_falls_back_to_defaults(self):
         # A single line that is not a version range and doesn't carry a
@@ -440,24 +431,24 @@ class TestBuildAffectedMultiProduct:
         entries = build_affected(
             "all versions",
             vendor="Apache Software Foundation",
-            product="Apache Airflow Helm Chart",
-            package_name="apache-airflow-helm-chart",
+            product="Apache Example Helm Chart",
+            package_name="apache-example-helm-chart",
             collection_url="https://airflow.apache.org/",
             version_start=None,
         )
         assert len(entries) == 1
-        assert entries[0]["packageName"] == "apache-airflow-helm-chart"
-        assert entries[0]["product"] == "Apache Airflow Helm Chart"
+        assert entries[0]["packageName"] == "apache-example-helm-chart"
+        assert entries[0]["product"] == "Apache Example Helm Chart"
 
     def test_multi_line_is_deterministic(self):
         # Re-generating must produce byte-identical output, which is
         # what keeps the `--attach` idempotence guarantee intact.
         first = build_affected(
-            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0",
+            "apache-example-project-foo <=6.5.0\napache-example-project-kerfluffle <=1.9.0",
             **DEFAULT_AFFECTED_ARGS,
         )
         second = build_affected(
-            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0",
+            "apache-example-project-foo <=6.5.0\napache-example-project-kerfluffle <=1.9.0",
             **DEFAULT_AFFECTED_ARGS,
         )
         assert first == second
@@ -466,38 +457,37 @@ class TestBuildAffectedMultiProduct:
         # The providers-tracker pattern: one line per affected package,
         # all with `< NEXT VERSION` until the wave ships.
         entries = build_affected(
-            "apache-airflow-providers-elasticsearch < NEXT VERSION\n"
-            "apache-airflow-providers-opensearch < NEXT VERSION",
+            "apache-example-project-foo < NEXT VERSION\napache-example-project-kerfluffle < NEXT VERSION",
             **DEFAULT_AFFECTED_ARGS,
         )
         assert len(entries) == 2
         es, os_ = entries
-        assert es["packageName"] == "apache-airflow-providers-elasticsearch"
+        assert es["packageName"] == "apache-example-project-foo"
         assert es["versions"] == [
             {"status": "affected", "version": "0", "versionType": "semver"},
         ]
-        assert os_["packageName"] == "apache-airflow-providers-opensearch"
+        assert os_["packageName"] == "apache-example-project-kerfluffle"
         assert os_["versions"] == [
             {"status": "affected", "version": "0", "versionType": "semver"},
         ]
 
     def test_per_line_lower_bound_only(self):
-        # Lower-bound-only line (e.g. `apache-airflow-providers-smtp >=2.0.0`)
+        # Lower-bound-only line (e.g. `apache-example-project-xyz >=2.0.0`)
         # — useful when affected versions are known to start at X but the
         # fix-shipped version isn't known yet.
         entries = build_affected(
-            "apache-airflow-providers-smtp >=2.0.0",
+            "apache-example-project-xyz >=2.0.0",
             **DEFAULT_AFFECTED_ARGS,
         )
         assert len(entries) == 1
-        assert entries[0]["packageName"] == "apache-airflow-providers-smtp"
+        assert entries[0]["packageName"] == "apache-example-project-xyz"
         assert entries[0]["versions"] == [
             {"status": "affected", "version": "2.0.0", "versionType": "semver"},
         ]
 
     def test_per_line_lower_bound_with_next_version_upper(self):
         entries = build_affected(
-            "apache-airflow-providers-smtp >= 2.0.0, < NEXT VERSION",
+            "apache-example-project-xyz >= 2.0.0, < NEXT VERSION",
             **DEFAULT_AFFECTED_ARGS,
         )
         assert len(entries) == 1
@@ -568,7 +558,7 @@ class TestBuildReferences:
             mailing_list_field="",
             pr_field="",
             extra_urls=[
-                "https://github.com/airflow-s/airflow-s/issues/256",
+                "https://github.com/apache-example-s/apache-example-s/issues/256",
                 "https://cveprocess.apache.org/cve5/CVE-2026-40948",
                 "https://github.com/apache/airflow/pull/64114",
             ],
@@ -584,7 +574,7 @@ class TestBuildReferences:
 
 class TestResolveTitle:
     def test_strips_apache_airflow_prefix_from_issue_title(self):
-        assert resolve_title("Apache Airflow: DAG auth bypass", "", None) == "DAG auth bypass"
+        assert resolve_title("Apache Example: DAG auth bypass", "", None) == "DAG auth bypass"
 
     def test_override_wins(self):
         assert resolve_title("from-issue", "summary", "my override") == "my override"
@@ -606,8 +596,8 @@ def _ready_cna() -> dict:
         "affected": build_affected(
             ">=3.0.0, <3.2.0",
             vendor="Apache Software Foundation",
-            product="Apache Airflow",
-            package_name="apache-airflow",
+            product="Apache Example",
+            package_name="apache-example",
             collection_url="https://pypi.python.org",
             version_start=None,
         ),
@@ -711,8 +701,8 @@ class TestWrapCveRecord:
 
     def test_envelope_carries_cna_private_block(self):
         record = wrap_cve_record(_ready_cna(), cve_id="CVE-2026-00001", org_id="org")
-        assert record["CNA_private"]["owner"] == "airflow"
-        assert record["CNA_private"]["userslist"] == "users@airflow.apache.org"
+        assert record["CNA_private"]["owner"] == "example"
+        assert record["CNA_private"]["userslist"] == "users@example.apache.org"
 
 
 # ---------------------------------------------------------------------------
@@ -747,27 +737,27 @@ class TestComputeCnaPrivateState:
 class TestComputePackageUrl:
     def test_pypi_python_org_returns_canonical_pypi_project_url(self):
         assert (
-            compute_package_url("https://pypi.python.org", "apache-airflow")
-            == "https://pypi.org/project/apache-airflow/"
+            compute_package_url("https://pypi.python.org", "apache-example")
+            == "https://pypi.org/project/apache-example/"
         )
 
     def test_pypi_org_alias_also_supported(self):
         assert (
-            compute_package_url("https://pypi.org", "apache-airflow")
-            == "https://pypi.org/project/apache-airflow/"
+            compute_package_url("https://pypi.org", "apache-example")
+            == "https://pypi.org/project/apache-example/"
         )
 
     def test_trailing_slash_is_tolerated(self):
         assert (
-            compute_package_url("https://pypi.python.org/", "apache-airflow-providers-elasticsearch")
-            == "https://pypi.org/project/apache-airflow-providers-elasticsearch/"
+            compute_package_url("https://pypi.python.org/", "apache-example-project-foo")
+            == "https://pypi.org/project/apache-example-project-foo/"
         )
 
     def test_unknown_collection_url_returns_none(self):
-        assert compute_package_url("https://airflow.apache.org/", "apache-airflow-helm-chart") is None
+        assert compute_package_url("https://airflow.apache.org/", "apache-example-helm-chart") is None
 
     def test_empty_inputs_return_none(self):
-        assert compute_package_url("", "apache-airflow") is None
+        assert compute_package_url("", "apache-example") is None
         assert compute_package_url("https://pypi.python.org", "") is None
 
 
@@ -835,7 +825,7 @@ class TestBuildAttachmentBody:
         assert "| CVE ID | `CVE-2026-00001` |" in body
         assert "| Title | Example vulnerability |" in body
         assert "| Vulnogram state | `REVIEW` |" in body
-        assert "| Affected packages | `apache-airflow` |" in body
+        assert "| Affected packages | `apache-example` |" in body
         assert "| Size | 8 bytes |" in body
 
     def test_no_envelope_renders_state_placeholder(self):
@@ -857,17 +847,17 @@ class TestBuildAttachmentBody:
         assert "**Packages this JSON covers:**" in body
         assert "| # | Package | Product | Versions | PyPI URL |" in body
         assert (
-            "| 1 | `apache-airflow` | Apache Airflow | `>= 3.0.0, < 3.2.0` | <https://pypi.org/project/apache-airflow/> |"
+            "| 1 | `apache-example` | Apache Example | `>= 3.0.0, < 3.2.0` | <https://pypi.org/project/apache-example/> |"
             in body
         )
 
     def test_per_package_table_renders_one_row_per_affected_entry(self):
         cna = _ready_cna_for_attachment()
         cna["affected"] = build_affected(
-            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0",
+            "apache-example-project-foo <=6.5.0\napache-example-project-kerfluffle <=1.9.0",
             vendor="Apache Software Foundation",
-            product="Apache Airflow",
-            package_name="apache-airflow",
+            product="Apache Example",
+            package_name="apache-example",
             collection_url="https://pypi.python.org",
             version_start=None,
         )
@@ -878,16 +868,15 @@ class TestBuildAttachmentBody:
             cna_private_state="REVIEW",
         )
         assert (
-            "| 1 | `apache-airflow-providers-elasticsearch` | Apache Airflow Providers Elasticsearch "
-            "| `<= 6.5.0` | <https://pypi.org/project/apache-airflow-providers-elasticsearch/> |"
+            "| 1 | `apache-example-project-foo` | Apache Example Project Foo "
+            "| `<= 6.5.0` | <https://pypi.org/project/apache-example-project-foo/> |"
         ) in body
         assert (
-            "| 2 | `apache-airflow-providers-opensearch` | Apache Airflow Providers OpenSearch "
-            "| `<= 1.9.0` | <https://pypi.org/project/apache-airflow-providers-opensearch/> |"
+            "| 2 | `apache-example-project-kerfluffle` | Apache Example Project Kerfluffle "
+            "| `<= 1.9.0` | <https://pypi.org/project/apache-example-project-kerfluffle/> |"
         ) in body
         assert (
-            "| Affected packages | `apache-airflow-providers-elasticsearch`, "
-            "`apache-airflow-providers-opensearch` |"
+            "| Affected packages | `apache-example-project-foo`, `apache-example-project-kerfluffle` |"
         ) in body
 
     def test_unknown_collection_url_renders_dash_in_url_column(self):
@@ -895,8 +884,8 @@ class TestBuildAttachmentBody:
         cna["affected"] = [
             {
                 "vendor": "Apache Software Foundation",
-                "product": "Apache Airflow Helm Chart",
-                "packageName": "apache-airflow-helm-chart",
+                "product": "Apache Example Helm Chart",
+                "packageName": "apache-example-helm-chart",
                 "collectionURL": "https://airflow.apache.org/",
                 "versions": [
                     {"version": "0", "lessThan": "1.18.0", "status": "affected", "versionType": "semver"},
@@ -910,7 +899,7 @@ class TestBuildAttachmentBody:
             cna=cna,
             cna_private_state="REVIEW",
         )
-        assert "| 1 | `apache-airflow-helm-chart` | Apache Airflow Helm Chart | `< 1.18.0` | — |" in body
+        assert "| 1 | `apache-example-helm-chart` | Apache Example Helm Chart | `< 1.18.0` | — |" in body
 
     def test_pipe_in_title_is_escaped(self):
         cna = _ready_cna_for_attachment()
diff --git a/uv.lock b/uv.lock
index 4b0eda0..f5a819d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -10,7 +10,7 @@ exclude-newer-span = "P7D"
 uv = { timestamp = "0001-01-01T00:00:00Z", span = "P1D" }
 
 [[package]]
-name = "apache-airflow-steward"
+name = "apache-steward"
 version = "0.0.0"
 source = { virtual = "." }