# ADR-097 Blogsmith AI Content Pipeline — Keyword to Published Article (Standalone App)

## Status

Accepted, 2026-07-01 (superseding earlier integrated design).

## Status History

```yaml
status_history:
  - date: 2026-07-02
    status: Accepted (amended)
    changed_by: hkl
    reason: >
      Two features added: (1) Category from keyword — article output contract
      gains a `category` field (1-3 words, Title Case, reusable bucket);
      resolve_wp_category() maps it to a WordPress category id at publish
      time, matching existing categories case-insensitively and creating the
      category when missing (term_exists 400 handled); category resolution
      failure never blocks the publish (post falls back to the site default).
      Verified live: WP category "Retail Branding" auto-created and assigned.
      (2) "Published on WordPress" PWA section — dedicated list of all live
      (published/scheduled) posts with view-live links and one-click
      Unpublish (WordPress trash via existing dismount endpoint); history
      list's "dismount" button relabelled "Unpublish". SW cache bumped to
      blogsmith-v3; Playwright suite extended (5 tests, all passing).
    changed_via: claude session PWA-BLOG (manual edit)
  - date: 2026-07-02
    status: Accepted (amended)
    changed_by: hkl
    reason: >
      WordPress credential onboarding reworked for Hostinger hPanel-managed
      sites (user's live site 360degreelogicalmktg.com is administered via
      hPanel, so users reach WP Admin through hPanel's one-click login and
      never know a WordPress password). Changes: (1) WP Settings panel now
      embeds a step-by-step hPanel walkthrough (hPanel → Websites → WP Admin
      → Users → Profile → Application Passwords), auto-expanded when no
      credentials are saved yet; (2) new POST /api/wp-test endpoint validates
      credentials live against GET /wp-json/wp/v2/users/me?context=edit,
      merging form values over stored ones (blank password = test saved one),
      with actionable error mapping (401/403 bad credentials, 404 REST API
      blocked/not WordPress, unreachable host) and a publish_posts capability
      check; (3) Save now auto-runs the connection test. SW cache bumped to
      blogsmith-v2; Playwright spec covers the new panel. Publishing
      mechanism itself unchanged — Hostinger hosts standard WordPress, so
      Application Password + REST API remains correct.
    changed_via: claude session PWA-BLOG (manual edit)
  - date: 2026-07-01
    status: Accepted (amended)
    changed_by: hkl
    reason: >
      Filesystem path migrated from /var/www/Others/blogsmith/ to
      /var/www/Others/Automation/blogsmith/ (new automation-tools grouping folder).
      Subdomain remains unchanged. WordPress publishing model switched from global
      env vars (WORDPRESS_URL/WORDPRESS_USER/WORDPRESS_APP_PASSWORD) to per-user
      database columns (wp_url, wp_username, wp_app_password on users table).
      Added GET/POST /api/wp-settings endpoints; user-configurable WordPress;
      dry-run per-user when credentials absent instead of global flag. All schema,
      API, publish logic, and references updated accordingly.
    changed_via: adr-kit (360lm)
  - date: 2026-07-01
    status: Accepted (amended)
    changed_by: hkl
    reason: >
      Research tier-ordering corrected to strict ADR-062 compliance (Tier 1: Claude
      OAuth → Tier 2: OpenRouter web-grounded). Prior documentation incorrectly
      described research as web-grounded-first with OAuth fallback. Implementation
      now correctly attempts Claude OAuth first; OpenRouter web-grounded is Tier 2
      fallback only. Trade-off accepted: research defaults to model-knowledge-only
      (not live-grounded) in normal operation because OAuth is reliable. Maintains
      pipeline consistency; no special-cased exception to ADR-062.
    changed_via: adr-kit (360lm)
  - date: 2026-07-01
    status: Accepted
    changed_by: hkl
    reason: >
      Blogsmith architecture revised from integrated (360lm-embedded PWA + shared-DB
      schema) to standalone isolated app (ADR-086) mid-build, per explicit user
      requirement. Implementation now follows Health Tracker pattern: separate
      database, single-process Python app serving static PWA + API, PIN auth with
      server-side bcrypt storage, subdomain routing. No PostgREST or hub integration.
      AI pipeline logic unchanged (ADR-062 auth chain, research→draft→publish, no
      review gate). Status remains Accepted since standalone build ships with intended
      feature complete.
    changed_via: adr-kit (360lm)
  - date: 2026-07-01
    status: Accepted (amended)
    changed_by: hkl
    reason: >
      Two features added to blogsmith AI pipeline:

      (1) One-time site style matching: New POST /api/analyze-style endpoint
      analyzes up to 8 recent WordPress posts (plain text via GET
      /wp-json/wp/v2/posts), sends corpus through ADR-062 auth chain (Claude
      OAuth → OpenRouter fallback) with STYLE_SYSTEM_PROMPT to generate
      structured style profile (tone, formality, POV, sentence rhythm,
      vocabulary, structural habits, typical length). Result saved to new
      users.style_profile (JSONB) and users.style_profile_at columns. GET
      /api/style-profile returns stored profile. When present, draft_article()
      embeds profile in user prompt to match site voice. One-time manual
      trigger only—no automatic re-analysis. Verified live against
      wordpress.org/news.

      (2) Scheduled publishing via WordPress native scheduling: New
      jobs.publish_at (TIMESTAMPTZ, NULL) column and 'scheduled' status value.
      Generation (research + draft) always immediate; only WordPress publish
      deferred. publish_to_wordpress() sets WordPress status='future' with
      date_gmt (ISO UTC string) when scheduled, else status='publish'
      (immediate). _status_from_pub() helper maps outcome to job status. PWA
      offers "Publish now" / "Schedule" radio toggle with datetime-local input.
      No custom scheduler or cron—WordPress's wp-cron handles publish timing.
      dismount_job() extended to accept 'published' or 'scheduled' jobs.
      Verified: unit tests on publish_to_wordpress branching.

      Jobs.status CHECK constraint extended to include 'scheduled' and
      'unpublished'. Users table receives style_profile and style_profile_at
      columns. New API endpoints: POST /api/analyze-style and GET
      /api/style-profile.
    changed_via: adr-kit (360lm)
```

