Skip to content

0019 — studio publish cockpit: compose · approve · post (R-UI-19/25/27/12)

Status: IN PROGRESS (studio Phase 2C — the final sub-phase. §8 decisions resolved 2026-06-27, all per the recommendations: (1) content-hash revision token; (2) write reel-caption.md on every social save when a bundle is linked; (3) platforms.<p>.constraints.* with the current hardcoded values as defaults; (4) an explicit UI confirm on post-now; (5) staged across separate MRs — the no-posting surface first, then R-CAP-4, then post-now last; (6) a dedicated Publish pane.) Date: 2026-06-27

Slice tracking: - Slice 1 — compose & approve (DONE): R-SOC-8 + R-SOC-5 + the optimistic guard in the shared internal/social core; the studio GET/PUT/approve /social surface; the Publish pane. (The Connections view moved out of slice 1 — it reads per-platform token-expiry metadata whose keys vary by platform / don't exist for YouTube, so it pairs better with the post/auth slice.) - Slice 2 — R-CAP-4 (caption file) + the AI composer (R-SOC-4): next. - Slice 3 — post-now (R-UI-27) + Connections (D6): last (the irreversible action). Depends on: the posting cores (internal/social = the social.json ledger + constraints, internal/socialcmd = compose/AI-gen, internal/postcmd = the approved-gated post fan-out, pkg/publish = the per-platform Publishers), the studio async/commit infra, per-project config (0014 + the gen-config fix), and the exposure gate (2D / spec 0018) — which is what makes "post now" over the network safe. Parent: spec 0015 (§5 build order: 2C last; D6/D7/D9/D10 resolved).

1. Goal & scope

Surface the Publish cockpit in the studio: per-platform social composition (R-UI-25), approve/schedule/post (R-UI-27), view/edit the social set (R-UI-19), and posting status (R-UI-12) — the studio half of keryx's reason to exist (turn a finished reel into published posts). The cores exist + are CLI-driven; the studio surfaces none.

2C also closes the two MUST gaps behind the Publish panel (0015 C8/D9): R-SOC-5 (per-platform constraints move to config) and R-CAP-4 (reel-caption.md written into the bundle). And it adds R-SOC-8 (editing an approved/posted variant drops it to draft) in the shared core, a read-only Connections health view (D6), and an optimistic concurrency guard on social.json (D7).

Posting is irreversible. The defining constraint of 2C: "post now" goes out to real platforms and can't be undone. The whole design is defence-in-depth around that (§6) — it is human-initiated only, through the same approved-gated postcmd path, gated by 2D when exposed, never on MCP, with unattended posting staying CI-only (post due).

In scope: the /social read/compose/edit/approve/schedule surface, post-now, the Connections view, R-SOC-⅝ + R-CAP-4, the Publish UI. Out of scope: studio-driven OAuth capture (stays CLI, MCP-gated — D6), the unattended scheduler (owning-project CI, not keryx code), a live auth refresh --dry-run "check now" (a fast-follow on D6).

2. What already exists (reuse map)

Need Existing core Gap
Social ledger internal/socialSet (social.json: platform→Entry{Text,Hashtags,Link,Title,Status,ScheduledAt,PostedAt,PostURL}), Load/Save, Status{draft,approved,posted} no studio surface; no R-SOC-8; no optimistic guard
Constraints social.Constraints (hardcoded map), Check (warnings), Violations (hard, posting-blocking — R-POST-10) hardcoded — R-SOC-5 wants config
Compose / AI-gen internal/socialcmdSet, Show, Gen (platform-appropriate AI draft, R-SOC-4) CLI-shaped; no caption file (R-CAP-4)
Post fan-out internal/postcmdPoster, PostAll/PostDue, Result, approved-gate + idempotency (R-POST-2/10/12) no studio /post; irreversible over HTTP (needs the 2D gate + human intent)
Publishers pkg/publishPublisher per platform, Resolve/Enabled

3. The studio surface (proposed)

Method · path Effect
GET …/social the whole set + per-platform constraint warnings + a revision token (D7); enabled/connection health folded in or a sibling …/connections
PUT …/social/{platform} upsert one platform's entry (text/hashtags/link/title/scheduled_at) {rev}; R-SOC-8 drops approved/posted→draft on a text change; 409 if rev is stale (D7)
POST …/social/{platform}/approve draft→approved, refused on hard Violations (R-POST-10) {rev}
POST …/social/{platform}/gen AI-compose a platform draft (R-SOC-4) → async job (it calls a provider; reuse the job harness + cost cue)
POST …/social/post {platforms?} post now the approved platforms via postcmd (§6) — async job, per-platform Result
GET …/connections read-only per-platform connected/expiry from non-secret config metadata (D6)

social.json is committed-on-save like the rest of the workspace; the caption file (R-CAP-4) is written into the linked bundle on save.

4. Core changes (shared CLI + studio)

  • R-SOC-8 (in internal/social). A helper that, when an entry's Text (or the posting-material fields) changes while Status is approved/posted, resets it to draft. Lives in the shared path so CLI social set and the studio PUT both get it.
  • R-SOC-5 (config constraints). social.Constraints becomes config-resolved: ConstraintsFor(cfg, platform) reads platforms.<p>.constraints.{text_cap, title_cap, links_clickable, hashtag_hint}, falling back to the current hardcoded values as defaults (which init may seed). Check/Violations take the resolved constraint. No behaviour change unless config overrides — parity preserved.
  • R-CAP-4 (caption file). A shared social.WriteCaption(fs, bundleDir, set) renders reel-caption.md (per-platform prose: title/text/hashtags/link, human-readable) into the linked bundle. Called by CLI social and the studio on save when a bundle is linked (no bundle → skip, not an error). A first-class committed deliverable.
  • Optimistic guard (D7). GET …/social returns a revision token (content hash of social.json — fs-agnostic, unlike mtime, so it holds for in-memory projects); every mutating call carries it back and the handler refuses (409 "reload — it changed") if social.json changed since read. Prevents a studio edit clobbering a concurrent CLI post.

5. The Connections view (D6 — read-only, no secrets)

GET …/connections surfaces, per platform: enabled (platforms.<p>.enabled) and token health read from the non-secret expiry metadata (platforms.<p>.*_expires_at) — connected / not / expiring-soon. The studio never reads, captures, or stores a token (R-CFG-2): capture stays the CLI's interactive, MCP-gated auth <platform>. A dead/expiring token is flagged in the cockpit before a publish fails. v1 reads stored metadata only (no subprocess/network).

6. Post-now safety (D10 — layered, defence-in-depth)

"Post now" is the one irreversible, outward action. Every layer must hold: 1. Human-initiated only — fired by the Publish button, never auto-run, never on a timer (the unattended path is CI's post due, separate). 2. The same postcmd path + approved-gate — refuses any platform not approved, and refuses on hard constraint Violations (R-POST-2/10); idempotent (a posted entry with a post_url is skipped). 3. Network-gated — reachable only localhost-bound (open) or authed (the 2D bearer/ cookie gate); an exposed studio already requires the token for /api. 4. Never on MCPpost/approve/auth already carry ExcludeFromMCP; the studio surface doesn't change that. The post runs as an async job (it's slow + per-platform); results are the existing postcmd.Result set (per-platform ok/skip/error + post_url). No paid/real posting in CI — the Publisher seam is faked.

7. The Publish UI

A Publish pane (collapsible, mobile-first per R-UI-20/21) over the reel: - A Connections strip — per-platform enabled + token health (green/amber/red dot), read-only (a dead token warns before posting). - A per-platform composer — text / hashtags / link / title (where the platform has one), live constraint warnings (from Check) + a hard-block indicator (from Violations), a status badge (draft/approved/posted, with post_url when posted), an AI-compose button (R-SOC-4, the async gen job), and approve / schedule controls. Editing an approved variant visibly drops it to draft (R-SOC-8). - A Post now action (the approved platforms), with a confirm — it's irreversible. Disabled with a reason when nothing's approved / a token's dead / the reel isn't rendered.

8. Decisions to confirm before building

Most are resolved by spec 0015 (D6/D7/D9/D10); the genuinely open ones:

  1. Optimistic-guard token (D7). A content hash of social.json (rec — fs-agnostic, holds for in-memory projects) vs file mtime (lighter but unreliable on some backends). Confirm hash.
  2. R-CAP-4 trigger. Write reel-caption.md on every social save when a bundle is linked (rec — always current) vs only on an explicit "export caption" action / only on post. Confirm save-time.
  3. R-SOC-5 config shape. platforms.<p>.constraints.{text_cap,title_cap, links_clickable,hashtag_hint}, defaults = the current hardcoded values (rec).
  4. Post-now extra confirm. A typed/explicit UI confirm on "post now" (rec — it's irreversible) vs relying on the approved-gate + button intent alone (0015 D10 calls the typed confirm optional). Confirm the level.
  5. 2C internal build order. Land the no-posting surface first (read/compose/ edit/approve + R-SOC-8 + guard + R-SOC-5 + Connections), then R-CAP-4, then post-now last (the irreversible action on a tested foundation) — rec — vs one shot. Possibly separate MRs per slice given 2C's size.
  6. UI placement. A dedicated Publish pane (rec, mobile tab like the others) vs folding into the associated panel (too dense — Publish is a distinct mode).

9. Testing / DoD

Failing test → code → green just ci. The Publisher seam is faked (no real posting in CI); the AI-compose provider faked (no spend). Unit: R-SOC-8 reset, ConstraintsFor config resolution + defaults parity, WriteCaption output, the optimistic-guard 409, the approved-gate refusal (post unapproved → refused). godog: a compose→approve→post-now flow through the fakes; a stale-rev 409; post-without-approve refused. -race over the job + ledger paths. Docs: the studio component page (Publish cockpit) + the R-CAP-4/R-SOC-5/R-SOC-8 cross-refs. /simplify + /code-review. The real platform posting stays INT_TEST=1-gated.