# Changelog

All notable changes to BW AI Schema Pro are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [2.6.0] - 2026-07-22

### Added (Export / Import)

- **New page: Settings → AI Schema Pro → Export / Import.** Download this
  site's schema configuration as a JSON file, and restore one back —
  as a backup before a big change, to move settings between sites, or
  to keep the configuration alongside the project.
- **Export options.** Portable settings are exported by default;
  site-specific values (page mappings, team/room post-type wiring) and
  author profiles are separate opt-in checkboxes — the latter because
  they contain real people's names, emails and bios.
- **Automatic snapshot and one-click Undo.** Every import saves the
  previous configuration first, and Undo restores it. Undo also removes
  keys that did not exist in the snapshot, so it leaves no debris.

### Fixed

- **`Attorney` was not recognised as a local business.** It is offered in
  the Organization tab's own type picker, but
  `BW_Schema_Org_Builder::is_local_business_type()` never listed it, so
  every law-firm site was silently emitting no `geo`,
  `openingHoursSpecification` or `priceRange` — the same class of bug as
  the lodging subtypes fixed in 2.4.0. `Warehouse`, `HardwareStore` and
  `AutoPartsStore` (offered by the Locations module's business-type
  picker) were added at the same time.

### Notes

- **Safety model.** Import only writes option keys present in the
  module's tier map, so a crafted file cannot set `active_plugins` or any
  other WordPress option. Values are sanitized by shape, delegating to
  `BW_Schema_Location`'s own sanitizers for locations and service areas.
  The uploaded file is read straight from the PHP upload tmp path and
  never stored, so a config file never lands in `uploads/`.
- **The sectioned-settings mirror is reconciled after every import.**
  `bw_schema_settings['content']` duplicates four top-level options and
  `BW_Schema_Core::get_settings()` reads the mirror first, so a stale
  mirror would keep serving the old team CPT after an import.
  Reconciliation converges both ways rather than blindly copying
  top-level → mirror, which would have wiped the team CPT on a site where
  only the mirror was ever populated.
- One new option: `bw_schema_config_backup` (single undo slot). Belongs
  in the 3.0.0 rename inventory. Full details in `docs/SPEC-config-io.md`.

## [2.5.0] - 2026-07-22

### Added (Locations & Service Areas module)

- **Multiple real locations.** New Settings → Locations tab holds a
  repeater of branches. Each publishable entry is emitted as its own
  `LocalBusiness` node (subtype selectable — Store, HVACBusiness,
  Plumber, Warehouse, …) with its own address, geo, telephone, email and
  URL, linked to the parent company by `parentOrganization`; the parent
  gains matching `subOrganization[]` references. Full location nodes are
  emitted on the homepage, interior pages carry only the references.
- **Structured service areas.** "Areas served" was a single
  comma-separated text box emitting bare strings. It is now a repeater
  where each entry is a **City**, **Province/State**, **Country**,
  **AdministrativeArea**, a **radius from a point** (a real `GeoCircle`
  with `geoMidpoint` + `geoRadius`), or free text. Available both
  company-wide and per location — a branch can serve different areas
  than its parent.
- **Radius units.** The admin accepts kilometres, miles, or metres and
  converts to metres on output, because `geoRadius` is metres unless a
  `Distance` object is supplied. An incomplete radius (no centre point or
  no distance) is **dropped rather than published half-built**.
- **Canada / US / international addresses.** `addressCountry` is emitted
  as an ISO 3166-1 alpha-2 code. The region field relabels itself
  "Province" for Canada and "State" for the US, stays neutral elsewhere,
  and offers a datalist of all Canadian provinces/territories and US
  states; the postal-code placeholder follows the country. Any
  well-formed two-letter country code is accepted, not just the listed
  ones.
- **Hybrid storefront + service-radius sites** are supported and emit
  both, with an admin note explaining the mixed storefront/service-area
  signal so the client can decide. This is community guidance, not
  Google policy, so it is a note rather than an enforced rule.
- **Per-location "uses the main opening hours" checkbox**, which copies
  the Organization's `openingHoursSpecification` onto that branch.
- Each location row carries an **immutable id**, so its `@id`
  (`<home>/#location-<id>`) survives renaming, reordering, and URL
  changes.

### Fixed

