-- 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