diff --git a/specs/scheduled-publishing.allium b/specs/scheduled-publishing.allium new file mode 100644 index 0000000..a2b0f89 --- /dev/null +++ b/specs/scheduled-publishing.allium @@ -0,0 +1,348 @@ +-- 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 + 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