Initial commit: allium behavioural specs distilled from bDS TypeScript app

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 18:36:20 +02:00
commit aec533b54f
32 changed files with 4569 additions and 0 deletions

129
specs/ai.allium Normal file
View File

@@ -0,0 +1,129 @@
-- allium: 1
-- bDS AI Integration
-- Distilled from: src/main/engine/ChatEngine.ts, ai/providers.ts,
-- ai/chat.ts, ai/tasks.ts, SecureKeyStore.ts
use "./post.allium" as post
use "./media.allium" as media
value AiProvider {
kind: opencode_zen | mistral | ollama | lm_studio
-- OpenCode Zen: routes claude* to Anthropic Messages API,
-- everything else to OpenAI Chat Completions
-- Mistral: native Mistral API
-- Ollama: localhost:11434, local models
-- LM Studio: localhost:1234, local models
}
value AiModel {
provider: AiProvider
name: String
modalities: Set<String> -- text, vision, etc.
}
entity SecureKeyStore {
-- Encrypts API keys using OS keychain
-- macOS: Keychain, Windows: DPAPI, Linux: libsecret
-- Stored as base64 in settings table with __encrypted_ prefix
-- No plain-text fallback
}
entity ChatConversation {
title: String
model: String -- default: claude-sonnet-4-5
created_at: Timestamp
updated_at: Timestamp
messages: ChatMessage with conversation = this
}
entity ChatMessage {
conversation: ChatConversation
role: system | user | assistant | tool
content: String
token_usage_input: Integer?
token_usage_output: Integer?
created_at: Timestamp
}
-- One-shot AI tasks (core scope, no streaming)
rule AnalyzeTaxonomy {
when: AnalyzeTaxonomyRequested(post)
requires: not airplane_mode
-- All AI activities gated by offline mode
-- Suggests tags and categories for a post
ensures: TaxonomySuggestion(tags, categories)
}
rule AnalyzeImage {
when: AnalyzeImageRequested(media)
requires: not airplane_mode
requires: is_image(media.mime_type)
-- Checks mime type is an image type
-- Vision model generates alt text and caption
ensures: ImageAnalysisResult(alt, caption)
}
rule AnalyzePost {
when: AnalyzePostRequested(post)
requires: not airplane_mode
-- Generates title, excerpt, slug suggestions
ensures: PostAnalysisResult(title, excerpt, slug)
}
rule DetectLanguage {
when: DetectLanguageRequested(text)
requires: not airplane_mode
ensures: LanguageDetectionResult(language_code)
}
rule TranslatePost {
when: TranslatePostRequested(post, target_language)
requires: not airplane_mode
-- Translates title, excerpt, content to target language
ensures: TranslationResult(title, excerpt, content)
}
rule TranslateMedia {
when: TranslateMediaRequested(media, target_language)
requires: not airplane_mode
-- Translates title, alt, caption to target language
ensures: MediaTranslationResult(title, alt, caption)
}
-- Chat (extension scope, with streaming and tool use)
rule StartChat {
when: StartChatRequested(model)
ensures: ChatConversation.created(model: model ?? "claude-sonnet-4-5")
}
rule SendChatMessage {
when: SendChatMessageRequested(conversation, content)
requires: not airplane_mode
ensures: ChatMessage.created(conversation: conversation, role: user, content: content)
ensures: AiStreamingResponse(conversation)
-- AI SDK v6 streamText() with tool-call loop (max 10 rounds)
-- Blog data tools for post/media querying during chat
-- Token usage tracking (input, output, cache read/write)
}
-- Model catalog
rule RefreshModelCatalog {
when: RefreshModelCatalogRequested(provider)
-- 5-minute cache TTL
ensures: ModelCatalogUpdated(provider)
}
invariant AirplaneModeGating {
-- All AI activities must be gated by airplane (offline) mode
-- When offline: either use local model (Ollama/LM Studio) or
-- inform the user via toast notification
}
invariant SecureKeyStorage {
-- API keys are never stored in plain text
-- Always encrypted via OS keychain before DB storage
}

59
specs/bds.allium Normal file
View File

@@ -0,0 +1,59 @@
-- allium: 1
-- bDS (Blogging Desktop Server) — Axiom Specification
-- Distilled from TypeScript implementation at ../bDS/
-- This is the behavioural baseline for the Rust rewrite (RuDS)
-- An offline-first desktop application for blog authoring with
-- static site generation, SSH publishing, AI integration, and
-- external tool integration via MCP.
-- Core domain
use "./project.allium" as project -- Multi-project management
use "./post.allium" as post -- Post lifecycle, frontmatter, file layout
use "./media.allium" as media -- Media import, thumbnails, sidecars
use "./translation.allium" as translation -- Post and media translations
use "./tag.allium" as tag -- Tags with mass operations
use "./template.allium" as template -- Liquid template management
use "./script.allium" as script -- Scripting (macros, utilities, transforms)
use "./menu.allium" as menu -- OPML navigation menu
use "./metadata.allium" as metadata -- Project config, categories, publishing prefs
-- Infrastructure
use "./search.allium" as search -- FTS5 full-text search with Snowball stemming
use "./generation.allium" as generation -- Static site generation (sections, routes, hashing)
use "./preview.allium" as preview -- Local HTTP preview server
use "./publishing.allium" as publishing -- SSH upload (SCP / rsync)
use "./task.allium" as task -- Background task manager
use "./i18n.allium" as i18n -- Split localization (UI vs content)
-- Integration
use "./git.allium" as git -- Git operations, LFS, reconciliation
use "./mcp.allium" as mcp -- MCP server (tools, resources, proposals)
use "./ai.allium" as ai -- AI one-shot tasks and chat
use "./embedding.allium" as embedding -- Semantic similarity (HNSW vectors)
use "./cli_sync.allium" as cli_sync -- CLI-to-app notification sync
use "./metadata_diff.allium" as metadata_diff -- DB/filesystem diff and rebuild
-- Compatibility contract (from RUST_PLAN.md)
--
-- MUST stay identical:
-- SQLite schema semantics, post markdown frontmatter,
-- translation file naming, media sidecars, thumbnail conventions,
-- template file formats, menu OPML, generated routes/feeds/sitemaps,
-- FTS5 search behaviour, slug generation, metadata diff, rebuild-from-filesystem
--
-- MAY change intentionally:
-- Implementation language (TS -> Rust), editor (WYSIWYG -> plain text + preview),
-- scripting runtime (Python -> Lua), process model (Electron -> native),
-- UI framework (React -> Iced)
-- Resolved questions:
--
-- 1. Slug generation scope: only German and English letters are used.
-- Verify deunicode handles ä/ö/ü/ß/ÄÖÜ correctly against transliteration npm.
--
-- 2. Liquid subset: see template.allium for the exact subset.
-- Only 5 tags, 4 standard filters, 2 custom filters, 5 operators.
--
-- 3. Macro calling convention: [[macroslug param1=value1 ...]]
-- Double-bracket syntax, not Liquid tags. Identical to current app.

59
specs/cli_sync.allium Normal file
View File

@@ -0,0 +1,59 @@
-- allium: 1
-- bDS CLI / App Notification Sync
-- Distilled from: src/main/engine/CliNotifier.ts, NotificationWatcher.ts
entity DbNotification {
entity_type: String -- post, media, script, template
entity_id: String
action: created | updated | deleted
from_cli: Boolean
seen_at: Timestamp?
created_at: Timestamp
-- Derived
is_processed: seen_at != null
}
rule CliWriteNotification {
when: CliMutationPerformed(entity_type, entity_id, action)
-- CLI inserts notification row; app watches for it
ensures: DbNotification.created(
entity_type: entity_type,
entity_id: entity_id,
action: action,
from_cli: true,
seen_at: null
)
}
rule AppWatchNotifications {
when: DbFileChangeDetected()
-- Watches SQLite DB file + WAL via filesystem watcher
-- Debounced at 100ms
let unseen = DbNotifications where seen_at = null and from_cli = true
for n in unseen:
ensures: n.seen_at = now
ensures: EngineCacheInvalidated(n.entity_type)
ensures: EntityChangedEvent(n.entity_type, n.entity_id, n.action)
-- IPC event to renderer for UI refresh
}
rule PruneProcessedNotifications {
when: n: DbNotification.created_at + 1.hour <= now
requires: n.is_processed
-- Processed rows: prune after 1 hour
ensures: not exists n
}
rule PruneUnprocessedNotifications {
when: n: DbNotification.created_at + 24.hours <= now
requires: not n.is_processed
-- Unprocessed rows: prune after 24 hours
ensures: not exists n
}
invariant AppNoopNotifier {
-- The Electron app uses a no-op notifier for its own writes
-- It already knows about its own mutations
-- Only CLI writes produce notification rows
}

