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

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
//! this crate knows about any specific story. Every story is rows.
pub mod authors;
pub mod config;
pub mod context;
pub mod db;