# BW AI Schema Pro — Session Log

Append-only history of development sessions. Newest entries at the top.

## Format

```
## YYYY-MM-DD HH:MM — <dev name>

**Goal:** What we set out to do
**Done:** What got done
**Left off at:** Where we stopped
**Notes:** Anything worth remembering
```

---

## 2026-05-29 — rian + Claude (2.2.0 byline-bug fix + RELEASED)

**Goal:** rian reported the long-running "wrong author / 404 byline link"
bug on risealliance.com (a Kadence customer site). The byline displayed
"Robert DiNozzi" but the link went to `/author/jennie/` which 404'd. He
said this bug has been "fixed many times" but keeps coming back. Diagnose,
fix in code, ship, so he can test on the live site.

**Diagnosis (browser + code spelunking):**

Three independent failures stacked on top of each other:

1. **Server-rendered byline `href` was never overridden.** The plugin
   only hooked `the_author` (display name). The dead `filter_author_link`
   method existed at line 456 of `class-bw-schema-author-override.php`
   but was never `add_filter`'d anywhere.
2. **JS rewriter masked the bug.** `output_author_js()` on wp_footer
   rewrote the visible vcard text from "jennie" to "Robert DiNozzi"
   after page load, so a human saw the right name but the link still
   pointed at the wrong place. Bots / direct-URL visitors / curl saw
   the unfixed state.
3. **The `/author/X/` -> team page 301 redirect requires a fragile
   pointer.** `_bw_schema_linked_user` on the team CPT post — which can
   be wiped by UpdraftPlus restores, "Rewrite & Republish," etc. On
   risealliance Robert's team page had Linked WordPress User = "(No
   linked user)", so the redirect didn't fire either, and `/author/jennie/`
   rendered an empty author archive.

The reason rian's working comparison site (aismartventures.com) showed
the right URL is unrelated to the plugin code path — the WP user's
`user_nicename` there happens to match the team CPT slug pattern, so
`get_author_posts_url()` returns something compatible. Fragile by
accident there too.

**Fix:**

- New filter `BW_Schema_Author_Override::filter_author_user_url` on
  `get_the_author_user_url`. Kadence prefers
  `get_the_author_meta('url')` over `get_author_posts_url()`; rewriting
  this is the actual fix for Kadence. (Confused 15 minutes by hooking
  `get_the_author_url` instead — WP internally renames the `url` field
  to `user_url` inside `get_the_author_meta()`, so the filter name is
  `get_the_author_user_url`. Documented inline.)
- New filter `BW_Schema_Author_Override::filter_author_link_url` on
  `author_link`. Fallback for themes that use `get_author_posts_url()`
  for the byline rather than user_url.
- New filter `BW_Schema_Author_Override::filter_author_display_name`
  on `get_the_author_display_name`. The `the_author` filter doesn't
  cover Kadence's `get_the_author_meta('display_name')` call; this does.
- All three scoped to: `is_singular() === true` + filtered author_id
  matches queried post's post_author + post has a team-member override
  resolved via the new `resolve_team_member_for_post()` helper. Comments,
  widgets, archive pages, admin screens untouched.
- New helper `resolve_team_member_for_post( $post_id )` — single source
  of truth for "does this post have a team-member author and what's the
  target?". Validates team post exists + is published + has a permalink.
- `maybe_redirect_author_archive()` got a fallback: when
  `_bw_schema_linked_user` is empty on every team post, look at recent
  posts by the visited WP user that have a team-member schema override
  and redirect to that team page. Rescues the common drift case without
  requiring users to keep the meta synced.
- New 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. Counts referencing posts via a coarse SQL LIKE on serialized
  `_bw_schema_multiple_authors` meta then verifies each in PHP.

**Tested end-to-end on bw-plugins.demoing.info:**