## Context

The 360lm platform operates multiple live AI pipelines (ADR-062). A new feature request
requires automating blog article generation from keywords: intake keyword → research step
(gather real facts) → draft step (generate article via master prompt) → structured output
→ auto-publish to WordPress.

**Isolation pattern (ADR-086):** This application does not belong in the 360lm ERP suite.
Unlike most 360lm PWAs (which share hub PIN auth, lm360 database, and employee user
population), blogsmith is a **standalone, isolated PWA**:
- Own database (`blogsmith`, not a schema in `lm360` or `lm360_prod`)
- Own authentication model (PIN-based server-side bcrypt, not hub SSO)
- Own domain (`blogsmith.srv1111289.hstgr.cloud`, not a PathPrefix on the main domain)
- Single-process app serving both static PWA files and API (mirrors Health Tracker)

**Pipeline structure:** Uses the three-tier AI auth chain from ADR-062 for all steps, operated
independently:
1. Research step via Claude OAuth (`claude -p`, Tier 1) → OpenRouter web-grounded Sonar (Tier 2) — strict ADR-062 compliance
2. Draft step via Claude OAuth → OpenRouter → Anthropic REST (cascading, per ADR-062)
3. Publish via WordPress REST API (dry-run fallback if credentials absent)

**Key difference from existing pipelines:** Blogsmith skips the human-review gate. Content
publishes directly to WordPress after AI generation — review happens post-publication on
the live site. This accepts risk (factual errors, tone issues) for speed.

**Explicitly NOT used:**
- 360lm's PostgREST / web_anon / hub SSO stack (ADR-012, ADR-014, ADR-074)
- 360lm's docker-compose.yml (separate docker-compose at app location)
- 360lm's PWA registry (no hub.pwa_registry entry; app is not hub-discoverable)
- Hub redirect auth flow (own PIN form, own session tokens in localStorage)
- Shared lm360 database schema (isolated database role with zero cross-schema grants)

## Decision

**Blogsmith is an isolated standalone PWA per ADR-086, implemented as a single Python
process that serves both the PWA static files and the AI content generation API.**

### 1. Location & Deployment

- **App directory:** `/var/www/Others/Automation/blogsmith/` (under new `Automation/` grouping folder,
  alongside `/var/www/Others/health/`, `/var/www/Others/drive-consolidator/` — outside the 360lm tree)