92
specs/embedding.allium Normal file
View File

@@ -0,0 +1,92 @@
-- allium: 1
-- bDS Semantic Similarity / Embeddings
-- Distilled from: src/main/engine/EmbeddingEngine.ts
use "./post.allium" as post
use "./tag.allium" as tag
value EmbeddingVector {
dimensions: Integer -- 384 (multilingual-e5-small)
values: List<Decimal>
}
entity EmbeddingKey {
label: Integer -- HNSW label for USearch
post: post/Post
content_hash: String -- Skip re-embedding unchanged posts
vector: EmbeddingVector
}
entity DismissedDuplicatePair {
post_a: post/Post
post_b: post/Post
}
config {
model: String = "multilingual-e5-small"
embedding_dimensions: Integer = 384
debounce_persist: Duration = 5.seconds
}
rule ReindexAll {
when: ReindexAllRequested(project)
-- Re-embeds all posts, rebuilds HNSW index
for p in project.posts:
ensures: EmbeddingKeyUpdated(p)
ensures: HnswIndexRebuilt(project)
}
rule IndexUnindexed {
when: IndexUnindexedRequested(project)
-- Only embeds posts without existing embeddings or with changed content_hash
for p in project.posts:
let existing = EmbeddingKey{post: p}
if not exists existing or existing.content_hash != p.checksum:
ensures: EmbeddingKeyUpdated(p)
}
rule FindSimilar {
when: FindSimilarRequested(post, limit)
-- HNSW vector search via USearch
-- Returns ranked list of similar posts with similarity scores
ensures: SimilarPostsResult(post, ranked_matches)
}
rule SuggestTags {
when: SuggestTagsRequested(post)
-- Uses semantic similarity to find related posts,
-- then aggregates their tags as suggestions
ensures: TagSuggestionResult(post, suggested_tags)
}
rule FindDuplicates {
when: FindDuplicatesRequested(project)
-- Finds near-duplicate post pairs above similarity threshold
-- Includes exact-match detection
-- Excludes dismissed pairs
let all_pairs = compute_all_similarities(project)
let above_threshold = filter_above_threshold(all_pairs)
let pairs = exclude_dismissed(above_threshold, DismissedDuplicatePairs)
ensures: DuplicateReport(pairs)
}
rule DismissDuplicatePair {
when: DismissDuplicatePairRequested(post_a, post_b)
ensures: DismissedDuplicatePair.created(post_a: post_a, post_b: post_b)
}
invariant ContentHashSkipsUnchanged {
-- If a post's content_hash matches the stored embedding's content_hash,
-- the post is not re-embedded. This makes bulk re-indexing efficient.
}
invariant DebouncedPersistence {
-- USearch index persistence is debounced at 5 seconds
-- Prevents excessive disk I/O during bulk operations
}
invariant VectorCacheInDb {
-- Vector cache persisted as BLOB in embedding_keys table
-- Float32Array, 384 dimensions per vector
-- Enables instant reload without re-embedding
}

146
specs/generation.allium Normal file
View File

@@ -0,0 +1,146 @@
-- allium: 1
-- bDS Static Site Generation
-- Distilled from: src/main/engine/BlogGenerationEngine.ts,
-- PageRenderer.ts, GenerationWorkerPool, RoutePageGenerationService
use "./post.allium" as post
use "./template.allium" as template
use "./metadata.allium" as meta
use "./menu.allium" as menu
use "./translation.allium" as translation
value GenerationSection {
kind: core | single | category | tag | date
}
value GeneratedFile {
relative_path: String
content_hash: String
}
entity SiteGeneration {
project_id: String
base_url: String
language: String -- main language
blog_languages: Set<String>
max_posts_per_page: Integer
pico_theme: String?
sections: Set<GenerationSection>
-- Output tracking
generated_files: GeneratedFile with project_id = this.project_id
}
invariant IncrementalByContentHash {
-- Files are only written when content_hash changes
-- generatedFileHashes table tracks (projectId, relativePath, contentHash)
-- A file with unchanged hash is skipped on regeneration
}
invariant MultiLanguageRoutes {
-- Main language: flat routes (/{yyyy}/{mm}/{dd}/{slug})
-- Additional languages: prefixed (/{lang}/{yyyy}/{mm}/{dd}/{slug})
-- Each language subtree gets its own feeds and archives
}
-- Core section: root pages, sitemap, RSS, Atom, calendar.json
rule GenerateCoreSectionPages {
when: GenerateSiteRequested(generation)
requires: core in generation.sections
ensures: FileGenerated("index.html")
ensures: FileGenerated("sitemap.xml")
-- Multi-language sitemap with hreflang alternates
ensures: FileGenerated("feed.xml")
-- RSS 2.0 feed
ensures: FileGenerated("atom.xml")
-- Atom feed
ensures: FileGenerated("calendar.json")
-- Post dates for calendar widget
for lang in generation.blog_languages - {generation.language}:
ensures: FileGenerated(format("{lang}/index.html", lang: lang))
ensures: FileGenerated(format("{lang}/feed.xml", lang: lang))
ensures: FileGenerated(format("{lang}/atom.xml", lang: lang))
}
-- Single section: one HTML page per published post
rule GenerateSinglePostPages {
when: GenerateSiteRequested(generation)
requires: single in generation.sections
for p in Posts where status = published:
let url = post_canonical_url(p)
ensures: FileGenerated(format("{url}/index.html", url: url))
for lang in generation.blog_languages - {generation.language}:
if p.translations.any(t => t.language.code = lang):
ensures: FileGenerated(format("{lang}/{url}/index.html",
lang: lang, url: url))
}
-- Category section: paginated archive per category
rule GenerateCategoryPages {
when: GenerateSiteRequested(generation)
requires: category in generation.sections
for cat in generation.categories:
let page_count = ceil(posts_in_category(cat).count / generation.max_posts_per_page)
ensures: FileGenerated(format("category/{cat}/index.html", cat: cat))
for page in page_range(2, page_count):
ensures: FileGenerated(format("category/{cat}/page/{page}/index.html",
cat: cat, page: page))
}
-- Tag section: paginated archive per tag
rule GenerateTagPages {
when: GenerateSiteRequested(generation)
requires: tag in generation.sections
for t in Tags where post_count > 0:
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slugify(t.name)))
}
-- Date section: year and month archives
rule GenerateDateArchivePages {
when: GenerateSiteRequested(generation)
requires: date in generation.sections
for year in distinct_years(Posts):
ensures: FileGenerated(format("{year}/index.html", year: year))
for month in distinct_months(Posts, year):
ensures: FileGenerated(format("{year}/{month}/index.html",
year: year, month: month))
}
-- Template rendering context
rule RenderPage {
when: PageRenderRequested(template, context)
-- LiquidJS rendering with full context:
-- posts, pagination, menus, tags, categories,
-- project metadata, i18n translations, theme settings
-- Macro expansion: [[slug param1=value1 ...]] in post content
-- HTML rewriting for canonical post/media paths
ensures: RenderedHtml(template, context, output)
}
-- Validation
rule ValidateSite {
when: ValidateSiteRequested(project)
-- Compares sitemap URLs to HTML files on disk
-- Detects: missing pages, extra (stale) pages, sitemap/file mismatches
ensures: ValidationReport(missing_pages, extra_pages, stale_pages)
}
rule ApplyValidation {
when: ApplyValidationRequested(project, report)
-- Targeted re-rendering for affected sections only
for section in report.affected_sections:
ensures: GenerateSiteRequested(project, sections: {section})
}
-- Day-block grouping for archives
invariant ArchiveDayBlocks {
-- Archive/list pages group posts by day
-- Each day block has a date header and the posts for that day
}

125
specs/git.allium Normal file
View File