- **`addressCountry` no longer defaults to `'US'`.** The per-post
  LocalBusiness path did `$address['country'] ?? 'US'`, so every site
  that left the country blank published itself as American — actively
  wrong data for the Canadian client base, not merely incomplete. The
  country (and every other address sub-property) is now omitted when
  unset rather than defaulted or emitted as an empty string.
- **`areaServed` no longer emits structurally invalid `GeoCircle`s.**
  The same path wrapped every service area as
  `{"@type": "GeoCircle", "name": "Calgary"}` — a GeoCircle is defined by
  a `geoMidpoint` and a `geoRadius`, so one carrying only a name says
  nothing. All service areas now route through the shared builder.

### Changed

- The Organization tab's free-text "Areas served" box is replaced by a
  pointer to the Locations tab, so the property has one source of truth.
  Existing values are shown there read-only and are **migrated
  automatically** the first time the Locations tab is saved:
  `BW_Schema_Location::get_service_areas()` falls back to the legacy
  `bw_schema_organization['areaServed']` strings until then, emitting
  them as valid `Text`. No existing site loses data.

### Notes

- Two new options: `bw_schema_locations` and `bw_schema_service_areas`.
  Both belong in the 3.0.0 rename inventory.
- `department` is deliberately **not** used for branches — Google
  documents it for departments *inside* one store ("gMart Pharmacy").
  `branchOf` is likewise avoided; schema.org superseded it with
  `parentOrganization`.
- Per-branch opening hours beyond the "same as main" checkbox are the
  top deferred item. See `docs/SPEC-locations.md`.

## [2.4.0] - 2026-07-22

### Added (Accommodations / Rooms module)

- **Room-level schema for lodging sites.** New module for resort, hotel,
  and villa-rental clients: nominate a post type as "rooms" in
  Settings → Rooms and each room page emits a proper `HotelRoom`,
  `Suite`, `House`, `Apartment`, `CampingPitch`, or `Room` node instead
  of falling through to the generic Article default. Previously the
  plugin could describe the *property* (`localbusiness:Resort`) but had
  no room-level type at all.
- **Per-room meta box, "Accommodation Schema Settings."** Uses the same
  detect-then-override idiom as the team-member box — each core field
  shows its auto-detected value and source with an override beneath.
  Fields: room type, capacity (`occupancy`), bed configuration repeater
  (`bed[]` → `BedDetails`), layout (bedrooms / rooms / full + half
  bathrooms / floor size + unit / floor level), amenities repeater
  (`amenityFeature[]`), policies (`petsAllowed`, `smokingAllowed`,
  `permittedUsage`), rates, floor plan (`accommodationFloorPlan`),
  `tourBookingPage`, and a room-tour video (`subjectOf`).
- **Graph wiring.** A room's `@id` is its permalink + `#room`, and its
  `containedInPlace` references the site Organization by `@id`. The
  Organization node gains `containsPlace[]` with a compact reference per
  published room — the full description stays on the room's own page
  under the same `@id`, so the graph is connected without duplicating
  every field onto the homepage.
- **Rates published as a starting price, with a staleness guard.** Each
  room's rate becomes an `Offer` on the property's `makesOffer[]`, with
  `itemOffered` referencing the room's `@id` — `offers` is not a valid
  property of `Accommodation`, and a top-level Offer node would be an
  unreferenced second root of the graph. The Offer carries a
  `UnitPriceSpecification` with `minPrice`
  and a `unitCode` of DAY / WK / MON — a floor, not an assertion of the
  price a guest will pay. Each
  rate carries a "last confirmed" date; once it passes the configurable
  window (default 180 days) the price is **withheld from the output**
  and the room list warns about it. A wrong price in structured data is
  worse than no price, and seasonal resort rates go stale silently.
- **Settings → Rooms tab.** Room post-type picker, default currency,
  default rate period, staleness window, and a fleet table showing each
  room's type, starting rate, rate age, and exclude-from-schema flag.
- **Per-room exclude flag.** Keeps a page visible while dropping it from
  both its own schema and the property's `containsPlace[]` — for
  placeholder or "coming soon" entries.
- Competing SEO plugins (Yoast, Rank Math, AIOSEO, SEOPress) are
  suppressed on single-room pages, matching team-member profile pages.

### Fixed

- **Lodging org types no longer lose their local-business fields.**
  `BW_Schema_Org_Builder::is_local_business_type()` listed
  `LodgingBusiness` but none of its subtypes, so a site whose
  Organization type was `Resort`, `Hotel`, `BedAndBreakfast`, `Motel`,
  `Hostel`, `Campground`, or `VacationRental` silently emitted no `geo`,
  `openingHoursSpecification`, or `priceRange`. All seven are now
  recognised.