- **Single service:** `blogsmith_ai.py` — Python 3.11, stdlib `http.server`, port 8790
  - Serves static files from `public/` (index.html, manifest.json, sw.js, icons/)
  - Exposes JSON API endpoints for auth and content generation
- **Docker deployment:** `/var/www/Others/Automation/blogsmith/docker-compose.yml`
  - Image: `python:3.11-slim`
  - Installs `psycopg2-binary` at container start
  - Mounts: app code, Claude binary (read-only), `.claude/` credentials (read-write)
  - Environment via `.env` (DATABASE_URL, API keys; WordPress credentials now per-user)
  - Network: `root_default` (shared Docker network with Traefik)

### 2. Routing & Networking

- **Traefik rule:** `Host(blogsmith.srv1111289.hstgr.cloud)` (whole-host, not PathPrefix)
- **Entrypoints:** websecure only (TLS mandatory)
- **Cert resolver:** `zerossl`
- **Service port:** 8790
- **Network:** `root_default` (external network, same as Traefik)
- **Labels in docker-compose:**
  ```yaml
  - "traefik.enable=true"
  - "traefik.http.routers.blogsmith.rule=Host(`blogsmith.srv1111289.hstgr.cloud`)"
  - "traefik.http.routers.blogsmith.entrypoints=websecure"
  - "traefik.http.routers.blogsmith.tls=true"
  - "traefik.http.routers.blogsmith.tls.certresolver=zerossl"
  - "traefik.http.services.blogsmith.loadbalancer.server.port=8790"
  ```

### 3. Database — Isolated Role & Database

**Per ADR-086, isolated PWAs use dedicated database + role with zero cross-schema access:**

- **Database:** `blogsmith` (separate from `lm360`, `lm360_prod`)
- **Role:** `blogsmith_app` (no superuser, no createdb grants, no access to lm360 schemas)
- **Schema:** Single `blogsmith` schema with three tables (see schema below)
- **Credentials:** Passed to container via `DATABASE_URL` env var (format:
  `postgresql://blogsmith_app:password@postgres:5432/blogsmith`)
- **Backup:** Independent cron script (not part of lm360 backup window)

**Schema (migrate_blogsmith_v1.sql):**

```sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE users (
  id                BIGSERIAL     PRIMARY KEY,
  name              TEXT          NOT NULL,
  pin_hash          TEXT          NOT NULL,   -- crypt(pin, gen_salt('bf')) — bcrypt
  wp_url            TEXT,                     -- WordPress site URL; NULL = no WordPress configured
  wp_username       TEXT,                     -- WordPress admin username; NULL = not configured
  wp_app_password   TEXT,                     -- WordPress Application Password (stored plaintext); NULL = not configured
  style_profile     JSONB,                    -- site voice analysis: {tone, formality, point_of_view, sentence_rhythm, vocabulary_notes, structural_habits, typical_length_words}; NULL if not analyzed
  style_profile_at  TIMESTAMPTZ,              -- when style profile was last analyzed
  created_at        TIMESTAMPTZ   NOT NULL DEFAULT now()
);

CREATE TABLE sessions (
  token      TEXT          PRIMARY KEY DEFAULT encode(gen_random_bytes(32), 'hex'),
  user_id    BIGINT        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at TIMESTAMPTZ   NOT NULL DEFAULT now(),
  expires_at TIMESTAMPTZ   NOT NULL DEFAULT now() + interval '30 days'
);

CREATE TABLE jobs (
  id           BIGSERIAL     PRIMARY KEY,
  user_id      BIGINT        REFERENCES users(id) ON DELETE SET NULL,
  keyword      TEXT          NOT NULL,
  status       TEXT          NOT NULL DEFAULT 'pending'
               CHECK (status IN ('pending','researching','drafting','published','scheduled','failed','unpublished')),
  title        TEXT,
  wp_post_id   BIGINT,
  wp_url       TEXT,
  publish_at   TIMESTAMPTZ,                  -- target publication time; NULL = publish immediately
  error        TEXT,
  created_at   TIMESTAMPTZ   NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ
);
```

### 4. Authentication — PIN-Based, Server-Side Bcrypt, Device Auto-Reconnect

**Deliberate contrast to Drive Consolidator (which uses client-side SHA-256 hashing):**
Blogsmith stores PINs server-side as bcrypt hashes to protect against offline DB theft.

