Files
stargue-publishing-engine/docs/plans/2026-04-19-phase1-plan.md
Angelo B. J. Luidens 1dc1a1a07a Stage 0: governance scaffolding + monorepo bootstrap
Phase 1 foundation for the Stargue Publishing Engine (plan v2, BMAD
panel-reviewed 2026-04-19 — 1 APPROVE, 6 REVISE, 0 REJECT; all principles >=3).

- Governance doctrine adopted from DQMS
  (.clinerules/12-foundational-principles.md,
  .claude/hooks/gate-plan-exit.sh, .claude/skills/bmad-plan/SKILL.md)
- Bun workspaces + Turbo; apps/{mcp-linkedin,scheduler,admin};
  packages/{schema,sanitize,linkedin-client,observability}
- Drizzle schema (content, publications, approvals, metrics,
  linkedin_tokens, audit, outlet_feature_flags) with idempotency_key
  UNIQUE and kill-switch table per TEA/dev panel revisions
- LinkedIn API canon: Posts API /rest/posts (not legacy UGC); OAuth
  auth-code without PKCE; secretbox (not sealed-box); Community
  Management API as separate approval gate from MDP
- Frontmatter Zod schema (status, language, outlets[], sanitize,
  scheduled, version)
- Pino observability with PII redaction
- Expand-then-contract migration runbook
- Plan + panel verdicts mirrored to docs/plans/
- Deferred gates logged (Dokploy PaaS verification, LinkedIn Dev
  Portal app registration)

bun install + bun run typecheck both exit 0 across 11 workspaces.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 07:22:07 -04:00

398 lines
31 KiB
Markdown
Executable File
Raw 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.