@@ -0,0 +1,125 @@
-- allium: 1
-- bDS Git Integration
-- Distilled from: src/main/engine/GitEngine.ts
use "./post.allium" as post
use "./script.allium" as script
use "./template.allium" as template
value GitProvider {
kind: github | gitlab | gitea_forgejo
-- Detected from remote URL patterns
}
value GitSyncStatus {
-- Per-commit: local_only | remote_only | both
kind: local_only | remote_only | both
}
entity GitRepository {
is_initialized: Boolean
remote_url: String?
provider: GitProvider?
current_branch: String?
has_lfs: Boolean
}
rule InitializeRepo {
when: InitializeRepoRequested(project)
ensures: GitRepository.created(is_initialized: true, has_lfs: true)
ensures: GitignoreCreated(project)
-- .gitignore manages: thumbnails, generated html, node_modules, etc.
ensures: LfsTrackingConfigured(project)
-- Git LFS auto-tracks image patterns (*.jpg, *.png, *.gif, etc.)
}
rule GetStatus {
when: GitStatusRequested(project)
-- Returns file-level status: added, modified, deleted, renamed, untracked
ensures: GitStatusReport(files)
}
rule GetDiff {
when: GitDiffRequested(project)
ensures: GitDiffReport(staged_diff, unstaged_diff)
}
rule GetHistory {
when: GitHistoryRequested(project, branch)
-- Returns commit history with sync status per commit
ensures: GitHistoryReport(commits)
@guidance
-- Each commit annotated with: local_only, remote_only, or both
-- This drives the "push needed" / "pull needed" indicators
}
rule Fetch {
when: GitFetchRequested(project)
ensures: RemoteRefsUpdated(project)
}
rule Pull {
when: GitPullRequested(project)
ensures: LocalBranchUpdated(project)
ensures: ReconcileFromGit(project)
-- After pull, detect changed files and reconcile DB
}
rule Push {
when: GitPushRequested(project)
ensures: RemoteBranchUpdated(project)
}
rule CommitAll {
when: GitCommitAllRequested(project, message)
ensures: AllChangesStaged(project)
ensures: CommitCreated(project, message)
}
-- Git reconciliation: sync DB from filesystem changes
rule ReconcileFromGit {
when: GitReconcileRequested(project, old_commit, new_commit)
-- Detect changed files between commits for posts, scripts, templates
let post_changes = changed_post_files(old_commit, new_commit)
let script_changes = changed_script_files(old_commit, new_commit)
let template_changes = changed_template_files(old_commit, new_commit)
for added in post_changes.added:
ensures: post/Post.created(parse_post_file(added))
for modified in post_changes.modified:
ensures: PostUpdatedFromFile(modified)
for deleted in post_changes.deleted:
ensures: PostDeletedByPath(deleted)
for renamed in post_changes.renamed:
ensures: PostFileRenamed(renamed.old, renamed.new)
-- Same pattern for scripts and templates
for added in script_changes.added:
ensures: script/Script.created(parse_script_file(added))
for added in template_changes.added:
ensures: template/Template.created(parse_template_file(added))
ensures: EntityChangedEventsEmitted(project)
}
invariant NonInteractiveGit {
-- All git operations run non-interactively:
-- GIT_TERMINAL_PROMPT=0
-- GCM_INTERACTIVE=never
-- ssh -oBatchMode=yes
-- No password prompts ever surface to the user
}
invariant StructuredAuthErrors {
-- Auth failures produce structured guidance:
-- per platform (macOS/Windows/Linux)
-- per provider (GitHub/GitLab/Gitea)
-- Instead of raw git error messages
}
rule PruneLfsCache {
when: PruneLfsCacheRequested(project, retain_recent)
-- Prunes LFS cache with configurable recent commit retention
ensures: LfsCachePruned(project)
}

52
specs/i18n.allium Normal file
View File