**Auth flow:**

1. **Register:** `POST /api/register` with `{name, pin}` (PIN: 4–8 digits)
   - Validates PIN format
   - Hashes via `crypt(pin, gen_salt('bf'))` — bcrypt, 12-round cost
   - Creates user record
   - Issues session token (32 random bytes, hex-encoded)
   - Returns `{user: {id, name}, token, expires_at}` to PWA
   - PWA stores token in `localStorage` (key: `blogsmith-token`), never stores PIN

2. **Login:** `POST /api/login` with `{user_id, pin}`
   - Verifies PIN via `crypt(pin, pin_hash) = pin_hash` (bcrypt comparison)
   - Issues new session token (30-day expiry)
   - Returns `{user, token, expires_at}`

3. **Session Persistence (auto-reconnect):** On page load, PWA retrieves stored token and
   silently calls `GET /api/jobs` with `Authorization: Bearer <token>`. If valid, user is
   logged in without re-entering PIN. On 401 (expired), falls back to login form.

4. **Logout:** `POST /api/logout` with Bearer token → deletes session row

**Session lifecycle:** 30-day expiry from creation; can be extended by logging in again.
Auto-logout if `expires_at` < now().

### 5. API Endpoints

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/health` | None | Health check: `{"ok": true, "service": "blogsmith-ai"}` |
| GET | `/api/users` | None | List all users (for user selection on login screen) |
| POST | `/api/register` | None | `{name, pin}` → `{user, token, expires_at}` |
| POST | `/api/login` | None | `{user_id, pin}` → `{user, token, expires_at}` |
| POST | `/api/logout` | Bearer | Deletes session, returns `{"ok": true}` |
| GET | `/api/wp-settings` | Bearer | Returns `{wp_url, wp_username, has_password: bool}` (password never exposed) |
| POST | `/api/wp-settings` | Bearer | `{wp_url?, wp_username?, wp_app_password?}` → updates user's WordPress settings; preserves password if omitted |
| POST | `/api/wp-test` | Bearer | `{wp_url?, wp_username?, wp_app_password?}` (form values merged over stored; blank password = saved one) → live credential check via `GET /wp-json/wp/v2/users/me?context=edit`; returns `{ok, wp_user, can_publish}` or actionable 400 error |
| POST | `/api/analyze-style` | Bearer | Analyzes WordPress posts, saves style profile to users.style_profile and style_profile_at |
| GET | `/api/style-profile` | Bearer | Returns `{style_profile, style_profile_at}` (or `{style_profile: null}` if not analyzed) |
| GET | `/api/jobs` | Bearer | Returns `{jobs: [...]}` for current user, ordered by created_at DESC |
| POST | `/api/generate` | Bearer | `{keyword, allow_api?}` → research + draft + publish, tracks status in jobs table |
| * | `/` + static paths | None | Serves public/index.html, manifest.json, sw.js, icons/ |

### 6. Content Generation Pipeline (AI Auth Chain per ADR-062)

**Research step (strict ADR-062 compliance):**
1. Try Claude OAuth (`claude -p` command, training-knowledge only) — Tier 1
2. Fallback to OpenRouter Perplexity Sonar or claude-haiku-4-5:online (web-grounded search) — Tier 2
3. Returns array of research notes + boolean `grounded` (true if web-grounded, false if model-knowledge)

**Trade-off rationale:** Because OAuth succeeds reliably in normal operation, research defaults to
model-knowledge-only notes (not live-web-grounded). This trade-off was explicitly confirmed; no
ADR-062 exception was granted. This maintains pipeline consistency with all other AI calls in
blogsmith.

**Draft step:**
1. Try Claude OAuth (`call_claude_oauth`, using MASTER_PROMPT)
2. Try OpenRouter free models (gemma-4, nemotron-super, etc.)
3. Try Anthropic REST API (only if user submitted `allow_api: true`; returns 503 with
   `need_api_permission: true` otherwise)
4. Returns structured article `{title, meta_description, slug, body_html, tags}` or error

**Publish step:**
- Fetches the logged-in user's WordPress settings (`wp_url`, `wp_username`, `wp_app_password`)
- If all three are configured:
  - POST to WordPress REST API (`{wp_url}/wp-json/wp/v2/posts`)
  - Auth via Basic (base64-encoded `{wp_username}:{wp_app_password}`)
  - Creates post with status='publish'
  - Returns `wp_post_id` and `wp_url`, sets job status='published'
- If any setting is missing:
  - **Per-user dry-run mode:** Logs the article payload to stderr instead of POSTing
  - Returns `{"dry_run": true, "wp_post_id": null, "wp_url": null}` and sets job status='drafting'
  - PWA displays "Article saved as draft; configure WordPress to publish"

**Job tracking:** Each `POST /api/generate` call creates a job record (status=pending) and
updates it through states: researching → drafting → published or drafting (terminal, if dry-run).
On publish attempt, `run_generation_job(user_id, keyword)` fetches that user's WordPress settings
via `get_wp_settings(user_id)` before calling `publish_to_wordpress(article, wp_settings)`.
PWA polls `GET /api/jobs` to display status and history; drafting jobs include a flag/message
explaining why they didn't publish (no WordPress credentials).

**Style Profile Analysis (One-Time):**

Users can optionally analyze their WordPress site's voice to inform article drafting.

- **Trigger:** "Analyze site style" button in PWA WordPress Settings panel (manual, one-time)
- **Flow:** `POST /api/analyze-style` (authenticated)
  - Fetches user's WordPress credentials
  - Calls `GET /wp-json/wp/v2/posts?per_page=8` → extracts plain text (HTML stripped, max 2000 chars/post)
  - Sends corpus + `STYLE_SYSTEM_PROMPT` through ADR-062 auth chain (Claude OAuth → OpenRouter fallback)
  - LLM returns structured JSON: `{tone, formality, point_of_view, sentence_rhythm, vocabulary_notes, structural_habits, typical_length_words}`
  - Saves to `users.style_profile` (JSONB) and `users.style_profile_at`
- **Retrieval:** `GET /api/style-profile` (authenticated) returns `{style_profile, style_profile_at}` or null if not analyzed
- **Usage:** When `draft_article()` runs, it fetches style profile and embeds it in user prompt if present: "Match this site voice: [profile]"
- **Notes:** One-time analysis only; user can re-click button to refresh. Verified live against wordpress.org/news.

**Scheduled Publishing:**

Jobs can defer publication to a specific future date/time. Generation (research + draft) always runs immediately; only WordPress publish is deferred.

Design: Zero custom scheduler. Uses WordPress's native `status='future'` + `date_gmt`; WordPress's wp-cron handles actual publish timing.

- **New schema:** `jobs.publish_at` (TIMESTAMPTZ, NULL); new status value `'scheduled'` in CHECK constraint
- **PWA input:** "Publish now" / "Schedule" radio toggle on keyword form; Schedule reveals `datetime-local` input, converted to UTC ISO string and sent as `publish_at`
- **Publishing logic:** `publish_to_wordpress(article, wp_settings, publish_at=None)`
  - If `publish_at` is future: POST to WordPress with `status='future'` and `date_gmt=<ISO UTC string>`
  - Otherwise (NULL or past date): POST with `status='publish'` (immediate)
  - Dry-run (no credentials): remains `'drafting'`
- **Job status via `_status_from_pub()` helper:**
  - WordPress POST succeeds, status='future' → job becomes `'scheduled'`
  - WordPress POST succeeds, status='publish' → job becomes `'published'`
  - Error or dry-run → `'failed'` or `'drafting'`
- **Article cleanup:** Local `article_json` cleared after successful WordPress POST (scheduled or immediate); WordPress holds canonical content
- **Dismounting:** `dismount_job()` now accepts `'published'` or `'scheduled'` jobs; deletes via WordPress DELETE
- **Verification:** Unit tests confirm `publish_to_wordpress()` sends correct payload in all branches

### 7. Master Prompt Versioning

Path: `/var/www/Others/Automation/blogsmith/master_prompt.md`

The prompt is stored as a separate file (not baked into Python code) so:
- Tone, structure, and quality improvements can be updated without redeploying the container
- Version history lives in git
- Alternative prompts can be tested via env var override

Quality guidelines embedded in the prompt:
- Heterogeneous paragraph and sentence length
- Avoid AI-tell phrases ("In conclusion", "It's important to note", etc.)
- Ground statements in research notes — no generic claims
- Natural voice, not templated

### 8. Initial Rollout — Per-User WordPress Configuration

On startup, the `.env` file contains only `DATABASE_URL` and `API keys` (Claude OAuth,
OpenRouter). WordPress credentials are no longer global; each user configures them individually:

- Users login via PIN (no WordPress configured initially)
- PWA provides "WordPress Settings" panel (GET `/api/wp-settings`)
- User enters their WordPress site URL, username, Application Password (POST `/api/wp-settings`)
- Once configured, all that user's generation jobs auto-publish to their WordPress
- If not configured, generation jobs save as drafts (per-user dry-run)
- No container restart needed; settings persist in database immediately

This allows multiple users with different WordPress sites to use blogsmith concurrently.
Individual dry-run per user (no special startup flag needed).

### 9. PWA UI

Located at: `/var/www/Others/Automation/blogsmith/public/index.html`

Single-file HTML PWA (no framework) with:
- User grid (profile avatars, select user to login)
- PIN pad for login/register
- "Stay logged in" checkbox (controls session token persistence)
- WordPress Settings panel (configure site URL, username, Application Password; shows "not configured" badge if missing)
- Keyword input form (submit new article generation job)
- Job status list (spinner while researching/drafting, links to published articles, or "draft only" badge for jobs without WordPress)

Service worker (sw.js): cache namespace `blogsmith-v1`, cache-first strategy for assets,
network-fallback for API.

Manifest: app name "Blogsmith", icons, standalone mode.

## No Review Gate — Explicit Exception

Unlike most ADR-062 pipelines, blogsmith publishes directly to WordPress without mandatory
human review. This is intentional:

- **Rationale:** Content review can happen post-publication; enables speed
- **Risk:** Factual errors, tone issues, or off-topic content published immediately
- **Mitigation:** Monitor real output closely; add review gate if > 5% require removal
- **Responsibility:** Product team owns the "go live without review" decision; flag quality
  regressions immediately

## Alternatives Considered

### 1. Integrate into 360lm (shared lm360 schema + hub SSO)

**Rejected:**
- Users creating blog content are NOT 360lm employees; app serves external writers/admins
- Exposing blog-generation jobs in hub.pwa_registry mixes business domains (ERP vs. content)
- Shared lm360 schema creates cross-app coupling; schema changes affect all PWAs
- PostgREST multi-schema routing (ADR-074) adds complexity; not justified for single app
- Auth model differs: blogsmith needs user/admin roles for writers; hub PIN is employee-only
- Backup strategy: blogs should be restorable independently from payroll/finance data

### 2. Use Drive Consolidator's auth model (client-side SHA-256, zero backend)

**Rejected (blogsmith differs):**
- Drive Consolidator has zero backend; PINs are hashed client-side and never stored
- Blogsmith needs server-side storage (job history, user registry)
- For a non-trivial app with persistent data, client-side PIN hashing is insufficient —
  if the DB leaks, attacker can brute-force PINs offline (no salt, no bcrypt cost)
- Explicit requirement: "PIN stored in DB, server-side"
- Compromise: keep UX similarity (profile avatar grid, PIN pad) but use bcrypt server-side

### 3. Use hub PIN auth (verify_pin RPC)

**Rejected:**
- Hub PIN auth is employee-only (ADR-012)
- Blogsmith users are NOT 360lm employees (external writers, admins)
- Using hub auth would expose blogsmith to all hub-authenticated employees
- Cannot restrict to a subset of employees without creating new PWA roles in hub (scope creep)

### 4. Publish via a review queue (add human gate like other pipelines)

**Rejected for v1:**
- Requires PWA admin UI for approval, adds feature scope
- Revisit once real output is available; if quality issues emerge, add gate immediately
- Current design accepts the risk in exchange for simplicity

## Consequences

### Positive

- Automated blog content generation removes manual writing bottleneck
- Dry-run mode allows safe testing without WordPress credentials
- Versioned master prompt enables quality iteration without code redeploy
- Server-side bcrypt storage protects PINs (vs. client-side hashing in Drive Consolidator)
- Isolated database and auth model allow independent scaling (separate backup, deploy schedule)
- Follows ADR-086 pattern: new apps don't force hub/ERP re-architecture

### Negative / Trade-Offs

- **No review gate:** Factual errors or tone issues published directly to live site
- **Master prompt maintenance:** Quality depends on ongoing tuning; poor prompt = poor articles
- **Research quality:** If research step is insufficiently detailed in prompt, articles may be
  generic despite design intent
- **WordPress credentials required:** Cannot publish until credentials are provided; dry-run
  is diagnostic only
- **AI cost per article:** Each keyword incurs research + draft token cost; scale monitoring needed
- **Operational complexity:** New Docker service, backup script, deploy procedure, monitoring

### Risks and Mitigations

| Risk | Mitigation |
|---|---|
| AI generates harmful/misinformation/copyrighted content → published to live site → reputational/legal damage | Monitor first 50 articles manually; add content moderation (keyword filter, similarity check) if patterns emerge |
| Master prompt becomes stale → articles degrade in quality | Monthly review of published articles; update prompt if quality trends downward |
| WordPress credentials leaked → attacker auto-publishes spam | Use WordPress Application Passwords (scoped, not full user password); rotate periodically; monitor WP audit log |
| Research step generates outdated info → published as fact | Test with 2–3 well-known keywords first; compare against published competitors; add reference section to articles |
| Database backup fails silently | Monitor `/var/log/blogsmith-backup.log`; set up cron alert on non-zero exit; verify backup files exist |
| Session token brute-force or theft → attacker can generate articles as another user | tokens are 32 random bytes (256-bit entropy, no brute-force risk); https-only transmission; consider session rotation on sensitive operations |

## Related Decisions

- **ADR-086 (Isolated PWA Architecture):** Blogsmith IS an instance of the isolated PWA
  pattern. Follows Health Tracker precedent: own database, own auth, own domain, single process.
  This is the governing ADR for the isolation decision.

- **ADR-062 (Live AI Pipeline Contract):** Blogsmith uses the three-tier auth chain (OAuth →
  OpenRouter → Anthropic REST) and 503 error handling for permission gating. Differs in
  skipping the human-review gate; trade-off explicitly accepted.

- **ADR-078 (Paperclip Isolation):** Paperclip uses the same database-role isolation pattern
  (own role, zero lm360 grants). Blogsmith follows the same security model.

- **ADR-012 (Hub as SSO Gateway):** Hub authenticates only 360lm employees. Blogsmith uses
  its own PIN-based auth (not hub verify_pin RPC).

- **ADR-013 (Single HTML File, No Framework):** PWA is a single self-contained HTML file,
  no build pipeline (same as 360lm PWAs, though not binding for isolated apps per ADR-086).

- **ADR-073 (Traefik Docker Labels):** Blogsmith uses standard Traefik label conventions
  (Host rule, TLS, cert resolver, port mapping).

- **ADR-093 (Zero-Backend Standalone PWA):** Contrasts with blogsmith — Drive Consolidator
  has no backend; blogsmith has a backend. Referenced to explain the auth model difference.

- **ADR-015 (Dev/Prod Two Stacks Same VPS):** Blogsmith runs on the same VPS as 360lm
  (following Health Tracker), but in a completely isolated database and service.

## References

- **Code:** `/var/www/Others/Automation/blogsmith/blogsmith_ai.py` (single Python process, includes `run_generation_job`, `get_wp_settings`, `publish_to_wordpress`)
- **PWA:** `/var/www/Others/Automation/blogsmith/public/index.html` (single-file HTML + CSS + JS, includes WordPress Settings panel)
- **Schema:** `/var/www/Others/Automation/blogsmith/migrate_blogsmith_v1.sql` (includes users table ALTER for wp_url, wp_username, wp_app_password columns)
- **Master prompt:** `/var/www/Others/Automation/blogsmith/master_prompt.md`
- **Docker:** `/var/www/Others/Automation/blogsmith/docker-compose.yml` (volume mount: `/var/www/Others/Automation/blogsmith:/app`)
- **Environment:** `/var/www/Others/Automation/blogsmith/.env` (DATABASE_URL, API keys; WordPress credentials now per-user in DB)
- **Service worker:** `/var/www/Others/Automation/blogsmith/public/sw.js` (cache namespace: blogsmith-v1)
- **Manifest:** `/var/www/Others/Automation/blogsmith/public/manifest.json`

---

**Decision Maker:** hkl  
**Changed Via:** adr-kit (360lm)  
**Status:** Superseded integrated design with standalone, accepted both versions for audit trail  
**Last Updated:** 2026-07-01
