Files
prautoblogger/CONVENTIONS.md
peptiderepo 6dd23351cb feat(phase2b): P2b.5 — Authority admin controls + new-stage panels (#182, v0.32.0)
Surfaces the master enable toggle (default OFF), per-category tier-map editor, citation threshold, and curate/seo/authority step panels + research-agent-count/editorial-max-rounds in Pipeline Settings. Image pipeline honors _prautoblogger_imagery_suppressed. Completes Phase 2b. QA APPROVE-WITH-P2 d1447340; CI green (phpcs+phpunit VPS-verified). 3 trivial P2 docs/cleanup follow-ups noted.

Agent-Session: prab-phase2b-5-merge
2026-06-24 11:06:27 +07:00

907 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PRAutoBlogger — Conventions
This document codifies the naming patterns, coding conventions, and step-by-step guides for extending PRAutoBlogger. Update this file whenever a new pattern is introduced.
---
## Naming Conventions
### PHP Classes
- **Format:** `PRAutoBlogger_{PascalCase}` (e.g., `PRAutoBlogger_Content_Generator`)
- **File naming:** `class-{kebab-case}.php` (e.g., `class-content-generator.php`)
- **Interfaces:** `interface-{kebab-case}.php` with `PRAutoBlogger_{Name}_Interface` class name
### Hooks (Actions & Filters)
- **Prefix:** All hooks start with `prautoblogger_`
- **Actions:** `prautoblogger_{event}` (e.g., `prautoblogger_before_content_generation`)
- **Filters:** `prautoblogger_filter_{what}` (e.g., `prautoblogger_filter_article_idea_score`)
- **Hook callbacks registered in:** `class-prautoblogger.php` (central loader), never in the classes themselves
### Database
- **Table prefix:** `{$wpdb->prefix}prautoblogger_` (e.g., `wp_prautoblogger_source_data`)
- **Option prefix:** `prautoblogger_` (e.g., `prautoblogger_openrouter_api_key`)
- **Post meta prefix:** `_prautoblogger_` (underscore prefix hides from custom fields UI)
- **Transient prefix:** `prautoblogger_` (e.g., `prautoblogger_reddit_token`)
### Constants
- **Format:** `PRAUTOBLOGGER_{UPPER_SNAKE}` (e.g., `PRAUTOBLOGGER_MAX_RETRIES`)
- **Defined in:** `prautoblogger.php` (bootstrap file) for global constants
### Hook Callback Methods
- **Action callbacks:** Prefixed with `on_` (e.g., `on_daily_generation_triggered`)
- **Filter callbacks:** Prefixed with `filter_` (e.g., `filter_content_quality`)
---
## Error Handling & Logging
### Structured Logger
All logging uses `PRAutoBlogger_Logger` (singleton) instead of raw `error_log()`. The logger writes to the `prab_event_log` custom table and forwards errors/warnings to PHP's `error_log()` for server-level monitoring. The verbosity threshold is user-configurable via Settings → Publishing → Log Level.
**Never call `error_log()` directly.** Always use the Logger:
```php
$logger = PRAutoBlogger_Logger::instance();
$logger->error( 'API call failed: ' . $error_message, 'openrouter' );
$logger->warning( 'Budget nearing limit.', 'cost-tracker' );
$logger->info( 'Pipeline complete: 3 posts generated.', 'pipeline' );
$logger->debug( 'Token counts: prompt=1200, completion=800', 'openrouter' );
```
### Log levels (ascending verbosity)
| Level | Numeric | Use for |
|---------|---------|---------|
| error | 0 | Failures: API errors, parse failures, DB write failures |
| warning | 1 | Degraded behavior: budget approaching, missing providers, fallback pricing |
| info | 2 | Key events: pipeline start/stop, articles generated, metrics collected |
| debug | 3 | Verbose detail: token counts, timing, skipped posts, intermediate values |
### Context strings
The second argument to every log call is a short context tag identifying the origin:
`pipeline`, `scheduler`, `collector`, `analyzer`, `editor`, `openrouter`, `reddit`, `ga4`, `metrics`, `cost-tracker`, `encryption`.
### What gets shown to the user
- API key issues → admin notice (persistent until resolved)
- Budget exceeded → admin notice + email to site admin
- Generation failures → logged to `prab_generation_log` with `response_status = 'error'`
- Generation failures also shown in the admin dashboard widget
- All log entries visible in PRAutoBlogger → Activity Log (filterable by level)
### What gets retried
- LLM API calls: exponential backoff, max 3 retries, then fail loudly
- Reddit API calls: exponential backoff, max 3 retries, then skip this collection cycle
- **Never retry silently.** Every retry is logged. Every final failure is surfaced.
### Error severity levels
1. **Fatal:** Plugin cannot function (missing API key, budget exhausted) → admin notice, stop all scheduled jobs
2. **Retriable:** Transient API failure → retry with backoff, log each attempt
3. **Skippable:** Single post generation failed → log, skip this post, continue with others
---
## How To: Add a New LLM Provider
1. Create `includes/providers/class-{name}-provider.php`
2. Implement `PRAutoBlogger_LLM_Provider_Interface` (see `interface-llm-provider.php`)
3. Required methods: `send_chat_completion()`, `get_available_models()`, `estimate_cost()`
4. Add provider option to the settings page in `admin/class-admin-page.php`
5. Register the provider in `class-prautoblogger.php` loader
6. Update ARCHITECTURE.md external API integrations table
### Interface contract:
```php
interface PRAutoBlogger_LLM_Provider_Interface {
public function send_chat_completion(array $messages, string $model, array $options = []): array;
public function get_available_models(): array;
public function estimate_cost(string $model, int $prompt_tokens, int $completion_tokens): float;
public function get_provider_name(): string;
}
```
---
## How To: Add a New Source Provider (Social Platform)
1. Create `includes/providers/class-{platform}-provider.php`
2. Implement `PRAutoBlogger_Source_Provider_Interface` (see `interface-source-provider.php`)
3. Required methods: `collect_data()`, `get_source_type()`, `validate_credentials()`
4. Add API credentials fields to the settings page in `admin/class-admin-page.php`
5. Register the provider in `class-prautoblogger.php` loader
6. Add the source type string to the `ab_source_data.source_type` allowed values
7. Update ARCHITECTURE.md file tree and external API integrations table
### Interface contract:
```php
interface PRAutoBlogger_Source_Provider_Interface {
public function collect_data(array $config): array; // Returns PRAutoBlogger_Source_Data[]
public function get_source_type(): string; // e.g., 'reddit', 'tiktok'
public function validate_credentials(): bool;
public function get_rate_limit_status(): array;
}
```
---
## How To: Add a New Image Provider
1. Create `includes/providers/class-{name}-image-provider.php`
2. Implement `PRAutoBlogger_Image_Provider_Interface` (see `interface-image-provider.php`)
3. Required methods: `generate_image()`, `estimate_cost()`, `get_provider_name()`, `validate_credentials_detailed()`
4. Split helpers into companions if you'd exceed the 300-line cap. The Cloudflare provider splits into provider + pricing + validator — mirror that shape.
5. Add API credentials fields to `admin/class-settings-fields.php` under the `prautoblogger_images` section
6. Register the provider in `class-prautoblogger.php` loader once the image pipeline lands (commit 1b of the image workstream)
7. Update `ARCHITECTURE.md` file tree, external API integrations table, and the options table
### Interface contract:
```php
interface PRAutoBlogger_Image_Provider_Interface {
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array; // {bytes, mime_type, width, height, model, seed, cost_usd, latency_ms}
public function estimate_cost( int $width, int $height, array $options = [] ): float;
public function get_provider_name(): string; // e.g., 'cloudflare_workers_ai'
public function validate_credentials_detailed(): array; // {status, message, debug?}
}
```
### Retry + cost rules (non-negotiable)
- 5xx / 429 / network errors: retry with exponential backoff up to `PRAUTOBLOGGER_MAX_RETRIES`, respect `Retry-After` when present.
- 4xx other than 429: fail loudly on the first response — never retry a deterministic bad request.
- Validation check must be non-destructive (no image generation).
- Every attempt logs via `PRAutoBlogger_Logger` with context `{provider-slug}-image`.
---
## How To: Tune the Image Composer (v0.17.0+)
The deterministic composer (ARCHITECTURE.md #21) renders branded variants between
provider bytes and sideload. Three rules when touching it:
1. **Geometry lives in code, not settings.** All sizes/colors/opacities come from
`PRAutoBlogger_Image_Composer_Layout::defaults()` and can be overridden via the
`prautoblogger_image_compose_layout` filter:
```php
add_filter( 'prautoblogger_image_compose_layout', function ( array $layout ): array {
$layout['featured']['mark_opacity'] = 0.40;
return $layout;
} );
```
Promote a value to a settings field only on explicit CEO request.
2. **Keep renderer classes WP-free.** `Image_Composer_Imagick`, `Image_Composer_Canvas`,
and `Image_Composer_Layout` must not call WordPress functions — they are exercised by
a standalone render harness (and the determinism test) without a WP bootstrap. Only
the orchestrator (`Image_Composer`) and the GD rung (`Image_Composer_Editor`) may
touch WP APIs.
3. **Never fatal a publish.** Any new render path needs a try/catch that degrades to
pass-through and logs at most one WARNING per run (`warn_once()`). Capability checks
go through the cached probe (`prautoblogger_image_compose_capability` option +
same-named override filter) — never re-probe per image.
Adding a new variant role: extend `Layout::defaults()` + the renderer, whitelist the
role in `Image_Composer::configured_variants()` and in
`Image_Attacher::ROLE_POST_META` (new `_prautoblogger_{role}_image_id` post meta keeps
the uninstall prefix-sweep effective), then cover it in the unit ladder tests.
---
## How To: Add a New Admin Setting
1. Define the option key in the settings array in `admin/class-admin-page.php` → `get_settings_fields()` method
2. Format: add an entry to the appropriate section array:
```php
[
'id' => 'prautoblogger_new_setting',
'label' => __('Setting Label', 'prautoblogger'),
'type' => 'text', // text, number, select, textarea, checkbox, password
'default' => '',
'description' => __('Help text shown below the field.', 'prautoblogger'),
'sanitize' => 'sanitize_text_field', // sanitization callback
]
```
3. The settings page renderer handles registration, rendering, and sanitization automatically from this array.
4. Access the setting anywhere with: `get_option('prautoblogger_new_setting', $default)`
5. For encrypted settings (API keys): use `PRAutoBlogger_Encryption::encrypt()` / `decrypt()`
---
---
## Retired Settings Tabs (M2, v0.24.0)
Settings tabs may be retired when their fields are promoted to a more contextual UI
surface (e.g. Pipeline Settings per-step panels). The retirement pattern:
1. **Remove the section** from `PRAutoBlogger_Settings_Fields::get_sections()`.
2. **Remove field definitions** from `get_core_fields()` / `_Extended::get_fields()`.
This stops them appearing in the Settings page and stops `register_setting()` calls
for them — which is correct: the new UI surface (Pipeline page) has its own
nonce+capability+sanitize handler and does NOT use the WP Settings API.
3. **DO NOT delete the wp_option.** The underlying data key is unchanged.
`uninstall.php` purges everything via `LIKE 'prautoblogger\_%'`.
4. **Add field definitions to** `PRAutoBlogger_Pipeline_Settings_Option_Fields_Data`
under the appropriate context method.
5. **Document** the retirement in ARCHITECTURE.md #25 and CHANGELOG.md.
6. **Do not add `register_setting()`** in the Pipeline page — its save handler sanitizes
directly, so WP Settings API registration is not needed.
Retired sections in M2: `prautoblogger_models`, `prautoblogger_content`, `prautoblogger_sources`.
## How To: Add a New Pipeline Stage
Writing stages are methods on `Content_Generator` that delegate to its `execute_stage()`
helper (Opik span + LLM dispatch + cost log + run-stage state in one place).
1. Add the stage method to `class-content-generator.php` and call
`$this->execute_stage( $stage, $span_name, $user_prompt, $request, $model, $options )`
2. Wire it into `generate_multi_step()` (or the relevant mode path)
3. Add the stage to `PRAutoBlogger_Stage_Display_Map` (label, default agent role, prompt key)
and — if it has registry-managed copy — a prompt key in the defaults classes
4. Cost logging and per-run stage state (idempotent resume, output snapshot) come free from
`execute_stage()`; the per-run cost governor guards the call inside the provider
5. If a stage fails after retries, the entire pipeline fails (no partial publishes); the
stage row is failed by the worker's catch-all and the run is swept by Run_Reaper
---
## How To: Add a New Frontend Component
Frontend components use `wp.element` (WordPress-bundled React) — no JSX, no build step.
1. Create the PHP class in `includes/frontend/class-{name}.php`:
- Register a shortcode via `add_shortcode()` in an `on_register_shortcode()` method
- Register a REST endpoint if the component needs async data (in `on_register_rest_route()`)
- Enqueue JS (`wp-element` dependency) and CSS via `wp_enqueue_script/style()`
- Pass config to JS via `wp_localize_script()`
2. Create JS in `assets/js/{name}.js`:
- Use `wp.element.createElement` (aliased as `el`) for rendering
- Import hooks: `var useState = wp.element.useState;`
- Read config from `window.{localizedObjectName}`
- Handle all states: loading, loaded, empty, error
- Mount into the shortcode's `<div id="...">` mount point
3. Create CSS in `assets/css/{name}.css`:
- Use Peptide Starter theme CSS custom properties with fallbacks: `var(--color-text-primary, #f9fafb)`
- Prefix all classes with `prab-` to avoid collisions
- Include responsive breakpoints, focus-visible states, and loading skeletons
4. Wire hooks in `class-prautoblogger.php`:
- Instantiate the PHP class in `register_frontend_hooks()`
- Register shortcode on `init`, REST routes on `rest_api_init`
5. Add `'frontend/'` to the autoloader's `$directories` array (already done for the first component)
6. Update ARCHITECTURE.md file tree and add a data flow section
### REST endpoint conventions:
- Namespace: `prautoblogger/v1`
- Permission callback: use a filter hook for extensibility (e.g., `prautoblogger_rest_{name}_public`)
- Sanitize all params with `sanitize_callback` in arg registration
- Return only the fields the frontend needs — no unnecessary data
- Set `Cache-Control` header for cacheable responses
---
## Code Quality Rules
1. **`declare(strict_types=1);`** in every PHP file
2. **Type declarations** on all method parameters, return types, and class properties
3. **No file exceeds 300 lines.** Split when approaching this limit
4. **Every class has a 3-question preamble docblock:** What / Who triggers / Dependencies
5. **Every public method has a docblock** with `@param`, `@return`, and side effects
6. **`@see` references** at the top of files that participate in multi-class flows
7. **All strings translatable** via `__()` / `_e()` with `prautoblogger` text domain
8. **No magic methods** without explicit justification documented in code
9. **No `echo` of raw data** — always escape with `esc_html()`, `esc_attr()`, etc.
10. **Nonce on every form/AJAX** — `wp_nonce_field()` / `check_ajax_referer()`
## How To: Add an OpenRouter model picker field (v0.11.0+)
Use the `model_select` field type to let admins choose OpenRouter models with a searchable dropdown, capability filtering, and estimated cost preview based on usage history.
### 1. Declare the field in `class-settings-fields.php`
```php
[
'id' => 'my_plugin_model',
'label' => __( 'My Model Setting', 'my-plugin' ),
'type' => 'model_select',
'section' => 'my_plugin_settings',
'default' => 'anthropic/claude-3.5-haiku',
'capability' => 'text→text', // see ARCHITECTURE.md §18 for all capability strings
'description' => __( 'Choose a model...', 'my-plugin' ),
'badge' => __( 'Quality', 'my-plugin' ),
],
```
### 2. Map the setting to pipeline stages (for cost preview)
If you want cost preview to work, add your setting ID and stage list to the `get_stages_for_setting()` method in `includes/admin/fields/class-open-router-model-field.php`:
```php
private static function get_stages_for_setting( string $field_id ): array {
$map = [
'prautoblogger_analysis_model' => [ 'analysis' ],
'prautoblogger_writing_model' => [ 'outline', 'draft', 'polish' ],
'prautoblogger_editor_model' => [ 'review' ],
'my_new_model_setting' => [ 'my_stage' ], // ADD HERE
];
return $map[ $field_id ] ?? [];
}
```
The cost preview queries `get_avg_tokens_for_stages()` on the Cost Tracker to show estimated cost per generation based on the last 30 days of usage.
### 3. That's it
The field renderer, AJAX refresh, model picker JS, and capability filtering are already wired. The admin will see a "Refresh Model List" button on the AI Models tab (or whichever tab contains the picker field).
### Capability strings (locked in v0.11.0)
- `text→text` — text input, text output (default for analysis/writing/editor)
- `text+image→text` — vision models
- `text+audio→text` — audio input models
- `text→image` — image generation
- `text→audio` — voice synthesis
- `text→video` — video generation
- `text→embedding` — embeddings
Phase 3 will add provider selection; v0.11.0 is OpenRouter-only.
## How To: Change a Pipeline Prompt (v0.18.0+)
Prompt copy is **versioned data**, not code. The bodies live in
`wp_prautoblogger_prompts` (one ACTIVE version per key); the hardcoded texts in
`class-prompt-defaults.php` / `class-prompt-defaults-editorial.php` are the canonical v1 seed
AND the fallback when the table is unavailable — never edit a stored version in place.
1. **Never `UPDATE` a body.** Create a new version and activate it:
```php
$v = PRAutoBlogger_Prompt_Registry_Writer::create_version(
'content.polish', $new_body, $model, $params, 'your-name', true
);
```
2. **Keys** are dot-namespaced: `content.system`, `content.single_pass`, `content.outline`,
`content.draft`, `content.polish`, `analysis.system`, `analysis.user`, `editor.system`,
`editor.review`, `research.system`, `image.rewriter_system`, `image.style_template`.
The two `image.*` keys are the image-composer seam — coordinate on the convo thread
before touching them.
3. **Tokens** use the `{{ name }}` syntax (exactly one space each side — same convention as
the Style Template's `{{ topic_summary }}`). Token VALUES are computed by code
(`Content_Prompts`, `Analysis_Prompts`, `Chief_Editor`); a new prompt version may reorder
or drop tokens but must not invent new ones without adding the value at the call site.
4. **Pinning.** A run pins the active version of every key at start
(`runs.pinned_prompts_json`, written by `Cost_Tracker::set_run_id()`); every
generation_log row is stamped with the pinned `prompt_version`. Activating a new version
affects the NEXT run, never a run in flight.
5. **Call sites** render via `PRAutoBlogger_Prompt_Registry::render( $key, $tokens )` and
must keep working when the table is absent (the registry falls back to the defaults
automatically — do not add your own fallback).
6. If you add a key: define the body in the appropriate defaults class, add it to `defs()`,
map its stage in `Stage_Display_Map`, and it will seed on the next migration pass.
## Git Workflow — PR-Gated, Soft-Enforced
This repo is private on GitHub's free plan, which does not support branch protection or rulesets. The review gate is enforced at the agent layer, not server-side. Every agent and human contributor follows these rules; the `.github/workflows/main-push-audit.yml` tripwire opens an audit issue on any direct push to `main` that did not come from a merged PR.
### Rules
1. **Never push to `main` directly.** Every change lands via a pull request that the maintainer merges. No self-merging. No force-pushing to `main`.
2. **Branch naming**: `claude/<scope>-<YYYYMMDD>` for agent-authored work, `fix/<scope>` or `feat/<scope>` for human-authored. The scope is a 1–3 word kebab-case description.
3. **Commit trailer**: every commit authored by an agent must end with an `Agent-Session:` trailer so commits can be correlated back to the conversation that produced them, even though all commits share the `peptiderepo` bot identity.
```
feat: add per-request cost tracking
Agent-Session: cowork-2026-04-14-cost-audit
```
4. **PR description template**: every PR description covers
- **What changed** (one paragraph)
- **Why** (motivation or incident link)
- **Risk flags** (schema changes, API contract changes, cost impact, compatibility)
- **Test plan** (what was run locally, what to smoke-test after merge)
5. **Emergency push exception**: if a situation genuinely requires pushing to `main` without a PR (site down, CI broken, one-line hotfix), surface it in the chat before doing it. Every emergency push gets a follow-up PR that commits the same changes through the normal flow so git stays the source of truth. The tripwire will open an audit issue — close it with a comment explaining why.
### Opening a PR from an agent session
The `gh` CLI is not installed in the Cowork sandbox. Use `curl` with the PAT from the workspace `.env.credentials`:
```bash
GH_TOKEN=$(grep "^GITHUB_PAT=" "$WORKSPACE/.env.credentials" | cut -d= -f2)
git push -u origin HEAD
curl -s -X POST -H "Authorization: token $GH_TOKEN" \
-H "Content-Type: application/json" \
"https://api.github.com/repos/peptiderepo/<repo>/pulls" \
-d "$(jq -cn --arg title "<title>" --arg head "<branch>" --arg body "<body>" \
'{title:$title, head:$head, base:"main", body:$body}')"
```
### When this changes
If the peptiderepo GitHub account is ever upgraded to Pro (or the repo goes public), replace this soft-enforcement section with a note pointing to the real branch protection rules in repo settings.
## Instrumenting a New LLM Call Site for Opik (v0.12.0+)
When you add a new LLM call (via `$llm->send_chat_completion()`), follow this pattern to capture it in Opik:
### 1. Get the trace context
At the start of your method, grab the per-request singleton:
```php
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
```
### 2. Start the span
Before the LLM call, declare a span with metadata:
```php
$span_id = $ctx->start_span(
array(
'name' => 'my_llm_operation', // kebab-case, descriptive
'type' => 'llm',
'model' => $model, // e.g. 'gpt-4', from get_option()
'provider' => 'openrouter', // or the actual provider name
'input' => array( 'key' => 'value' ), // optional metadata (no full prompts!)
)
);
```
### 3. Make the LLM call (unchanged)
```php
$response = $this->llm->send_chat_completion(
array(
array( 'role' => 'system', 'content' => $system_prompt ),
array( 'role' => 'user', 'content' => $user_prompt ),
),
$model,
array( 'temperature' => 0.7, 'max_tokens' => 4000 )
);
```
### 4. End the span with output
After the response, record the outcome:
```php
$ctx->end_span(
$span_id,
array(
'output' => array( 'response_length' => strlen( $response['content'] ?? '' ) ),
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
)
);
```
### Key rules
- **Never log full prompts.** Use prompt hashes or version tags in `input`. Full text blows payload size and leaks content policy.
- **Check the feature flag at call-site level?** No. The singleton is always available; Opik_Dispatcher checks the flag before dispatch.
- **Async dispatch?** Yes. Spans are queued; dispatch happens via `prautoblogger_opik_dispatch` cron (no blocking).
- **Cost logging?** Unchanged. Your `cost_tracker->log_api_call()` remains separate from Opik; Opik is observability-only.
### Example: minimal instrumentation
```php
public function my_operation( $input ) {
$model = get_option( 'my_model', 'default-model' );
$ctx = PRAutoBlogger_Opik_Trace_Context::current();
$span_id = $ctx->start_span(
array(
'name' => 'my_operation',
'type' => 'llm',
'model' => $model,
'provider' => 'openrouter',
)
);
$response = $this->llm->send_chat_completion( /* ... */ );
$ctx->end_span(
$span_id,
array(
'usage' => array(
'prompt_tokens' => $response['prompt_tokens'],
'completion_tokens' => $response['completion_tokens'],
'total_tokens' => $response['prompt_tokens'] + $response['completion_tokens'],
),
)
);
return $response['content'];
}
```
That's it. No config, no feature-flag checks, no try/catch needed.
---
## Hidden Admin Pages (v0.19.3 convention, codified after two incidents)
### Rule: use options.php as parent; NEVER unset/remove registered pages from $submenu
When a WordPress admin page must be accessible by URL but hidden from the nav menu, use the
**options.php-parent pattern** exclusively:
```php
add_submenu_page(
'options.php', // parent: built-in WP page; pages hidden by construction
__( 'My Page', 'prautoblogger' ), // page title
__( 'My Page', 'prautoblogger' ), // menu title (never shown; options.php is hidden)
'manage_options', // capability
'my-page-slug', // page slug
array( $this, 'render_page' ), // callback
);
// No $submenu manipulation. options.php-parent pages are hidden from nav by construction.
```
The resulting hookname is `admin_page_my-page-slug` at BOTH registration time and request time.
### Why the hide-by-unset pattern causes 403 (the incident class)
WordPress computes the page hookname at two distinct moments:
**Registration time** (inside `add_submenu_page`):
WP resolves the parent slug against `$admin_page_hooks`. If found, hookname = `{parent_key}_page_{slug}`. WP registers the render callback and `$_registered_pages` entry under that name.
**Request time** (inside `wp-admin/admin.php`):
WP calls `get_admin_page_parent()` which scans `$submenu` for the page slug. If found, it derives the hookname the same way as (1). If **not found** (e.g., because the entry was unset), it falls back to `admin_page_{slug}`.
When these two hooknames diverge, WP dispatches the request to a hookname with no registered callback → `wp_die(403)`.
**Why options.php avoids this:** `options.php` is a built-in page that WordPress always keeps intact in `$admin_page_hooks` and `$submenu`. For its children, `get_admin_page_parent()` returns `options.php` consistently at both moments, so both produce `admin_page_{slug}`. No mutation required.
### Incident history
| Version | Symptom | Root cause | Fix |
|---------|---------|------------|-----|
| v0.19.1 | Board page 404 | Board submenu registered at same priority as parent (both 10) — add_submenu_page fired before add_menu_page → wrong hookname at registration time | Board moved to priority 11, after parent at 10 |
| v0.19.3 | Dossier page 403 | Dossier registered under `prautoblogger-settings` parent (priority 12, after parent ✓) but post-registration `unset($submenu[...])` removed the slug → get_admin_page_parent() returned false at request time → orphan `admin_page_*` hookname → no handler → 403 | Switched to `options.php` parent; removed all $submenu mutation |
Both incidents are in the same **hookname-mismatch class**: registration-time hookname ≠ request-time hookname.
### Test pattern
The `DossierMenuRegistrationTest::test_request_time_hookname_matches_registration_hookname` test
replicates the request-time resolution algorithm (scan `$submenu` for the slug; if found derive
hookname; if absent return `admin_page_*`) and asserts it equals the registration-time hookname.
This test FAILS on hide-by-unset implementations and passes on the options.php-parent pattern.
Apply the same test strategy to any new hidden admin page.
### Asset enqueue gate
For `options.php`-parent pages, the hook suffix passed to `admin_enqueue_scripts` is
`admin_page_{slug}`. For plugin-parent pages it would be `{parent_key}_page_{slug}`. Always
match the asset enqueue gate to the actual hookname for the chosen parent pattern:
```php
// options.php parent → 'admin_page_{slug}'
public function on_enqueue_assets( string $hook_suffix ): void {
if ( $hook_suffix !== 'admin_page_' . self::PAGE_SLUG ) {
return;
}
// ... enqueue
}
```
---
## How To: Add a New Pipeline-Style Admin Page
Use this pattern when a page needs per-step or multi-section rendering with
a separate save-handler (i.e. when the page is too complex for a single-class
approach). The Pipeline Settings page (`prautoblogger-pipeline`) is the
canonical reference.
### Four-class split
| Class | File | Responsibility |
|-------|------|---------------|
| `PRAutoBlogger_{Name}_Page` | `class-{name}-page.php` | Registration, asset enqueue, render entry-point. Delegates immediately. |
| `PRAutoBlogger_{Name}_Renderer` | `class-{name}-renderer.php` | Gathers ALL view data (options, spend, computed values). Passes a typed `$view` array to the template. No raw echo; no DB writes. |
| `PRAutoBlogger_{Name}_Save_Handler` | `class-{name}-save-handler.php` | Stateless. Verifies nonce + capability, sanitises input against an allowlist, persists. Returns `{status, message}`. Called before output starts. |
| `PRAutoBlogger_{Name}_Step_Map` | `class-{name}-step-map.php` | Single source of truth for step metadata and key allowlists. Read by both Renderer and Save_Handler. |
### Registration priority
The page submenu must register AFTER the parent menu. Convention:
- Parent menu: priority 10 (`PRAutoBlogger_Admin_Page`)
- Board: priority 11
- Dossier: priority 12
- Pipeline: priority 13
- Next pipeline-style page: priority 14+
### Renderer contract (non-negotiable)
The renderer must gather **all** data the template needs — including service
objects like `PRAutoBlogger_Cost_Reporter` — so the template receives only
plain scalars and arrays:
```php
// In Renderer::render():
$cost_reporter = new PRAutoBlogger_Cost_Reporter();
$view['monthly_spend'] = $cost_reporter->get_monthly_spend();
$view['budget'] = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 );
```
**Never** instantiate service objects inside a template. The template must be
a logic-free view layer.
### Save-handler model-field POST key
`PRAutoBlogger_OpenRouter_Model_Field::render($id, ...)` emits:
```html
<input type="hidden" id="$id" name="$id" value="..." />
```
`model-picker.js` writes the selected model to that input **by DOM id**, so
the form posts the value under the option name, not a separate `model_id` key.
In the save handler, read:
```php
$model_id = isset( $_POST[ $option_name ] )
? sanitize_text_field( wp_unslash( $_POST[ $option_name ] ) )
: '';
```
Do **not** read `$_POST['model_id']` — that key is never present.
### Nonce / capability pattern
```php
// 1. Check method (bail silently if not POST or nonce field absent).
if ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) { return $idle; }
if ( empty( $_POST[ NONCE_FIELD ] ) ) { return $idle; }
// 2. Capability BEFORE nonce (avoids timing oracle for unauthenticated users).
if ( ! current_user_can( 'manage_options' ) ) { return error; }
// 3. Verify nonce.
if ( ! wp_verify_nonce( ... ) ) { return error; }
```
### Allowlist enforcement
All POST keys that map to DB writes (model option names, prompt keys) must be
validated against a static allowlist before use. Define the allowlist on
`Step_Map` and call it from the save handler:
```php
if ( ! in_array( $option_name, PRAutoBlogger_{Name}_Step_Map::allowed_model_options(), true ) ) {
return array( 'status' => 'error', 'message' => __( 'Unknown model option.', 'prautoblogger' ) );
}
```
### Prompt key slug round-trip
Form fields carry dots as hyphens (`content.single_pass` → `content-single_pass`)
because HTML name attributes cannot contain dots in some contexts.
`sanitize_key()` preserves underscores, so the slug round-trip is unambiguous — no two
keys produce the same slug. Use `sanitize_key( str_replace( '.', '-', $key ) )` to compare,
not string parsing:
```php
private static function resolve_key_from_slug( string $slug ): ?string {
foreach ( Step_Map::allowed_prompt_keys() as $key ) {
if ( sanitize_key( str_replace( '.', '-', $key ) ) === $slug ) {
return $key;
}
}
return null;
}
```
### Tests
Every pipeline-style page must have PHPUnit coverage for:
- `Save_Handler`: allowlist enforcement (valid + invalid keys/options), correct
option persistence, prompt versioning (`create_version()` called with correct
key and `$activate = true`), `resolve_key_from_slug()` round-trip.
- `Step_Map`: `allowed_prompt_keys()` and `allowed_model_options()` regression
guards so keys do not drift from the registry.
Place tests under `tests/unit/Admin/PipelineSettings*Test.php`, extending
`PRAutoBlogger\Tests\BaseTestCase`.
---
## Authority Pipeline — Phase 2b conventions (v0.28.0)
### Adding a new pipeline stage
Each stage is a self-contained class behind an interface. Pattern:
1. Create `includes/providers/interface-<stage>-<role>.php` (the contract).
2. Create `includes/core/class-<stage>-<role>.php` implementing it.
3. If the implementation grows past 200 lines of logic, extract helpers into a
`class-<stage>-<helper>.php` — see `Research_Batch` (curl_multi layer) and
`Research_Source_Scorer` (weighting math) as examples.
4. Register the stage in `Stage_Display_Map::MAP` if it is net-new vocabulary.
5. PHPUnit tests must cover: quorum/gating logic, cost-reserve call verification,
invalid-output exclusion, graceful absent-table degradation.
### Research fan-out settings
| Option key | Default | Notes |
|---|---|---|
| `prautoblogger_research_agent_count` | 3 | Clamped 1–5. |
| `prautoblogger_research_model` | `PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL` | Per-run; read at call time. |
### Cost-reserve discipline for fan-out batches
Before dispatching N parallel calls, always reserve the SUMMED worst-case estimate:
```php
$per_estimate = PRAutoBlogger_Cost_Governor::estimate_chat_cost( $model, $messages[0], $options );
$reservation = PRAutoBlogger_Cost_Governor::open_amount_reservation(
$per_estimate * $n,
"stage_name:n={$n}:{$model}"
);
// ... dispatch ...
PRAutoBlogger_Cost_Governor::settle( $reservation, $actual_total );
```
Never reserve inside the per-agent loop — that creates a race between the ceiling
check and concurrent call dispatch.
---
## How To: Add a Bounded Editorial Loop Caller (P2b.2+)
The Authority-tier editorial pipeline uses a bounded editor<->writer loop
(`PRAutoBlogger_Editorial_Loop`) instead of the single-pass `Chief_Editor::review()`.
### Key patterns
- **Loop bound** is driven by `get_option('prautoblogger_editorial_max_rounds', 3)`,
clamped to `[1, 10]`. Never hardcode round counts.
- **Escalation**: when max rounds are exhausted without approval, `run()` returns `''`
and `was_escalated()` is `true`. The caller must handle this (save as draft to Review Queue).
- **Inline revision path**: if the editor's `PRAutoBlogger_Editorial_Review` object
carries a non-null `revised_content`, use it directly without calling the writer LLM.
- **Writer revision**: when no inline content is available, delegate to
`PRAutoBlogger_Editorial_Revision_Caller::call()`. Never put writer LLM logic inside
`Editorial_Loop` itself -- keeps it under 300 lines.
- **Round recording**: every round (including escalation) writes one `run_decisions` row
via `PRAutoBlogger_Audit_Writer::record_decision()`. Round rows carry `stage='editorial'`,
`verdict='approved'|'revised'|'rejected'`, `rationale='Round N: <notes>'`. Escalation
rows carry `verdict='escalated'`.
- **Stage lifecycle**: each editor check opens `run_stages` with `role='editor'` via
`Run_Stage_State::start()`; each writer revision opens with `role='writer'`. Both close
on `Run_Stage_State::done()` after their respective result is available.
- **Cost**: all LLM calls flow through the existing provider seam; cost governor reserves
are handled per-call inside `Chief_Editor::review()` and `Editorial_Revision_Caller::call()`.
No additional reservation needed in `Editorial_Loop::run()`.
### Splits to preserve the 300-line cap
| Class | Responsibility |
|---|---|
| `PRAutoBlogger_Editorial_Loop` | Loop orchestration, verdict routing, round recording, escalation |
| `PRAutoBlogger_Editorial_Revision_Caller` | Writer LLM call, stage lifecycle for 'writer' role |
| `PRAutoBlogger_Content_Prompts` | Static `build_revision_system()` + `build_revision_user()` |
| `PRAutoBlogger_Editorial_Round` | Immutable value object (one round snapshot) |
---
## Authority Pipeline — SEO Stage (P2b.3, v0.30.0)
### Design rules
The SEO stage is **deterministic** — no LLM calls, no external I/O beyond WordPress
post-meta writes and DB audit rows. It is the contract point between PRAB (writes
`_prab_*` meta) and prcore (reads meta to emit JSON-LD schema).
### Key patterns
- **No LLM calls**: `PRAutoBlogger_Seo_Stage::run()` is pure computation +
meta-write. All data comes from arguments already produced by earlier stages.
- **citation_score**: `sum(quality_score) / count(kept_sources)`. Returns `0.0`
when `$kept_sources` is empty — never divide-by-zero.
- **_prab_reviewed_by omitted**: the automated path never writes this key. Only
P2b.4 (human Review Queue approval) sets it. Absence signals `editorial-system` mode.
- **Threshold is additive**: `prautoblogger_citation_score_threshold` is read and
logged but the publish gate itself is in P2b.4. This stage stores the score only.
- **Stage lifecycle**: `Run_Stage_State::start('seo', 'seo', $item_key)` before writes;
`::done()` after. Decision row via `Audit_Writer::record_decision()` with
`stage='seo'`, `verdict='scored'`, `citation_score=$score`.
- **JSON values**: always use `wp_json_encode()`, never `json_encode()` directly.
- **Timestamps**: always use `gmdate('Y-m-d\TH:i:s')`, never `date()`.
### _prab_* key reference (ratified contract v1)
| Key | Type | Notes |
|---|---|---|
| `_prab_schema_version` | int `1` | Opt-in trigger — prcore emits JSON-LD only when present |
| `_prab_citations` | JSON string | `[{url, title, doi?, quality_score?}]` — kept sources only |
| `_prab_about_peptides` | JSON string | `[post_id, ...]` — related peptide IDs |
| `_prab_review_mode` | string | `editorial-system` (automated) or `human` (P2b.4) |
| `_prab_reviewed_at` | string | ISO 8601 datetime |
| `_prab_reviewed_by` | int | WP user ID — written only by P2b.4 on human approval |
| `_prab_citation_score` | string (float) | Avg quality_score of kept sources; publish gate in P2b.4 |
---
## Tier Routing + Master Flag Pattern (P2b.4, v0.31.0)
### Design rules
The master flag `prautoblogger_authority_pipeline_enabled` (default `false`) gates
the entire Authority pipeline. When OFF, `PRAutoBlogger_Tier_Router::resolve()` MUST
return `economy` unconditionally — no category-map reads, no secondary checks. This
guarantees zero behaviour change in production on deploy.
### Key patterns
- **Master flag check first**: always the first branch in `resolve()`. If false, return
economy immediately without reading the category map.
- **Economy is the safe default**: if the category map is empty, absent, or corrupt,
the tier defaults to authority (the additive default when flag is ON); but the flag
being OFF is the primary safety gate.
- **Per-category demotion**: the `prautoblogger_category_tiers` option stores a
serialized PHP array `[category_slug => economy]`. Only economy is a meaningful
explicit value — other values fall through to the Authority default.
- **Article_Worker integration**: three-line check before the Economy path:
```php
$tier = ( new PRAutoBlogger_Tier_Router() )->resolve( $idea );
if ( authority === $tier ) {
return ( new PRAutoBlogger_Authority_Pipeline( $cost_tracker ) )->run( $run_id, $idea, $cost_tracker );
}
```
The Economy path below the check is UNCHANGED — the branch is additive-only.
- **Imagery gate**: held articles (citation gate miss, escalation, or cost ceiling) get
`_prautoblogger_imagery_suppressed = 1` post-meta. Image pipeline checks this key
before running. Publish-gate-passed articles never have this key set.
---
## Authority Pipeline Options (P2b.5, v0.32.0)
### Option naming pattern
All Authority-pipeline controls follow the `prautoblogger_` prefix convention:
| Option | Type | Notes |
|--------|------|-------|
| `prautoblogger_authority_pipeline_enabled` | toggle (`'0'`/`'1'`) | Master switch; default `'0'` (OFF) |
| `prautoblogger_citation_score_threshold` | int 0–100 | Publish gate; 0 = skip gate while calibrating |
| `prautoblogger_category_tiers` | serialized PHP array | `[slug => 'economy']`; managed via parse_and_save_category_tiers() |
| `prautoblogger_category_tiers_input` | virtual textarea | Display-only; not a stored wp_option — see renderer special-case |
| `prautoblogger_research_agent_count` | int 1–5 | Fan-out agent count; default 3 |
| `prautoblogger_editorial_max_rounds` | int 1–5 | Editorial loop bound; default 3 |
| `prautoblogger_curate_model` | string | Model for the Curate step (research judge) |
| `prautoblogger_seo_model` | string | Model for the SEO stage LLM call |
| `prautoblogger_seo_instructions` | textarea | Custom instructions for SEO stage |
### parse_and_save_category_tiers() pattern
The category tier map is a special two-key surface:
1. **Display** (`prautoblogger_category_tiers_input`): The renderer reads
`prautoblogger_category_tiers` (a serialized array) and converts it to
`"slug: tier"` lines for the textarea. This textarea key is NOT a stored option.
2. **Save** (`prautoblogger_category_tiers`): After the normal field loop,
`Save_Handler::handle_step_settings_save()` calls `parse_and_save_category_tiers()`
which reads the `prautoblogger_category_tiers_input` POST value, splits on newlines,
validates each line, and writes the array to `prautoblogger_category_tiers`.
3. **Additive-safety rule**: any tier value other than `'economy'` is stored as
`'authority'`. This means a typo or empty tier always chooses the more conservative
path (more vetting, not less).
### contexts() extension rule
Adding a new Authority-pipeline admin step requires:
1. Add the step to `PRAutoBlogger_Pipeline_Settings_Step_Map::steps()`.
2. Add the context string to `PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts()`.
3. Add the field data to `PRAutoBlogger_Pipeline_Settings_Option_Fields_Data_Authority::fields_for()`.
4. Update ARCHITECTURE.md options table and this section.