- Created test post (id 120, post_author=support, team_member override
  pointing at Priya Sharma's team page id 24).
- Byline server-renders as
  `<a class="url fn n" href="https://bw-plugins.demoing.info/team/priya-sharma/">Priya Sharma</a>`.
  Both text + href correct, no JS rewrite needed.
- A second byline on the same page (Hello World post, no override)
  correctly passes through and shows the original user_url + display_name.
- `/author/support/` 301-redirects to `/team/priya-sharma/` via the new
  recent-posts fallback (the dev site team posts have no
  `_bw_schema_linked_user` set, so this confirms the fallback works).

**Got a few things wrong en route:**

- Initial comment block in `init()` included `?>` inside a `//` comment,
  which broke PHP parsing because PHP's lexer scans for `?>` even inside
  comments. Fatal error, plugin wouldn't load. Rewrote the comment.
- First version hooked `get_the_author_url` (wrong filter name — see above).
- Diag scaffolding accidentally left in `bw-ai-schema-pro.php` after
  testing. Cleaned up before scans / release.
- A backtick-in-comment (`get_author_posts_url($author_id)`) triggered
  security-scan's "backtick exec" detector. Rewrote without backticks.

**Released:**

- `tools/release.sh bw-ai-schema-pro 2.2.0` ran clean.
- Manifest at plugins.bowden.works/wp-json/bw/v1/update-check?slug=bw-ai-schema-pro
  serves 2.2.0.
- Customer sites pick up on next update poll (most ping daily). rian
  will test on risealliance.com.

**Notes:**

- 2.2.0 is the first release containing the new Team Survey workflow
  (Phase 1) AND the byline fix AND the rewrite-flush fix AND the
  admin manual intake. The Team Survey feature is gated off-by-default
  (master toggle, plus public form is closed unless explicitly opened),
  so existing sites see only the byline fix's user-visible effect.
- The JS rewriter (`output_author_js`) is kept for now as a safety net
  for stubborn themes; it's redundant for Kadence given the server-side
  filters but harmless. Can be revisited later if anything fights it.

---

## 2026-05-28 (follow-up) — rian + Claude (2.2.0 bug fixes, still STAGED)

**Goal:** Rian tried using the new Team Surveys feature and hit two
problems: the public survey URL returned 404, and there was no
admin-side way to manually enter team info (Path 1 from the original
spec — admin populates directly).

**Root cause of the 404 (rewrite registration):**

`BW_Schema_Survey::init()` was registering `register_rewrite` to the
`init` hook from inside an existing `init` callback at the same
priority (10). `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. Result: `register_rewrite` literally
never ran — the survey rewrite rule was never added to
`$wp_rewrite->extra_rules_top`. The activation hook still wrote the
rule to the DB-stored `rewrite_rules` option because it called
`register_rewrite()` directly (not via add_action), but any later
`flush_rewrite_rules()` (e.g. a permalink resave) regenerated the
option from in-memory and the rule was wiped — leaving the URL 404'd.

Diagnosed by walking the rewrite_rules option contents, dumping
`extra_rules_top` after manual + hooked invocations of
`register_rewrite()`, and noting the count only changed on direct
invocation.

**Fix:**

- `BW_Schema_Survey::init()` now calls `self::register_rewrite()` and
  `self::maybe_flush_rewrites()` directly — no more `add_action(
  'init', ... )` from inside an init callback.
- New `maybe_flush_rewrites()` does a one-shot version-gated flush
  via `bw_schema_survey_rewrite_version` option. Handles file-only
  deploys (no activation cycle), permalink-resave wipes, and any
  other case where the DB-stored rules lose our rule. After the
  flush, the option matches and the check becomes a single
  `get_option()` per request.
- `BW_Schema_Survey::activate()` now also sets the rewrite-version
  option after its own flush, so the next request's
  `maybe_flush_rewrites()` is a no-op.
- The settings-page slug-change and token-rotation paths now
  re-register + flush + bump the version option in one shot — so
  rotation invalidates the old URL immediately.

**Verified on the dev site:** deleted the rule from the
`rewrite_rules` option, deleted the version option, hit the URL with
a fresh curl, got 200 OK + form. Confirmed
`bw_schema_survey_rewrite_version` was set to `2.2.0` and the
`rewrite_rules` option count went from 196 → 197.

**Manual admin intake (Path 1) — what got built:**

The original request had three intake paths: admin populates
directly, send survey link, AI research. Only #2 made it into the
first cut of 2.2.0 — #1 was missed. Now added.

- New hidden submenu `bw-ai-schema-survey-add` with
  `BW_Schema_Survey_Admin::render_add_page()` + `handle_add_submit()`.
- View: `admin/views/survey-add.php`. Same survey questions as the
  public form, but rendered in WP-admin styling with the team-member
  picker dropdown ("Choose existing" or "Add a new team member —
  creates a draft").
- Submit path:
  - Skips moderator triage (admin is trusted) — creates the response
    at `status=structured` instead of `new`.
  - Pre-fills `structured_payload` via
    `BW_Schema_Survey_Publisher::default_structured_from_raw()` so
    the schema-review screen lands fully populated.
  - Redirects to the Schema Review tab so the admin can refine +
    approve + publish in two more clicks.
- "Add team info manually" button at the top of the queue page (next
  to Settings).
- Empty-queue state now surfaces both paths — manual entry button +
  the survey link or an "Open the public survey" button depending on
  whether the survey is open.

**Verified by simulating the admin submit:** posted a form payload
with prose expertise + multi-line social URLs to the handler, got a
redirect to the Schema Review tab, confirmed the new response had
`status=structured` and `structured_payload` containing correctly
parsed `knowsAbout` array, `sameAs` URL array, integer
`yearsOfExperience`. Cleaned the test response up after.

**Scans (re-run after changes):**

- `cleanup-scan`: clean.
- `security-scan`: clean (185 warning(s)) — 182 from prior baseline
  + 3 new in survey-add.php's POST reads, all inside the nonce-gated
  `handle_add_submit()` that sanitizes every value (same house style
  the 2026-05-06 audit validated).
- `test-plugin`: passed (version 2.2.0).

**Files added in this follow-up:**

- `admin/views/survey-add.php`

**Files modified:**

- `includes/class-bw-schema-survey.php` — direct register_rewrite()
  in init(), new `maybe_flush_rewrites()`, REWRITE_VERSION constant.
- `includes/class-bw-schema-survey-admin.php` — PAGE_ADD,
  NONCE_ADD, `render_add_page()`, `handle_add_submit()`,
  `sanitize_answer_for_admin()`, `get_team_member_choices()`,
  `url_add()`, plus rewrite-version bumps in the settings handler.
- `admin/views/survey-queue.php` — "Add team info manually" button
  next to Settings, empty-queue CTAs.
- `CHANGELOG.md` — added to the 2.2.0 block (still unreleased).

**Left off at:** STILL staged, still not released. Both fixes are
clean. Next decision is rian's: when ready, `tools/release.sh
bw-ai-schema-pro 2.2.0`.

**Notes:**

- The rewrite registration bug is a useful gotcha. The pattern
  "load a module from an init callback, and have the module register
  more init hooks" looks fine but is silently broken at the same
  priority. If we ever need to register more init hooks from a
  module, register them at a DIFFERENT priority than the caller
  (e.g. `add_action( 'init', ..., 11 )` from a priority-10 callback)
  — those WILL fire.
- The "skip triage for admin entry" decision keeps the workflow
  symmetric (still a Response row, still goes through schema-review
  + publish) while removing the only step that doesn't make sense
  when the admin is the source of the data. Could revisit if
  delegating to non-admin moderators creates a use case for admin
  data also passing through triage.

---

## 2026-05-28 — rian + Claude (2.2.0 — Team Survey workflow, Phase 1, STAGED not released)

**Goal:** Replace the Google-Forms-based team-member info intake with an
in-WordPress workflow. Spec'd Phase 1 (no AI) + Phase 2 (AI on mosiah,
out of scope for 2.2.0) up front. Rian approved the design with two
late additions: time-limited open window (max 7 days, renewable) and
robots-blocking on the public survey page.

**Done:**

- **Spec written:** `docs/SPEC-team-survey.md`. Full data model,
  lifecycle, capability, public URL, anti-abuse, file layout, and a
  Phase 2 sketch (mosiah API contract, jobs table) so the data model
  doesn't change between phases.
- **New module across 5 PHP classes:**
  - `class-bw-schema-survey-store.php` — table CRUD + dbDelta-driven
    install/upgrade. Schema version in `bw_schema_survey_db_version`.
  - `class-bw-schema-survey.php` — capability registration
    (`bw_schema_moderate_team_surveys`), rewrite rule, settings
    helpers, time-window helpers (`open_for_days`, `is_open`,
    `days_remaining`), token rotation, canonical survey-question
    definition, and the `field_map()` source-of-truth for
    schema_field → post_meta key on publish.
  - `class-bw-schema-survey-public.php` — public form intake at
    `template_redirect`. Robots header on every response, honeypot,
    HMAC-signed time-trap, IP rate-limit (5/hr), nonce-checked. Renders
    its own minimal HTML shell — does not load the active theme.
  - `class-bw-schema-survey-publisher.php` — copies
    `structured_payload` to live team-CPT post_meta. `default_structured_from_raw()`
    deterministically parses prose into list/url-list/int/string types
    for the Stage-3 first-visit defaults. Triggers cache clear +
    `bw_schema_survey_published` action.
  - `class-bw-schema-survey-admin.php` — three hidden submenu pages
    (queue, detail, settings). Detail page has triage / schema-review /
    publish tabs. State-changing handlers all nonce + cap gated.
- **Admin views:** `admin/views/survey-queue.php`, `survey-detail.php`,
  `survey-settings.php`, `survey-public-form.php`,
  `survey-missing-team-cpt.php`. Phase 1 has no separate admin JS — the
  queue checkbox toggle is inline, the rest is server-rendered.
- **Public CSS:** `assets/survey-public.css` — themes the
  no-theme survey shell.
- **Bootstrap wired:** `bw-ai-schema-pro.php` loads + inits the 5
  new classes (Admin only in `is_admin()`); activation hook calls
  `BW_Schema_Survey::activate()` which installs the table, grants the
  cap, sets default options, and flushes rewrites; deactivation
  flushes rewrites.
- **Dashboard navigation:** added a "Team Surveys" tab to
  `admin/views/dashboard.php`, gated on the new capability.
- **Version bumped 2.1.5 → 2.2.0** via `tools/bump-version.sh` — minor
  bump per umbrella rules (this is a new module, not an iteration on
  existing work). All four headers in sync; tools/bump-version.sh
  rewrote the CHANGELOG `[Unreleased]` block into `[2.2.0] - 2026-05-28`.

**Smoke-tested end-to-end on the local bw-plugins demoing site:**

- Plugin activates without fatals.
- Table `wp_bw_schema_survey_responses` created via dbDelta.
- All `bw_schema_survey_*` options initialized; capability granted to admin.
- Master toggle off (closed) by default — as designed.
- `BW_Schema_Survey::open_for_days(1)` opens the window; `is_open()` returns true.
- `curl` to the survey URL returns 200 OK with:
  - `<meta name="robots" content="noindex, nofollow, noarchive">`
  - `X-Robots-Tag: noindex, nofollow, noarchive` header
  - Form HTML with nonce, HMAC-signed timing token, honeypot field,
    team-member picker, all question fields rendered.
- `BW_Schema_Survey::close()` closes the window; same URL then returns
  the "Survey closed" page (still 200, still robots-blocked).
- Invalid token returns the same "no longer valid" page shape (still
  200, still robots-blocked) — no info leak about why.
- The bw-plugins site's mapped team CPT is `team` (Kadence-child test
  fixture); the holding-queue / picker logic works against it.

**Scans:**

- `cleanup-scan`: clean.
- `security-scan`: clean (182 warning(s)) — 140 pre-existing
  (audited 2026-05-06, all SAFE) + ~42 new in survey-admin.php.
  The new ones are all in `maybe_handle_*` methods which gate on cap +
  nonce and sanitize every value, matching the plugin's established
  pattern that the May 2026 audit validated. Not allowlisting them —
  per prior session-log policy, the allowlist is more trouble than
  it's worth since line numbers shift on edits.
- `test-plugin`: passed (version 2.2.0). The "Missing ABSPATH guard"
  warnings are advisory and pre-existing — the regex looks for the
  short-circuit form `defined() || exit;` but this plugin
  consistently uses the if-form `if ( ! defined() ) { exit; }`. My
  new files match the plugin's style.

**Left off at:** **Staged**, not released. `tools/release.sh` was NOT
run. Per umbrella + plugin-specific policy ("never release without an
explicit 'release it' from rian for THIS version"), this needs rian
to greenlight the 2.2.0 release. The plugin is on ~8 production
customer sites and a release ships to all of them on next update poll.

**When rian says release it:**

1. Verify scans still clean on the staged 2.2.0 source.
2. Spot-check: read SPEC-team-survey.md, CHANGELOG [2.2.0] block.
3. Run `tools/release.sh bw-ai-schema-pro 2.2.0`.
4. Confirm `plugins.bowden.works/wp-json/bw/v1/update-check?slug=bw-ai-schema-pro`
   serves 2.2.0 in the manifest.
5. Cap-check survey URL on demoing site survives the update poll (or
   verify on rian's own dev site).

**Notes:**

- Phase 2 (AI on mosiah) deferred to 2.3.0+ by design. The data model
  is AI-agnostic so layering AI on top will not require any schema
  changes — see SPEC § Phase 2 for the mosiah API contract sketch.
- The plugin's grandfathered `bw_schema_*` prefix applies to all new
  options/post_meta/transient/table-name keys introduced here. The
  3.0.0 rename will migrate the new keys alongside everything else.
- Anti-abuse design is moderation-first: nothing reaches the live
  schema until a moderator clicks "Approve" then "Publish". Honeypot
  + 3s time-trap + 5/hr IP rate-limit are belt-and-braces. No CAPTCHA
  in Phase 1 (intentional — adds friction and the gating mostly makes
  it unnecessary). If spam becomes real, Cloudflare Turnstile is a
  cheap patch.
- The public survey page renders its own HTML shell and never loads
  the active theme — this keeps it predictable across customer sites
  and avoids interference from theme-level analytics, popups, or
  scripts that might leak the rotatable token.
- Survey window enforcement is server-side via `is_open()`; the form
  template doesn't try to enforce it on the client. Even if a bot
  caches the form HTML after the window closes, submission is rejected.

---

## 2026-05-27 10:59 — rian + Claude (2.1.5 perf fixes — released)

**Goal:** rian reported slowness on client sites; bw-ai-schema-pro was flagged as a contributor. Fix the high-impact perf issues and ship.

**Done — all in 2.1.5, RELEASED to plugins.bowden.works:**
- **CRITICAL:** Deleted `BW_Schema_Team_Member::remove_remaining_schemas()` and its `add_action( 'wp_head', ..., 0 )` registration. On team-member pages it iterated `$wp_filter['wp_head']` every page load with `strpos()` string-matching to remove "schema-looking" hooks — expensive + fragile + redundant with `BW_Schema_Core::disable_conflicting_schema()` which uses proper named filter hooks driven by `bw_schema_disable_sources`. Kept the `remove_all_actions( 'wp_head', 60 )` next to it (that's the Yoast-priority cleanup, not the iteration).
- **HIGH:** Added transient fallback to `BW_Schema_Cache`. Routed `get`/`set`/`delete` through `set_transient`/`get_transient`/`delete_transient` when `wp_using_ext_object_cache()` is false. Sites with Redis/Memcached unchanged (continue `wp_cache_*`); Flywheel + similar hosts where cache was previously a no-op now get a real persistent cache. `clear_all()` does prefix-scoped DELETE on options table for the transient path. Cache key prefix `bw_schema_` so cleanup is safe.
- **Low:** Consolidated 6 default-priority `init` hooks into one wrapper `init_default_priority_modules()`. Kept priority-1 (`maybe_migrate_options`) and priority-5 (`disable_conflicting_schema`) as separate registrations (they need their explicit priorities).
- **Low:** `BW_Schema_Renderer` now singleton via `get_instance()`. Both call sites in `bw-ai-schema-pro.php` (`output_schema_markup` on wp_head, `ajax_get_post_schema`) updated. The class is stateless so reuse is safe and saves the per-request allocation.

**Released:**
- `tools/release.sh bw-ai-schema-pro 2.1.5` ran clean.
- Manifest confirmed: https://plugins.bowden.works/wp-json/bw/v1/update-check?slug=bw-ai-schema-pro serves 2.1.5.
- Zip sha256: 675aeb64d112870937e4fda77b77658bf934fc913ff642335159df3c10215adc, 350981 bytes.
- Source snapshot: `.releases/bw-ai-schema-pro/2.1.5/`.
- Customer sites will pick up on next update poll.

**Deferred (not in 2.1.5):**
- "Early-exit for non-schema post types" in the renderer. Plugin handles many contexts (singular, home, author, archive, team page, breadcrumbs, etc.) — the bar for "non-schema" isn't obvious from code. Needs rian to define an allow-list of post types before this can ship. Worth a 2.1.6 once that list exists.

**Notes:**
- The 140 advisory security-scan warnings persist (audited 2026-05-06, all SAFE — see prior entry). Scans report "clean (140 warning(s))" — that's a pass.
- The CRITICAL fix has the biggest impact on sites with active team-member CPTs in heavy use — those sites stopped walking the wp_head hook table on every page load.
- Side fix in same session: bumped bw-blocks (separate plugin in `/srv/apps/aisv/`) 1.0.0 → 1.0.1. Someone had already removed the CSS `@import` and added the separate font enqueue in PHP but forgot the version bump. Not a bw-plugins-framework plugin; no release process there.

---

## 2026-05-06 15:30 — rian + Claude (security audit of 140 scan warnings)

**Goal:** The security-scan flagged 140 advisory `[raw $_GET/POST]` warnings on the imported plugin. Determine which are real issues, fix them, leave the rest documented.

**Done:**
- Full audit of all 140 warnings across 8 files (delegated to a subagent for the read-and-categorize pass).
- Verdict: **0 HIGH, 1 LOW, 139 SAFE.** The plugin's author follows a consistent pattern — nonce + `current_user_can()` + autosave check at the top of every save handler, then `sanitize_text_field` / `sanitize_key` / `sanitize_email` / `sanitize_textarea_field` / `intval` / `absint` / `esc_url_raw` / `wp_kses_post` / `array_map(...)` on every superglobal read. AJAX handlers double-gate (nonce + cap). Output uses `esc_html` / `esc_attr` / `esc_url` consistently. Only one `$wpdb` call in the whole plugin and it uses `prepare()`. The grep-based scanner can't see the guard four lines above a `$_POST` read, hence the high false-positive rate.
- The one real gap: `bw-ai-schema-pro.php:990` `maybe_redirect_to_setup()` honored `?skip_setup=1` for any logged-in user reaching wp-admin (subscribers visiting their own profile page included), allowing them to mark the setup wizard "complete." Impact was minimal (just suppressed the wizard for admins), but it was the only nonce/cap gap. Fixed by adding `current_user_can( 'manage_options' )` to the `update_option` branch.
- Documented in CHANGELOG.md under [Unreleased] (will ship with the next release; not worth a 2.1.5 on its own).
- Added an audit-summary comment to `.security-allowlist` so future scan runs come with context. Deliberately did NOT add the 139 SAFE findings to the allowlist — line numbers shift on edits and a stale allowlist would be worse than noisy warnings.

**Left off at:** Plugin's security posture is documented and verified clean. The advisory 140-warning count will persist in scan output; that's expected and acceptable. No further action needed unless code changes invalidate the audit (rerun the audit on substantive changes to admin handlers, AJAX endpoints, or anything that introduces direct SQL).

**Notes:**
- Subagent's full report (with per-file SAFE summaries) is available in this session's transcript if anyone wants the granular receipts. The condensed conclusion is in this entry and in the `.security-allowlist` comment.

---

## 2026-05-06 14:55 — rian + Claude (URL flip release)

**Goal:** Complete the URL-flip release (2.1.4) — bring the plugin up to BW header standards and move customer sites onto the new bw-update-server.

**Done:**
- Added compat constant `BW_AI_SCHEMA_PRO_VERSION` (literal mirror of header version) so BW release tooling sees what it expects without renaming any internal `bw_schema_*` references.
- Updated plugin header per BW standard: `Plugin URI`, `Update URI`, `Requires at least: 6.0`, `Requires PHP: 7.4`, license normalized to `GPL-2.0-or-later`.
- Changed `PucFactory::buildUpdateChecker()` URL from `https://bwgeo.demoing.info/.../bw-ai-schema-pro.json` to `https://plugins.bowden.works/wp-json/bw/v1/update-check?slug=bw-ai-schema-pro`.
- Added missing `defined( 'ABSPATH' ) || exit;` guard to `includes/class-bw-schema-author-box.php`.
- Added `.security-allowlist` justifying the two `require $var` warnings in the autoloader (path is class-name + constant base dir, file_exists() bounded — not user input).
- Bumped to 2.1.4. CHANGELOG and CLAUDE.md updated.
- All three scans clean (cleanup, security, test-plugin).
- Ran `tools/release.sh bw-ai-schema-pro 2.1.4` → built zip, registered on plugins.bowden.works.
- Deployed the same zip to the legacy bwgeo server (`/srv/apps/bwgeo/wp-content/uploads/plugin-updates/bw-ai-schema-pro-2.1.4.zip`) and updated `bw-ai-schema-pro-data.json` to advertise 2.1.4 (the file actually served — `bw-ai-schema-pro.json` is rewritten to `update-check.php` via .htaccess, which serves `-data.json`; almost edited the wrong file).

**Left off at:** Customer sites will pick up 2.1.4 on their next update poll (most ping daily). Watch `/srv/apps/bwgeo/wp-content/uploads/plugin-updates/update-log.json` for sites flipping to `installed_version: 2.1.4`. Once all sites have flipped, the legacy bwgeo distribution can be retired.

**Notes:**
- The compat constant uses a literal (`define( 'BW_AI_SCHEMA_PRO_VERSION', '2.1.4' );`) not a constant alias — `test-plugin.sh`'s regex requires a quoted literal. `bump-version.sh` updates this literal alongside the header on each version bump, so the two stay in sync.
- The legacy bwgeo update mechanism is more involved than expected: a `.htaccess` rewrite maps `bw-ai-schema-pro.json` → `update-check.php` → reads `bw-ai-schema-pro-data.json`. When advertising future versions from bwgeo (which we shouldn't need to do again after the flip), edit `-data.json`, not `bw-ai-schema-pro.json`.

---

## 2026-05-06 11:20 — rian + Claude (initial import)

**Goal:** Migrate `bw-ai-schema-pro` (a.k.a. the legacy "bwgeo" plugin) into the bw-plugins development framework, following BW standards as much as possible without breaking the 16 production sites running it.

**Done:**
- Identified the 16 sites running it via the legacy bwgeo update server's check log (8 production customer sites + 8 mosiah dev sites).
- Decided to keep the existing `bw_schema_*` internal prefix (grandfathered) — renaming would orphan customer data. Rename intent captured in ROADMAP.md as a future 3.0.0.
- Copied source from `/srv/apps/bwgeo/wp-content/plugins/bw-ai-schema-pro/` to `/srv/apps/bw-plugins/wp-content/plugins/bw-ai-schema-pro/`. No code changes. Permissions normalized to BW conventions (dirs 2775, files 664, group `bw-plugins-dev`).
- Scaffolded BW meta files: `CHANGELOG.md`, `README.md`, `LICENSE`, `uninstall.php` (intentionally minimal), `CLAUDE.md` (with prominent prefix-exception note), and the full `docs/` set (`ARCHITECTURE.md`, `SPEC.md`, `ROADMAP.md`, `TESTING.md`, `KNOWN-ISSUES.md`, `HANDOFF-NOTES.md`, `SESSION-LOG.md`).

**Left off at:** Plugin is in place; meta files are scaffolded with import-aware content. BW scans not yet run. Update-server flip release not yet planned in detail. See `HANDOFF-NOTES.md` for the four candidate next steps.

**Notes:**
- The version constant naming mismatch (`BW_SCHEMA_VERSION` vs. expected `BW_AI_SCHEMA_PRO_VERSION`) is the most pressing operational issue — it breaks BW release tooling. KNOWN-ISSUES.md lists three resolution options; rian needs to pick one before the next release.
- The rename to a new slug is desired but the new name hasn't been chosen yet.