- **Room nodes carry no `datePublished` / `dateModified`.** Those are
  `CreativeWork` properties and are invalid on an `Accommodation`
  (which descends from `Place`). Caught by validator.schema.org during
  pre-release verification, along with the `offers` placement above.

### Notes

- New meta keys are namespaced `_bw_schema_room_*` so a room post can
  never collide with the LocalBusiness path's `_bw_schema_amenities` or
  the Person module's `_bw_schema_awards` / `_bw_schema_credentials`.
  Two new options: `bw_schema_room_post_type` and the module-defaults
  array `bw_schema_accommodation`. Both belong in the 3.0.0 rename
  inventory (`docs/ARCHITECTURE.md` → Data Storage).
- Google's lodging rich results (`VacationRental`, hotel price cards)
  require Hotel Center participation and a feed; markup alone will not
  produce a property card. This module targets AI/LLM comprehension and
  leaves the site feed-ready. See `docs/SPEC-accommodations.md`.

## [2.3.1] - 2026-06-05

### Added (Tabbed settings page)
- **Settings page now mirrors the Setup Wizard.** Inner-tab navigation
  surfaces every wizard setting on a single evergreen page: Organization
  / People / Contact / Pages / Content / Features / Advanced. The wizard
  is still recommended for first-time setup, but admins no longer have
  to re-run it to adjust a single field. Switching tabs is JS-only (no
  page reload) and a `#tab=` hash remembers the active section.
- **People tab.** Maps the team-member post type, surfaces every team
  CPT row as a table with Is Author / Is Founder / Exclude from schema
  toggles and a Linked WordPress User selector — same fields as wizard
  step 2.
- **Contact tab.** Editable postal address + a 3-row contactPoint[]
  table (extra rows appear automatically as you fill them).
- **Pages tab.** Page mappings for About / Services / Contact / Blog /
  Team / Terms / Privacy — same canonical keys as the wizard.
- **Content tab.** Per-post-type schema-type defaults including the
  new "— Don't emit (handled elsewhere) —" option (see below). Auto-
  flags post types that another plugin or our own dedicated path
  already covers (team CPT → ProfilePage+Person; `job_listing` → WP
  Job Manager; `product` → WooCommerce).
- **Features tab.** Master toggles (schema / breadcrumbs / sitelinks
  search / cache), Publishing Principles + Fact-Checking URLs, "Take
  over from other schema plugins" (Yoast / RankMath / SEOPress / AIOSEO
  detection) plus the existing Post FAQ, Author Box, and Meta Box
  Display controls.
- **Advanced tab.** Wizard-gap fields that previously had no UI: VAT
  ID, LEI code, ISO 6523 code, NAICS, Tax ID, DUNS, foundingLocation,
  geo coordinates (lat / lng), and an openingHoursSpecification[]
  table with day-checkbox + time inputs per row.

### Added (Setup Wizard improvements)
- **"None / handled elsewhere" option in post-type-defaults.** The
  wizard's per-post-type schema select now includes an explicit "—
  Don't emit (handled by another plugin or our dedicated path) —"
  choice. Detects WP Job Manager (`job_listing`), WooCommerce
  (`product`), and the active team CPT, and shows a green confirmation
  note under the dropdown when "Don't emit" is the right pick.
- **Take-over-from-SEO-plugin default on first-run setup.** When the
  wizard is being completed for the first time AND a known SEO plugin
  (Yoast / RankMath / SEOPress / AIOSEO) is detected, the "Take over"
  checkbox is pre-checked. This avoids the `@id` collision documented
  below — both plugins emit a node with `@id = ".../#organization"`,
  and crawler merging is order-dependent. With the box checked, the
  SEO plugin's schema is fully suppressed and AI Schema Pro is the
  single source. Admins who want the older co-existence mode can
  uncheck the box.
- **Wizard step 6 copy rewritten.** "Schema Conflict Management"
  became "Take over from other schema plugins" with explanatory text
  that clarifies what changes when each box is checked.

