firehose/specs/scheduled-publishing.allium
2026-04-02 09:56:49 +00:00

349 lines
8.9 KiB
Plaintext

-- allium: 1
-- scheduled-publishing.allium
-- Scope: Scheduled blog post publishing and authenticated author dashboard
-- Includes: Post visibility rules, date-based scheduling, authentication,
-- registration gating, author dashboard
-- Excludes:
-- - Post editing / CMS (posts are markdown files in git)
-- - Role-based access (single role: authenticated user)
-- - Multi-tenancy
-- - Post creation workflow
-- Constraints: Posts are compiled from markdown at build time (NimblePublisher).
-- All filtering is runtime — no database for post content.
------------------------------------------------------------
-- External Entities
------------------------------------------------------------
-- Posts are compiled from markdown at build time, not managed by this spec
external entity Post {
id: String
title: String
author: String
body: String
description: String
date: Date
tags: Set<String>
blog: Blog
published: Boolean
}
external entity Blog {
blog_id: String
title: String
description: String
base_path: String
posts: Post with blog = this
}
------------------------------------------------------------
-- Enumerations
------------------------------------------------------------
enum PostVisibility { live | scheduled | draft }
------------------------------------------------------------
-- Entities and Variants
------------------------------------------------------------
entity User {
email: String
password_hash: String
active_sessions: Session with user = this
-- Derived
is_authenticated: active_sessions.count > 0
}
entity Session {
user: User
token: String
}
------------------------------------------------------------
-- Value Types
------------------------------------------------------------
value PostWithVisibility {
post: Post
visibility: PostVisibility
-- Derived
days_until_live: if visibility = scheduled: post.date - today else: null
}
------------------------------------------------------------
-- Config
------------------------------------------------------------
config {
allowed_registration_email: String?
}
------------------------------------------------------------
-- Defaults
------------------------------------------------------------
-- Dev-only seed user
default User demo_user = {
email: "demo@example.com",
password_hash: hash("password123")
}
------------------------------------------------------------
-- Rules
------------------------------------------------------------
-- Post visibility
rule DeterminePostVisibility {
when: post: Post.created
ensures:
if not post.published:
post.visibility = draft
else if post.date > today:
post.visibility = scheduled
else:
post.visibility = live
}
rule FilterPublicPosts {
when: PublicPostsRequested(blog)
ensures: PublicPostsResolved(
posts: blog.posts where published and date <= today
)
}
rule FilterPublicPostsByTag {
when: PublicPostsByTagRequested(blog, tag)
let matching = blog.posts where published and date <= today
ensures: PublicPostsResolved(
posts: matching where tag in tags
)
}
rule ResolvePostForPublic {
when: PostRequested(blog, slug)
let post = blog.posts where id = slug
requires: exists post
ensures: PostResolved(post: post)
-- No date/published filter — direct URL access always works
guidance:
-- Direct URL access is intentionally unfiltered so authors
-- can share preview links with reviewers before publish date
}
rule GenerateFeed {
when: FeedRequested(blog)
ensures: FeedResolved(
posts: blog.posts where published and date <= today
)
}
-- Authentication
rule UserRegisters {
when: RegisterRequested(email, password)
requires: config.allowed_registration_email != null
requires: email = config.allowed_registration_email
ensures: User.created(
email: email,
password_hash: hash(password)
)
}
rule RegistrationRejectedWhenDisabled {
when: RegisterRequested(email, password)
requires: config.allowed_registration_email = null
ensures: RegistrationRejected(reason: "registration is invite only.")
}
rule RegistrationRejectedWhenEmailNotAllowed {
when: RegisterRequested(email, password)
requires: config.allowed_registration_email != null
requires: email != config.allowed_registration_email
ensures: RegistrationRejected(reason: "registration is invite only.")
}
rule UserLogsIn {
when: LoginRequested(email, password)
let user = User{email}
requires: exists user
requires: verify(password, user.password_hash)
ensures: Session.created(user: user)
}
rule UserLogsOut {
when: LogoutRequested(session)
ensures: not exists session
}
------------------------------------------------------------
-- Actor Declarations
------------------------------------------------------------
actor Author {
identified_by: User where is_authenticated
}
------------------------------------------------------------
-- Surfaces
------------------------------------------------------------
surface PublicBlogIndex {
facing visitor: User
context blog: Blog
let visible_posts = blog.posts where published and date <= today
exposes:
blog.title
blog.description
for post in visible_posts:
post.title
post.date
post.author
post.description
post.tags
provides:
PublicPostsRequested(blog)
related:
PublicPostDetail(post) when post in visible_posts
PublicBlogByTag(blog)
}
surface PublicPostDetail {
facing visitor: User
context post: Post
exposes:
post.title
post.date
post.author
post.body
post.description
post.tags
post.visibility
guidance:
-- Accessible by direct URL regardless of date or published status.
-- This allows authors to share preview links with reviewers.
-- When viewer is authenticated and post is not live:
-- draft → banner: "Draft — not published"
-- scheduled → banner: "This post is scheduled for {date}"
}
surface PublicBlogByTag {
facing visitor: User
context blog: Blog
let visible_posts = blog.posts where published and date <= today
exposes:
for tag in blog.all_tags:
tag
for post in visible_posts where tag in post.tags:
post.title
post.date
provides:
PublicPostsByTagRequested(blog, tag)
}
surface PublicFeed {
facing visitor: User
context blog: Blog
exposes:
blog.title
blog.description
for post in blog.posts where published and date <= today:
post.title
post.date
post.author
post.description
post.body
provides:
FeedRequested(blog)
guidance:
-- Serves RSS and Atom feeds.
-- Only includes posts that are published and not future-dated.
-- Feeds reflect the filtered list at request time; no cache-busting needed.
}
surface EditorDashboard {
facing author: Author
context blog: Blog
let drafts = blog.posts where not published
let scheduled = blog.posts where published and date > today
exposes:
blog.title
for draft in drafts:
draft.title
draft.date
draft.author
draft.blog
draft.visibility
for scheduled_post in scheduled:
scheduled_post.title
scheduled_post.date
scheduled_post.author
scheduled_post.blog
scheduled_post.visibility
scheduled_post.days_until_live
guidance:
-- Dashboard at /editor/dashboard.
-- Drafts and scheduled posts shown in separate tabs.
-- Unified timeline across all blogs (not grouped by blog).
-- Drafts sorted by date descending.
-- Scheduled posts sorted by date ascending (soonest first).
-- Scheduled posts show "X days until live" countdown.
related:
PublicPostDetail(post) when post in drafts or post in scheduled
}
surface Login {
facing visitor: User
provides:
LoginRequested(email, password)
guidance:
-- Login page is not linked from public navigation.
-- Authors access it directly by URL.
}
surface Registration {
facing visitor: User
provides:
RegisterRequested(email, password)
guidance:
-- Registration page is not linked from public navigation.
-- Rejects with "registration is invite only." when email does not
-- match ALLOWED_REGISTRATION_EMAIL or when the env var is unset.
}
------------------------------------------------------------
-- Open Questions
------------------------------------------------------------
-- No open questions remaining