# 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-07-22 — adi + Claude (2.6.0 Export / Import — STAGED, not released)

**⚠️ Scope changed after the build: sample presets were REMOVED before release.**
adi's call, and the right one: *"as a human, Sample presets is scary. I don't want
this. But I like export and import configuration."* A one-click button that
replaces an entire site configuration does not belong in the admin of a plugin
installed on ~8 live client sites — the risk of someone loading "Resort" onto a
real client outweighs the demo convenience.

**What was removed:** the seven preset files (moved, not deleted, to
`/srv/apps/bw-plugins/.removed-presets-2026-07-22/` — this repo has no git),
`get_presets()` / `get_preset()` / `apply_preset()` / `read_preset_file()` /
`presets_dir()`, the `diff()` / `humanise_key()` / `summarise()` preview helpers
(only the preset preview used them — left in they would have been dead code), the
preset cards and preview UI, and the two preset POST actions. Page renamed
**Presets & Backup → Export / Import** everywhere.

**What was kept:** export, import, the tier map, per-key sanitization, the
allowlist, snapshot/undo, and the mirror reconciliation. Also kept the `Attorney`
bug fix, which the preset work surfaced but which stands on its own.

**Re-verified after the removal:** all seven preset methods gone and no
`presets/` dir in the plugin; round trip still identity (0 differing keys); undo
still restores 36 keys; hostile import still skips unknown keys with
`active_plugins` intact; page renders with no preset markup; site config
untouched. cleanup + security scans exit 0.

--- original build notes below ---


**Goal:** One-click loading of realistic sample configurations for demos and new
sites, plus JSON export/import of a site's whole schema config. Planned first and
approved before any code (see `docs/SPEC-config-io.md`).

**Decisions (adi chose):** seven presets; apply = replace with automatic snapshot
and one-click Undo; presets carry full fictional business identity.

**Done:**
- `includes/class-bw-schema-config-io.php` — tier map, export/import, validation,
  per-key sanitization, snapshot/undo, preset loading, mirror reconciliation.
- `admin/views/tools.php` + `bw-ai-schema-tools` page + four nonce-guarded POST
  actions (preview / apply / import / undo) and an `admin_post_` export download.
- Seven presets in `presets/`, each setting the same 16 config keys so switching
  between them is deterministic.
- Nav item added to dashboard, settings, author-profiles.

**The design work was the tier map**, not the UI. The plugin's ~51 options split
four ways: portable **config**; **site_specific** (page mappings hold POST IDs —
verified live as `{"about":123,...}`); **authors** (personal data, incl. the
legacy `bw_schema_founders`, which holds real names); and **never** (version
state, security log). Presets are stripped of the middle two on read, so applying
one cannot break page mappings or delete author profiles.

**Two things that were wrong on the first attempt and got fixed:**
1. `reconcile_settings_mirror()` originally copied top-level → mirror
   unconditionally. On a site where only the mirror was ever populated that
   would have **wiped the team CPT**, because `get_settings()` prefers the mirror.
   Now converges both ways; backfill verified by test.
2. `get_preset()` used `sanitize_key()`, which silently turned `../resort` into
   `resort` and served a different preset than was asked for. Safe, but wrong —
   now ids must match `^[a-z0-9-]+$` or are rejected outright.

**Bug found and fixed:** `is_local_business_type()` did not list **`Attorney`**,
though the Organization tab's own picker offers it — so every law-firm site was
silently emitting no `geo`, `openingHoursSpecification` or `priceRange`. Same
class as the 2.4.0 lodging bug. Found only because a law-firm preset was being
built; `Warehouse`, `HardwareStore`, `AutoPartsStore` added too.

**Verified on the dev site:**
- Round trip: export → mutate → import → **zero** differing keys.
- Hostile JSON: `active_plugins` untouched, `javascript:` URL stripped to empty,
  wrong-block keys skipped, all five malformed payloads rejected.
- All seven presets applied in sequence: correct org type, location count,
  service-area count, `subOrganization` count, and local-business fields retained
  for every type. Radius conversion 120 km → `120000`.
- Preset apply leaves `page_mappings`, `team_post_type`, `room_post_type` unchanged.
- Bad nonce does not apply; preview is read-only; undo restores exactly.
- Tools page renders 13 KB with all 7 preset cards; nav item on all three views.
- Site's own config left exactly as found (org `Demoing`, type `Resort`, 2 locations).