### Fixed
- **Byline now falls back to the team-CPT Linked WordPress User.**
  `resolve_team_member_for_post()` in `class-bw-schema-author-override.php`
  used to consult only the per-post `_bw_schema_multiple_authors` override —
  if a site never wired up the per-post selection, the byline silently
  fell through to `/author/USERNAME/`, even when the team CPT post had a
  Linked WordPress User set. The author-box already had this fallback
  (`get_team_member_for_user()`); now the byline filter shares it. Posts
  authored by a WP user whose linked team page is published will have
  their `rel="author"`, `get_the_author_meta('url')`, and display-name
  byline rewritten to the staff page — no per-post setup needed.
  Verified on Red Seal: Kael's posts now byline-link to
  `/staff-member/kael-campbell/` and the author box renders his team-page
  featured image instead of a Gravatar mystery-man placeholder.
- **Setup-completion check now sees the v2.0 team-CPT flags.** The
  "Leadership / Founders" row in the dashboard checklist was driven
  by the legacy `bw_schema_founders` option that v2.0 stopped writing
  to. Sites running the modern flow (founders flagged via
  `_bw_schema_is_leader` post-meta on the team CPT) showed "Missing"
  despite having the data. The check now accepts either signal —
  legacy option OR at least one published team-CPT post with
  `_bw_schema_is_leader = 1`. A new sibling row tracks the same
  thing for authors (`_bw_schema_is_author`).
- **Renderer now honors the bare `none` value.** The new "Don't emit"
  option saves as the string `"none"` (no colon). The renderer's
  skip-this-post test recognized `'none:None'`, `'no_schema'`, and
  `'none:*'` — adding bare `'none'` so the new wizard / settings UI
  works end-to-end.
- **Founding date repopulates for year-only legacy values.** Older
  installs may have stored just the year (e.g. `"2005"`); the Settings
  page now normalizes to `YYYY-01-01` on read so the HTML5 date input
  isn't silently blank.

## [2.3.0] - 2026-06-05

### Added (Post FAQ module)
- **New module: Post FAQ.** Attach a question-and-answer list to any
  supported post type via a sortable metabox on the post-edit screen,
  rendered on the front-end as an inline FAQ section accompanied by
  FAQPage JSON-LD. Pattern adapted from the stackd-consulting child-theme
  implementation, generalized to work without TotalWP.
- **Configurable insertion point.** Settings dropdown chooses how the
  FAQ slots into the page:
  - **After the post content** — uses the `the_content` filter, works
    on virtually every theme. Default.
  - **Kadence: after content, before comments** — hooks
    `kadence_single_after_content` for tight slotting under the body.
  - **Kadence: after the author byline / title area** — hooks
    `kadence_single_after_entry_header`.
  - **Genesis: after the entry** (`genesis_after_entry`, priority 12).
  - **Astra: after the entry** (`astra_entry_after`).
  - **GeneratePress: after content** (`generate_after_content`).
  - **Custom hook** — text input for any action hook name on themes
    that aren't pre-supported.
  - **Shortcode only** — no auto-insertion. Use `[bw_schema_faq]`
    inside the post body to place manually.
- **Shortcode** `[bw_schema_faq]` (current post) or
  `[bw_schema_faq post_id="123"]`. Always available, even when
  auto-insertion is set to shortcode-only. Idempotent guard prevents
  double-rendering if both the shortcode and the auto-insert match.
- **Per-post overrides** in the metabox:
  - Heading (falls back to the global default)
  - Accordion mode (inherit / on / off)
  - Hide on this post (overrides the global enable)
- **Global settings** in Settings → AI Schema Pro → Settings:
  - Enable Post FAQ
  - Post types
  - Default heading
  - Default accordion behavior (one-open-at-a-time vs all-expanded)
  - Insertion point + custom-hook input
- **Minimal CSS.** Renders inline, uses `currentColor` and `em` units
  so it inherits typography and link color from the surrounding theme.
  No external stylesheet, no jQuery on the front-end — accordion is
  vanilla JS, idempotent, no dependencies.
- **Storage keys** (post meta): `_bw_schema_post_faq_items`,
  `_bw_schema_post_faq_heading`, `_bw_schema_post_faq_accordion`,
  `_bw_schema_post_faq_disable`. Settings option:
  `bw_schema_post_faq_settings`.
- Independent of the existing `BW_Schema_FAQ` schema class — that one
  generates FAQPage schema for users who manually set a post's schema
  type to FAQPage and write into `_bw_schema_faq_items`. The new module
  has its own data store and emits its own inline JSON-LD so a regular
  Article-schema'd post can carry an FAQPage block as a parallel,
  visible section without the two systems fighting.

