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
postcmdpath, 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/social — Set (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/socialcmd — Set, Show, Gen (platform-appropriate AI draft, R-SOC-4) |
CLI-shaped; no caption file (R-CAP-4) |
| Post fan-out | internal/postcmd — Poster, 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/publish — Publisher 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'sText(or the posting-material fields) changes whileStatusisapproved/posted, resets it todraft. Lives in the shared path so CLIsocial setand the studio PUT both get it. - R-SOC-5 (config constraints).
social.Constraintsbecomes config-resolved:ConstraintsFor(cfg, platform)readsplatforms.<p>.constraints.{text_cap, title_cap, links_clickable, hashtag_hint}, falling back to the current hardcoded values as defaults (whichinitmay seed).Check/Violationstake the resolved constraint. No behaviour change unless config overrides — parity preserved. - R-CAP-4 (caption file). A shared
social.WriteCaption(fs, bundleDir, set)rendersreel-caption.md(per-platform prose: title/text/hashtags/link, human-readable) into the linked bundle. Called by CLIsocialand the studio on save when a bundle is linked (no bundle → skip, not an error). A first-class committed deliverable. - Optimistic guard (D7).
GET …/socialreturns a revision token (content hash ofsocial.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") ifsocial.jsonchanged since read. Prevents a studio edit clobbering a concurrent CLIpost.
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 MCP — post/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:
- 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. - R-CAP-4 trigger. Write
reel-caption.mdon 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. - R-SOC-5 config shape.
platforms.<p>.constraints.{text_cap,title_cap, links_clickable,hashtag_hint}, defaults = the current hardcoded values (rec). - 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.
- 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.
- 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.