**Scans:** cleanup + security both exit 0. (One security ERROR was the same
backtick-in-a-docblock false positive as 2.4.0 — reworded, not allowlisted.)

**Left off at:** Staged at 2.6.0, not released. `test-plugin.sh` still cannot run
in this session (its lint step shells out to docker, which `adi` has no access to
by design). Syntax verified by loading the plugin under wp-cli and rendering the
new page.

**Notes:**
- The gateway (`srv-gw`) went unresponsive mid-session for ~20 minutes — every
  command returned empty/exit 7 while the site itself stayed up. It recovered on
  its own; no restart was needed and no config was left half-applied (verified
  against the live JSON-LD before continuing). Not caused by plugin code: the
  same operations timed at 0 ms afterwards.
- Optional follow-up not done: the nav row is duplicated across dashboard,
  settings, author-profiles and tools. Extracting a shared partial would be a
  clean consolidation but mixes a refactor into a feature.

## 2026-07-22 — adi + Claude (2.5.0 Locations & Service Areas — STAGED, not released)

**Goal:** Support multi-location and service-area clients (the motivating case:
an air compressor supplier with branches and a truck that covers two provinces),
with Canada / US / international address handling.

**Decisions (rian/adi chose):** locations live in a **Settings repeater, not a
CPT** (client sites don't have per-branch pages); service areas are **named
places + optional radius**; hybrid storefront-plus-radius sites **emit both with
an admin warning** rather than being forced into one mode.

**Done:**
- `includes/class-bw-schema-location.php` (new) — data model, country/region
  tables, the shared `build_area_served()` builder, `build_location_node()`,
  `collect_location_nodes()` / `collect_location_references()`, and the
  sanitizers.
- Settings → Locations tab: company-wide service-area repeater + a card per
  location with a nested per-location area repeater, JS template cloning,
  country-driven region label and postal placeholder, CA/US datalists.
- Org builder: `areaServed` now routes through the shared builder;
  `subOrganization[]` refs added. Renderer emits full location nodes on the
  homepage only.
- Each row gets an **immutable id** so `@id` survives renames/reordering.

**Two real bugs fixed** (both in `schemas/class-bw-schema-localbusiness.php`,
both hitting the Canadian client base):
1. `addressCountry` defaulted to `'US'` — any site with a blank country was
   publishing itself as American. Now omitted when unset.
2. `areaServed` wrapped every entry as `{"@type":"GeoCircle","name":"…"}` with
   no midpoint or radius — structurally meaningless. Now routed through the
   shared builder.

**Consolidation:** the Organization tab's free-text "Areas served" box is gone,
replaced by a pointer to the Locations tab — otherwise one property would have
two sources of truth and editing the old box would silently do nothing. Legacy
values are read as a fallback and migrate on first save of the new tab, so no
site loses data.

**Verified on the dev site** with a fixture covering all three shapes at once
(Calgary storefront with address + 150 km radius, Regina service-only branch
with no street address, an unpublishable name-only row):
- Homepage graph: Organization + WebPage + `Store` + `HVACBusiness`; the
  name-only row correctly absent.
- `areaServed` emits `State` / `Country` / a real `GeoCircle`; the deliberately
  incomplete radius row was dropped, not emitted as a shell.
- Unit conversion: 300 km → `300000`, 150 km → `150000`, 50 mi → `80467` metres.
- Interior page: `subOrganization` refs present, **0** full location nodes.
- "Uses main opening hours" copies both org specs onto that branch only.
- Sanitizer behaviour: name trimmed, bogus type → `LocalBusiness`, `ca` → `CA`,
  non-numeric lng dropped, invalid email dropped, `javascript:` URL dropped,
  existing id preserved, **forged id replaced**, nameless row dropped, invalid
  area kind → text, incomplete radius dropped.
- Settings page renders 137 KB with all 9 tabs; People and Rooms tabs intact.

**Scans:** cleanup + security both exit 0.

**Left off at:** Staged at 2.5.0, not released. `test-plugin.sh` still cannot run
in this session (its lint step needs docker, which `adi` has no access to by
design) — syntax verified by loading the plugin under wp-cli and rendering the
settings page.

**Notes:**
- The dev site now carries a "Prairie Air" locations fixture while its org type
  is still `Resort` from the 2.4.0 work — incoherent as a demo, but harmless test
  data. Clear `bw_schema_locations` if it gets confusing.
- Deferred: per-branch opening hours beyond the checkbox, a locations CPT
  (explicitly decided against), automatic geocoding.

## 2026-07-22 — adi + Claude (2.4.0 Accommodations/Rooms module — STAGED, not released)

**Goal:** Resort clients (luxury properties in Turks & Caicos and similar) have a
page per room/suite/villa. The plugin could describe the *property*
(`localbusiness:Resort` → lodging props) but had **no room-level type at all** —
no `Accommodation`, `HotelRoom`, `Suite`, `bed`, `occupancy`. Room pages fell
through to the `article:Article` default. Build the room equivalent of the People
module.

**Design decisions (rian/adi chose):** site Organization IS the Resort (one
property per site, no Resort CPT); rooms live in their own CPT; rates in scope as
a "from" rate. Full spec in `docs/SPEC-accommodations.md`.

**Done:**
- `includes/class-bw-schema-room.php` (new) — the whole module in one class:
  CPT config, meta box, save handler, and the single `get_room_schema_data()`
  provider that every consumer reads. Deliberately NOT split the way
  `BW_Schema_Person` / `BW_Schema_Team_Member` are.
- Meta box uses the People module's detect-then-override idiom. Fields: room
  type, occupancy, bed repeater → `BedDetails`, layout (bedrooms / rooms /
  full + half baths / floor size + unit / floor level), amenity repeater,
  policies, rates, floor plan, `tourBookingPage`, room-tour video.
- Graph: room `@id` = permalink + `#room`, `containedInPlace` → `#organization`;
  `BW_Schema_Org_Builder` gained `containsPlace[]` (compact refs only, gated on
  `is_local_business_type()` since `containsPlace` is a Place property).
- Renderer intercepts the room CPT in `build_page_node()` before
  `get_post_schema()`, so rooms never fall through to Article.
- **Rate staleness guard.** A "from" rate is emitted as `minPrice` on a
  `UnitPriceSpecification` (a floor, not an assertion) and is **withheld** once
  its "last confirmed" date passes the window (default 180d). Room list + edit
  screen warn. Seasonal rates go stale silently; a wrong price is worse than none.
- Settings → Rooms tab (CPT picker, currency, rate period, staleness window,
  fleet table with rate age + exclude flags) + save handler in
  `class-bw-schema-admin.php`, guarded by a `room_table_posted` marker so another
  tab's save can't clear every exclude flag.
- **Fixed a latent bug:** `is_local_business_type()` listed `LodgingBusiness` but
  none of its subtypes, so a site typed `Resort`/`Hotel`/etc. silently emitted no
  `geo`, `openingHoursSpecification`, or `priceRange`. Seven types added.
- Dev-site fixture: `room` CPT registered in `kadence-child/functions.php`
  (mirrors the existing `team` fixture), plus 3 test rooms (168 full suite,
  169 deliberately stale rate, 170 excluded).

**Verified on the dev site (bw-plugins.demoing.info, org type already Resort):**
- Full `@graph` on a room page: `Resort` (with `containsPlace` × 2) + `Suite`
  (with `containedInPlace` back-reference) + `BreadcrumbList`. No stray Article.
- Stale room 169: `offers` keeps its booking URL, `priceSpecification` withheld.
- Excluded room 170: empty node, absent from `containsPlace`.
- Settings page renders 89 KB, all 8 tabs present, Rooms tab populated, People
  tab intact.
- Save-handler behaviour test: invalid room type → `HotelRoom`, invalid
  occupancy min dropped, blank bed row dropped, non-numeric bed count → 1,
  invalid area unit dropped, invalid pets value dropped, currency `usd` → `USD`,
  invalid availability + malformed date dropped, **bad nonce writes nothing**.

**Scans:** `cleanup-scan` clean (exit 0). `security-scan` clean (exit 0) — the
294 remaining `raw $_GET/POST` warnings are the pre-existing advisory class
(mine are all `isset()`-guarded + `wp_unslash()`ed). One initial ERROR was a
false positive: backticks in a docblock tripped the "backtick exec" detector —
reworded rather than allowlisted.

**Left off at:** Staged at 2.4.0. **`test-plugin.sh` could not be run** — its
syntax-check step shells out to `docker`, which this session's user (`adi`) has
no access to by design; it reports "syntax error" for every file including
untouched ones. Syntax was verified instead by loading the plugin under wp-cli
(all classes parse) and by rendering the settings page (parses
`class-bw-schema-admin.php` + `admin/views/settings.php`).

**Follow-up same session — validator caught 3 real modelling errors (fixed):**
Ran the live pages through validator.schema.org. Three warnings, all genuine:
`offers`, `datePublished`, and `dateModified` are **not valid properties of
`Accommodation`** (it descends from `Place`; those first belong to
Product/Service/CreativeWork/etc. and the last two to `CreativeWork`). I had
copied the date properties from the team-member path, where they're correct
because that node is a `ProfilePage`. Fixes:
- Dropped `datePublished` / `dateModified` from the room node.
- Inverted the offer: `build_offer_node()` emits an `Offer` at
  `<permalink>#offer` with `itemOffered` → the room's `@id`.

**Second correction (same session) — where the Offer hangs.** The first fix put
that Offer at the **top level** of the `@graph`. rian spotted the consequence
immediately: "I no longer see Resort type" in the validator. A top-level Offer is
referenced by nothing, so it becomes an additional **root** of the graph, and the
validator re-roots its tree there — rendering the whole property as a nested
detail of the Offer instead of showing `Resort` as the headline item. Corrected
by moving offers to **`Resort.makesOffer[]`** (valid on `Organization`, which
`Resort` inherits), built by `BW_Schema_Room::collect_offers()` and **appended**
to any option-driven `makesOffer` rather than replacing it. `build_page_node()`
returns a single node again. Graph is back to the 3-node shape that validated
cleanly (Resort / room / BreadcrumbList), now with offers nested under Resort.
**Lesson worth keeping: a node nothing references is a new graph root, and
changes what a consumer treats as the page's primary entity.**
- Staleness guard unchanged and still works: the stale room's Offer keeps its
  booking URL and omits `priceSpecification`.
Re-verified live: both room pages now emit Resort + room + Offer +
BreadcrumbList with zero invalid properties on the room node.

**Also:** the room URLs 404'd at first — the `room` CPT was registered after
permalinks were last saved, so there were 0 rewrite rules for `rooms` (vs 23 for
`team`). `wp rewrite flush --hard` fixed it. Not a plugin bug, but since setting
up the module means nominating a CPT that may predate the last permalink save,
the Rooms tab now carries a one-line hint pointing at Settings → Permalinks.

**Unexplained, low stakes:** room 169's `_bw_schema_room_type` was found as
`Suite` when the fixture/test had set it to `HotelRoom`. Could not reproduce — the
value is now stable across saves, renders, and live HTTP hits. Most likely my own
fixture-command sequencing during testing. Flagging rather than claiming a fix;
if a room type ever appears to change on its own, start here.

**Notes:**
- **Release is rian's call and has not been made.** ~8 production customer sites.
  Someone with docker access should run `tools/test-plugin.sh bw-ai-schema-pro`
  before any release.
- Google's lodging rich results need Hotel Center + a feed; markup alone won't
  produce a property card. Don't promise a client one. The payoff here is AI/LLM
  comprehension (the plugin's stated purpose) and being feed-ready later.
- Deferred: multi-property (Resort CPT + room→resort relation), a rooms-archive
  `ItemList`, `VacationRental` output.

## 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.

## 2026-06-05 — Tabbed settings page + Q1-Q4 fixes (2.3.1, staged)

**Released:** no (staged 2.3.1, do not release)
**Scope:** Followups to the wizard walkthrough on Red Seal that surfaced
four "why is X missing?" questions, plus the deeper request to make every
wizard setting evergreen-editable from the regular Settings page.

### Fixes for the four Red Seal questions

- **Page mappings** (`bw_schema_page_mappings` key mismatch): one-off data
  cleanup on Red Seal (was `about_us` / `contact_us`; canonical keys are
  `about` / `contact`). No code change needed; bulk-load scripts that go
  through the wizard's canonical key list won't repeat this.
- **Leadership / Founders → "Missing"**: `BW_Schema_Core::get_setup_completion_status()`
  was reading the legacy `bw_schema_founders` option which v2.0 stopped
  writing. Now also accepts a published team-CPT post with
  `_bw_schema_is_leader = 1`. Added a new sibling row for Authors
  (`_bw_schema_is_author = 1`). Red Seal now shows 10/10 (100%).
- **Jobs / staff blank in post-type-defaults**: wizard's `<select>` had
  no `<option value="none">`, so `'none'` saved values fell through to
  the placeholder. Added an explicit "— Don't emit (handled elsewhere) —"
  option, plus a green confirmation note below the select that detects
  WP Job Manager (`job_listing`), WooCommerce (`product`), and the team
  CPT (handled by our ProfilePage+Person dedicated path). Updated the
  renderer's skip test to recognize bare `'none'` in addition to the
  legacy `'none:None'` / `'no_schema'` forms.
- **Yoast schema co-existence**: confirmed by source-read that Yoast
  uses `@id = "…/#organization"` — same as our `BW_Schema_Org_Builder::id()`.
  Two scripts on the page both emit a node with that id, so consumers
  merge them by JSON-LD 1.1 rules; shared properties (`name`, `logo`,
  `sameAs`) become order-dependent. Made the wizard pre-check the
  "Take over" box on first-run setup when a known SEO plugin is
  detected, and rewrote the section copy to explain the trade-off
  clearly. Box is opt-out (uncheck to keep the co-existence mode).

### Tabbed settings page

- `admin/views/settings.php` rebuilt with inner-tab navigation:
  Organization | People | Contact | Pages | Content | Features | Advanced.
- Tabs switch via JS (no page reload). Active tab is also persisted to
  `#tab=` in the URL hash + a hidden input so deep-links work.
- One form spans every tab; `save_settings()` updates only the options
  whose fields are in `$_POST`, leaving the rest untouched (so jumping
  in to fix one Advanced field doesn't blow away the Organization tab).
- **People tab** surfaces the full team-CPT table (Author / Founder /
  Exclude from schema / Linked WP User).
- **Contact tab** has address fields + 3 contactPoint rows.
- **Pages tab** mirrors wizard step 4 (about / services / contact / blog
  / team / terms / privacy).
- **Content tab** has the post-type-defaults select with the new
  "Don't emit" option + the same handled-elsewhere green note.
- **Features tab** combines master toggles + "Take over from other
  schema plugins" + the existing Post FAQ, Author Box, and Meta Box
  Display sections.
- **Advanced tab** is the wizard-gap: VAT ID, LEI code, ISO 6523,
  NAICS, Tax ID, DUNS, foundingLocation, geo coordinates, and an
  openingHoursSpecification[] table.

### Files touched

- `includes/class-bw-schema-core.php` — modernized `get_setup_completion_status()`.
- `includes/class-bw-schema-renderer.php` — bare `'none'` skip-test.
- `admin/views/setup-wizard.php` — wizard step 5 "None" option + green
  note; wizard step 6 take-over copy + first-run pre-check.
- `admin/views/settings.php` — full rebuild with tabs.
- `includes/class-bw-schema-admin.php::save_settings()` — handlers for
  every new tab (org/people/contact/pages/content/features/advanced).
- `CHANGELOG.md` — 2.3.1 entry.
- Bumped to 2.3.1 via `tools/bump-version.sh`.

### Scans

- cleanup-scan: clean (after fixing one empty-catch in tab-switcher JS).
- security-scan: clean (259 pre-existing raw-$_GET/POST warnings, all in
  `bw-ai-schema-pro.php`, unrelated to this change).
- test-plugin: passed at 2.3.1.

### Verified on Red Seal

- Schema render via wp-cli `eval-file`: single `@graph`, full Organization
  with EmploymentAgency type, founder[] including Kael, employee[] with
  ProfilePage links, no Yoast Organization collision after takeover flag
  flipped.
- Settings page renders 255KB no fatal; all 7 tabs present; "Take over
  from Yoast" label present; "Don't emit" option present; VAT/LEI/geo/
  opening-hours fields present.
- Completion checklist: 10/10 (100%), including the new "Authors" row.

### Outstanding

- Release decision is rian's. Plugin is staged at 2.3.1.
- Auth gate on `redsealrecruiting.demoing.info` is open for 1h — Caddy
  was down for ~10 min during this session ("error code: 521"); came
  back on its own around 23:06 UTC.