### Added (Post FAQ — universal CSS-selector insertion + heading styles)
- **New insertion mode: "Insert at an HTML element (CSS selector)".**
  For themes and page builders that don't expose a clean action hook
  (Elementor, Bricks, Divi, WPBakery, etc., plus any theme we
  haven't seen yet). The admin enters a CSS selector and a position
  (after / before the element, or inside at the start/end). On the
  front-end, the FAQ is server-rendered inside a hidden `<div hidden>`
  container in the footer; a tiny vanilla-JS snippet locates the
  selector at DOMContentLoaded and moves the FAQ next to it via
  `insertAdjacentElement`. If the selector doesn't match, the FAQ
  stays hidden (better silent than wrong-place). The FAQPage JSON-LD
  is inside the hidden container too, but JSON-LD is DOM-parsed
  regardless of CSS display state so schema validators still see it.
- **Zero JS cost when not in selector mode.** The DOM-injection
  snippet is only emitted when "Insert at an HTML element" is the
  active insertion mode. Other modes (`the_content`, Kadence, etc.)
  emit no extra JS at all.
- **New "Heading color" and "Heading size" settings.** Both optional.
  Heading color is gated behind an "Override the theme color"
  checkbox so the default behavior (inherit from theme) is preserved
  unless the admin explicitly opts in. Heading size is a preset
  dropdown (Inherit / 0.9em / 1em / 1.1em / 1.25em / 1.5em / 2em).
  When set, both are emitted as inline `style="..."` on the section's
  H2 only.
- Reordered the insertion-mode dropdown so "Custom action hook" is
  clearly labeled as advanced and sits next to the new selector
  option. Renamed from "Custom hook" → "Custom action hook" to make
  its scope explicit (only `add_action` hooks; filters and shortcode
  interception use the CSS-selector path instead).

## [2.2.3] - 2026-06-03

### Fixed (description missing from rendered Organization schema)
- **Renderer now fills `description` (and `slogan`) onto a legacy
  `bw_schema_organization_override` JSON before returning it on the
  homepage path.** Previously the renderer at
  `class-bw-schema-renderer.php:220` did an early `return $decoded;`
  on the override, completely bypassing the code path that adds
  description from `bw_schema_org_description`. On any site where
  `bw_schema_organization_override` was populated by an older plugin
  version (or carried forward by the bwgeo → bw-plugins import), the
  description field saved via the Setup Wizard would never appear in
  the rendered JSON-LD — even though the wizard's textarea would
  show it (because the textarea reads from the saved option, which
  was correct; it's the renderer that was ignoring it). The override
  still wins per-field — the patch only fills fields the override
  doesn't already have. Closes the gap without requiring users to
  re-run setup (re-running Step 7 deletes the override entirely,
  which is the alternative if a site prefers a clean state).
- Verified on aismartventures.com: rendered Organization schema had
  `name`, `url`, `telephone`, `email`, `logo`, `sameAs`, `foundingDate`,
  `numberOfEmployees`, `award`, `hasCredential`, `areaServed`,
  `contactPoint`, `founder`, `employee` — but no `description`. The
  wizard's Step 1 textarea clearly showed a real org bio, and
  `bw_schema_org_description` was saved correctly. The early-return
  on the override path was the cause.

### Added (Description on the regular settings page)
- **Description textarea on Settings → AI Schema Pro → Settings →
  Advanced Organization Details.** Saves to the same
  `bw_schema_org_description` option the Setup Wizard's Step 1 writes,
  so admins can edit it without walking through the 7-step wizard. The
  wizard and the settings page stay in lockstep — neither overrides
  the other, both read/write the same option.

## [2.2.2] - 2026-06-01

### Added (Team Survey — inline setup UI)
- When no team post type is mapped yet, the Team Surveys page now
  shows a setup screen inline instead of pushing the admin off to
  the main AI Schema Pro settings page. The screen offers:
  - **Likely matches** — scored suggestions based on common team-CPT
    slugs and labels (`team`, `team_member`, `our-team`, `staff`,
    `people`, `employee`, `crew`, etc.). Each candidate shows the
    label, the slug, and the published post count so admins can
    confirm at a glance.
  - **Fallback dropdown** of all non-core public CPTs on the site,
    alphabetical. Overrides the radio choice if both are selected.
  - **"Connect this post type" button** — writes the chosen slug
    to the existing `bw_schema_team_post_type` option (the same
    one the main AI Schema Pro settings page writes to). Admin can
    still change it from the main settings page later.
- New **"Proceed without a team post type" button** with a warning
  notice explaining the tradeoff: submissions can still be
  collected and reviewed (they all wait in the holding queue),
  but the publish step has no destination until a CPT is
  connected. Sets a sticky `bw_schema_survey_proceed_without_team_cpt`
  option so the workflow unlocks.
- When the admin is in "proceed without" mode, a persistent yellow
  banner appears at the top of the Team Surveys queue with a
  "Connect one now" link that takes them back to the setup screen
  (via a nonced GET). Manual entry (`Add team info manually`)
  shows a friendlier "set up a team post type to use this" notice
  rather than a broken form.

### Added (internal)
- `BW_Schema_Survey::is_setup_complete()` — true when a team CPT is
  mapped OR proceed-without is set. Now used as the gate everywhere
  the workflow needed `BW_Schema_Team_Member::get_team_post_type()`,
  including `is_open()`.
- `BW_Schema_Survey::suggest_team_post_types()` /
  `all_eligible_post_types()` /
  `set_team_post_type()` /
  `set_proceed_without_team_cpt()` /
  `is_proceeding_without_cpt()` — public helpers for the setup UI
  and any future "fix it" actions elsewhere in the admin.

## [2.2.1] - 2026-06-01

### Added (Team Survey — reset controls for testing)
- New "Reset" section at the bottom of the Team Survey settings page
  with two actions, both gated by JS confirm dialogs:
  - **Delete all submissions** — empties the
    `bw_schema_survey_responses` table. Survey window, URL, and
    settings stay as-is. Use between test runs to re-submit without
    starting from scratch.
  - **Reset everything** — closes the window, deletes all
    submissions, rotates the URL token (invalidating the current
    public link immediately), and restores every Team Survey
    setting (slug, intro HTML, notification email + toggle, grant
    Editors toggle) to its default. Caps are re-synced so the
    Editor role's `bw_schema_moderate_team_surveys` cap reflects
    the reset state. Schema data already published onto team
    posts is intentionally not touched — admins can edit those
    posts directly to clear.
- New helper `BW_Schema_Survey_Store::truncate_responses()` for the
  single SQL DELETE used by both reset actions. Returns rows
  deleted so the success notice can show the count.

## [2.2.0] - 2026-05-28

### Fixed (recurring "wrong byline link / 404 author" bug on Kadence sites)
- **Byline link `href` is now correctly overridden server-side** when a
  post has a team-member author override (e.g. risealliance.com's
  /resource/ posts displayed by Robert DiNozzi).
  Previously: `the_author` filter overrode the *display name* only;
  the link `href` still pointed at the WP author archive
  `/author/{post_author}/`. A wp_footer JavaScript rewriter masked
  this by replacing the visible text after page load, so the byline
  *looked* right but the link still went to the wrong place — and
  bots / paste-the-URL users hit 404s. Now three filters work
  together server-side:
  - `get_the_author_display_name` -> overrides byline name
  - `get_the_author_user_url` -> overrides Kadence's preferred href
    source (`get_the_author_meta('url')`). This is the actual filter
    Kadence prefers — note WP renames the `url` field to `user_url`
    internally, so the filter is `get_the_author_user_url`, not
    `get_the_author_url`.
  - `author_link` -> overrides the byline href when themes use
    `get_author_posts_url()` instead (fallback)
  All three are scoped to: singular-post context, same author as the
  queried post. Comments, widgets, user-profile reads elsewhere are
  untouched. The `the_author` filter and the JS rewriter remain in
  place as belt-and-braces.
- **`/author/USERNAME/` redirect has a fallback now.** The
  `_bw_schema_linked_user` meta on the team page (the configuration
  pointer that historically drifts due to UpdraftPlus restores,
  "Rewrite & Republish," CSV reimports, etc.) is no longer the only
  way to resolve the redirect target. When that meta is empty, the
  plugin now looks at recent posts authored by the visited WP user
  for one with a team-member schema author override, and redirects
  to that team page. This rescues the common drift case without
  requiring the user to keep the meta synced manually.
- **Admin notice on the team CPT edit screen** when this team page
  is referenced as a schema author on at least one post but has no
  Linked WordPress User set. Loud yellow banner with copy that
  points at the field to fix. Prevents the bad state from being
  silent — even if the byline link works (via the new
  `author_link` filter), this still flags the config gap so the
  /author/X/ redirect stays solid.

### Added (manual admin intake — Path 1)
- **New admin page: Add team info manually.** Reachable from the
  "Add team info manually" button at the top of the Team Surveys
  queue, and from the empty-queue state. Renders the same survey
  questions in admin context, lets admin pick an existing team member
  or create a new draft, and on save creates a response with
  `status=structured` (skips moderator triage — admin is trusted) with
  the structured payload pre-filled by the deterministic raw→structured
  parser. Lands the admin on the Schema Review tab so they can refine
  before publishing.
- Empty-queue state now surfaces both intake paths (manual entry,
  public survey link) so first-time users know how to start.

### Fixed (rewrite registration — 404 on the survey link)
- **`register_rewrite()` is now called directly from
  `BW_Schema_Survey::init()` instead of being hooked onto `init` at the
  same priority as the caller.** `WP_Hook`'s priority foreach iterates
  an array copy of the bucket, so a same-priority callback added during
  iteration never fires on the current request — meaning the rule was
  missing from `extra_rules_top` on every request, and a follow-up
  `flush_rewrite_rules()` (from a permalink resave, etc.) would wipe
  the activation-time rule out of the DB-stored `rewrite_rules` option.
- **Added one-shot version-tracked auto-flush** via
  `BW_Schema_Survey::maybe_flush_rewrites()`, gated on the
  `bw_schema_survey_rewrite_version` option. Handles file-only deploys
  (no activation cycle) so the rule lands in the DB on the next request
  without needing the user to deactivate/reactivate or visit Settings →
  Permalinks. Subsequent requests are a single option lookup; the
  flush runs at most once per deploy.
- Settings handlers for slug change and token rotation now also
  re-register the rewrite, flush, and bump the version option in one
  shot — so URL rotation invalidates the old URL immediately.

### Added (Team Survey workflow — Phase 1)
- **New module: Team Survey workflow** for collecting team-member info
  in-WordPress instead of via Google Forms. Replaces the out-of-band
  intake process with a four-stage pipeline: public survey → moderator
  triage → schema-shaped review → publish to live Person-schema fields.
  See `docs/SPEC-team-survey.md` for the full spec.
- Public survey page at `/{slug}/{token}/` (default
  `/bw-team-survey/<token>/`), served via a plugin-owned rewrite rule.
  Robots-blocked (`<meta robots noindex>` + `X-Robots-Tag` header) so
  the rotatable token never lands in any index.
- Time-limited open window: admin opens the survey for up to 7 days at
  a time, with a "Renew" action for up to another 7 days. Survey is
  closed by default until explicitly opened.
- New DB table `{prefix}bw_schema_survey_responses` for staging
  responses (raw + structured payloads, status, moderator notes).
  Installed via dbDelta on activation/upgrade; schema version tracked
  in `bw_schema_survey_db_version`.
- New capability `bw_schema_moderate_team_surveys`. Granted to
  administrators by default; settings toggle to also grant to editors.
- Admin pages under the existing AI Schema Pro options menu: queue,
  per-submission detail (triage / schema review / publish tabs),
  settings (open/renew, regenerate URL, notifications, capability
  grant).
- Holding queue for "I'm not listed" submissions — moderator
  graduates them to a team CPT draft before they enter the workflow.
- Anti-abuse: honeypot field, 3-second time-trap, IP rate-limit
  (5/hour, same pattern as `BW_Schema_Security::check_rate_limit()`),
  WP nonce on the form. No CAPTCHA — moderation gating is the primary
  defense.
- Precondition gate: workflow is hidden / closed until a team post
  type is mapped in the existing AI Schema Pro settings.

### Notes
- AI features (mosiah-backed research and parse assists) are
  intentionally **out of scope for 2.2.0** — Phase 2 / 2.3.0+. The
  data model is AI-agnostic so Phase 2 layers on without schema
  changes. See `docs/SPEC-team-survey.md` § Phase 2 for the planned
  contract.
- All new code lives under the grandfathered `bw_schema_*` prefix per
  `CLAUDE.md` § Pre-existing-plugin exception. The 3.0.0 rename will
  migrate the new keys alongside the existing ones.

## [2.1.5] - 2026-05-27

### Fixed (performance)
- **CRITICAL: Removed `BW_Schema_Team_Member::remove_remaining_schemas()`** and its
  `add_action( 'wp_head', ..., 0 )` registration in
  `includes/class-bw-schema-team-member.php`. On team-member pages it ran every
  `wp_head` and iterated the entire `$wp_filter['wp_head']` table using fragile
  `strpos()` string-matching to remove "schema-looking" hook keys. Both expensive
  and dangerous (could match legitimate non-schema hooks). The job it was trying
  to do — disabling other plugins' schema output — is already handled correctly
  by `BW_Schema_Core::disable_conflicting_schema()`, which uses named filter
  hooks driven by the `bw_schema_disable_sources` setting. The
  `remove_all_actions( 'wp_head', 60 )` call in `disable_conflicting_schemas()`
  is retained — that's the Yoast-priority cleanup, not the catch-all iteration.

- **HIGH: Added transient fallback to `BW_Schema_Cache`** in
  `includes/class-bw-schema-cache.php` for hosts without a persistent object
  cache (Flywheel, most shared hosts). Previously `get`/`set`/`delete` used only
  `wp_cache_*`, which defaults to request-scoped on those hosts — meaning the
  cache was a no-op and schema regenerated on every page load. The class now
  routes through transients (DB-backed, persistent across requests) when
  `wp_using_ext_object_cache()` returns false; sites with a real object cache
  continue using `wp_cache_*`. `clear_all()` nukes the plugin's own transients
  via a prefix-scoped DELETE on the options table (no other plugin's data is
  touched). Cache key prefix is `bw_schema_` so all keys are namespaced.

### Changed (performance)
- **Consolidated 6 plugin-owned `init` hooks into one wrapper** in
  `bw-ai-schema-pro.php`. Previously each module (textdomain, Cache, Hooks,
  Security, Author_Override, Team_Member) registered its own
  `add_action( 'init', ... )`; WP walks `$wp_filter['init']` on every request,
  so cutting 6 entries down to 1 (named `init_default_priority_modules`) saves
  per-request work. **The priority-1 (`maybe_migrate_options`) and priority-5
  (`disable_conflicting_schema`) registrations remain separate** — they need
  their explicit priorities.

- **`BW_Schema_Renderer` is now a singleton** (via `get_instance()`). The
  renderer is stateless — `render()` reads context from globals/options/post
  meta each call — but was constructed fresh on every `wp_head` and every
  schema-preview AJAX request. Both call sites in `bw-ai-schema-pro.php` now
  reuse the shared instance.

### Security
- `maybe_redirect_to_setup()`: gate the `?skip_setup=1` self-completion of the setup
  wizard behind `current_user_can( 'manage_options' )`. Previously any logged-in user
  reaching wp-admin (e.g. a subscriber on their own profile page) could trip the
  "setup complete" flag. Impact was minor (only suppressed the wizard redirect for
  admins) but it was the single nonce/cap gap surfaced by the 2026-05-06 security
  audit. See `docs/SESSION-LOG.md` for the audit summary.

## [2.1.4] - 2026-05-06

### Changed
- **Update server moved.** Plugin now checks for updates from
  `https://plugins.bowden.works` (the bw-update-server in the bw-plugins framework)
  instead of the legacy `https://bwgeo.demoing.info/wp-content/uploads/plugin-updates/`.
  No functional change for site administrators — updates continue to arrive automatically;
  only the source server has changed.
- Plugin headers updated to declare `Requires at least: 6.0`, `Requires PHP: 7.4`, and
  `Update URI` per the BW plugins framework standard. Plugin URI and Author URI now
  point at `bowden.works` rather than the legacy `bowdenworks.com`.
- License field standardized to `GPL-2.0-or-later`.

### Added
- `BW_AI_SCHEMA_PRO_VERSION` constant (mirror of `BW_SCHEMA_VERSION`) so BW release
  tooling can introspect the version. Internal code continues to use `BW_SCHEMA_VERSION`.
- ABSPATH guard added to `includes/class-bw-schema-author-box.php`.
- Imported into the bw-plugins development framework. Source moved from
  `/srv/apps/bwgeo/wp-content/plugins/bw-ai-schema-pro/` to
  `/srv/apps/bw-plugins/wp-content/plugins/bw-ai-schema-pro/`. See `docs/ROADMAP.md`
  for the follow-up rename + prefix-conformance plan.

## [2.1.3] - pre-2026-05-06

History prior to bw-plugins import is not captured here. The plugin's prior changelog
(if any) lived in `/srv/apps/bwgeo/`. Future entries follow the Keep a Changelog format.
