v0.3 step 1: migration 0004 + authors module + web form panels

Migration 0004 — authors + author_revisions + stories.author_id +
stories.author_revision_id + stories.cross_story_memory +
author_corpus. Soul versioning built in from day one per cobb's
locked decisions:
- authors.id immutable identity (slug + display_name + tagline + model)
- author_revisions tracks each soul revision with n monotonic
- Partial unique index 'idx_author_revisions_current' enforces
  exactly one is_current=true per author
- stories.author_revision_id pins to the exact soul used at gen
  time (so 'this was the Orson Black active when chapter 8 was
  written' is always recoverable)
- author_corpus tracks 'authored' + 'read' relationships for the
  v0.3 cross-story memory toggle

skald-core::authors module — CRUD: get_by_slug,
get_with_current_revision, get_current_revision, get_revision,
create_or_get (idempotent), add_revision (transactional, demotes
prior is_current=true), assign_to_story (also touches
author_corpus).

Web v0.1 forms (the second feedback bucket — 'no way to make new
stories', 'no options for sequels'): handlers + form panels +
POST routes for /stories/new and /stories/:id/continue. Both
create a story stub with status='seed'; actual generation will be
fired by 'skald continue' (next commit) walking seed rows.

Norse visual revamp + mobile collapse deferred — vetting full gen
is the priority per cobb's 'green light for v0.3'. Coming back to
the aesthetic after the pipeline works end-to-end against a real
Orson Black-authored Chapter 8 of Coast-Down.
This commit is contained in:
Kayos 2026-05-13 12:01:29 -07:00
parent 7187bf5ace
commit 713ba41977
4 changed files with 529 additions and 1 deletions

100
migrations/0004_authors.sql Normal file
View file

@ -0,0 +1,100 @@
-- Authors as personas with souls (v0.3).
--
-- Each story has a named author whose soul (SOUL.md-style markdown
-- blob) IS the LLM's system prompt. Soul replaces opus's default
-- base prompt via clawdforge's system_mode='replace' so the model
-- BECOMES the author, not "Claude playing the author."
--
-- Souls are versioned: authors.id is the immutable identity, while
-- author_revisions tracks soul edits over time. Stories pin to a
-- specific revision so "this was the version of Orson Black active
-- when chapter 8 of Coast-Down was written" is always recoverable.
CREATE TABLE authors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE, -- "orson-black"
display_name TEXT NOT NULL, -- "Orson Black"
persona_tagline TEXT, -- "Orwell but more rebel + pissed off"
-- Model the author writes with. Skald is opinionated: always
-- opus max effort. Stored per-author so we can later vary
-- (e.g. test sonnet for a faster-iterating author).
model TEXT NOT NULL DEFAULT 'opus',
-- Flag for the operator: this is a synthetic literary persona,
-- not a real human author. Display somewhere obvious.
is_synthetic BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One row per soul revision. `n` is monotonic per author. Exactly
-- one revision per author is_current=true at any time (partial
-- unique index).
CREATE TABLE author_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
n INTEGER NOT NULL,
-- The soul: SOUL.md-style markdown blob. Replaces claude's base
-- system prompt when this revision is used.
soul TEXT NOT NULL,
-- Optional override of the default scaffold prompt that wraps
-- the soul. Default scaffold lives in skald-core::forge.
system_template TEXT,
-- Tools the author can call during gen. Empty default — fiction
-- writing doesn't normally need them. Researcher-bent authors
-- could opt in for WebSearch / Read.
tools TEXT[] NOT NULL DEFAULT '{}',
-- Human note: "first cut", "tightened voice after Chapter 8 read"
note TEXT,
is_current BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (author_id, n)
);
-- At most one current revision per author.
CREATE UNIQUE INDEX idx_author_revisions_current
ON author_revisions(author_id) WHERE is_current = true;
CREATE INDEX idx_author_revisions_author ON author_revisions(author_id);
-- Auto-touch authors.updated_at on author or revision change.
CREATE OR REPLACE FUNCTION touch_author_updated_at()
RETURNS TRIGGER AS $$
BEGIN
UPDATE authors SET updated_at = now()
WHERE id = COALESCE(NEW.author_id, OLD.author_id);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER author_revisions_touch_author
AFTER INSERT OR UPDATE OR DELETE ON author_revisions
FOR EACH ROW
EXECUTE FUNCTION touch_author_updated_at();
-- Stories carry author identity + the exact revision used at gen
-- time. author_id is also denormalized on the row (instead of
-- joining through revision) so common "all stories by Orson"
-- queries don't need the join.
ALTER TABLE stories
ADD COLUMN author_id UUID REFERENCES authors(id) ON DELETE SET NULL,
ADD COLUMN author_revision_id UUID REFERENCES author_revisions(id) ON DELETE SET NULL,
-- Per-story toggle for cross-story memory. When true,
-- ContinuationContext::assemble pulls characters / canon /
-- summaries from EVERY story in the author's corpus, not just
-- the parent chain.
ADD COLUMN cross_story_memory BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX idx_stories_author ON stories(author_id);
-- Track which stories an author has access to. Auto-populated via
-- app code on every story creation (role='authored'); operator can
-- mark other stories as 'read' for cross-corpus memory.
CREATE TABLE author_corpus (
author_id UUID NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
story_id UUID NOT NULL REFERENCES stories(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('authored', 'read')),
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (author_id, story_id)
);
CREATE INDEX idx_author_corpus_author ON author_corpus(author_id);

263
skald-core/src/authors.rs Normal file
View file

@ -0,0 +1,263 @@
//! Authors module. Owns the read/write paths for the persona layer.
//!
//! An [`Author`] is the immutable identity (slug, display_name, model).
//! A [`Revision`] is the versioned soul content — exactly one revision
//! per author is marked `is_current = true` at any time.
//!
//! When skald-the-binary runs a forge pass, it pulls the current
//! revision via [`get_with_current_revision`], stuffs the soul into
//! the system prompt with `SystemMode::Replace`, and the model
//! becomes the author for the duration of the call.
use anyhow::{Context, bail};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Author {
pub id: Uuid,
pub slug: String,
pub display_name: String,
pub persona_tagline: Option<String>,
pub model: String,
pub is_synthetic: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Revision {
pub id: Uuid,
pub author_id: Uuid,
pub n: i32,
pub soul: String,
pub system_template: Option<String>,
pub tools: Vec<String>,
pub note: Option<String>,
pub is_current: bool,
pub created_at: DateTime<Utc>,
}
/// An author + their currently-active revision. Most pipeline calls
/// want both at once.
#[derive(Debug, Clone)]
pub struct AuthorWithRevision {
pub author: Author,
pub revision: Revision,
}
/// Fetch an author by slug. Returns None if no such author.
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> anyhow::Result<Option<Author>> {
let row: Option<(Uuid, String, String, Option<String>, String, bool, DateTime<Utc>, DateTime<Utc>)> =
sqlx::query_as(
"SELECT id, slug, display_name, persona_tagline, model, is_synthetic,
created_at, updated_at
FROM authors WHERE slug = $1",
)
.bind(slug)
.fetch_optional(pool)
.await?;
Ok(row.map(
|(id, slug, display_name, persona_tagline, model, is_synthetic, created_at, updated_at)| {
Author {
id,
slug,
display_name,
persona_tagline,
model,
is_synthetic,
created_at,
updated_at,
}
},
))
}
/// Fetch an author + their current revision in one call.
pub async fn get_with_current_revision(
pool: &PgPool,
slug: &str,
) -> anyhow::Result<Option<AuthorWithRevision>> {
let Some(author) = get_by_slug(pool, slug).await? else {
return Ok(None);
};
let revision = get_current_revision(pool, author.id)
.await?
.with_context(|| format!("author {slug} has no current revision"))?;
Ok(Some(AuthorWithRevision { author, revision }))
}
/// Fetch the current revision for an author.
pub async fn get_current_revision(
pool: &PgPool,
author_id: Uuid,
) -> anyhow::Result<Option<Revision>> {
let row: Option<(Uuid, Uuid, i32, String, Option<String>, Vec<String>, Option<String>, bool, DateTime<Utc>)> =
sqlx::query_as(
"SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at
FROM author_revisions WHERE author_id = $1 AND is_current = true",
)
.bind(author_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| {
Revision {
id,
author_id,
n,
soul,
system_template,
tools,
note,
is_current,
created_at,
}
}))
}
/// Fetch a specific revision by id (e.g. the one pinned on a story).
pub async fn get_revision(pool: &PgPool, revision_id: Uuid) -> anyhow::Result<Option<Revision>> {
let row: Option<(Uuid, Uuid, i32, String, Option<String>, Vec<String>, Option<String>, bool, DateTime<Utc>)> =
sqlx::query_as(
"SELECT id, author_id, n, soul, system_template, tools, note, is_current, created_at
FROM author_revisions WHERE id = $1",
)
.bind(revision_id)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id, author_id, n, soul, system_template, tools, note, is_current, created_at)| {
Revision {
id,
author_id,
n,
soul,
system_template,
tools,
note,
is_current,
created_at,
}
}))
}
/// Create a new author. Idempotent on slug — if an author with this
/// slug exists, returns it instead.
pub async fn create_or_get(
pool: &PgPool,
slug: &str,
display_name: &str,
persona_tagline: Option<&str>,
) -> anyhow::Result<Author> {
if let Some(existing) = get_by_slug(pool, slug).await? {
return Ok(existing);
}
sqlx::query(
"INSERT INTO authors (slug, display_name, persona_tagline)
VALUES ($1, $2, $3)",
)
.bind(slug)
.bind(display_name)
.bind(persona_tagline)
.execute(pool)
.await?;
get_by_slug(pool, slug)
.await?
.with_context(|| format!("author {slug} just created but not found"))
}
/// Add a new revision to an author and mark it current. The previous
/// current revision (if any) gets flipped to is_current=false.
pub async fn add_revision(
pool: &PgPool,
author_id: Uuid,
soul: &str,
system_template: Option<&str>,
tools: &[String],
note: Option<&str>,
) -> anyhow::Result<Revision> {
let mut tx = pool.begin().await?;
// Demote the current revision if there is one.
sqlx::query("UPDATE author_revisions SET is_current = false WHERE author_id = $1 AND is_current = true")
.bind(author_id)
.execute(&mut *tx)
.await?;
// Next n.
let next_n: i32 = sqlx::query_scalar(
"SELECT COALESCE(MAX(n), 0) + 1 FROM author_revisions WHERE author_id = $1",
)
.bind(author_id)
.fetch_one(&mut *tx)
.await?;
let revision_id: Uuid = sqlx::query_scalar(
"INSERT INTO author_revisions
(author_id, n, soul, system_template, tools, note, is_current)
VALUES ($1, $2, $3, $4, $5, $6, true)
RETURNING id",
)
.bind(author_id)
.bind(next_n)
.bind(soul)
.bind(system_template)
.bind(tools)
.bind(note)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
get_revision(pool, revision_id)
.await?
.with_context(|| format!("revision {revision_id} just inserted but not found"))
}
/// Assign an author + revision to an existing story. Pins the
/// revision so future re-reads always know which version wrote
/// what. Also adds a row to author_corpus marking the author as
/// having authored this story.
pub async fn assign_to_story(
pool: &PgPool,
story_id: Uuid,
author_id: Uuid,
revision_id: Uuid,
) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
let affected = sqlx::query(
"UPDATE stories
SET author_id = $1, author_revision_id = $2
WHERE id = $3",
)
.bind(author_id)
.bind(revision_id)
.bind(story_id)
.execute(&mut *tx)
.await?;
if affected.rows_affected() == 0 {
bail!("story {story_id} not found");
}
sqlx::query(
"INSERT INTO author_corpus (author_id, story_id, role)
VALUES ($1, $2, 'authored')
ON CONFLICT (author_id, story_id) DO NOTHING",
)
.bind(author_id)
.bind(story_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
#[cfg(test)]
mod tests {
// DB-touching tests live in skald/tests/integration.rs against a
// real ephemeral postgres. Unit tests would mostly check trivial
// mappings.
}

View file

@ -4,6 +4,7 @@
//! assembly for LLM calls. The story-independence rule: nothing in //! assembly for LLM calls. The story-independence rule: nothing in
//! this crate knows about any specific story. Every story is rows. //! this crate knows about any specific story. Every story is rows.
pub mod authors;
pub mod config; pub mod config;
pub mod context; pub mod context;
pub mod db; pub mod db;

View file

@ -13,10 +13,12 @@ use std::sync::Arc;
use axum::Router; use axum::Router;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::Html; use axum::response::{Html, Redirect};
use axum::routing::get; use axum::routing::get;
use axum::Form;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use maud::{DOCTYPE, Markup, html}; use maud::{DOCTYPE, Markup, html};
use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@ -28,7 +30,9 @@ pub struct WebState {
pub fn router(state: WebState) -> Router { pub fn router(state: WebState) -> Router {
Router::new() Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/stories/new", get(new_story_form).post(new_story_create))
.route("/stories/{id}", get(story_detail)) .route("/stories/{id}", get(story_detail))
.route("/stories/{id}/continue", get(continue_form).post(continue_create))
.route("/stories/{id}/chapters/{n}", get(chapter_view)) .route("/stories/{id}/chapters/{n}", get(chapter_view))
.route("/stories/{id}/runs", get(runs_view)) .route("/stories/{id}/runs", get(runs_view))
.with_state(Arc::new(state)) .with_state(Arc::new(state))
@ -92,6 +96,122 @@ async fn index(State(state): State<Arc<WebState>>) -> Html<String> {
Html(render_shell(&stories, None, welcome_panel()).into_string()) Html(render_shell(&stories, None, welcome_panel()).into_string())
} }
#[derive(Debug, Deserialize)]
pub struct NewStoryForm {
title: String,
#[serde(default)]
prompt: String,
#[serde(default)]
word_count_target: String,
}
async fn new_story_form(State(state): State<Arc<WebState>>) -> Html<String> {
let stories = fetch_stories(&state.pool).await;
Html(render_shell(&stories, None, new_story_panel(None)).into_string())
}
async fn new_story_create(
State(state): State<Arc<WebState>>,
Form(form): Form<NewStoryForm>,
) -> Result<Redirect, (StatusCode, String)> {
let title = form.title.trim();
if title.is_empty() {
return Err((StatusCode::BAD_REQUEST, "title is required".into()));
}
let target: Option<i32> = form.word_count_target.trim().parse().ok();
let prompt = if form.prompt.trim().is_empty() {
None
} else {
Some(form.prompt.trim().to_string())
};
let id: Uuid = sqlx::query_scalar(
"INSERT INTO stories (title, status, prompt, word_count_target)
VALUES ($1, 'seed', $2, $3)
RETURNING id",
)
.bind(title)
.bind(prompt.as_deref())
.bind(target)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query("UPDATE stories SET root_story_id = id WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Redirect::to(&format!("/stories/{}", id)))
}
#[derive(Debug, Deserialize)]
pub struct ContinueForm {
title: String,
#[serde(default)]
direction: String,
#[serde(default)]
word_count_target: String,
}
async fn continue_form(
State(state): State<Arc<WebState>>,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let stories = fetch_stories(&state.pool).await;
let parent = stories
.iter()
.find(|s| s.id == id)
.cloned()
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Html(
render_shell(&stories, Some(id), continue_panel(&parent)).into_string(),
))
}
async fn continue_create(
State(state): State<Arc<WebState>>,
Path(parent_id): Path<Uuid>,
Form(form): Form<ContinueForm>,
) -> Result<Redirect, (StatusCode, String)> {
let title = form.title.trim();
if title.is_empty() {
return Err((StatusCode::BAD_REQUEST, "title is required".into()));
}
let target: Option<i32> = form.word_count_target.trim().parse().ok();
let direction = if form.direction.trim().is_empty() {
None
} else {
Some(form.direction.trim().to_string())
};
let root_id: Uuid = sqlx::query_scalar(
"SELECT COALESCE(root_story_id, id) FROM stories WHERE id = $1",
)
.bind(parent_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "parent story not found".into()))?;
let id: Uuid = sqlx::query_scalar(
"INSERT INTO stories (title, status, prompt, word_count_target, parent_story_id, root_story_id)
VALUES ($1, 'seed', $2, $3, $4, $5)
RETURNING id",
)
.bind(title)
.bind(direction.as_deref())
.bind(target)
.bind(parent_id)
.bind(root_id)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Redirect::to(&format!("/stories/{}", id)))
}
async fn story_detail( async fn story_detail(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -243,6 +363,50 @@ fn render_shell(stories: &[StoryRow], current: Option<Uuid>, main: Markup) -> Ma
} }
} }
fn new_story_panel(err: Option<&str>) -> Markup {
html! {
article.form-panel {
h1 { "New story" }
p.muted { "Create a story stub. Actual generation lands when v0.3 wires the gen pipeline." }
@if let Some(e) = err { p.error { (e) } }
form method="post" action="/stories/new" {
label { "Title"
input type="text" name="title" required;
}
label { "Seed prompt (optional)"
textarea name="prompt" rows="4" placeholder="What is this story about?" {}
}
label { "Target word count (optional)"
input type="number" name="word_count_target" min="500" step="500";
}
button type="submit" { "Create" }
}
}
}
}
fn continue_panel(parent: &StoryRow) -> Markup {
html! {
article.form-panel {
a.back href=(format!("/stories/{}", parent.id)) { "← back to " (parent.title) }
h1 { "Continue: " (parent.title) }
p.muted { "Creates a child story stub with parent_story_id = " code { (parent.id) } ". Generation queued for v0.3." }
form method="post" action=(format!("/stories/{}/continue", parent.id)) {
label { "Title for the sequel"
input type="text" name="title" required value=(format!("{} — continued", parent.title));
}
label { "Direction (optional)"
textarea name="direction" rows="4" placeholder="What should the sequel explore?" {}
}
label { "Target word count (optional)"
input type="number" name="word_count_target" min="500" step="500";
}
button type="submit" { "Queue sequel" }
}
}
}
}
fn welcome_panel() -> Markup { fn welcome_panel() -> Markup {
html! { html! {
div.welcome { div.welcome {