@@ -0,0 +1,52 @@
-- allium: 1
-- bDS Internationalization
-- Distilled from: src/main/shared/i18n.ts, i18n/locales/*.json
value SupportedLanguage {
code: String
-- en, de, fr, it, es
flag: String
-- en=GB, de=DE, fr=FR, it=IT, es=ES
}
config {
supported_languages: Set<SupportedLanguage> = {
SupportedLanguage(code: "en", flag: "GB"),
SupportedLanguage(code: "de", flag: "DE"),
SupportedLanguage(code: "fr", flag: "FR"),
SupportedLanguage(code: "it", flag: "IT"),
SupportedLanguage(code: "es", flag: "ES")
}
default_language: String = "en"
}
invariant SplitLocalization {
-- Two independent locale scopes:
-- 1. UI locale: follows OS system locale
-- 2. Content/render locale: follows project settings (mainLanguage)
-- These are resolved independently and may differ
}
invariant LanguageNormalization {
-- Input language codes are normalized:
-- Take base language code (split on '-'): "en-US" -> "en"
-- Fall back to "en" if unrecognized
}
invariant MenuTranslations {
-- Menu item labels are separately translatable
-- translateMenu() uses render locale, not UI locale
}
invariant RenderTranslations {
-- Template rendering i18n strings (date formats, archive labels,
-- "older posts", "newer posts", etc.) come from locale JSON files
-- translateRender() and getRenderTranslations() provide these
}
-- Stemmer language support for search (broader than UI languages)
invariant SnowballStemmerCoverage {
-- 24 languages supported for FTS5 search stemming
-- ISO 639-1 mapped to Snowball stemmer names
-- All 5 UI languages are a subset of stemmer languages
}

256
specs/mcp.allium Normal file
View File

@@ -0,0 +1,256 @@
-- allium: 1
-- bDS MCP Server (Model Context Protocol)
-- Distilled from: src/main/engine/MCPServer.ts, ProposalStore, MCPAgentConfigEngine.ts
use "./post.allium" as post
use "./media.allium" as media
use "./script.allium" as script
use "./template.allium" as template
entity McpServer {
transport: http | stdio
host: String -- 127.0.0.1 for HTTP
port: Integer -- 4124 for HTTP
is_running: Boolean
}
entity Proposal {
kind: draft_post | propose_script | propose_template | propose_media_metadata | propose_post_metadata
status: pending | accepted | discarded | expired
entity_id: String
data: String
created_at: Timestamp
expires_at: Timestamp
-- Derived
is_expired: expires_at <= now
transitions status {
pending -> accepted
pending -> discarded
pending -> expired
}
}
config {
http_port: Integer = 4124
proposal_ttl_app: Duration = 30.minutes
proposal_ttl_cli: Duration = 8.hours
}
invariant LocalhostOnlyHttp {
-- HTTP transport binds to 127.0.0.1 only
-- Origin validation: localhost only
-- CORS headers present
}
invariant StatelessHttpHandling {
-- Each HTTP request creates a fresh McpServer instance
-- No session state between requests
}
-- Read-only resources (bds:// scheme)
surface PostsResource {
facing viewer: McpClient
context posts: Posts
exposes:
for p in posts:
p.id
p.title
p.slug
p.status
p.tags
p.categories
p.created_at
p.backlinks
p.outlinks
@guidance
-- Paginated: 50 per page, base64url cursor
-- bds://posts, bds://posts?cursor={cursor}
}
surface MediaResource {
facing viewer: McpClient
context media_items: Media
exposes:
for m in media_items:
m.id
m.filename
m.title
m.alt
m.caption
m.tags
@guidance
-- bds://media, bds://media?cursor={cursor}
}
surface TagsResource {
facing viewer: McpClient
context tags: Tags
exposes:
for t in tags:
t.name
t.color
t.post_count
@guidance
-- bds://tags
}
surface CategoriesResource {
facing viewer: McpClient
context categories: Categories
exposes:
for c in categories:
c.name
c.post_count
@guidance
-- bds://categories
}
-- Read-only tools
rule CheckTerm {
when: McpToolInvoked("check_term", term)
-- Disambiguates a term as category, tag, or both
-- Returns post counts for each
let is_category = is_category_term(term)
let is_tag = is_tag_term(term)
ensures: TermCheckResult(
is_category: is_category,
category_post_count: if is_category: category_post_count(term) else: 0,
is_tag: is_tag,
tag_post_count: if is_tag: tag_post_count(term) else: 0
)
}
rule SearchPosts {
when: McpToolInvoked("search_posts", params)
-- Full-text + filtered search with pagination envelope
-- Params: query, category, tags[], language, missingTranslationLanguage,
-- year, month, status, offset, limit
-- Returns: { total, offset, limit, hasMore, posts[] }
-- Each post includes backlinks[] and linksTo[]
ensures: SearchEnvelope(results)
}
rule CountPosts {
when: McpToolInvoked("count_posts", params)
-- Grouped counts by: year, month, tag, category, status
-- Params: groupBy[], optional filters
ensures: GroupedCounts(results)
}
rule ReadPostBySlug {
when: McpToolInvoked("read_post_by_slug", slug, language)
-- Full post content by slug
-- Optional language parameter for translation view
ensures: FullPostContent(post)
}
-- Write tools (proposal-based)
rule DraftPost {
when: McpToolInvoked("draft_post", params)
-- Creates a draft post in DB
-- Returns proposalId for accept/discard lifecycle
ensures:
let new_post = post/Post.created(
title: params.title,
content: params.content,
status: draft
)
Proposal.created(kind: draft_post, entity_id: new_post.id, status: pending)
}
rule ProposeScript {
when: McpToolInvoked("propose_script", params)
requires: ValidateScript(params.content) = valid
ensures:
let new_script = script/Script.created(
title: params.title,
kind: params.kind,
content: params.content,
status: draft
)
Proposal.created(kind: propose_script, entity_id: new_script.id, status: pending)
}
rule ProposeTemplate {
when: McpToolInvoked("propose_template", params)
requires: ValidateLiquid(params.content) = valid
ensures:
let new_template = template/Template.created(
title: params.title,
kind: params.kind,
content: params.content,
status: draft
)
Proposal.created(kind: propose_template, entity_id: new_template.id, status: pending)
}
rule ProposeMediaMetadata {
when: McpToolInvoked("propose_media_metadata", params)
ensures: Proposal.created(kind: propose_media_metadata, entity_id: params.media_id, data: params, status: pending)
}
rule ProposePostMetadata {
when: McpToolInvoked("propose_post_metadata", params)
ensures: Proposal.created(kind: propose_post_metadata, entity_id: params.post_id, data: params, status: pending)
}
-- Proposal lifecycle
rule AcceptProposal {
when: AcceptProposalRequested(proposal)
requires: not proposal.is_expired
ensures:
if proposal.kind = draft_post:
post/PublishPostRequested(proposal.entity_id)
if proposal.kind = propose_script:
script/PublishScriptRequested(proposal.entity_id)
if proposal.kind = propose_template:
template/PublishTemplateRequested(proposal.entity_id)
if proposal.kind = propose_media_metadata:
media/UpdateMediaRequested(proposal.entity_id, proposal.data)
if proposal.kind = propose_post_metadata:
post/UpdatePostRequested(proposal.entity_id, proposal.data)
not exists proposal
}
rule DiscardProposal {
when: DiscardProposalRequested(proposal)
ensures:
if proposal.kind = draft_post:
post/DeletePostRequested(proposal.entity_id)
if proposal.kind = propose_script:
script/DeleteScriptRequested(proposal.entity_id)
if proposal.kind = propose_template:
template/DeleteTemplateRequested(proposal.entity_id)
not exists proposal
}
rule ExpireProposal {
when: proposal: Proposal.is_expired becomes true
-- On expiry: clean up draft DB rows
ensures: DiscardProposalRequested(proposal)
}
-- Agent configuration
value McpAgentKind {
-- Supported: claude_code, claude_desktop, github_copilot,
-- gemini_cli, opencode, mistral_vibe, openai_codex
kind: String
}
rule InstallAgentConfig {
when: InstallAgentConfigRequested(agent_kind)
-- Writes stdio MCP server config into the agent's config file
ensures: AgentConfigInstalled(agent_kind)
}
rule UninstallAgentConfig {
when: UninstallAgentConfigRequested(agent_kind)
ensures: AgentConfigRemoved(agent_kind)
}

148
specs/media.allium Normal file
View File

@@ -0,0 +1,148 @@
-- allium: 1
-- bDS Media Lifecycle
-- Distilled from: src/main/engine/MediaEngine.ts, schema.ts
use "./project.allium" as project
value ThumbnailSet {
small: String -- 150px width (binary path)
medium: String -- 400px width (binary path)
large: String -- 800px width (binary path)
ai: String -- 448x448 JPEG for vision models (binary path)
}
value SidecarFile {
-- {media_file}.meta (YAML-like key-value format)
-- Fields: title, alt, caption, author, tags, language, linkedPostIds
-- Translations: {media_file}.{lang}.meta
path: String
}
entity Media {
project: project/Project
filename: String
original_name: String
mime_type: String
size: Integer
width: Integer?
height: Integer?
title: String?
alt: String?
caption: String?
author: String?
language: String?
file_path: String
sidecar_path: String
checksum: String?
tags: Set<String>
created_at: Timestamp
updated_at: Timestamp
-- Relationships
translations: MediaTranslation with media = this
linked_posts: PostMediaLink with media_id = this.id
-- Derived
available_languages: translations -> language
thumbnails: ThumbnailSet
}
entity MediaTranslation {
media: Media
language: String
title: String?
alt: String?
caption: String?
}
invariant UniqueMediaTranslation {
for a in MediaTranslations:
for b in MediaTranslations:
(a != b and a.media = b.media) implies a.language != b.language
}
invariant DateBasedMediaLayout {
for m in Media:
m.file_path = format("media/{yyyy}/{mm}/{uuid}.{ext}",
yyyy: m.created_at.year,
mm: m.created_at.month_padded,
uuid: stem(m.filename),
ext: extension(m.filename))
}
rule ImportMedia {
when: ImportMediaRequested(project, source_file)
let uuid_name = generate_uuid() + extension(source_file)
let dest = format("media/{yyyy}/{mm}/{uuid_name}",
yyyy: now.year, mm: now.month_padded)
ensures: Media.created(
project: project,
filename: uuid_name,
original_name: source_file.name,
mime_type: detect_mime(source_file),
size: source_file.size,
width: detect_width(source_file),
height: detect_height(source_file),
file_path: dest,
tags: {}
)
ensures: FileCopied(source_file, dest)
ensures: SidecarWritten(media)
ensures: ThumbnailsGenerated(media)
ensures: SearchIndexUpdated(media)
}
rule UpdateMedia {
when: UpdateMediaRequested(media, changes)
ensures: MediaFieldsUpdated(media, changes)
ensures: media.updated_at = now
ensures: SidecarWritten(media)
-- Metadata changes flush to .meta sidecar
ensures: SearchIndexUpdated(media)
}
rule DeleteMedia {
when: DeleteMediaRequested(media)
ensures: not exists media
ensures: MediaFileDeleted(media)
ensures: SidecarDeleted(media)
ensures: ThumbnailsDeleted(media)
ensures:
for t in media.translations:
not exists t
ensures: SearchIndexUpdated(media)
}
rule UpsertMediaTranslation {
when: UpsertMediaTranslationRequested(media, language, title, alt, caption)
ensures: MediaTranslation.created(
media: media,
language: language,
title: title,
alt: alt,
caption: caption
)
ensures: TranslationSidecarWritten(media, language)
-- Writes {file}.{lang}.meta
}
rule RebuildMediaFromFiles {
when: RebuildMediaFromFilesRequested(project)
-- Scans media directory for .meta sidecars, reimports to DB
for sidecar in scan_directory(project.effective_data_dir + "/media", "*.meta"):
let parsed = parse_sidecar(sidecar)
ensures: Media.created(parsed)
-- or updated if already exists
@guidance
-- This is the filesystem-to-DB reconciliation path
-- Used after git pull or manual file changes
}
invariant SidecarRoundtrip {
-- Sidecar files faithfully represent DB metadata
for m in Media:
parse_sidecar(m.sidecar_path).title = m.title
parse_sidecar(m.sidecar_path).alt = m.alt
parse_sidecar(m.sidecar_path).caption = m.caption
parse_sidecar(m.sidecar_path).tags = m.tags
}

39
specs/menu.allium Normal file
View File

@@ -0,0 +1,39 @@
-- allium: 1
-- bDS Navigation Menu
-- Distilled from: src/main/engine/MenuEngine.ts
value MenuItem {
kind: page | submenu | category_archive | home
label: String
slug: String?
children: List<MenuItem>? -- only for submenu kind
}
entity Menu {
items: List<MenuItem>
-- Derived
home_items: items where kind = home
home_entry: home_items.first
}
invariant HomeAlwaysPresent {
-- The menu always has a Home entry, extracted and prepended
for menu in Menus:
menu.items.first.kind = home
}
invariant MenuPersistedAsOpml {
-- meta/menu.opml is the canonical storage format
-- Uses OPML with outline elements for each item
parse_opml(read_file("meta/menu.opml")) = menu.items
}
rule UpdateMenu {
when: UpdateMenuRequested(menu, items)
-- Normalizes Home entry: extracts from items, prepends
let without_home = items where kind != home
let home = MenuItem{kind: home, label: "Home"}
ensures: menu.items = build_menu_items(home, without_home)
ensures: MenuFileWritten(menu)
}

102
specs/metadata.allium Normal file
View File

@@ -0,0 +1,102 @@
-- allium: 1
-- bDS Project Metadata, Categories, Publishing Preferences
-- Distilled from: src/main/engine/MetaEngine.ts, schema.ts
use "./project.allium" as project
value CategoryRenderSettings {
render_in_lists: Boolean
show_title: Boolean
post_template_slug: String?
list_template_slug: String?
}
entity ProjectMetadata {
project: project/Project
name: String
description: String?
public_url: String?
main_language: String? -- ISO 639-1
default_author: String?
max_posts_per_page: Integer -- 1..500, default 50
blogmark_category: String?
pico_theme: String? -- 12+ named Pico CSS themes
semantic_similarity_enabled: Boolean
blog_languages: Set<String> -- subset of supported languages
categories: Set<String> -- category names
category_settings: Set<CategoryRenderSettings>
}
entity PublishingPreferences {
ssh_host: String?
ssh_user: String?
ssh_remote_path: String?
ssh_mode: scp | rsync
}
invariant DefaultCategories {
-- New projects start with: article, picture, aside, page
-- These are defaults, not invariants — user can remove them
}
invariant MetadataPersistedAsFiles {
-- Four separate JSON files in meta/:
-- meta/project.json — name, description, publicUrl, mainLanguage, etc.
-- meta/categories.json — sorted category list
-- meta/category-meta.json — per-category render settings
-- meta/publishing.json — SSH connection details (non-secret)
-- All writes are atomic (temp file + rename)
}
config {
default_max_posts_per_page: Integer = 50
min_posts_per_page: Integer = 1
max_posts_per_page: Integer = 500
default_categories: Set<String> = {"article", "picture", "aside", "page"}
supported_pico_themes: Set<String> = {
"default", "amber", "blue", "cyan", "fuchsia", "green",
"grey", "indigo", "jade", "lime", "orange", "pink",
"pumpkin", "purple", "red", "sand", "slate", "violet",
"yellow", "zinc"
}
}
rule UpdateProjectMetadata {
when: UpdateProjectMetadataRequested(project, changes)
ensures: MetadataFieldsUpdated(project, changes)
ensures: ProjectJsonWritten(project)
}
rule AddCategory {
when: AddCategoryRequested(project, name)
requires: not (name in project.metadata.categories)
ensures: project.metadata.categories = project.metadata.categories + {name}
ensures: CategoriesJsonWritten(project)
}
rule RemoveCategory {
when: RemoveCategoryRequested(project, name)
ensures: project.metadata.categories = project.metadata.categories - {name}
ensures: CategorySettingsRemoved(project, name)
ensures: CategoriesJsonWritten(project)
ensures: CategoryMetaJsonWritten(project)
}
rule UpdateCategorySettings {
when: UpdateCategorySettingsRequested(project, category, settings)
ensures: CategorySettingsUpdated(project, category, settings)
ensures: CategoryMetaJsonWritten(project)
}
rule SetPublishingPreferences {
when: SetPublishingPreferencesRequested(project, prefs)
ensures: project.publishing_preferences = prefs
ensures: PublishingJsonWritten(project)
}
rule StartupSync {
when: AppStarted(project)
-- Loads metadata from filesystem, merges with DB,
-- creates defaults for new projects
ensures: ProjectMetadata.synced_from_filesystem(project)
}

View File

@@ -0,0 +1,72 @@
-- allium: 1
-- bDS Metadata Diff and Rebuild
-- Distilled from: src/main/engine/MetadataDiffEngine.ts
use "./post.allium" as post
use "./media.allium" as media
use "./script.allium" as script
use "./template.allium" as template
value DiffField {
name: String
db_value: String
file_value: String
}
value DiffReport {
entity_type: String -- post, media, script, template
entity_id: String
differences: List<DiffField>
}
value OrphanReport {
file_path: String
-- File exists on disk but has no DB record
}
rule RunMetadataDiff {
when: MetadataDiffRequested(project)
-- Runs as background task via TaskManager
-- Compares DB records against filesystem files for:
-- posts, translations, media, scripts, templates
-- Detected fields: tags, categories, title, excerpt, author,
-- language, status, templateSlug, dates
for post in project.posts:
let file_data = parse_post_file(post.file_path)
let diffs = compare_fields(post, file_data)
if diffs.count > 0:
ensures: DiffReport.created(entity_type: "post", entity_id: post.id, differences: diffs)
-- Detect orphan files (on disk but not in DB)
for file in scan_directory(project.effective_data_dir + "/posts", "*.md"):
let matching = Posts where file_path = file
if matching.count = 0:
ensures: OrphanReport.created(file_path: file)
-- Same pattern for media sidecar files, scripts, templates
}
rule RebuildFromFilesystem {
when: RebuildFromFilesystemRequested(project, entity_type)
-- The inverse of metadata diff: filesystem is treated as truth
-- Reads all files and upserts into DB
ensures:
if entity_type = "post":
post/RebuildPostsFromFiles(project)
if entity_type = "media":
media/RebuildMediaFromFiles(project)
if entity_type = "script":
script/RebuildScriptsFromFiles(project)
if entity_type = "template":
template/RebuildTemplatesFromFiles(project)
}
invariant ThreeWaySync {
-- Metadata must stay in sync across three representations:
-- 1. Database records
-- 2. Filesystem files (frontmatter/sidecars)
-- 3. Generated site output
-- MetadataDiff detects divergence between (1) and (2)
-- Rebuild resolves divergence by treating (2) as truth
-- Site generation consumes (1) to produce (3)
}

189
specs/post.allium Normal file
View File

@@ -0,0 +1,189 @@
-- allium: 1
-- bDS Post Lifecycle
-- Distilled from: src/main/engine/PostEngine.ts, postFileUtils.ts, schema.ts
use "./project.allium" as project
value Slug {
value: String
-- Generated by: transliterate unicode to ASCII, lowercase,
-- replace [^a-z0-9]+ with hyphens, strip leading/trailing hyphens
-- Transliteration scope: only German (ä/ö/ü/ß/ÄÖÜ) and English letters used.
-- Verify deunicode handles this set correctly against transliteration npm.
-- Uniqueness: tries base, then {slug}-2 .. {slug}-999, then {slug}-{timestamp}
}
value PostFilePath {
-- posts/YYYY/MM/{slug}.md
-- YYYY and MM derived from created_at
base_dir: String
year: String
month: String
slug: Slug
}
value PostCanonicalUrl {
-- /{YYYY}/{MM}/{DD}/{slug}
-- YYYY/MM/DD from created_at (zero-padded)
year: String
month: String
day: String
slug: Slug
}
value Frontmatter {
-- YAML between --- delimiters at start of .md file
-- Always present: id, title, slug, status, createdAt, updatedAt, tags, categories
-- Optional (written only when truthy): excerpt, author, language,
-- doNotTranslate (only when true), templateSlug, publishedAt
}
entity Post {
project: project/Project
title: String
slug: Slug
excerpt: String?
content: String?
status: draft | published | archived
author: String?
language: String?
do_not_translate: Boolean
template_slug: String?
file_path: String
checksum: String?
tags: Set<String>
categories: Set<String>
created_at: Timestamp
updated_at: Timestamp
published_at: Timestamp?
-- Relationships
translations: PostTranslation with canonical_post = this
linked_media: PostMediaLink with post = this
outgoing_links: PostLink with source = this
incoming_links: PostLink with target = this
-- Derived
available_languages: translations -> language
is_slug_frozen: published_at != null
-- Slug changes only allowed before first publish
content_location: if status = published: file_path else: content
-- Published: body in filesystem. Draft: body in DB field.
transitions status {
draft -> published
draft -> archived
published -> draft
published -> archived
archived -> draft
archived -> published
}
}
entity PostLink {
source: Post
target: Post
link_text: String?
}
entity PostMediaLink {
post: Post
media_id: String
sort_order: Integer
}
invariant UniqueSlugPerProject {
for a in Posts:
for b in Posts:
(a != b and a.project = b.project) implies a.slug != b.slug
}
rule CreatePost {
when: CreatePostRequested(project, title, content, tags, categories, author, language, template_slug)
let slug = Slug.generate(title ?? "untitled")
let unique_slug = Slug.ensure_unique(slug, project)
ensures: Post.created(
project: project,
title: title ?? "",
slug: unique_slug,
content: content,
status: draft,
author: author,
language: language,
tags: tags ?? {},
categories: categories ?? {},
template_slug: template_slug,
do_not_translate: false,
file_path: ""
)
ensures: SearchIndexUpdated(post)
}
rule UpdatePost {
when: UpdatePostRequested(post, changes)
requires: not post.is_slug_frozen or changes.slug = null
-- Cannot change slug after first publish
ensures: post.updated_at = now
ensures: PostFieldsUpdated(post, changes)
ensures: SearchIndexUpdated(post)
@guidance
-- If post is published and content/metadata changed,
-- status auto-transitions back to draft
}
rule PublishPost {
when: PublishPostRequested(post)
requires: post.status = draft or post.status = archived
ensures: post.status = published
ensures: post.published_at = post.published_at ?? now
-- Preserve original publish date on re-publish
ensures: PostFileWritten(post)
-- Writes frontmatter + markdown to posts/YYYY/MM/{slug}.md
ensures: post.content = null
-- Content cleared from DB; now lives in filesystem only
ensures: SearchIndexUpdated(post)
ensures: PostLinksUpdated(post)
-- Parse inter-post links, update link graph
ensures:
for t in post.translations:
TranslationFileWritten(t)
}
rule DeletePost {
when: DeletePostRequested(post)
ensures: not exists post
ensures: PostFileDeleted(post)
-- Remove .md file if it exists
ensures:
for t in post.translations:
not exists t
ensures: SearchIndexUpdated(post)
}
rule ArchivePost {
when: ArchivePostRequested(post)
ensures: post.status = archived
}
-- File format axioms
invariant FrontmatterRoundtrip {
-- Reading a post file written by the system produces identical
-- field values to the database record at time of writing
for post in Posts where status = published:
parse_frontmatter(read_file(post.file_path)) = frontmatter_fields(post)
}
invariant DateBasedFileLayout {
for post in Posts where file_path != "":
post.file_path = format("posts/{yyyy}/{mm}/{slug}.md",
yyyy: post.created_at.year,
mm: post.created_at.month_padded,
slug: post.slug)
}
-- Slug freezing behaviour matches TypeScript app:
-- slug changes allowed on draft posts even if previously published,
-- frozen only while status = published (is_slug_frozen = published_at != null
-- is the guard, but status must also be published for the file to exist)

86
specs/preview.allium Normal file
View File

@@ -0,0 +1,86 @@
-- allium: 1
-- bDS Local Preview Server
-- Distilled from: src/main/engine/PreviewServer.ts, PageRenderer.ts
use "./template.allium" as template
use "./generation.allium" as generation
entity PreviewServer {
host: String -- 127.0.0.1
port: Integer -- 4123
is_running: Boolean
}
config {
preview_host: String = "127.0.0.1"
preview_port: Integer = 4123
}
rule StartPreview {
when: StartPreviewRequested(project)
ensures: PreviewServer.created(
host: config.preview_host,
port: config.preview_port,
is_running: true
)
}
rule StopPreview {
when: StopPreviewRequested(server)
-- Graceful shutdown with inflight request tracking
ensures: server.is_running = false
}
-- Route resolution
rule ServePostPreview {
when: PreviewRequest(path)
requires: is_post_path(path)
-- path matches "/{yyyy}/{mm}/{dd}/{slug}"
-- Renders post via Liquid template with full PageRenderer context
ensures: PreviewResponse(rendered_html)
}
rule ServeDraftPreview {
when: PreviewDraftRequest(path, post_id)
-- Renders draft content (from DB, not filesystem)
ensures: PreviewResponse(rendered_html)
}
rule ServeArchivePreview {
when: PreviewRequest(path)
requires: is_archive_path(path)
-- Category, tag, date archives with pagination
ensures: PreviewResponse(rendered_html)
}
rule ServeMediaFile {
when: PreviewRequest(path)
requires: is_media_path(path)
-- Path-traversal protection: validates path stays within media directory
ensures: PreviewResponse(media_file)
}
rule ServeAssets {
when: PreviewRequest(path)
requires: is_asset_path(path)
ensures: PreviewResponse(asset_file)
}
rule ServeLanguagePrefixedRoute {
when: PreviewRequest(path)
requires: is_language_prefixed(path)
-- Detects language prefix from supported languages
-- Renders with translation overlay for that language
ensures: PreviewResponse(translated_html)
}
invariant ThemeSwitching {
-- Preview supports live theme/mode switching via query params
-- ?theme=amber&mode=dark etc.
-- Uses Pico CSS with configurable themes
}
invariant LocalhostOnly {
-- Preview server binds to 127.0.0.1 only, never 0.0.0.0
}

73
specs/project.allium Normal file
View File

@@ -0,0 +1,73 @@
-- allium: 1
-- bDS Project Management
-- Distilled from: src/main/engine/ProjectEngine.ts, schema.ts
entity Project {
name: String
slug: String
description: String?
data_path: String?
is_active: Boolean
created_at: Timestamp
updated_at: Timestamp
-- Relationships
posts: Post with project = this
media: Media with project = this
tags: Tag with project = this
-- Derived
internal_base_dir: String
-- {user_data}/projects/{id}/
-- Contains: meta/, thumbnails/, tags.json
effective_data_dir: data_path ?? internal_base_dir
-- Custom data path overrides default
}
invariant SingleActiveProject {
-- Exactly one project is active at any time
let active = Projects where is_active
active.count = 1
}
invariant UniqueProjectSlug {
for a in Projects:
for b in Projects:
a != b implies a.slug != b.slug
}
rule CreateProject {
when: CreateProjectRequested(name, data_path)
let slug = slugify(name)
ensures: Project.created(
name: name,
slug: slug,
data_path: data_path,
is_active: false
)
ensures: StarterTemplatesCopied(project)
-- Bundled starter templates are copied into the new project
}
rule SetActiveProject {
when: SetActiveProjectRequested(project)
let previous = Projects where is_active = true
ensures:
for p in previous:
p.is_active = false
ensures: project.is_active = true
}
rule DeleteProject {
when: DeleteProjectRequested(project)
requires: project != default_project
-- The default project cannot be deleted
ensures: not exists project
@guidance
-- deleteProjectWithData removes DB rows + internal directory
-- but preserves external data at custom data_path
}
config {
default_project_name: String = "My Blog"
}

62
specs/publishing.allium Normal file
View File

@@ -0,0 +1,62 @@
-- allium: 1
-- bDS SSH Publishing
-- Distilled from: src/main/engine/PublishEngine.ts
use "./metadata.allium" as meta
entity PublishJob {
ssh_host: String
ssh_user: String
ssh_remote_path: String
ssh_mode: scp | rsync
status: pending | running | completed | failed
transitions status {
pending -> running
running -> completed
running -> failed
}
}
value UploadTarget {
kind: html | thumbnails | media
local_dir: String
remote_dir: String
}
rule UploadSite {
when: UploadSiteRequested(project, credentials)
-- Three upload targets run as parallel tasks
ensures: UploadTargetStarted(html, "html/", credentials.ssh_remote_path, credentials)
ensures: UploadTargetStarted(thumbnails, "thumbnails/", credentials.ssh_remote_path + "/thumbnails", credentials)
ensures: UploadTargetStarted(media, "media/", credentials.ssh_remote_path + "/media", credentials)
}
rule UploadViaScp {
when: UploadTargetCompleted(target, credentials)
requires: credentials.ssh_mode = scp
-- mtime-based upload detection: skip unchanged files
-- Uses SSH agent (SSH_AUTH_SOCK) for authentication
ensures: ScpUploadCompleted(target)
}
rule UploadViaRsync {
when: UploadTargetCompleted(target, credentials)
requires: credentials.ssh_mode = rsync
-- rsync --update --compress --verbose
-- Media uploads exclude .meta sidecar files
ensures: RsyncUploadCompleted(target)
@guidance
-- rsync exclude filters for .meta files on media target
}
invariant MediaSidecarsExcludedFromUpload {
-- .meta sidecar files are never uploaded to the remote server
-- They are project metadata, not public content
}
invariant SshAgentAuth {
-- Publishing uses SSH_AUTH_SOCK for key-based authentication
-- No password prompts, no interactive auth
}

112
specs/script.allium Normal file
View File

@@ -0,0 +1,112 @@
-- allium: 1
-- bDS Scripting System
-- Distilled from: src/main/engine/ScriptEngine.ts, schema.ts
-- Note: TypeScript app uses Python/Pyodide. Rust rewrite uses Lua.
-- Behavioural contract is identical; only runtime changes.
entity Script {
slug: String
title: String
kind: macro | utility | transform
entrypoint: String -- default: "render" for macros
enabled: Boolean
status: draft | published
content: String?
version: Integer
file_path: String
created_at: Timestamp
updated_at: Timestamp
-- Derived
content_location: if status = published: file_path else: content
transitions status {
draft -> published
published -> draft
}
}
invariant UniqueScriptSlug {
for a in Scripts:
for b in Scripts:
a != b implies a.slug != b.slug
}
invariant ScriptFileLayout {
for s in Scripts where file_path != "":
s.file_path = format("scripts/{slug}.lua", slug: s.slug)
}
-- TypeScript app uses .py with triple-quote docstring frontmatter
-- Rust app uses .lua with standard --- frontmatter
rule CreateScript {
when: CreateScriptRequested(title, kind, content, entrypoint)
let slug = slugify(title)
ensures: Script.created(
slug: slug,
title: title,
kind: kind,
content: content,
entrypoint: entrypoint ?? "render",
status: draft,
enabled: true,
version: 1,
file_path: ""
)
}
rule PublishScript {
when: PublishScriptRequested(script)
requires: ValidateScript(script.content) = valid
-- AST parsing must succeed
ensures: script.status = published
ensures: ScriptFileWritten(script)
ensures: script.content = null
}
rule DeleteScript {
when: DeleteScriptRequested(script)
ensures: not exists script
ensures: ScriptFileDeleted(script)
}
-- Script execution contracts by kind
rule ExecuteMacro {
when: MacroExpansionRequested(script, template_context)
requires: script.kind = macro
requires: script.enabled = true
-- Macro scripts are invoked during template rendering
-- via [[slug param1=value1 param2=value2]] syntax in post content
-- They receive named parameters and the template context, return HTML
ensures: MacroOutputProduced(script, html_output)
}
rule ExecuteUtility {
when: RunUtilityRequested(script)
requires: script.kind = utility
requires: script.enabled = true
-- Runs on-demand from the UI, produces stdout output
ensures: UtilityOutputProduced(script, stdout)
}
rule ExecuteTransform {
when: BlogmarkReceived(data)
-- Transform scripts run sequentially on blogmark deep link data
-- Input: title, content, tags, categories, source url
-- Each transform can modify the data before post creation
let transforms = Scripts where kind = transform and enabled = true
for t in ordered_by(transforms, s => s.slug):
ensures: TransformApplied(t, data)
@guidance
-- bds://new-post deep links from browser bookmarks
-- Max 5 toast notifications per script, 20 total
}
rule RebuildScriptsFromFiles {
when: RebuildScriptsFromFilesRequested(project)
for file in scan_directory(project.effective_data_dir + "/scripts", "*.lua"):
let parsed = parse_script_file(file)
ensures: Script.created(parsed)
}

94
specs/search.allium Normal file
View File

@@ -0,0 +1,94 @@
-- allium: 1
-- bDS Full-Text Search
-- Distilled from: src/main/engine/PostEngine.ts (FTS methods),
-- MediaEngine.ts (FTS methods), stemmer.ts
use "./post.allium" as post
use "./media.allium" as media
value StemmerLanguage {
-- Snowball stemmers for 24 languages
-- ISO 639-1 to Snowball mapping
-- Applied to both indexing and query processing
code: String
}
entity PostSearchIndex {
-- SQLite FTS5 virtual table
-- Indexed fields: title, excerpt, content, tags, categories
-- Plus all translation titles, excerpts, and content
post: post/Post
stemmed_content: String
}
entity MediaSearchIndex {
-- SQLite FTS5 virtual table
-- Indexed fields: title, alt, caption, original_name, tags
-- Plus all translation titles, alts, and captions
media: media/Media
stemmed_content: String
}
invariant CrossLanguageStemming {
-- Search index uses Snowball stemmer matched to content language
-- A post in German is stemmed with the German stemmer
-- Translations are stemmed with their respective language stemmers
-- Query-time stemming matches the index language
}
rule SearchPosts {
when: SearchPostsRequested(query, filters)
-- Full-text search with optional filters:
-- status, tags, categories, language, missingTranslationLanguage,
-- year, month, date range (from/to)
-- Returns paginated results with total count
let stemmed_query = stem(query, detect_language(query))
let matched = search_fts(PostSearchIndex, stemmed_query, filters)
ensures: SearchResults(
posts: matched,
total: matched.count,
offset: filters.offset,
limit: filters.limit
)
}
rule SearchMedia {
when: SearchMediaRequested(query)
let stemmed_query = stem(query, detect_language(query))
let matched = search_fts(MediaSearchIndex, stemmed_query)
ensures: SearchResults(
media: matched
)
}
rule IndexPost {
when: SearchIndexUpdated(post)
-- Stems: title + excerpt + content + tags + categories
-- Plus all translations' title + excerpt + content
let all_text = concat_post_text(post)
-- Concatenates: post.title, post.excerpt, post.content,
-- join(post.tags, " "), join(post.categories, " "),
-- and all translations' title, excerpt, content
let index_entry = PostSearchIndex{post: post}
ensures:
if exists index_entry:
index_entry.stemmed_content = stem(all_text)
else:
PostSearchIndex.created(post: post, stemmed_content: stem(all_text))
}
rule IndexMedia {
when: SearchIndexUpdated(media)
-- Stems: title + alt + caption + original_name + tags
-- Plus all translations' title, alt, caption
let all_text = concat_media_text(media)
-- Concatenates: media.title, media.alt, media.caption,
-- media.original_name, join(media.tags, " "),
-- and all translations' title, alt, caption
let index_entry = MediaSearchIndex{media: media}
ensures:
if exists index_entry:
index_entry.stemmed_content = stem(all_text)
else:
MediaSearchIndex.created(media: media, stemmed_content: stem(all_text))
}

94
specs/tag.allium Normal file
View File

@@ -0,0 +1,94 @@
-- allium: 1
-- bDS Tag System
-- Distilled from: src/main/engine/TagEngine.ts, schema.ts
use "./project.allium" as project
use "./post.allium" as post
entity Tag {
project: project/Project
name: String
color: String? -- hex color code
post_template_slug: String?
created_at: Timestamp
updated_at: Timestamp
-- Derived
posts: post/Post with this.name in tags
post_count: posts.count
}
invariant UniqueTagNamePerProject {
-- Case-insensitive uniqueness
for a in Tags:
for b in Tags:
(a != b and a.project = b.project)
implies lowercase(a.name) != lowercase(b.name)
}
invariant TagsPersistToFilesystem {
-- meta/tags.json is the portable format (no internal IDs)
-- Must stay in sync with DB tag table
parse_json(read_file("meta/tags.json")) = serialize_portable(Tags)
}
rule CreateTag {
when: CreateTagRequested(project, name, color)
let existing_tags = Tags where project = project
requires: not existing_tags.any(t => lowercase(t.name) = lowercase(name))
-- Case-insensitive duplicate check
ensures: Tag.created(
project: project,
name: name,
color: color
)
ensures: TagsFileWritten(project)
}
rule UpdateTag {
when: UpdateTagRequested(tag, changes)
ensures: TagFieldsUpdated(tag, changes)
ensures: tag.updated_at = now
ensures: TagsFileWritten(tag.project)
}
rule DeleteTag {
when: DeleteTagRequested(tag)
-- Runs as background task, removes tag from all posts
for p in tag.posts:
ensures: p.tags = p.tags - {tag.name}
ensures: not exists tag
ensures: TagsFileWritten(tag.project)
}
rule RenameTag {
when: RenameTagRequested(tag, new_name)
-- Runs as background task
let old_name = tag.name
for p in tag.posts:
ensures: p.tags = (p.tags - {old_name}) + {new_name}
ensures: tag.name = new_name
ensures: TagsFileWritten(tag.project)
}
rule MergeTags {
when: MergeTagsRequested(sources, target)
-- Runs as background task
-- Merges multiple source tags into a single target
requires: sources.count >= 1
for source in sources:
for p in source.posts:
ensures: p.tags = (p.tags - {source.name}) + {target.name}
ensures: not exists source
ensures: TagsFileWritten(target.project)
}
rule SyncTagsFromPosts {
when: SyncTagsFromPostsRequested(project)
-- Discovers tags used in posts that are not in the tags table
for post in project.posts:
for tag_name in post.tags:
if not exists Tag{project: project, name: tag_name}:
ensures: Tag.created(project: project, name: tag_name)
ensures: TagsFileWritten(project)
}

86
specs/task.allium Normal file
View File

@@ -0,0 +1,86 @@
-- allium: 1
-- bDS Background Task Manager
-- Distilled from: src/main/engine/TaskManager.ts
entity Task {
title: String
status: pending | running | completed | failed | cancelled
progress: Decimal? -- 0.0..1.0
message: String?
created_at: Timestamp
transitions status {
pending -> running
running -> completed
running -> failed
running -> cancelled
}
}
config {
max_concurrent: Integer = 3
progress_throttle: Duration = 250.milliseconds
}
invariant MaxConcurrency {
-- At most max_concurrent tasks run simultaneously
let running_tasks = Tasks where status = running
running_tasks.count <= config.max_concurrent
}
invariant FifoQueue {
-- When max concurrent reached, new tasks queue in FIFO order
-- Queued tasks transition to running as slots open
}
rule SubmitTask {
when: SubmitTaskRequested(title, work)
let running_tasks = Tasks where status = running
ensures: Task.created(title: title, status: pending)
ensures:
if running_tasks.count < config.max_concurrent:
TaskStarted(task, work)
}
rule CompleteTask {
when: TaskWorkCompleted(task)
ensures: task.status = completed
ensures: task.progress = 1.0
ensures: NextQueuedTaskStarted()
}
rule FailTask {
when: TaskWorkFailed(task, error_message)
ensures: task.status = failed
ensures: task.message = error_message
ensures: NextQueuedTaskStarted()
}
rule CancelTask {
when: CancelTaskRequested(task)
requires: task.status = running or task.status = pending
-- AbortController-based cancellation
ensures: task.status = cancelled
ensures: NextQueuedTaskStarted()
}
rule ReportProgress {
when: ProgressReported(task, value, message)
-- Progress events throttled to 250ms
ensures: task.progress = value
ensures: task.message = message
}
invariant ProgressThrottled {
-- Progress update events are throttled to prevent UI flooding
-- At most one progress event per 250ms per task
}
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
rule RegisterExternalTask {
when: RegisterExternalTaskRequested(title)
ensures: Task.created(title: title, status: running)
@guidance
-- External tasks are not managed by the queue
-- The caller is responsible for updating status
}

167
specs/template.allium Normal file
View File

@@ -0,0 +1,167 @@
-- allium: 1
-- bDS Liquid Template System
-- Distilled from: src/main/engine/TemplateEngine.ts, PageRenderer.ts, schema.ts,
-- bundled starter templates in src/main/engine/templates/
entity Template {
slug: String
title: String
kind: post | list | not_found | partial
enabled: Boolean
status: draft | published
content: String?
version: Integer
file_path: String
created_at: Timestamp
updated_at: Timestamp
-- Derived
content_location: if status = published: file_path else: content
referencing_posts: Posts where template_slug = this.slug
referencing_tags: Tags where post_template_slug = this.slug
transitions status {
draft -> published
published -> draft
}
}
invariant UniqueTemplateSlug {
for a in Templates:
for b in Templates:
a != b implies a.slug != b.slug
}
invariant TemplateFrontmatter {
-- .liquid files use standard --- YAML frontmatter
-- Fields: id, slug, title, kind, enabled, version, createdAt, updatedAt
for t in Templates where status = published:
parse_frontmatter(read_file(t.file_path)).slug = t.slug
}
invariant TemplateFileLayout {
for t in Templates where file_path != "":
t.file_path = format("templates/{slug}.liquid", slug: t.slug)
}
rule CreateTemplate {
when: CreateTemplateRequested(title, kind, content)
let slug = slugify(title)
ensures: Template.created(
slug: slug,
title: title,
kind: kind,
content: content,
status: draft,
enabled: true,
version: 1,
file_path: ""
)
}
rule UpdateTemplate {
when: UpdateTemplateRequested(template, changes)
ensures: TemplateFieldsUpdated(template, changes)
ensures: template.updated_at = now
ensures: template.version = template.version + 1
}
rule PublishTemplate {
when: PublishTemplateRequested(template)
requires: ValidateLiquid(template.content) = valid
-- LiquidJS parser must accept the template
ensures: template.status = published
ensures: TemplateFileWritten(template)
-- Writes frontmatter + liquid to templates/{slug}.liquid
ensures: template.content = null
}
rule DeleteTemplate {
when: DeleteTemplateRequested(template)
requires: template.referencing_posts.count = 0
requires: template.referencing_tags.count = 0
-- Cannot delete a template still referenced by posts or tags
ensures: not exists template
ensures: TemplateFileDeleted(template)
}
rule CascadeSlugUpdate {
when: template: Template.slug transitions_to new_slug
-- When a template slug changes, update all references
for p in template.referencing_posts:
ensures: p.template_slug = new_slug
for t in template.referencing_tags:
ensures: t.post_template_slug = new_slug
}
rule RebuildTemplatesFromFiles {
when: RebuildTemplatesFromFilesRequested(project)
for file in scan_directory(project.effective_data_dir + "/templates", "*.liquid"):
let parsed = parse_template_file(file)
ensures: Template.created(parsed)
-- or updated if slug already exists
}
-- Exact Liquid subset required (distilled from bundled starter templates)
-- No features beyond this list are used.
invariant LiquidTagSubset {
-- Only these 5 tags are used:
-- {% if %} / {% elsif %} / {% else %} / {% endif %}
-- {% for %} / {% endfor %}
-- {% assign %}
-- {% render 'partial', named_param: value %} (with named parameters)
-- Whitespace-stripped variants: {%- -%}
--
-- NOT used: include, capture, case/when, unless, raw, comment,
-- cycle, tablerow, increment, decrement, liquid, echo
}
invariant LiquidFilterSubset {
-- Standard filters (4):
-- | escape
-- | url_encode
-- | default: fallback_value
-- | append: suffix_string
--
-- Custom filters (2):
-- | i18n: language — translates a key string for given language
-- | markdown: post_id, post_data_json_by_id, canonical_post_path_by_slug,
-- canonical_media_path_by_source_path, language, language_prefix
-- — renders Markdown to HTML with link rewriting (6 arguments)
--
-- NOT used: date, strip_html, truncate, split, join, size (as filter),
-- upcase, downcase, replace, remove, sort, map, where, first, last,
-- reverse, concat, uniq, compact, strip, newline_to_br, json, prepend,
-- and all math filters
}
invariant LiquidOperatorSubset {
-- Comparison: ==, >
-- Logical: or, and
-- Truthy/falsy: bare variable in {% if variable %}
-- Special values: blank (nil/empty comparison)
-- Property access: dot notation (object.property), .size on arrays,
-- bracket notation for map lookups (map[key])
}
invariant LiquidRenderContext {
-- Template rendering context provides these top-level variables:
-- language, language_prefix, html_theme_attribute,
-- page_title, pico_stylesheet_href,
-- blog_languages (array of {is_current, code, flag, href_prefix}),
-- alternate_links (array of {hreflang, href}),
-- menu_items (tree of {href, title, has_children, children}),
-- calendar_initial_year, calendar_initial_month,
-- post (single post context: {title, content, id, slug, show_title}),
-- post_categories, post_tags, tag_color_by_name (map),
-- backlinks (array of {path, display_slug}),
-- day_blocks (array of {show_date_marker, date_label, posts, show_separator}),
-- archive_context ({kind, name, month, year, day}),
-- show_archive_range_heading, min_date, max_date,
-- canonical_post_path_by_slug (map), canonical_media_path_by_source_path (map),
-- post_data_json_by_id (map),
-- is_list_page, is_first_page, is_last_page,
-- has_prev_page, has_next_page, prev_page_href, next_page_href,
-- not_found_message, not_found_back_label
}

111
specs/translation.allium Normal file
View File

@@ -0,0 +1,111 @@
-- allium: 1
-- bDS Translation System
-- Distilled from: src/main/engine/PostEngine.ts (translation methods),
-- postTranslationFileUtils.ts, MediaEngine.ts
use "./post.allium" as post
use "./media.allium" as media
value SupportedLanguage {
-- en, de, fr, it, es
code: String
}
entity PostTranslation {
canonical_post: post/Post
language: SupportedLanguage
title: String
excerpt: String?
content: String?
status: draft | published
file_path: String
checksum: String?
created_at: Timestamp
updated_at: Timestamp
published_at: Timestamp?
-- Derived
content_location: if status = published: file_path else: content
transitions status {
draft -> published
published -> draft
}
}
invariant UniqueTranslationPerLanguage {
for a in PostTranslations:
for b in PostTranslations:
(a != b and a.canonical_post = b.canonical_post)
implies a.language != b.language
}
invariant TranslationFilePath {
-- posts/YYYY/MM/{slug}.{language}.md
for t in PostTranslations where file_path != "":
t.file_path = format("posts/{yyyy}/{mm}/{slug}.{lang}.md",
yyyy: t.canonical_post.created_at.year,
mm: t.canonical_post.created_at.month_padded,
slug: t.canonical_post.slug,
lang: t.language.code)
}
rule UpsertPostTranslation {
when: UpsertPostTranslationRequested(post, language, title, content, excerpt)
requires: not post.do_not_translate
ensures: PostTranslation.created(
canonical_post: post,
language: language,
title: title,
content: content,
excerpt: excerpt,
status: draft,
file_path: ""
)
-- If translation already exists, update it instead
}
rule PublishPostTranslation {
when: PostPublished(post)
-- All translations are also published when the canonical post is published
for t in post.translations:
ensures: t.status = published
ensures: t.published_at = t.published_at ?? now
ensures: TranslationFileWritten(t)
ensures: t.content = null
-- Content moves to filesystem
}
rule DeletePostTranslation {
when: DeletePostTranslationRequested(translation)
ensures: not exists translation
ensures: TranslationFileDeleted(translation)
ensures: SearchIndexUpdated(translation.canonical_post)
-- FTS index includes all translations of a post
}
rule ValidateTranslations {
when: ValidateTranslationsRequested(project)
-- Checks all posts against configured blog languages
-- Reports: missing translations, orphan translation files,
-- posts marked do_not_translate
for post in project.posts where status = published:
for lang in project.blog_languages:
if lang != project.main_language:
if not post.do_not_translate:
if not (lang in post.available_languages):
ensures: ValidationIssueReported(post, lang, "missing")
@guidance
-- This produces a validation report, not automatic fixes
-- The report drives targeted re-rendering
}
invariant FtsIncludesTranslations {
-- Full-text search index for a post includes stemmed content
-- from all its translations, not just the canonical language
for post in Posts:
includes_text(search_index(post), post.title)
for t in post.translations:
includes_text(search_index(post), t.title)
}