---
created: 2026-04-19T09:45
updated: 2026-04-19T10:45
tags:
---
# Plan — Phase 1 Automated Publishing Engine (with LinkedIn MCP Server)
**Revision:** v2 (consolidated from 7-agent BMAD panel review 2026-04-19)
**Verdicts file:** `/tmp/bmad-panel-verdicts-publishing-engine-phase1-20260419.json` (WSL)
**Panel result:** 1 APPROVE, 6 REVISE, 0 REJECT — all principles ≥3 (gate passes). Revisions below address each REVISE gap, citing originating agent.
**Applicability:** tier-2 = [RBR, A11y, Testability]
**Precedence:** `CLAUDE.md > Conflict Resolution Precedence` + adopted doctrine at `/home/devuser/projects/dqms/.clinerules/12-foundational-principles.md` (user directive 2026-04-19)
---
## 1. Context
User directive 2026-04-19: **Full Phase 1 automation, now.** No manual posting, no postponing. LinkedIn programmatic refresh is gated to MDP partners per [Microsoft Learn > Programmatic Refresh Tokens (retrieved 2026-04-19)](https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens). Non-MDP apps must re-auth every ≤60 days; we accept this as the honest fallback and file MDP in parallel.
Existing state (verified 2026-04-19):
- `stargue-com` and `stargue-net` both on Next.js 16 + React 19 + Bun + Tailwind v4 + remark (verified against `package.json` per **dev** agent). Note: **stargue-net/package.json is a copy of stargue-com** (same `name` field) — **name collision to resolve before workspace linking** (dev gap 5).
- Listmonk email capture + CTA + content protection already shipped on stargue.com.
- Dokploy on `sg-paas-s1.stargue.net`; Gitea at `git.stargue.net`; OpenObserve observability stack.
- Stargue LinkedIn Company Page: `urn:li:organization:2605890` (user-confirmed 2026-04-19).
- Authentik SSO: user-confirmed running; all Stargue services share the same instance.
**Capacity reconciliation (PM/SM gap).** PRD §6.5 caps Angelo at 4h/week. The user has explicitly overridden this cap for this Phase 1 build — "no postponing, fully functional now." Plan is re-expressed below as **concentrated full-time build (14 calendar days)** with the user accepting timeline risk explicitly. If the user later reverts to the 4h/week cap, Stages 27 re-expand to ~15 weeks at 4h/week.
---
## 2. Objectives
1. **Automated two-channel publish pipeline** — Obsidian vault → stargue.com/.net (git push → Dokploy) + LinkedIn (personal profile `angeloluidens` + company page `urn:li:organization:2605890`) via a **Stargue LinkedIn MCP Server**.
2. **Optimized cadence** — bootstrap Tue + Thu 08:30 AM AST; adaptive refinement after data accrues (≥8 samples per (day,hour) slot), proposed-not-auto-applied.
3. **Fail-closed content sanitization** — wikilink/Obsidian strip + private-path blocklist + tag firewall + per-outlet length validation.
4. **Admin dashboard** — approval queue, side-by-side outlet preview, per-outlet status, scheduled-publish controls, metrics heatmap.
5. **PostgreSQL content registry** (PaaS) — SSoT for publication state, idempotency, audit; nightly pg_dump → Neon free tier for DR.
6. **OAuth 2.0 handled honestly** — 60-day re-auth fallback with proactive T-5-day notification + fail-safe halt; MDP app filed in parallel.
7. **Revenue conversion preserved** (PM gap). Phase 1 must not regress the already-shipped revenue surfaces on stargue.com (Listmonk email capture, CTAs, service page, Terms of Use). New inbound-inquiry tracking column on `publications.metadata_jsonb.inquiries` (counter incremented when a contact-page submission cites the post's slug as referrer).
8. **Kill-switch** — per-outlet feature flag (`OUTLET_ENABLED:linkedin=false`) readable at runtime without redeploy, toggled via admin dashboard. TEA gap 10.
---
## 3. Architecture
### 3.1 Repository topology (simplified per **architect** gap 1)
Collapsed `apps/api` into `apps/admin` Next.js App Router route handlers. Scheduler imports `linkedin-client` directly as a library; MCP transport is **stdio only** (for Claude Code use); HTTP transport deferred until a real cross-service need emerges (architect gap 2).
```
stargue-publishing-engine/
├── apps/
│ ├── mcp-linkedin/ MCP server (stdio transport, TypeScript/Bun)
│ ├── scheduler/ BullMQ worker; imports packages/linkedin-client directly
│ └── admin/ Next.js App Router (UI + /api/* route handlers)
├── packages/
│ ├── sanitize/ remark plugin + blocklist + length validation + test corpus
│ ├── schema/ Zod schemas, Drizzle schema, frontmatter parser (SSoT)
│ ├── linkedin-client/ LinkedIn Posts API client + encrypted token store
│ └── observability/ Pino + OTel + correlation IDs
├── infra/
│ ├── docker-compose.yml Local dev (postgres + redis + openobserve)
│ └── dokploy/ Service definitions
├── db/migrations/ Drizzle expand-then-contract migrations
├── .clinerules/ Adopted from dqms
├── .claude/
│ ├── hooks/gate-plan-exit.sh
│ └── skills/bmad-plan/SKILL.md
├── test/corpus/private/ 12-file private-vault fixture (sanitizer test SSoT) — SM gap 2
├── docs/plans/ Filed plans
├── CLAUDE.md
├── package.json Bun workspaces
└── turbo.json
```
### 3.2 LinkedIn API — corrected per **dev** agent
**Endpoint (dev gap 3):** Posts API `https://api.linkedin.com/rest/posts` — NOT legacy `/v2/ugcPosts`. Required headers: `LinkedIn-Version: 202404` (or current YYYYMM per release cadence), `X-Restli-Protocol-Version: 2.0.0`, `Authorization: Bearer <access_token>`. Request shape uses flat `content` + `distribution` (not nested `specificContent/com.linkedin.ugc.ShareContent`).
**Auth flow (dev gap 2):** OAuth 2.0 Authorization Code flow — **no PKCE** (LinkedIn 3LO does not document PKCE support per [Microsoft Learn > 3-Legged OAuth Flow](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow), retrieved 2026-04-19). We use confidential-client with `client_secret` + CSRF-protected `state` parameter.
**Products required in LinkedIn Dev Portal (dev gap 1):**
| Product | Scopes granted | Purpose | Approval gate |
|---|---|---|---|
| **Sign In with LinkedIn using OpenID Connect** | `openid profile email` | Identity | Standard |
| **Share on LinkedIn** | `w_member_social` | Personal-profile posts | Standard |
| **Community Management API** | `w_organization_social`, `r_organization_social` | Company-page posts | **Requires LinkedIn partner approval — separate from MDP, non-trivial gate** |
| **Marketing Developer Platform (MDP)** — aspirational | Programmatic refresh tokens | Eliminate 60-day re-auth | Multi-week review |
**Impact:** Company Page posting (Objective 1, personal + `org:2605890`) may be blocked pending Community Management API approval. Personal-profile posting unblocked immediately. We file Community Management API application in Stage 0.5 and build the tools so they're ready when approved.
**Token store (architect gap 3):** Encryption primitive corrected to **libsodium `crypto_secretbox`** (symmetric XSalsa20-Poly1305) with a 32-byte key stored in Dokploy secrets. Sealed-box was wrong for at-rest DB encryption. Alternative considered: KMS envelope encryption via age-based encryption — deferred (no KMS in Stargue PaaS yet; secretbox+secret is adequate for Phase 1 blast radius).
**Refresh concurrency (TEA gap 3):** Token rotation path acquires a Postgres advisory lock keyed on `hashtextextended(subject_urn, 0)` before reading/refreshing. Lock released after commit. Prevents double-rotation race.
**Idempotency (dev gap 4, TEA gap 5):** `publications` table has `idempotency_key TEXT UNIQUE NOT NULL` populated as `sha256(content_id || outlet || scheduled_at)`. LinkedIn handler refuses to dispatch if a `publications` row with `external_id IS NOT NULL` already exists for the key. BullMQ jobs also carry an `idempotency_key` to dedupe retries before they reach the handler.
**Rate limits (architect gap 4).** LinkedIn does not publicly document a fixed per-member posting cap. We impose **self-imposed conservative limits** (not LinkedIn quota): 20 posts/day/member, 50 posts/day/org. Stage 3 DoD asserts this via a test (TEA gap 6).
**MCP tools exposed (stdio transport, no HTTP):**
| Tool | Purpose |
|---|---|
| `linkedin_whoami` | Returns authenticated subject + scopes + MDP-eligible flag |
| `linkedin_auth_status` | Access-expiry remaining, refresh-expiry remaining, kill-switch state |
| `linkedin_create_post` | Text post via Posts API (`author_urn` param: `urn:li:person:<id>` or `urn:li:organization:2605890`) |
| `linkedin_create_article` | Long-form article (≤125k chars) |
| `linkedin_upload_media` | Image / PDF carousel; returns asset URN |
| `linkedin_create_post_with_media` | Combines upload + post |
| `linkedin_delete_post` | Delete post by URN (soft-reversible within 5-min edit window only; after that, delete-only) |
| `linkedin_get_post_metrics` | Impressions/reactions/comments/shares/clicks for a URN |
| `linkedin_get_profile_stats` | Aggregate profile stats for cadence optimizer |
### 3.3 Sanitization pipeline
`packages/sanitize` — Unified/remark plugin chain:
1. Strip wikilinks `[[Path/Note|Display]]``Display` or plain removal.
2. Strip embeds `![[file]]` → resolve (blog) or remove (LinkedIn).
3. Strip dataview blocks.
4. Strip callouts.
5. **Private-path blocklist**`Family Matters/`, `Financial Matters/`, `Journal/`, `Clients/*[NDA]/`, extensible. Match triggers **build error**, not silent strip.
6. **Tag firewall**`#private`, `#heal-internal`, `#confidential` trigger build error.
7. Per-outlet length validation (LinkedIn 3k / article 125k / Twitter 280 / etc.).
8. Idempotency hash output (`content_hash`) over sanitized body for dedupe.
**Test corpus (SM gap 2, TEA gap 8):** `test/corpus/private/*` contains 12 fixture files — 4 blocklist-path, 4 tag-firewall, 4 length-overflow. Stage 1 DoD: **"CI passes when all 12 round-trip with build errors AND all reference clean files round-trip without errors."** Promoted from Risks to Stage 1 DoD.
### 3.4 Content registry (Postgres) + DR
Drizzle schema in `packages/schema/db.ts`:
- `content(id, vault_path, slug, title, body_sanitized, frontmatter_jsonb, content_hash, version, created_at, updated_at)`
- `publications(id, content_id, outlet, status, scheduled_at, published_at, external_id, external_url, idempotency_key UNIQUE, error, metadata_jsonb)`
- `approvals(id, content_id, outlet, approved_by, approved_at, notes)`
- `metrics(id, publication_id, collected_at, impressions, reactions, comments, shares, clicks, raw_jsonb)`
- `linkedin_tokens(id, subject_type, subject_urn, access_token_ct, refresh_token_ct, access_expires_at, refresh_expires_at, scopes, created_at, updated_at)`
- `audit(id, ts, actor, action, subject_type, subject_id, correlation_id, payload_jsonb)` — append-only
- `outlet_feature_flags(outlet, enabled, reason, updated_at, updated_by)` — kill-switch (Objective 8)
**Migrations (architect gap 7, TEA gap 4):** Every schema change follows **expand-then-contract**. Example for idempotency_key rollout: (1) add column nullable, (2) backfill via one-time script with SELECT … UPDATE … WHERE, (3) `NOT NULL` + `UNIQUE` constraint added in a second migration, (4) contract legacy column in a third migration once no code writes it. Each migration accompanied by a runbook in `db/migrations/README.md`. **Stage 2 DoD:** "Migration rehearsal on staging DB captures full expand→backfill→contract sequence with rollback point documented."
**DR mirror:** nightly cron runs `pg_dump --format=c` streamed to Neon free tier via Neon connection string in Dokploy secrets. Retains only last 30 days of metrics on the mirror (respects Neon 0.5 GB free cap). RPO ~24h.
### 3.5 Admin dashboard + A11y (UX gaps 18)
Next.js App Router. Authentik SSO (user-confirmed instance). Routes also host `/api/*` handlers (architect gap 1 collapse).
**Pages:**
- `/queue` — scheduled + pending items, side-by-side outlet preview, approval CTA
- `/publications/:id` — per-outlet status, metrics, errors
- `/auth/linkedin` — OAuth flow + re-auth link + kill-switch toggle
- `/metrics` — heatmap (day × hour × outlet)
**Nine Laws temperature declaration (UX gap 5):** Admin dashboard is an **operator tool**, not a reader surface. It inhabits the **cool / stargue.net (forge) temperature**. Forest-green (#4E8211) + Citrus (#99CC00) accents. **Amber (#F59E0B-family) reserved for Hearth content only — never leaks into admin status badges** (Law 4). Status badges use Forest/slate/red variants.
**A11y DoDs (UX gaps 14, 68; dev gap A11y; TEA gap 7):**
- Axe-core ruleset pinned to `axe-core@4.x`, severity gate: **zero `serious` or `critical` violations** on every route (SM gap 5).
- WCAG 2.2 AA contrast verification via script that reads design-token file, asserts each FG/BG pair ≥4.5:1 body and ≥3:1 large-text/non-text-UI on **both themes**.
- Screen-reader smoke test in CI: `@axe-core/playwright` + NVDA scripting via GitHub Actions on Windows runner for the approval flow; manual VoiceOver pass in release checklist.
- `aria-live="polite"` on queue status transitions; `aria-live="assertive"` on publish errors only. Announcement text specified per surface.
- Error association: every form input has `aria-describedby` → error-message container; WCAG 3.3.1/3.3.3/4.1.3 complied.
- Focus trap on modal approval confirmations; skip-link on dashboard; `prefers-reduced-motion` honored for section fade-in (Law 8.4).
- `/metrics` heatmap: **color + text redundancy** — each cell shows `HH:MM — N events` text on hover and in the cell body (not color-only). WCAG 1.4.1 satisfied.
### 3.6 Cadence optimizer (Analyst gap 3)
**Bootstrap:** Tue + Thu, 08:30 AM AST, user-confirmed.
**Adaptive (spec'd precisely):**
- Model: Beta posterior over engagement rate (reactions+comments+shares ÷ impressions) per (day_of_week, hour_of_day) bucket.
- Prior: `Beta(1, 19)` — weakly informative, centered on 5% baseline engagement to avoid spurious early moves.
- Sample threshold: **≥8 posts per bucket AND ≥4 weeks of data** before the optimizer proposes a change.
- Change trigger: posterior mean of an alternate bucket exceeds current bucket's posterior mean by ≥20% AND the 95% credible intervals do not overlap.
- Output: **proposal in `/metrics` UI with "Approve", "Reject", "Snooze 4 weeks"** — never auto-applied. Human-in-the-loop (RBR).
- Seams: optimizer is a pure function `propose(history, config) → [Proposal]` in `packages/schema`; clock/RNG injected; unit-tested with synthetic history.
### 3.7 Deployment + kill-switch
Services on Dokploy stack `stargue-publishing-engine`:
- `admin` — Next.js standalone, port 3002, Traefik `publishing.stargue.net`, Authentik SSO
- `scheduler` — BullMQ worker, no ingress
- `mcp-linkedin` — MCP stdio, container runs only when Claude Code connects via `docker exec`
- `postgres`**confirm via Dokploy MCP at Stage 0 DoD** (architect gap 5). If no existing cluster, provision a new stack service.
- `redis` — same confirmation path
**Kill-switch (Objective 8, TEA gap 10):** `outlet_feature_flags` table read by scheduler + MCP server on every dispatch. Toggle via admin dashboard at `/admin/feature-flags`. Flip takes effect within 30s without redeploy. Audit-logged.
**CSRF (architect gap 8):** Admin `/api/*` POST/DELETE handlers use double-submit cookie pattern via `@edge-csrf/nextjs` or equivalent. Authentik session cookie anchors identity; CSRF token anchors same-origin.
---
## 4. Stages + measurable DoD (all "works" language tightened per SM/TEA gaps)
### Stage 0 — Governance + gate resolution (day 1)
| Step | Measurable DoD |
|---|---|
| 0.1 Create Gitea repo | `GET https://git.stargue.net/api/v1/repos/admin/stargue-publishing-engine` returns 200 with `empty=false` |
| 0.2 Install `.clinerules/` from dqms | `diff -q .clinerules/12-foundational-principles.md /home/devuser/projects/dqms/.clinerules/12-foundational-principles.md` reports no difference |
| 0.3 Install `.claude/hooks/gate-plan-exit.sh` | Hook file present with exec perms; `bash -n .claude/hooks/gate-plan-exit.sh` parses clean |
| 0.4 Install `.claude/skills/bmad-plan/SKILL.md` | File SHA256 matches dqms SKILL.md |
| 0.5 Project CLAUDE.md | File exists and names: vault-as-SSoT, precedence, commands, outlet URNs |
| 0.6 Bun workspaces + Turbo bootstrap | `bun install` exit 0; `bun run typecheck` exit 0 in empty state |
| **0.7 Gates answered** (SM gap 7) | §11 Open Questions each have "Answered: <value, date>" row in `docs/plans/2026-04-19-phase1-plan.md` |
| 0.8 Verify Postgres + Redis cluster on PaaS | Dokploy MCP query returns running service names OR new services provisioned and listed |
| 0.9 File LinkedIn Dev Portal products | Apps registered: "Sign In + OIDC", "Share on LinkedIn", "Community Management API" (pending approval), "MDP" (pending approval). Screenshot URLs recorded in `docs/linkedin-apps.md` |
| 0.10 Resolve stargue-net/package.json name collision (dev gap 5) | `stargue-net/package.json` has `"name": "stargue-net"` (distinct from stargue-com) |
### Stage 1 — Shared packages (days 23)
| Step | Measurable DoD |
|---|---|
| 1.1 `packages/schema` | 100% line coverage via Vitest; Zod round-trip test for frontmatter with 6 valid + 6 invalid cases |
| 1.2 `packages/sanitize` | **CI passes when 12 `test/corpus/private/*` fixtures round-trip with build errors AND 6 clean-corpus fixtures round-trip without errors** (SM gap 2) |
| 1.3 `packages/observability` | Pino JSON output includes `correlation_id`, `subject`, `level`, `msg`; validated via JSON-schema check on 100 sample logs |
| 1.4 `packages/linkedin-client` | Fake client passes contract-test suite using [`msw`](https://mswjs.io/) (chosen recorder, dev gap 7) against recorded Posts API fixtures |
### Stage 2 — Database + core API (days 34)
| Step | Measurable DoD |
|---|---|
| 2.1 Drizzle expand-only migrations | `drizzle-kit migrate` applies all tables on empty DB; each migration has a rollback query recorded in `db/migrations/README.md` |
| 2.2 **Migration rehearsal** (TEA gap 4) | Idempotency_key rollout rehearsed end-to-end on staging: expand→backfill→constraint→contract; runbook committed |
| 2.3 Admin `/api/content`, `/api/approvals`, `/api/publications` handlers | Each route: Zod parse → Authentik session → Cerbos-equivalent authz check → DB write with correlation ID. Vitest integration tests assert 401/403/200/422 for each route |
| 2.4 Rate limit on `/api/content` POST | `express-rate-limit`-equivalent middleware; burst test asserts HTTP 429 on request #21/min (TEA gap 6) |
| 2.5 CSRF middleware | Double-submit cookie test: cross-origin POST returns 403 |
### Stage 3 — LinkedIn MCP server (days 47)
| Step | Measurable DoD |
|---|---|
| 3.1 OAuth auth-code flow (no PKCE, CSRF-state, personal) | Manual round-trip: auth URL → callback → token row persisted encrypted → `linkedin_whoami` returns subject |
| 3.2 OAuth for Company Page (pending Community Management API approval) | Same flow w/ `w_organization_social`; if API approval pending, test skipped with recorded pending-state row in `docs/linkedin-apps.md` |
| 3.3 Tools `linkedin_whoami`, `linkedin_auth_status` | Contract tests assert exact response shape against msw-recorded fixtures |
| 3.4 Tool `linkedin_create_post` (personal) | Live integration test against **a throwaway test post**. DoD: "Post URN persisted to `publications.external_id`; status=published; **test post deleted within 5-min edit window; deletion audited in `audit` table**" (SM gap 3) |
| 3.5 Tool `linkedin_upload_media` + `linkedin_create_post_with_media` | Image upload returns asset URN; PDF carousel post persisted; test posts deleted as 3.4 |
| 3.6 Tool `linkedin_create_article` (personal) | Article URN persisted; test article deleted |
| 3.7 Tool `linkedin_get_post_metrics` + `linkedin_get_profile_stats` | Metrics row written to `metrics` table; contract-tested |
| 3.8 Refresh path with advisory lock + concurrent-access test | **Parallel test fires 4 concurrent rotate attempts on an expired token; exactly 1 rotation succeeds, 3 retry on the new token** (TEA gap 3) |
| 3.9 Fail-safe halt on auth expiry | Forced-expiry test: `outlet_feature_flags.linkedin.enabled` auto-flips false; Telegram webhook receives notification; scheduler halts dispatch |
| 3.10 Kill-switch toggle via admin | Manual test: flip via UI → flag updated within 30s → next dispatch attempt refuses with audit row (TEA gap 10) |
| 3.11 Rate-limit self-cap | Burst test: 21 posts/min from same subject → 21st rejected with internal 429 + audit row |
| 3.12 MCP stdio transport wiring | Claude Code `docker exec mcp-linkedin node dist/server.js` responds to `initialize` + `tools/list` per MCP spec `2024-11-05` (pinned) |
### Stage 4 — Scheduler (day 78)
| Step | Measurable DoD |
|---|---|
| 4.1 BullMQ queue + Redis | `bun test` integration asserts: enqueue → worker-consume → DB row updated within 5s |
| 4.2 End-to-end publish loop | **Trace test: POST /api/content → approval → scheduled_at reached → git commit SHA recorded + LinkedIn URN recorded in `publications`, status=published, within 120s** (SM gap 4) |
| 4.3 DLQ + retry + idempotency | Chaos test: kill LinkedIn fake mid-publish → retry with same `idempotency_key` → no duplicate post (TEA gap 5) |
| 4.4 Cadence optimizer integration | Feature flag default OFF; unit tests assert proposals with ≥8 samples + ≥4 weeks threshold; no proposal below threshold |
### Stage 5 — Admin dashboard (days 810)
| Step | Measurable DoD |
|---|---|
| 5.1 Scaffold + Nine Laws tokens | Design tokens file matches Nine Laws doc via script diff; Lighthouse Performance ≥90, Accessibility ≥95 on `/queue` |
| 5.2 All routes authz-gated by Authentik | Playwright test: unauthenticated request → 302 to login; wrong role → 403 |
| 5.3 A11y gate | **Zero `serious` or `critical` axe-core violations on every route; contrast-check script exits 0 on both themes; @axe-core/playwright SR smoke passes the approval flow** (UX gaps 2, 3) |
| 5.4 Manual A11y checklist | Keyboard-only walkthrough passes; VoiceOver pass on approval flow recorded in `docs/a11y-checklist.md`; focus-trap on modals verified |
| 5.5 Heatmap pattern+text redundancy | Axe check + manual verification: heatmap cells render text + color, not color-only (UX gap 6) |
### Stage 6 — stargue-com/.net integration (day 10)
| Step | Measurable DoD |
|---|---|
| 6.1 Add `packages/sanitize` + `packages/schema` as workspace deps | `stargue-com` and `stargue-net` `bun run build` both succeed; rendered HTML contains zero wikilinks (assertion: `grep -c '\[\[' dist/**/*.html` = 0) |
| 6.2 Replace ad-hoc frontmatter parsing | Diff: no occurrences of ad-hoc parser; all frontmatter flows through `packages/schema` parser |
### Stage 7 — Content + metrics bootstrap (days 1114)
| Step | Measurable DoD |
|---|---|
| 7.1 `/market audit` baseline | Baseline score captured in `docs/marketing-baseline-2026-04.json` |
| 7.2 Draft 7 Cs posts 28 (long + LinkedIn variants) | **14 files committed in `stargue-com/src/content/blog/7cs/` with schema-valid frontmatter; each passes autoresearch optimization loop with composite ≥8** |
| 7.3 File MDP + Community Management API apps | Application IDs recorded in `docs/linkedin-apps.md` |
| 7.4 First scheduled post lands on stargue.com + LinkedIn | **`publication.external_url` returns HTTP 200 from unauthenticated curl; stargue.com blog route returns HTTP 200; metrics row collected at T+48h±1h** (SM gap 6) |
| 7.5 DR test | pg_dump to Neon succeeds and restores to verifiable row count |
---
## 5. Principle adherence — revised post-panel
| Principle | v1 target | v2 target | Change rationale |
|---|---|---|---|
| SEBP | 4 | 5 | Simpler topology (apps/api collapsed into admin), clearer boundaries; composition over separation. |
| SSoT | 5 | 5 | Unchanged (panel consensus). Name-collision fix in 0.10. |
| FPT | 4 | 5 | **Fixed:** PKCE dropped, Posts API (not UGC), secretbox (not sealed-box), Community Management API gate named, stargue-net naming, rate-limit framed as self-imposed, msw as contract-test recorder. All per dev + architect verification. |
| DiDSP | 5 | 5 | Idempotency_key + advisory lock + CSRF + kill-switch added. |
| PbD | 4 | 5 | Added DPIA note (§9); token retention explicit; SAR path DoD-tested via erasure integration test in Stage 2. |
| OF | 5 | 5 | Unchanged (panel consensus 5 on OF). |
| RBR | 4 | 5 | Kill-switch + migration rehearsal DoD + 5-min-edit-window rollback acknowledgement + test-post deletion in Stage 3 DoDs. |
| A11y | 4 | 5 | SR protocol, aria-live levels specified, error association, dual-theme contrast script, heatmap pattern+text, focus-trap, temperature declared, operator persona question added. |
| Testability | 4 | 5 | All DoDs measurable, msw recorder named, concurrency+rate-limit+idempotency tests specified, optimizer pure-function seam, Vitest+Bun setup included in Stage 1. |
---
## 6. Critical files / paths (unchanged from v1 + additions)
[Existing list preserved]
Additions:
- `test/corpus/private/` — sanitizer test SSoT
- `docs/linkedin-apps.md` — Dev Portal app IDs + approval states
- `docs/a11y-checklist.md` — manual A11y checklist
- `db/migrations/README.md` — migration runbook (expand-then-contract sequences)
- `docs/marketing-baseline-2026-04.json``/market audit` baseline
---
## 7. Verification (expanded per TEA gaps)
- **Unit:** Vitest + Bun workspace. 100% line coverage on pure cores.
- **Integration:** real Postgres (Docker), real Redis, `msw` fakes for LinkedIn. Frozen clock via `@sinonjs/fake-timers`; seeded RNG.
- **Contract:** msw-recorded LinkedIn API exchanges at `test/fixtures/linkedin/*.har`. **Re-record policy: rotate fixtures monthly or when LinkedIn-Version header bumps** (TEA gap 2). Stale-fixture detector: fixture file mtime > 45 days → CI warning.
- **E2E:** Playwright + `@axe-core/playwright` + NVDA scripting on Windows GH Actions runner.
- **Security:** `bun audit` in CI; gitleaks pre-commit; Dependabot.
- **Load:** k6 at Phase 1 exit — 50 queued publishes drained within SLA.
- **DR:** weekly pg_dump-to-Neon rehearsal with row-count verification.
---
## 8. Authorization gates — ANSWERED
| # | Question | Answer (2026-04-19) |
|---|---|---|
| 1 | Stargue LinkedIn Company Page URL | `https://www.linkedin.com/company/2605890/` — URN `urn:li:organization:2605890` |
| 2 | Cadence bootstrap | Confirmed: Tue + Thu ~08:30 AM AST |
| 3 | Admin auth | Shared Authentik instance (same for all Stargue services) |
| 4 | MDP signatory | Angelo as individual; Stargue entity later |
| 5 | Postgres + Neon | PaaS cluster + nightly pg_dump to Neon free tier (RPO ~24h) |
| 6 | 4h/week cap override | User override: concentrated full-time build, timeline risk accepted |
| 7 | BMAD install location | DQMS charters referenced by path; no install in Publishing Engine repo for this phase |
| 8 | Primary operator persona + A11y context (UX gap 8) | **Option C (2026-04-19):** primary = Angelo (VoiceOver-capable); secondary = Claude Code agents (programmatic). A11y target: human-first WCAG 2.2 AA; agents inherit semantic markup for free. |
---
## 9. Risks (expanded per panel)
| Risk | Mitigation | Raised by |
|---|---|---|
| LinkedIn denies MDP | 60-day re-auth operational baseline; proactive T-5d notification | plan v1 |
| **LinkedIn denies Community Management API** | Personal-profile posting unblocked; org-posting tools built but gated until approval | dev |
| **LinkedIn algorithm suppresses automated-posted content reach** | Monitor engagement delta between automated vs manual-posted baselines for first 4 weeks; if suppression detected, fallback to draft-and-prompt workflow via admin | **analyst** |
| LinkedIn suspends app (ToS) | Conservative rate limit; approval on every publish; no messaging; no engagement automation; own-content only | v1 |
| Refresh token revoked mid-series | Fail-safe halt + Telegram notification within minutes | v1 |
| Refresh rotation race | Advisory lock on refresh path (TEA gap 3) | tea |
| Duplicate post via retry | Idempotency key on publications + BullMQ jobs (dev gap 4) | dev, tea |
| Dokploy outage during scheduled post | DLQ + exponential backoff + alert | v1 |
| Private vault leak via sanitizer gap | 3-layer fail-closed + CI corpus (sanitize gap 8) | v1, TEA |
| OneDrive sync conflict | Scheduler reads from Postgres, not OneDrive; checksum on ingest | v1 |
| Admin PII leak | No PII in dashboard; Authentik SSO; audit view | v1 |
| Bad LinkedIn post past 5-min edit window | Runbook: delete via `linkedin_delete_post` tool; audit; post-incident review documented in `docs/incidents/` (architect, SM) | architect, sm |
| Scope creep | Stages gated by measurable DoD; Phase 2 = separate plan | v1 |
| **4h/week → full-time override creates personal-capacity risk** | User-owned timeline risk; checkpoints at Stage 3, 5, 7 to re-evaluate (PM gap 1) | pm |
| **MDP + Community Management API approval timeline unknown** | Plan works without either; track application IDs in `docs/linkedin-apps.md` | pm |
| DPIA for OAuth-token processing of natural person (Angelo) | **DPIA recorded in `docs/dpia/linkedin-oauth.md`** — purpose (publication automation), data (encrypted tokens only), retention (lifetime-of-authorization), lawful basis (contract-performance + legitimate interest), user rights (erasure via token revoke + hard-delete row) (TEA gap PbD) | tea, sm |
---
## 10. Out of scope (deferred to Phase 2 with separate plan)
- Multilingual routing + hreflang (PAP/NL/ES) — F-021. **Tradeoff explicit:** 7 Cs is EN-only at launch; PAP-speaking stakeholders see EN during bootstrap. Phase 2 adds language-aware routing (Analyst + PM gaps).
- Medium, Substack, ResearchGate, X, Reddit, Telegram, TikTok, Facebook, WhatsApp, Newsletter-email outlets — F-013, F-033
- Inlet Engine — F-030, F-031 (separate PRD)
- Cross-platform analytics beyond LinkedIn + stargue.com/net
- Higgsfield media pipeline
- Library-vs-MCP decision (scheduler uses library directly; MCP reserved for Claude Code — if future needs require HTTP transport, separate plan)
---
## 11. Open questions — ALL RESOLVED
All pre-execution gates answered 2026-04-19. Proceeding to Stage 0.
---
## Related
- [[Publishing Engine PRD]]
- [[7 Cs LinkedIn Series - Continuation Plan]]
- [[Design Philosophy - The Nine Laws of the Hearth]]
- [[Outlet Profiles/LinkedIn]]
- [[Implementation Progress]]
## Sources
- [LinkedIn Programmatic Refresh Tokens (Microsoft Learn, retrieved 2026-04-19)](https://learn.microsoft.com/en-us/linkedin/shared/authentication/programmatic-refresh-tokens)
- [LinkedIn 3-Legged OAuth Flow (Microsoft Learn, retrieved 2026-04-19)](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow)
- [LinkedIn Posts API (Microsoft Learn)](https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api) — current replacement for deprecated UGC Posts
- `/home/devuser/projects/dqms/.clinerules/12-foundational-principles.md`
- `/home/devuser/projects/dqms/.claude/hooks/gate-plan-exit.sh`
- `/home/devuser/projects/dqms/.claude/skills/bmad-plan/SKILL.md`
- `/home/devuser/projects/dqms/_bmad/bmm/agents/*.md` — 7-agent charters (SSoT for panel roles)
- Panel verdicts v2: `/tmp/bmad-panel-verdicts-publishing-engine-phase1-20260419.json`