chore: brought RuDS up to bDS2 alignment, as that is the new baseline we want to hit

This commit is contained in:
2026-07-18 10:16:30 +02:00
parent 7880e37c34
commit a594b99e90
50 changed files with 3140 additions and 449 deletions

View File

@@ -106,7 +106,7 @@ rule MediaMetadataTranslationCascade {
-- Four distinct patterns used across the application:
-- Pattern 1: Native confirm dialog (rfd crate)
-- Pattern 1: System confirm dialog
-- Simple yes/no system dialog, no custom UI
-- Used by: PostDelete, PostDiscard, TemplateDelete (when references exist)

View File

@@ -3,9 +3,8 @@
-- Scope: core (one-shot operations), extension Bucket C (chat + streaming)
-- Distilled from: src/main/engine/ChatEngine.ts, ai/providers.ts,
-- ai/chat.ts, ai/tasks.ts, SecureKeyStore.ts
-- Note: Rust rewrite simplifies providers to two configurable
-- OpenAI-compatible endpoints (online + airplane mode),
-- replacing the TypeScript app's 4 named providers.
-- The rewrite models AI access as two configurable OpenAI-compatible
-- endpoints (online + airplane mode) instead of a fixed named-provider set.
use "./post.allium" as post
use "./media.allium" as media
@@ -19,6 +18,61 @@ entity AiEndpoint {
-- airplane: local model (Ollama, LM Studio, etc.)
}
entity AiCatalogProvider {
id: String
name: String
env_keys: Set<String>
package_ref: String?
api_url: String?
doc_url: String?
updated_at: Timestamp
}
entity AiModel {
provider: AiCatalogProvider
model_id: String
name: String
family: String?
supports_attachment: Boolean
supports_reasoning: Boolean
supports_tool_calls: Boolean
supports_structured_output: Boolean
supports_temperature: Boolean
knowledge: String?
release_date: String?
last_updated_date: String?
open_weights: Boolean
input_price: Integer?
output_price: Integer?
cache_read_price: Integer?
cache_write_price: Integer?
context_window: Integer
max_input_tokens: Integer
max_output_tokens: Integer
interleaved: String?
status: String?
updated_at: Timestamp
-- Relationships
modalities: AiModelModality with provider = this.provider and model_id = this.model_id
-- Derived
input_modalities: modalities where direction = input -> modality
output_modalities: modalities where direction = output -> modality
}
entity AiModelModality {
provider: AiCatalogProvider
model_id: String
direction: input | output
modality: text | image | audio | file | tool
}
entity AiCatalogMeta {
key: String
value: String
}
surface AiEndpointSurface {
context endpoint: AiEndpoint
@@ -29,10 +83,40 @@ surface AiEndpointSurface {
endpoint.model
}
surface AiModelSurface {
context catalog_model: AiModel
exposes:
catalog_model.provider
catalog_model.model_id
catalog_model.name
catalog_model.family when catalog_model.family != null
catalog_model.supports_attachment
catalog_model.supports_reasoning
catalog_model.supports_tool_calls
catalog_model.supports_structured_output
catalog_model.supports_temperature
catalog_model.knowledge when catalog_model.knowledge != null
catalog_model.release_date when catalog_model.release_date != null
catalog_model.last_updated_date when catalog_model.last_updated_date != null
catalog_model.open_weights
catalog_model.input_price when catalog_model.input_price != null
catalog_model.output_price when catalog_model.output_price != null
catalog_model.cache_read_price when catalog_model.cache_read_price != null
catalog_model.cache_write_price when catalog_model.cache_write_price != null
catalog_model.context_window
catalog_model.max_input_tokens
catalog_model.max_output_tokens
catalog_model.interleaved when catalog_model.interleaved != null
catalog_model.status when catalog_model.status != null
catalog_model.input_modalities
catalog_model.output_modalities
catalog_model.updated_at
}
entity SecureKeyStore {
-- Encrypts API keys using OS keychain
-- macOS: Keychain, Windows: DPAPI, Linux: libsecret
-- Stored as base64 in settings table with __encrypted_ prefix
-- Encrypts API keys using the host operating system's secure storage.
-- Stored in application settings in encrypted form.
-- No plain-text fallback
}
@@ -64,8 +148,12 @@ entity ChatMessage {
conversation: ChatConversation
role: system | user | assistant | tool
content: String
tool_call_id: String?
tool_calls: String?
token_usage_input: Integer?
token_usage_output: Integer?
cache_read_tokens: Integer?
cache_write_tokens: Integer?
created_at: Timestamp
}
@@ -76,11 +164,24 @@ surface ChatMessageSurface {
message.conversation
message.role
message.content
message.tool_call_id when message.tool_call_id != null
message.tool_calls when message.tool_calls != null
message.token_usage_input when message.token_usage_input != null
message.token_usage_output when message.token_usage_output != null
message.cache_read_tokens when message.cache_read_tokens != null
message.cache_write_tokens when message.cache_write_tokens != null
message.created_at
}
surface AiConfigurationSurface {
facing _: AiOperator
provides:
SetAiEndpointRequested(kind, url, api_key, model)
RemoveAiEndpointRequested(kind)
RefreshModelCatalogRequested(source)
}
surface OneShotAiSurface {
facing _: AiOperator
@@ -99,7 +200,31 @@ surface AiChatSurface {
provides:
StartChatRequested(model)
SendChatMessageRequested(conversation, content)
RefreshModelCatalogRequested(endpoint)
CancelChatRequested(conversation)
}
config {
model_catalog_ttl: Duration = 5.minutes
chat_max_tool_rounds: Integer = 10
default_max_output_tokens: Integer = 16384
}
rule SetAiEndpoint {
when: SetAiEndpointRequested(kind, url, api_key, model)
ensures:
let endpoint = AiEndpoint.created(
kind: kind,
url: url,
api_key: api_key,
model: model
)
endpoint.kind = kind
}
rule RemoveAiEndpoint {
when: RemoveAiEndpointRequested(kind)
for endpoint in AiEndpoints where endpoint.kind = kind:
ensures: not exists endpoint
}
-- One-shot AI tasks (core scope, no streaming)
@@ -174,18 +299,25 @@ rule SendChatMessage {
)
ensures: conversation.updated_at = now
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)
-- Streaming response with bounded tool-call loop.
-- Blog data tools for post/media querying and mutation during chat.
-- Render tools may emit structured chart/table/form payloads.
-- Token usage tracking includes input, output, cache read, cache write.
}
rule CancelChat {
when: CancelChatRequested(conversation)
ensures: AiStreamingResponseCancelled(conversation)
}
-- Model catalog
rule RefreshModelCatalog {
when: RefreshModelCatalogRequested(endpoint)
-- Queries the endpoint's model list API
-- 5-minute cache TTL
ensures: ModelCatalogUpdated(endpoint)
when: RefreshModelCatalogRequested(source)
-- Refreshes advisory provider/model metadata used for capability checks,
-- default token budgeting, and model selection UX.
-- Uses conditional GET with ETag where supported.
ensures: ModelCatalogUpdated()
}
invariant AirplaneModeGating {
@@ -198,12 +330,86 @@ invariant AirplaneModeGating {
-- show toast "AI unavailable — configure {online|airplane} endpoint in Settings"
}
invariant AirplaneModeModelSwap {
-- In airplane mode, cloud models are never contacted.
-- Chat uses the configured offline chat model when needed.
-- Image analysis uses the configured offline vision-capable model when needed.
-- If no suitable offline model is configured, the operation fails with
-- actionable guidance instead of silently falling back to the online endpoint.
}
invariant TwoEndpointModel {
-- Two configurable OpenAI-compatible endpoints:
-- online: for cloud providers (requires API key)
-- airplane: for local models (no API key required)
-- Both use the OpenAI Chat Completions wire format.
-- This replaces the TypeScript app's 4 named provider model.
-- Endpoint selection is configurable rather than tied to hard-coded providers.
}
invariant AdvisoryModelCatalog {
-- Model metadata is stored separately from runtime endpoint configuration.
-- It supplies capability hints such as context window, tool-call support,
-- structured output support, vision/input modalities, and pricing metadata.
-- The catalog remains usable offline after the last successful refresh.
}
invariant ConditionalCatalogRefresh {
-- Model catalog refresh uses conditional HTTP requests when possible.
-- The latest ETag and fetch timestamp are persisted in AiCatalogMeta.
-- A 304 response updates freshness metadata without rewriting model rows.
exists meta in AiCatalogMeta where meta.key = "etag" or meta.key = "last_fetched_at"
}
invariant ProviderDetection {
-- Runtime provider selection may be inferred from model identifiers,
-- local-endpoint registration, or explicit endpoint configuration.
-- The system does not rely on a single hard-coded provider list for routing.
}
invariant VisionCapabilityGate {
-- AnalyzeImage only runs against models that accept image input.
-- Local/offline models must advertise or be configured with image capability
-- before the runtime sends multimodal requests to them.
}
invariant ChatContextTruncation {
-- Chat requests are trimmed to fit within the selected model's context window.
-- Oldest user/assistant pairs are dropped first.
-- The system prompt, tool schema budget, and output-token reserve are preserved.
}
invariant BoundedToolLoop {
-- Chat tool execution is bounded by config.chat_max_tool_rounds.
-- Tool-capable models may call blog-domain tools and render tools.
-- Non-tool-capable models skip tool exposure entirely.
}
invariant TokenUsageAccounting {
-- Chat turn accounting tracks input, output, cache-read, and cache-write tokens.
-- Usage is reported per turn and accumulated per conversation.
-- Cache token accounting is surfaced when the underlying provider reports it.
}
invariant ChatCancellation {
-- Each in-flight chat turn can be aborted independently.
-- Cancellation stops streaming and tool execution for that request only.
}
invariant StructuredRenderTools {
-- Chat may emit structured render payloads via tool calls prefixed "render_".
-- 9 inline surface types: card, chart, form, list, metric, mindmap, table, tabs, text, json.
-- Render tool names: render_card, render_chart, render_form, render_list,
-- render_metric, render_mindmap, render_table, render_tabs.
-- Unrecognized render tool names render as JSON (raw dump).
-- Tab content may nest any surface type including plain text.
-- These payloads are data contracts, not arbitrary HTML strings.
-- Surface data contracts are defined in editor_chat.allium InlineSurface value.
}
invariant BlogStatsPromptAugmentation {
-- The base system prompt may be augmented with current blog statistics
-- such as post counts, media counts, tag/category totals, and date ranges
-- so long as the augmentation reflects current project state.
}
invariant AiSpecPartitioning {
@@ -215,5 +421,5 @@ invariant AiSpecPartitioning {
invariant SecureKeyStorage {
-- API keys are never stored in plain text
-- Always encrypted via OS keychain before DB storage
-- Always encrypted via host secure storage before persistence
}

View File

@@ -1,7 +1,7 @@
-- allium: 1
-- bDS (Blogging Desktop Server) — Axiom Specification
-- Distilled from TypeScript implementation at ../bDS/
-- This is the behavioural baseline for the Rust rewrite (RuDS)
-- Distilled from the existing bDS application at ../bDS2/
-- This is the behavioural baseline for the rewrite effort.
-- An offline-first desktop application for blog authoring with
-- static site generation, SSH publishing, AI integration, and
@@ -20,6 +20,7 @@ use "./metadata.allium" as metadata -- Project config, categories, publi
-- Infrastructure
use "./search.allium" as search -- FTS5 full-text search with Snowball stemming
use "./rendering.allium" as rendering -- Template render assigns, filters, macros (preview + generation)
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)
@@ -53,23 +54,24 @@ 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)
-- Compatibility contract
--
-- MUST stay identical:
-- SQLite schema semantics, post markdown frontmatter,
-- persistence 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
-- full-text search behaviour, slug generation, metadata diff,
-- rebuild-from-filesystem
--
-- MAY change intentionally:
-- Implementation language (TS -> Rust), editor (WYSIWYG -> plain text + preview),
-- scripting runtime (Lua), process model (Electron -> native),
-- UI framework (React -> Iced)
-- implementation language, desktop container, UI framework,
-- editor implementation, internal process model, runtime libraries
-- Resolved questions:
--
-- 1. Slug generation scope: only German and English letters are used.
-- Verify deunicode handles ä/ö/ü/ß/ÄÖÜ correctly against transliteration npm.
-- Verify transliteration preserves the established bDS behaviour for
-- ä/ö/ü/ß/ÄÖÜ.
--
-- 2. Liquid subset: see template.allium for the exact subset.
-- Only 5 tags, 4 standard filters, 2 custom filters, 5 operators.
@@ -78,7 +80,6 @@ use "./metadata_diff.allium" as metadata_diff -- DB/filesystem diff and rebuil
-- 3. Macro calling convention: [[macroslug param1=value1 ...]]
-- Double-bracket syntax, not Liquid tags. Identical to current app.
--
-- 4. AI provider model: Rust rewrite uses two configurable OpenAI-compatible
-- endpoints (online + airplane mode) instead of the TypeScript app's
-- 4 named providers (OpenCode Zen, Mistral, Ollama, LM Studio).
-- 4. AI provider model: the rewrite uses two configurable OpenAI-compatible
-- endpoints (online + airplane mode) rather than a fixed named-provider set.
-- See ai.allium for details.

175
specs/cli.allium Normal file
View File

@@ -0,0 +1,175 @@
-- allium: 1
-- Workspace CLI tool (issue #25)
-- Scope: extension (Bucket G — MCP + Automation)
-- Distilled from: lib/bds/cli.ex, lib/bds/cli/commands.ex, lib/bds/cli/install.ex,
-- rel/overlays/cli/bin/bds-cli
entity CliInvocation {
command: rebuild | repair | render | upload | push | pull | post | media
| gallery | config | project | tui | lua
incremental: Boolean
force: Boolean
exit_code: Integer
-- Parsed by Optimus: subcommands, options, flags, and auto-generated
-- help/version output. Unknown commands and invalid options exit 1
-- with formatted errors on stderr.
}
surface CliSurface {
facing _: CliRuntime
provides:
CliCommandExecuted(command)
CliInstallRequested()
}
invariant SharedDatabase {
-- The CLI boots the application in BDS_MODE=cli: the same repo,
-- settings, and cache database as the GUI/TUI app — but with no
-- HTTP listener, no SSH daemon, no window, and no sync watcher.
-- Console logging is redirected to the rotating log file so stdout
-- carries only command output.
}
invariant CliWritesNotify {
-- Every CLI mutation (post/media/gallery creation, config set,
-- project add/switch, bulk rebuild/repair) inserts DbNotification
-- rows (from_cli: true) so a concurrently running app picks the
-- change up through its sync watcher (see cli_sync.allium). Bulk
-- maintenance uses wildcard entity ids per entity type.
}
rule ExitCode {
when: CliCommandExecuted(command)
-- Success prints the result (stdout) and exits 0; any error prints
-- to stderr and exits 1.
requires: CliInvocation.command = command
ensures: CliInvocation.exit_code.updated()
}
rule RebuildFull {
when: CliCommandExecuted(command: rebuild)
requires: not CliInvocation.incremental
-- The same step sequence as the GUI "Rebuild Database"
-- (BDS.Maintenance.full_rebuild_steps — posts, media, scripts,
-- templates, post links, thumbnails, embedding index), run
-- synchronously with progress on stdout.
ensures: CacheDatabaseRebuilt()
}
rule RebuildIncremental {
when: CliCommandExecuted(command: rebuild)
requires: CliInvocation.incremental
-- Metadata diff, then auto-apply file→db for every difference and
-- import every orphan file.
ensures: CacheDatabaseRebuilt()
}
rule Repair {
when: CliCommandExecuted(command: repair)
-- Subcommand argument selects the repair part: post-links,
-- media-links, thumbnails, embeddings, or search — the standard
-- rebuild tasks outside the full rebuild.
ensures: RepairTaskCompleted()
}
rule Render {
when: CliCommandExecuted(command: render)
-- Default: render all site sections plus the search index.
-- --incremental: validate the generated output and apply only the
-- differences (targeted render + extra-file deletion + calendar).
-- --force: full re-render ignoring (but updating) content hashes.
requires: not (CliInvocation.incremental and CliInvocation.force)
ensures: SiteRendered()
}
rule Upload {
when: CliCommandExecuted(command: upload)
-- Uses the project publishing preferences (ssh host/user/path/mode)
-- exactly like the app's upload, and waits for the publish job.
ensures: SiteUploaded()
}
rule GitSync {
when: CliCommandExecuted(command: push | pull)
-- push: git push of the project repository to origin.
-- pull: git pull --ff-only, then the incremental cache update
-- (metadata diff auto-apply + orphan import) so the database
-- reflects the pulled files.
ensures: RepositorySynchronized()
}
rule CreatePost {
when: CliCommandExecuted(command: post)
-- Post data from parameters or JSON on stdin (LLM-friendly).
-- Language is auto-detected when missing: the configured AI
-- endpoint (airplane mode routes to the local model), falling back
-- to the offline heuristic with a printed notice — the CLI
-- equivalent of the airplane-mode toast. After creation the same
-- auto-translation as the GUI is scheduled and awaited; when
-- nothing can be scheduled the user is told.
ensures: PostCreated()
}
rule CreateMedia {
when: CliCommandExecuted(command: media)
-- Imports the image, then best-effort AI enrichment: generated
-- title, alt text, caption, and translations to all configured
-- blog languages (the gallery pipeline without post linking).
ensures: MediaImported()
}
rule CreateGallery {
when: CliCommandExecuted(command: gallery)
-- Creates the post, then runs the shared gallery import pipeline
-- for every referenced image: import, mandatory post link, AI
-- enrichment, translations; finally the post auto-translation.
ensures: PostCreated()
ensures: MediaImported()
}
rule Preferences {
when: CliCommandExecuted(command: config)
-- get/set/list of the global settings store shared with the app.
ensures: PreferencesAccessed()
}
rule Projects {
when: CliCommandExecuted(command: project)
-- list, add <path> (register a folder in the cache database), and
-- switch <id|slug|name> (change the active project).
ensures: ProjectRegistryUpdated()
}
rule Tui {
when: CliCommandExecuted(command: tui)
-- Handled by the launcher script: exec the release in BDS_MODE=tui
-- so the interactive terminal UI owns the terminal.
ensures: TuiStarted()
}
rule RunLuaTask {
when: CliCommandExecuted(command: lua)
-- Runs a utility ("task", long-running) script from the database by
-- slug in the active project, with the managed-job execution budget
-- (unlimited time/reductions) but synchronously. Macro and
-- transform scripts are rejected.
ensures: ScriptExecuted()
}
rule InstallLauncher {
when: CliInstallRequested()
-- Install buttons in the GUI settings (Data section) and the TUI
-- settings (data form action field) both call
-- BDS.UI.SettingsForm.run_action("install_cli"): a shim exec'ing
-- the release's cli/bin/bds-cli launcher is written to
-- ~/.local/bin/bds-cli. Outside a packaged release the action
-- reports that installing requires the packaged application.
ensures: LauncherInstalled()
}
invariant LauncherArgv {
-- bin/bds eval cannot forward arguments, so the launcher passes the
-- argv in BDS_CLI_ARGV joined with the ASCII unit separator (0x1f)
-- and the release evaluates BDS.CLI.main().
}

View File

@@ -15,6 +15,14 @@ entity DbNotification {
is_processed: seen_at != null
}
surface CliSyncRuntimeSurface {
facing _: CliSyncRuntime
provides:
CliMutationPerformed(entity_type, entity_id, action)
DbFileChangeDetected()
}
rule CliWriteNotification {
when: CliMutationPerformed(entity_type, entity_id, action)
-- CLI inserts notification row; app watches for it
@@ -29,7 +37,7 @@ rule CliWriteNotification {
rule AppWatchNotifications {
when: DbFileChangeDetected()
-- Watches SQLite DB file + WAL via filesystem watcher
-- Watches the persisted notification store for external mutations
-- Debounced at 100ms
let unseen = DbNotifications where seen_at = null and from_cli = true
for n in unseen:
@@ -54,7 +62,7 @@ rule PruneUnprocessedNotifications {
}
invariant AppNoopNotifier {
-- The Electron app uses a no-op notifier for its own writes
-- The desktop application uses a no-op notifier for its own writes
-- It already knows about its own mutations
-- Only CLI writes produce notification rows
}

View File

@@ -23,9 +23,72 @@ value ChatMessage {
role: String -- user | assistant | system
content: String -- user: plain text; assistant: GFM markdown
tool_markers: List<ToolMarker>
inline_surfaces: List<InlineSurface>
is_streaming: Boolean -- true while accumulating
}
value InlineSurface {
id: String
type: "card" | "chart" | "form" | "list" | "metric" | "mindmap" | "table" | "tabs" | "text" | "json"
title: String?
subtitle: String? -- card only
body: String? -- card, text
actions: List<SurfaceAction> -- card only
columns: List<String> -- table
rows: List<List<String>> -- table
chart_type: String? -- chart: bar/pie/line
series: List<ChartSeries> -- chart
max_value: Integer? -- chart
label: String? -- metric
value: String? -- metric
items: List<String> -- list
nodes: List<MindmapNode> -- mindmap
fields: List<FormField> -- form
submit_label: String? -- form
submit_action: String? -- form
tabs: List<TabPanel> -- tabs
selected_index: Integer? -- tabs
raw: Map? -- json catch-all
}
value SurfaceAction {
label: String
action: String
payload: Map
}
value ChartSeries {
label: String
value: Integer
segments: List
}
value MindmapNode {
id: String?
label: String
children: List<String>
}
value FormField {
key: String
label: String
input_type: String
placeholder: String?
value: String?
options: List<FieldOption>
required: Boolean
}
value FieldOption {
label: String
value: String
}
value TabPanel {
label: String
content: List<InlineSurface> -- nested surfaces or text
}
value ToolMarker {
tool_name: String
args_preview: String -- string args truncated to config.chat_tool_args_max_length
@@ -66,6 +129,7 @@ value ModelEntry {
config {
chat_tool_args_max_length: Integer = 30
chat_input_max_height: Integer = 200
surface_form_debounce_ms: Integer = 500
}
surface ChatPanelSurface {
@@ -85,6 +149,9 @@ surface ChatPanelSurface {
tm.tool_name
tm.args_preview
tm.is_complete
for sfc in msg.inline_surfaces:
sfc.type
sfc.id
provides:
ChatSendMessage(panel.conversation_id, panel.input_text)
@@ -138,6 +205,19 @@ surface ChatPanelSurface {
-- Actions dispatched through store, same as user clicks.
-- Navigation actions open tabs with pin intent.
@guarantee InlineSurfaceRendering
-- Assistant render-tool calls produce structured inline surfaces
-- rendered between message content and tool markers.
-- 9 surface types: card, chart, form, list, metric, mindmap, table, tabs, text, json.
-- Render tool names: render_card, render_chart, render_form, render_list,
-- render_metric, render_mindmap, render_table, render_tabs.
-- Unrecognized render names render as json (raw dump).
-- Tab content nests any surface type including plain text.
-- Form surfaces persist field values in conversation surface_state.
-- Tab surfaces track selected_index in conversation surface_state.
-- Surfaces can be dismissed, persisted in conversation surface_state.
-- Form debounce: config.surface_form_debounce_ms.
@guarantee TokenTracking
-- Token usage tracked per conversation.
-- Displayed in status bar, not in the chat panel itself.

View File

@@ -116,7 +116,11 @@ surface MediaEditorSurface {
-- Translate Metadata (globe icon).
@guarantee PreviewArea
-- Images: rendered via bds-media:// protocol with cache-busting timestamp.
-- Images: rendered at full resolution via the /media-file/:id route
-- (serves the original file; falls back to the large thumbnail for
-- formats browsers cannot display inline) with cache-busting
-- timestamp. Never upscaled beyond natural size. The sidebar keeps
-- using small thumbnails.
-- Non-images: SVG file icon placeholder + original filename text.
@guarantee MetadataForm
@@ -130,9 +134,15 @@ surface MediaEditorSurface {
@guarantee TranslationsSection
-- Shown only when language is set.
-- List of existing translations: flag emoji + language name + title.
-- Per-translation actions: click to edit inline, refresh button, delete button.
-- Per-translation actions: click to edit (opens modal), refresh button, delete button.
-- "No translations" message when list is empty.
@guarantee TranslationEditModal
-- Editing a translation opens a modal dialog ("Edit Translation"), not
-- an inline form. Hidden language field plus Title, Alt Text, and
-- Caption (textarea) inputs. Footer: Cancel and Save buttons.
-- Save persists the translation; Cancel/close discards.
@guarantee LinkedPostsSection
-- "Link to Post" button opens inline post picker overlay.
-- List of currently linked posts with document icon. Click navigates to post tab.
@@ -214,8 +224,9 @@ rule MediaLinkToPost {
rule MediaTranslationEdit {
when: MediaTranslationEditClicked(media_id, language)
-- Loads translation fields inline (title, alt, caption) for that language
-- Edit in place, save persists to translated sidecar {path}.{lang}.meta
-- Opens the "Edit Translation" modal pre-filled with the translation's
-- title, alt, and caption for that language
-- Save persists to DB + translated sidecar {path}.{lang}.meta; Cancel discards
}
rule MediaTranslationRefresh {

View File

@@ -148,6 +148,16 @@ surface DashboardSurface {
-- Single-click: preview tab. Double-click: pin tab.
}
rule ComputeDashboardData {
when: DashboardRequested(project)
-- stats: post counts grouped by status (total = sum of all statuses),
-- plus media/image counts, total media size, tag and category counts.
-- timeline: posts grouped by (year, month) of created_at, newest first,
-- limited to the most recent config.dashboard_timeline_months with data.
-- tag_cloud / category_cloud / recent_posts populated per their guarantees.
ensures: Dashboard
}
-- ─── Menu editor view ────────────────────────────────────────
-- Visual editor for the OPML navigation menu (meta/menu.opml).
@@ -448,10 +458,11 @@ rule SiteValidationScan {
rule SiteValidationApply {
when: SiteValidationApplyRequested(report)
-- Classifies affected paths into generation sections (core, single, category, tag, date)
-- Renders only affected sections in parallel
-- Renders only affected routes (a post's own single page plus only its own
-- categories, tags, and date archives); unaffected routes are left untouched
-- Deletes extra HTML files, removes empty directories
-- Regenerates calendar if anything changed
-- Rebuilds search index if anything rendered or deleted
-- Regenerates calendar if anything changed (feed/atom/404/sitemap/search
-- index are NOT rebuilt on apply)
ensures: ApplyValidationRequested(report.project_id, report.affected_sections)
}
@@ -697,7 +708,7 @@ value ImportYearDistribution {
value ImportConflict {
item_type: String -- post | page | media
item_name: String
resolution: String -- import | skip | merge
resolution: String -- ignore | overwrite | import
}
value ImportMacro {
@@ -745,8 +756,8 @@ surface ImportAnalysisSurface {
-- Year-by-year bar charts for posts + media.
@guarantee ConflictsSection
-- Collapsible. Per-item dropdown: Import/Skip/Merge.
-- Default: Import for new items, Skip for existing matches.
-- Collapsible. Per-item dropdown: Ignore/Overwrite/Import.
-- Default: Import for new items, Ignore for existing matches.
@guarantee TaxonomySection
-- Collapsible. Category + tag pills.

View File

@@ -19,7 +19,7 @@ value PostEditorView {
metadata: PostEditorMetadata
metadata_expanded: Boolean -- starts expanded when title is empty
excerpt_expanded: Boolean
editor_mode: String -- visual | markdown | preview
editor_mode: String -- markdown | preview
footer: PostEditorFooter
}
@@ -137,10 +137,14 @@ surface PostEditorSurface {
@guarantee TagAutocomplete
-- Tag input with autocomplete.
-- Standard: prefix match on existing tag names (case-insensitive).
-- When semanticSimilarityEnabled: also suggests tags from 10 similar posts,
-- weighted by similarity, top 5 shown.
-- Merged results: prefix matches first, then embedding suggestions.
-- While typing: substring match on existing tag names (case-insensitive),
-- top 8 shown, plus a "create tag" row when the query is new.
-- While the query is empty (on focus): semantic suggestions appear under a
-- "Suggested tags" label — tags drawn from up to 10 similar posts,
-- weighted by similarity, top 5, excluding tags already on the post.
-- Requires semanticSimilarityEnabled and a built embedding index;
-- it is an index read (no model inference) so it works offline and
-- yields nothing when similarity is disabled or unindexed.
@guarantee TranslationFlagsBar
-- Row of flag emoji buttons inline with metadata toggle.
@@ -152,15 +156,15 @@ surface PostEditorSurface {
-- Collapsible section with textarea (4 rows).
@guarantee EditorBodyToolbar
-- Toolbar: "Content" label, mode toggle (Visual/Markdown/Preview),
-- Toolbar: "Content" label, mode toggle (Markdown/Preview),
-- action buttons (markdown mode only): Gallery (with media count),
-- Insert Post Link, Insert Media.
@guarantee EditorModes
-- Visual: rich-text WYSIWYG editor.
-- Markdown: code editor with markdown-with-macros language,
-- highlighting [[macro ...]] syntax. Word wrap on, minimap off, 14px font.
-- Preview: iframe showing rendered preview.
-- Note: visual/WYSIWYG mode not implemented; "visual" normalizes to markdown.
@guarantee DragDropImages
-- Drop image file onto editor area triggers import chain.
@@ -193,8 +197,8 @@ invariant PostDirtyTracking {
}
invariant PostEditorModePersistence {
-- Editor mode (visual/markdown/preview) persists per session.
-- Default mode comes from editor settings.
-- Editor mode (markdown/preview) persists per session.
-- Default mode comes from editor settings (markdown).
}
-- ─── Post editor actions ────────────────────────────────────
@@ -227,6 +231,8 @@ rule PostTranslateAction {
rule PostAutoTranslateOnSave {
when: PostSaved(post_id)
-- Only an explicit manual save (or publish) triggers this. Auto-saves and
-- post creation (sidebar, deeplink/bookmarklet, import, scripting) never do.
-- Gate: airplane mode check + auto_translate not disabled (doNotTranslate=false)
-- For each configured blog language missing a translation:
-- Enqueue background translation task (title model)
@@ -247,7 +253,7 @@ rule PostPublishAction {
rule PostDiscardChanges {
when: PostDiscardRequested(post_id)
-- Only available for published posts with pending draft changes
-- Native confirm dialog (rfd): "Discard changes to this post?"
-- System confirm dialog: "Discard changes to this post?"
-- On confirm: reads published version from .md file,
-- restores DB to published state (content=null, status=published)
-- Editor reloads with restored content
@@ -255,7 +261,7 @@ rule PostDiscardChanges {
rule PostDeleteAction {
when: PostDeleteRequested(post_id)
-- Native confirm dialog (rfd): "Delete this post?"
-- System confirm dialog: "Delete this post?"
-- If published: also deletes .md file and all translation files
-- If never published: only deletes DB record
-- Removes from DB, closes tab, sidebar removes item

View File

@@ -16,9 +16,10 @@ value ScriptEditorView {
title: String -- editable text input
slug: String -- editable text input, auto-generated from title
kind: String -- select: utility | macro | transform
entrypoint: String -- select: dynamically discovered functions, "main" always first
entrypoint: String -- select: discovered Lua functions available as entrypoints
enabled: Boolean -- checkbox
content: String -- code editor content
can_publish: Boolean -- true while status = draft
created_at: String -- locale-formatted date
updated_at: String -- locale-formatted date
}
@@ -37,20 +38,23 @@ surface ScriptEditorSurface {
provides:
ScriptSaveRequested(editor.script_id)
ScriptPublishRequested(editor.script_id)
when editor.can_publish
ScriptRunRequested(editor.script_id)
ScriptCheckSyntaxRequested(editor.script_id)
ScriptDeleteRequested(editor.script_id)
@guarantee HeaderLayout
-- Header bar with script title tab.
-- Actions (right side): Save button, Run button,
-- Actions (right side): Save button, Publish button (shown only when
-- can_publish, i.e. status = draft), Run button,
-- Check Syntax button, Delete button (danger style).
@guarantee MetadataRow
-- Two rows of metadata fields above the editor.
-- Row 1: Title (text input), Slug (text input, auto-generated from title).
-- Row 2: Kind (select: utility/macro/transform),
-- Entrypoint (select: dynamically discovered functions with "main" always first),
-- Entrypoint (select: discovered Lua functions exposed by the script),
-- Enabled (checkbox).
@guarantee EditorBody
@@ -66,10 +70,19 @@ surface ScriptEditorSurface {
rule ScriptSave {
when: ScriptSaveRequested(script_id)
-- Lua syntax check first (parse validation via Lua parser/runtime semantics)
-- Lua syntax check first using the configured runtime semantics
-- If syntax error: blocks save, shows error inline in editor gutter
-- If valid: bumps version, saves to DB + rewrites .lua file
-- Entrypoint list re-discovered from AST after save
-- If valid: bumps version, saves to DB + rewrites the published script file
-- Entrypoint list re-discovered from Lua source after save
}
rule ScriptPublish {
when: ScriptPublishRequested(script_id)
-- Only offered while the script is a draft (can_publish gate on the button)
-- Validates Lua syntax first (publish gate); invalid source blocks publish
-- Saves current draft fields, then publishes (status -> published)
-- Writes the published script file to disk
-- See script.allium PublishScript
}
rule ScriptCheckSyntax {
@@ -81,8 +94,8 @@ rule ScriptCheckSyntax {
rule ScriptRun {
when: ScriptRunRequested(script_id)
-- Executes script in Lua runtime
-- Calls configured entrypoint function (default: main)
-- Executes the script in the configured Lua runtime
-- Calls the configured Lua entrypoint function
-- stdout/stderr directed to Output panel tab
-- Output panel auto-opens if not already visible
-- Errors shown in Output panel with line numbers
@@ -91,6 +104,6 @@ rule ScriptRun {
rule ScriptDelete {
when: ScriptDeleteRequested(script_id)
-- No confirmation dialog
-- Deletes DB record + .lua file on disk
-- Deletes DB record + published script file on disk
-- Closes script tab, sidebar removes item
}

View File

@@ -16,8 +16,10 @@ value SettingsView {
editor_section: SettingsEditorSection?
categories: List<SettingsCategoryRow>
ai_section: SettingsAISection?
technology_section: SettingsTechnologySection?
publishing_section: SettingsPublishingSection?
mcp_section: SettingsMCPSection?
data_section: SettingsDataSection?
}
value SettingsProjectSection {
@@ -29,11 +31,12 @@ value SettingsProjectSection {
blog_languages: List<String> -- checkboxes (main language disabled)
default_author: String -- text input
max_posts_per_page: Integer -- number input (1-500, default 50)
image_import_concurrency: Integer -- number input (1-8, default 4)
blogmark_category: String -- select from categories
}
value SettingsEditorSection {
default_mode: String -- select: wysiwyg | markdown | preview
default_mode: String -- select: markdown | preview
diff_view_style: String -- select: inline | side-by-side
wrap_long_lines: Boolean -- checkbox
hide_unchanged_regions: Boolean -- checkbox
@@ -61,6 +64,12 @@ value SettingsAISection {
system_prompt: String -- textarea (12 rows) + Save + Reset to Default
}
value SettingsTechnologySection {
semantic_similarity_enabled: Boolean -- checkbox: enable duplicate search + related-post embeddings
-- Scripting runtime is fixed at the application layer; no runtime switch is exposed.
-- Saved together with project metadata (settings_project form).
}
value SettingsPublishingSection {
ssh_mode: String -- select: scp | rsync
ssh_host: String -- text input
@@ -69,13 +78,19 @@ value SettingsPublishingSection {
}
value SettingsMCPSection {
status: String -- port number or "Not running"
agents: List<MCPAgentRow>
}
value MCPAgentRow {
agent_name: String -- Claude Code, Claude Desktop, GitHub Copilot, etc.
is_installed: Boolean -- Add/Remove toggle
is_supported: Boolean -- false agents show a disabled button + "not supported yet" note
is_installed: Boolean -- toggle label: Remove when installed, else Add
config_path: String? -- agent config file path (nil when unsupported)
}
value SettingsDataSection {
-- Action-only section: rebuild buttons + Open Data Folder. No persisted fields.
rebuild_targets: List<String> -- posts | media | scripts | templates | links | thumbnails | embedding
}
invariant SettingsProtectedCategories {
@@ -87,11 +102,16 @@ invariant SettingsMCPAgents {
-- MCP section has exactly 7 agent rows in this order:
-- Claude Code, Claude Desktop, GitHub Copilot, Gemini CLI,
-- OpenCode, Mistral Vibe, OpenAI Codex.
-- Only Claude Code and GitHub Copilot are supported; the rest render a
-- disabled toggle and a "not supported in the rewrite yet" note.
}
config {
settings_max_posts_per_page: Integer = 500
settings_default_posts_per_page: Integer = 50
settings_image_import_concurrency_default: Integer = 4
settings_image_import_concurrency_min: Integer = 1
settings_image_import_concurrency_max: Integer = 8
settings_system_prompt_rows: Integer = 12
}
@@ -116,9 +136,11 @@ surface SettingsViewSurface {
SettingsAISystemPromptReset()
SettingsPublishingSaved(publishing_data)
SettingsPublishingCleared()
SettingsTechnologySaved(technology_data)
SettingsMCPAgentToggled(agent_name)
SettingsRebuildRequested(entity_type)
SettingsRegenerateThumbnailsRequested()
SettingsEmbeddingIndexRebuildRequested()
SettingsOpenDataFolderRequested()
StyleThemeSelected(theme_name)
StyleApplyRequested(theme_name)
@@ -131,14 +153,20 @@ surface SettingsViewSurface {
-- Section 1: Project Name, Description (textarea 3 rows), Data Path (text + Browse + Reset),
-- Public URL, Main Language (select), Blog Languages (checkbox grid, main disabled),
-- Default Author, Max Posts Per Page (number 1-500),
-- Image Import Concurrency (number 1-8, default 4),
-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button.
@guarantee BookmarkletCopy
-- Copy button copies bookmarklet JavaScript to clipboard.
-- Bookmarklet uses project's publicUrl to construct POST endpoint.
-- Copy button copies the generated bookmarklet JavaScript to the system
-- clipboard for pasting into a browser bookmark.
-- The bookmarklet captures the active tab's document.title and
-- location.href (each URI-encoded) and navigates to a
-- bds2://new-post?title=...&url=... deep link. When a project is active
-- its id is appended as &project_id=... so the link targets that
-- project regardless of which one is currently open.
@guarantee EditorSection
-- Section 2: Default Editor Mode (select: WYSIWYG/Markdown/Preview),
-- Section 2: Default Editor Mode (select: Markdown/Preview),
-- Diff View Style (select: Inline/Side-by-side),
-- Wrap Long Lines (checkbox), Hide Unchanged Regions (checkbox).
@@ -169,22 +197,27 @@ surface SettingsViewSurface {
-- Per-model info: max output tokens, context window (when available).
@guarantee TechnologySection
-- Section 5: Lua is the only scripting runtime in the Rust app;
-- there is no scripting language selector.
-- Semantic Similarity toggle.
-- Section 5: Semantic Similarity toggle
-- ("Enable duplicate search and related-post embeddings").
-- Scripting Runtime row is read-only descriptive text: scripting capabilities
-- are configured at the application layer and expose no runtime switch here.
-- Save button persists with project metadata (settings_project form).
@guarantee PublishingSection
-- Section 6: SSH Mode (scp/rsync), Host, Username, Remote Path.
-- Save + Clear buttons.
@guarantee MCPSection
-- Section 7: Status badge (port or "Not running").
-- 7 agent rows with Add/Remove toggle each.
-- Section 7: 7 agent rows, each with the agent label, its config-file path
-- as a subtitle (or "not supported yet" note), and a toggle button.
-- Supported agents (Claude Code, GitHub Copilot) toggle Add/Remove,
-- writing/removing the bDS server entry in the agent config file.
-- Unsupported agents render a disabled button.
@guarantee DataMaintenanceSection
-- Section 8: 6 rebuild buttons (Posts from Files, Media from Files,
-- Section 8: 7 rebuild buttons (Posts from Files, Media from Files,
-- Scripts from Files, Templates from Files, Links,
-- Regenerate Missing Thumbnails).
-- Regenerate Missing Thumbnails, Rebuild Embedding Index).
-- Open Data Folder button.
-- Each rebuild executes immediately (no confirmation).
-- Runs as background task with progress in Tasks panel.
@@ -198,7 +231,7 @@ surface SettingsViewSurface {
rule SettingsRebuild {
when: SettingsRebuildRequested(entity_type)
-- entity_type: posts | media | scripts | templates | links | thumbnails
-- entity_type: posts | media | scripts | templates | links | thumbnails | embedding
-- Executes immediately (no confirmation dialog)
-- Runs as background task with progress visible in Tasks panel
-- On completion: wholesale replaces store data for that entity type
@@ -207,10 +240,16 @@ rule SettingsRebuild {
-- ─── Style view ───────────────────────────────────────────────
-- The Style view is its OWN singleton tab (tab type `style`, see tabs.allium),
-- NOT one of the SettingsView collapsible sections. It is opened from the
-- Style menu/tab entry and renders the Pico CSS theme editor, distinct from
-- the `settings` tab. Shares the settings_editor code group only because both
-- persist into project metadata.
value StyleView {
themes: List<StyleTheme>
selected_theme: String?
applied_theme: String?
themes: List<StyleTheme> -- 20 named Pico themes (see metadata.supported_pico_themes)
selected_theme: String? -- local selection, defaults to applied_theme
applied_theme: String? -- persisted picoTheme (default when none set)
preview_mode: String -- auto | light | dark
}
@@ -240,9 +279,14 @@ surface StyleViewSurface {
when style.selected_theme != style.applied_theme
StylePreviewModeChanged(mode)
@guarantee SeparateTab
-- Rendered in its own `style` singleton tab (tabs.allium), never inline
-- in the settings view. Requires an active project; no project => no view.
@guarantee ThemePicker
-- Grid of theme buttons (one per Pico CSS theme).
-- Each button: swatch with 3 colour tones (accent, light bg, dark bg) + theme name.
-- Theme name shown via display transform: "-" -> " ", first letter capitalised.
-- Selected theme highlighted with aria-pressed.
@guarantee ControlsRow

View File

@@ -18,6 +18,7 @@ value TemplateEditorView {
kind: String -- select: post | list | not_found | partial
enabled: Boolean -- checkbox
content: String -- code editor content
can_publish: Boolean -- true while status = draft
created_at: String -- locale-formatted date
updated_at: String -- locale-formatted date
}
@@ -35,12 +36,15 @@ surface TemplateEditorSurface {
provides:
TemplateSaveRequested(editor.template_id)
TemplatePublishRequested(editor.template_id)
when editor.can_publish
TemplateValidateRequested(editor.template_id)
TemplateDeleteRequested(editor.template_id)
@guarantee HeaderLayout
-- Header bar with template title tab.
-- Actions (right side): Save button, Validate button,
-- Actions (right side): Save button, Publish button (shown only when
-- can_publish, i.e. status = draft), Validate button,
-- Delete button (danger style).
@guarantee MetadataRow
@@ -67,6 +71,15 @@ rule TemplateSave {
-- See engine_side_effects.allium UpdateTemplateSideEffects
}
rule TemplatePublish {
when: TemplatePublishRequested(template_id)
-- Only offered while the template is a draft (can_publish gate on the button)
-- Validates Liquid first (publish gate); invalid source blocks publish
-- Saves current draft fields, then publishes (status -> published)
-- Writes the published .liquid file to disk
-- See template.allium PublishTemplate
}
rule TemplateValidate {
when: TemplateValidateRequested(template_id)
-- Validates Liquid syntax without saving
@@ -77,7 +90,7 @@ rule TemplateValidate {
rule TemplateDelete {
when: TemplateDeleteRequested(template_id)
-- Checks for references: posts using this template, tags with postTemplateSlug
-- If references exist: native confirm dialog (rfd)
-- If references exist: system confirm dialog
-- "This template is used by N posts and M tags. Force delete?"
-- Force delete: nulls templateSlug on referencing posts,
-- nulls postTemplateSlug on referencing tags

View File

@@ -9,15 +9,49 @@
use "./post.allium" as post
use "./tag.allium" as tag
surface EmbeddingModelSurface {
context model: EmbeddingModel
exposes:
model.model_id
model.dimensions
}
surface EmbeddingRuntimeSurface {
facing _: EmbeddingRuntime
provides:
PostCreated(post)
PostUpdated(post)
PostDeleted(post)
}
surface EmbeddingControlSurface {
facing _: EmbeddingOperator
provides:
ReindexAllRequested(project)
IndexUnindexedRequested(project)
FindSimilarRequested(post, limit)
ComputeSimilaritiesRequested(source_post, target_post_ids)
SuggestTagsRequested(post, input_text)
FindDuplicatesRequested(project)
DismissDuplicatePairRequested(post_a, post_b)
}
-- ─── Model ──────────────────────────────────────────────────
value EmbeddingModel {
-- multilingual-e5-small: 384-dimensional sentence embeddings
-- Loaded via ONNX runtime (ort crate), model files from Hugging Face Hub
-- Model files are obtained from an external model source and cached locally
-- Downloaded on first use, cached in app data directory
-- Lazy-loaded: pipeline created on first embedding request, not at startup
-- Text preprocessing: prefix all input with "query: " (e5 convention)
-- Pooling: mean pooling + L2 normalization
-- Loaded on-device via Bumblebee (EMLX/Apple GPU or EXLA-CPU, see
-- NativeAcceleratedExecution); the canonical e5 weights come from
-- the "intfloat/multilingual-e5-small" repository, surfaced under the
-- "Xenova/multilingual-e5-small" model_id identifier.
model_id: String -- "Xenova/multilingual-e5-small"
dimensions: Integer -- 384
}
@@ -30,7 +64,7 @@ value EmbeddingVector {
-- ─── Entities ───────────────────────────────────────────────
entity EmbeddingKey {
label: Integer -- HNSW label for USearch
label: Integer -- HNSW node label / id
post: post/Post
content_hash: String -- SHA-256 of "{title}\n\n{content}"
vector: EmbeddingVector
@@ -42,9 +76,11 @@ entity DismissedDuplicatePair {
-- IDs stored in canonical order (sorted) for dedup
}
-- ─── USearch HNSW Index ─────────────────────────────────────
-- ─── HNSW Index ─────────────────────────────────────────────
config {
-- HNSW approximate-nearest-neighbour index (hnswlib). USearch has no Elixir
-- binding; hnswlib provides the same HNSW algorithm and parameters.
model_id: String = "Xenova/multilingual-e5-small"
embedding_dimensions: Integer = 384
hnsw_metric: String = "cosine"
@@ -53,7 +89,10 @@ config {
hnsw_expansion_search: Integer = 64 -- efSearch
debounce_persist: Duration = 5.seconds
-- Index file: {userData}/projects/{projectId}/embeddings.usearch
-- Key mapping in SQLite embedding_keys table (label, post_id, content_hash, vector BLOB)
-- Key mapping (label post_id) persisted in a sidecar (.meta.json) next
-- to the index file, plus the source-of-truth rows in embedding_keys
batch_size: Integer = 16 -- texts per batched inference run
sequence_length: Integer = 256 -- max tokens per input (truncated)
}
-- ─── Gating ─────────────────────────────────────────────────
@@ -77,7 +116,7 @@ rule EmbedPost {
let existing = EmbeddingKey{post: post}
if not exists existing or existing.content_hash != hash:
-- Compute embedding vector via local model
-- Upsert into USearch index + embedding_keys DB table
-- Upsert into HNSW index + embedding_keys DB table
-- Debounced index save (5s)
ensures: EmbeddingKeyUpdated(post)
}
@@ -116,9 +155,9 @@ rule IndexUnindexed {
rule FindSimilar {
when: FindSimilarRequested(post, limit)
requires: semantic_similarity_enabled
-- HNSW approximate nearest neighbor search via USearch
-- HNSW approximate nearest neighbor search (hnswlib)
-- Searches index for (limit + 1) neighbors, excludes self
-- Converts USearch cosine distance to similarity: max(0, 1 - distance)
-- Converts HNSW cosine distance to similarity: max(0, 1 - distance)
-- Returns ranked list sorted by descending similarity
ensures: SimilarPostsResult(post, ranked_matches)
}
@@ -127,7 +166,7 @@ rule ComputeSimilarities {
when: ComputeSimilaritiesRequested(source_post, target_post_ids)
requires: semantic_similarity_enabled
-- Exact pairwise cosine similarity between source vector and each target vector
-- Uses in-memory vector cache, NOT USearch search
-- Uses in-memory vector cache, NOT the HNSW index
-- Returns map of post_id -> similarity score
-- Used by InsertPostLinkModal to rank FTS search results
ensures: SimilarityScoresResult(source_post, scores)
@@ -172,7 +211,7 @@ invariant ContentHashSkipsUnchanged {
}
invariant DebouncedPersistence {
-- USearch index persistence is debounced at 5 seconds
-- HNSW index persistence is debounced at 5 seconds
-- Prevents excessive disk I/O during bulk operations
-- Index also force-saved on project switch and app shutdown
}
@@ -183,6 +222,34 @@ invariant VectorCacheInDb {
-- Enables instant reload without re-embedding
}
invariant RealNeuralModel {
-- Embeddings MUST be produced by the actual ONNX neural model (multilingual-e5-small),
-- not by lexical approximations (TF-IDF, bag-of-words, hash projections).
-- Cross-language semantic similarity is a primary requirement:
-- posts in different languages about the same topic must produce similar vectors.
-- This is only achievable with the trained multilingual transformer model.
}
invariant NativeAcceleratedExecution {
-- Model execution MUST use the platform's native hardware acceleration
-- where available (GPU/Metal/Neural Engine on Apple Silicon, CUDA on
-- NVIDIA, etc.), and otherwise fall back to optimised native CPU execution.
-- Inference MUST be batched: batch_size inputs are run per compiled
-- inference pass and inputs are truncated to a bounded sequence_length, so
-- (re)indexing many posts is not serialised one document at a time.
-- Current implementation: Bumblebee with a runtime-selected defn compiler.
-- On Apple Silicon the model runs on the Apple GPU via EMLX (MLX/Metal,
-- params placed on the EMLX.Backend GPU device); everywhere else, and as a
-- fallback when EMLX is unavailable, it runs on optimised native CPU via
-- EXLA. Selection is `accelerator: :auto | :emlx | :exla` (default :auto);
-- a request for an unavailable backend degrades to the other one.
-- Packaged releases ship only the backend their platform can run (runtime:
-- conditional deps): macOS releases exclude EXLA, Linux/Windows releases
-- exclude the Apple-only EMLX. The selected backend is started on demand
-- by the Neural backend, never assumed started at boot.
-- Neighbour search is HNSW (hnswlib).
}
invariant ModelCaching {
-- Model files (~100 MB) downloaded from Hugging Face Hub on first use
-- Cached in app data directory, persists across sessions
@@ -190,7 +257,7 @@ invariant ModelCaching {
}
invariant ProjectIsolation {
-- Each project has its own USearch index file and embedding_keys rows
-- Each project has its own HNSW index file and embedding_keys rows
-- On project switch: save current index, load new project's index
-- Model pipeline shared across projects (not reloaded)
}

View File

@@ -114,7 +114,7 @@ rule ImportMediaSideEffects {
if media.is_image:
ensures: ThumbnailsGenerated(media)
-- small=150px, medium=400px, large=800px, ai=448x448
-- Asynchronous, emits thumbnailsGenerated on completion
-- Synchronous (awaited), logged on error
ensures: FTSIndexUpdated(media)
}
@@ -185,7 +185,7 @@ rule DeleteTemplateSideEffects {
-- ─── Script operations ───────────────────────────────────
-- Same pattern as templates:
-- Create: write .lua file, insert DB
-- Create: write published script file, insert DB
-- Update: bump version, rewrite file, update DB
-- Publish: write file, clear DB content
-- Delete: delete file, delete DB row
@@ -299,7 +299,7 @@ rule DeleteMediaTranslationSideEffects {
-- updatePost | rename only* | yes | if Δ | no | no | yes | no
-- publishPost | .md + trans | yes | yes | no | no | yes | no
-- deletePost | delete .md | del | del | no | Δ media | del | no
-- importMedia | copy file | yes | no | async | write | no | no
-- importMedia | copy file | yes | no | sync | write | no | no
-- updateMedia | no | yes | no | no | rewrite | no | no
-- replaceMediaFile | overwrite | no | no | regen | no | no | no
-- deleteMedia | delete all | del | no | del | del all | no | no

59
specs/events.allium Normal file
View File

@@ -0,0 +1,59 @@
-- allium: 1
-- Domain Event Bus (issue #26, phase 2)
-- Scope: extension (multi-client synchronization over Phoenix.PubSub)
-- Distilled from: lib/bds/events.ex, context broadcast call sites,
-- lib/bds/desktop/shell_live.ex, lib/bds/cli_sync/watcher.ex
entity EntityChangedEvent {
entity: String -- post, media, tag, template, script
entity_id: String
action: created | updated | deleted
-- Same topic and payload shape as the CLI sync watcher, so one
-- subscription covers in-app mutations and external CLI writes.
}
entity SettingsChangedEvent {
key: String -- e.g. ui.language
}
surface EventBusSurface {
facing _: EventBus
provides:
ContextMutationSucceeded(entity, entity_id, action)
GlobalSettingWritten(key)
ClientSubscribed()
}
rule BroadcastEntityMutation {
when: ContextMutationSucceeded(entity, entity_id, action)
-- Posts, Media, Tags, Templates, Scripts broadcast after every
-- successful create/update/publish/delete (publish maps to updated).
ensures: EntityChangedEvent.created(
entity: entity,
entity_id: entity_id,
action: action
)
}
rule BroadcastSettingsChange {
when: GlobalSettingWritten(key)
ensures: SettingsChangedEvent.created(key: key)
}
rule ClientSynchronization {
when: ClientSubscribed()
-- Every shell (LiveView GUI or TUI session) subscribes on mount and
-- refreshes affected views on each event; deleted entities close
-- their open tabs. This keeps all connected clients synchronized
-- regardless of which client or pipeline originated the change.
ensures: ClientRefreshOnEvent()
}
rule ServerSideUiLanguage {
when: GlobalSettingWritten(key: "ui.language")
-- The UI language is a single server-side setting: changing it in any
-- client persists it and re-renders every connected client. The OS
-- locale is only the fallback when the setting is unset.
ensures: AllClientsRelocalized()
}

View File

@@ -1,12 +1,12 @@
-- allium: 1
-- bDS Frontmatter Specifications
-- Scope: core (Wave 1 — exact file format compatibility)
-- Distilled from: ../bDS/src/main/engine/postFileUtils.ts,
-- Distilled from: ../bDS2/src/main/engine/postFileUtils.ts,
-- TemplateEngine.ts, ScriptEngine.ts, MediaEngine.ts
--
-- This document specifies the exact YAML frontmatter format for all
-- file types. The Rust implementation must read and write these
-- formats byte-for-byte compatible with the TypeScript implementation.
-- file types. The rewrite must read and write these formats compatibly
-- with existing bDS content.
surface FrontmatterPersistenceSurface {
facing _: ContentPersistenceRuntime
@@ -25,7 +25,7 @@ surface PostFrontmatterSurface {
frontmatter.title
frontmatter.slug
frontmatter.status
frontmatter.published_at
frontmatter.publishedAt
frontmatter.tags
frontmatter.categories
}
@@ -35,11 +35,11 @@ surface MediaSidecarSurface {
exposes:
sidecar.id
sidecar.original_name
sidecar.mime_type
sidecar.originalName
sidecar.mimeType
sidecar.width
sidecar.height
sidecar.updated_at
sidecar.updatedAt
}
surface TemplateFrontmatterSurface {
@@ -70,21 +70,25 @@ surface MenuOpmlSurface {
exposes:
document.header.title
document.header.date_created
document.header.date_modified
document.header.dateCreated
document.header.dateModified
for item in document.body:
item.kind
item.label
item.slug
}
config {
script_extension: String = "lua"
}
-- ============================================================================
-- POST FILE FORMAT
-- ============================================================================
value PostFrontmatter {
-- File path: posts/{YYYY}/{MM}/{slug}.md
-- For translations: posts/{YYYY}/{MM}/{slug}.{language}.md
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4
title: String
slug: String
@@ -92,15 +96,46 @@ value PostFrontmatter {
status: draft | published | archived
author: String? -- Only written if present
language: String? -- Only written if present (ISO 639-1)
do_not_translate: Boolean -- Only written when true
template_slug: String? -- Only written if present
created_at: Timestamp -- Unix timestamp in milliseconds
updated_at: Timestamp -- Unix timestamp in milliseconds
published_at: Timestamp? -- Only written if published
doNotTranslate: Boolean -- Only written when true
templateSlug: String? -- Only written if present
createdAt: Timestamp -- Unix timestamp in milliseconds
updatedAt: Timestamp -- Unix timestamp in milliseconds
publishedAt: Timestamp? -- Only written if published
tags: List<String> -- Always written, even if empty
categories: List<String> -- Always written, even if empty
}
value TranslationFrontmatter {
-- File path: posts/{YYYY}/{MM}/{slug}.{language}.md
-- Translation files carry their own publication state and timestamps
-- so that each translation can be rebuilt independently.
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4
translationFor: String -- Canonical post UUID
language: String -- ISO 639-1 language code
title: String -- Translated title
excerpt: String? -- Only written when the translated excerpt differs
status: draft | published
createdAt: Timestamp -- Unix timestamp in milliseconds
updatedAt: Timestamp -- Unix timestamp in milliseconds
publishedAt: Timestamp -- Canonical post's publishedAt at time of publish
}
surface TranslationFrontmatterSurface {
context frontmatter: TranslationFrontmatter
exposes:
frontmatter.id
frontmatter.translationFor
frontmatter.language
frontmatter.title
frontmatter.excerpt when frontmatter.excerpt != null
frontmatter.status
frontmatter.createdAt
frontmatter.updatedAt
frontmatter.publishedAt
}
invariant PostFileLayout {
-- Posts are stored in date-based directory structure
-- YYYY and MM derived from created_at (zero-padded)
@@ -121,6 +156,14 @@ invariant PostTranslationFileLayout {
lang: t.language)
}
invariant TranslationFrontmatterRoundtrip {
-- Translation files carry status and timestamps explicitly.
-- On rebuild, these fields are read back directly; fallback to canonical
-- post values applies only when fields are absent (legacy files).
for t in PostTranslations where file_path != "":
parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t)
}
rule WritePostFile {
when: PublishPostRequested(post)
ensures: FileWritten(
@@ -138,11 +181,12 @@ rule WritePostFile {
value MediaSidecar {
-- File path: {binary_path}.meta (e.g., media/2024/03/a1b2c3d4.jpg.meta)
-- Binary file at: media/{YYYY}/{MM}/{uuid}.{ext}
-- Format: YAML-like key-value (hand-built, not gray-matter frontmatter)
-- Format: YAML-like key-value wrapped in --- delimiters (gray-matter style, hand-built serializer)
-- Note: 'filename' is NOT written to sidecar — it is implicit from the binary path
-- All keys serialized as camelCase
id: String -- UUID v4
original_name: String -- Original uploaded filename
mime_type: String
originalName: String -- Original uploaded filename
mimeType: String
size: Integer -- Bytes
width: Integer?
height: Integer?
@@ -152,8 +196,9 @@ value MediaSidecar {
author: String? -- Only written if present
language: String? -- Only written if present
tags: List<String> -- Always written, even if empty
created_at: Timestamp
updated_at: Timestamp
linkedPostIds: List<String> -- UUIDs of posts that reference this media
createdAt: Timestamp
updatedAt: Timestamp
}
invariant MediaSidecarLayout {
@@ -167,14 +212,16 @@ invariant MediaSidecarLayout {
value TemplateFrontmatter {
-- File path: templates/{slug}.liquid
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4
projectId: String -- Scoped to project
slug: String
title: String
kind: post | list | not_found | partial
enabled: Boolean
version: Integer
created_at: Timestamp
updated_at: Timestamp
createdAt: Timestamp
updatedAt: Timestamp
}
rule WriteTemplateFile {
@@ -192,24 +239,26 @@ rule WriteTemplateFile {
-- ============================================================================
value ScriptFrontmatter {
-- File path: scripts/{slug}.lua
-- File path: scripts/{slug}.{extension}
-- YAML frontmatter delimited by --- markers
-- All keys serialized as camelCase in YAML frontmatter
id: String -- UUID v4
projectId: String -- Scoped to project
slug: String
title: String
kind: macro | utility | transform
entrypoint: String -- Default: "render"
entrypoint: String -- Default: "render" for macros, "main" otherwise
enabled: Boolean
version: Integer
created_at: Timestamp
updated_at: Timestamp
createdAt: Timestamp
updatedAt: Timestamp
}
rule WriteScriptFile {
when: PublishScriptRequested(script)
requires: ValidateScript(script.content) = valid
ensures: FileWritten(
path: format("scripts/{slug}.lua", slug: script.slug),
path: format("scripts/{slug}.{extension}", slug: script.slug, extension: config.script_extension),
content: format_script_file(script)
)
ensures: script.content = null
@@ -219,24 +268,20 @@ rule WriteScriptFile {
-- TAGS FILE FORMAT
-- ============================================================================
value TagsFile {
-- File path: meta/tags.json
-- Portable JSON format (no internal IDs)
tags: List<TagEntry>
}
value TagEntry {
-- File path: meta/tags.json
-- Stored as a bare JSON array (no wrapper object)
-- Portable JSON format (no internal IDs), camelCase keys
name: String
color: String?
post_template_slug: String?
postTemplateSlug: String?
}
invariant TagsFileFormat {
-- Tags are stored as a sorted JSON array
-- Tags are stored as a bare sorted JSON array
-- Sorted alphabetically by name (case-insensitive)
parse_json(read_file("meta/tags.json")) = {
tags: sort_by(Tags, t => lowercase(t.name))
}
parse_json(read_file("meta/tags.json")) =
sort_by(tags, t => lowercase(t.name))
}
-- ============================================================================
@@ -245,16 +290,17 @@ invariant TagsFileFormat {
value ProjectJson {
-- File path: meta/project.json
-- All keys serialized as camelCase
name: String
description: String?
public_url: String?
main_language: String?
default_author: String?
max_posts_per_page: Integer
blogmark_category: String?
pico_theme: String?
semantic_similarity_enabled: Boolean
blog_languages: List<String>
publicUrl: String?
mainLanguage: String?
defaultAuthor: String?
maxPostsPerPage: Integer
blogmarkCategory: String?
picoTheme: String?
semanticSimilarityEnabled: Boolean
blogLanguages: List<String>
}
value CategoriesJson {
@@ -270,18 +316,19 @@ value CategoryMetaJson {
}
value CategorySettings {
render_in_lists: Boolean
show_title: Boolean
post_template_slug: String?
list_template_slug: String?
renderInLists: Boolean
showTitle: Boolean
postTemplateSlug: String?
listTemplateSlug: String?
}
value PublishingJson {
-- File path: meta/publishing.json
ssh_host: String?
ssh_user: String?
ssh_remote_path: String?
ssh_mode: scp | rsync
-- All keys serialized as camelCase
sshHost: String?
sshUser: String?
sshRemotePath: String?
sshMode: scp | rsync
}
invariant MetadataFileLayout {
@@ -292,7 +339,7 @@ invariant MetadataFileLayout {
meta/category-meta.json = serialize(CategoryMetaJson)
meta/publishing.json = serialize(PublishingJson)
meta/menu.opml = serialize(Menu)
meta/tags.json = serialize(TagsFile)
meta/tags.json = serialize(List<TagEntry>)
}
-- ============================================================================
@@ -308,8 +355,8 @@ value MenuOpml {
value OpmlHeader {
title: String
date_created: Timestamp
date_modified: Timestamp
dateCreated: Timestamp
dateModified: Timestamp
}
value MenuItem {
@@ -343,6 +390,11 @@ invariant YamlFormatting {
-- Boolean values are lowercase: true/false
}
invariant CamelCaseKeys {
-- All serialized keys in YAML frontmatter and JSON metadata use camelCase.
-- Entity/DB fields use snake_case internally; the mapping happens at serialization.
}
invariant AtomicWrites {
-- All file writes are atomic
-- Write to temp file first, then rename
@@ -357,7 +409,7 @@ invariant RequiredPostFields {
-- These fields are ALWAYS written for posts
for p in Posts:
required_fields(p) = {
id, title, slug, status, created_at, updated_at,
id, title, slug, status, createdAt, updatedAt,
tags, categories
}
}
@@ -366,9 +418,9 @@ invariant ConditionalPostFields {
-- These fields are ONLY written if truthy
for p in Posts:
conditional_fields(p) = {
excerpt, author, language, template_slug, published_at
excerpt, author, language, templateSlug, publishedAt
}
-- do_not_translate is only written when true
-- doNotTranslate is only written when true
}
invariant RequiredMediaFields {
@@ -376,8 +428,8 @@ invariant RequiredMediaFields {
-- Note: 'filename' is NOT a sidecar field — it is the binary path itself
for m in Media:
required_fields(m) = {
id, original_name, mime_type, size,
created_at, updated_at, tags
id, originalName, mimeType, size,
createdAt, updatedAt, tags
}
}

View File

@@ -24,7 +24,28 @@ surface GenerationRuntimeSurface {
provides:
PageRenderRequested(template, context)
GenerationProgressReported(current, total, label)
GenerateSiteCompleted(generation)
@guarantee ProgressReporting
-- Generation, reindex, and site-validation run as background tasks and
-- emit progress via the task progress channel (see task.allium
-- ReportProgress / ProgressThrottled).
--
-- Rendering (both full generation and validation apply) streams one
-- message per written page — "<verb> /url (n/total)" where verb is
-- "Generated" for a full render and "Rewrote" for a validation apply —
-- and advances the bar by the number of URLs written.
--
-- Full generation and validation apply share the same structuring: a
-- task group containing one task per section (Render Site Core, Render
-- Single Posts, Render Category Archives, Render Tag Archives, Render
-- Date Archives) followed by a final Build Search Index task. The only
-- difference is that full generation renders every URL while apply
-- renders only the affected URLs.
--
-- Multi-phase work (validation) maps each phase onto a fixed fraction
-- of the 0.0..1.0 bar.
}
value GenerationSection {
@@ -63,6 +84,23 @@ surface GenerationStatusSurface {
generation.generated_files.count
}
invariant GenerationPublishedOnly {
-- Generation renders the *published* state of the blog, never draft content.
--
-- Post universe: posts that have a published .md file on disk.
-- This includes status=published posts and status=draft posts that were
-- previously published (they still have a file_path with last-published content).
-- Posts that have never been published (no file_path) are excluded entirely.
--
-- Content source: always the .md file on disk (the last-published snapshot).
-- The DB content field (which holds draft edits) is never read during generation.
-- Snapshots set content=nil to ensure file-based resolution.
--
-- Contrast with preview (see preview.allium PreviewDraftOverlay):
-- Preview includes all drafts and prefers DB content over file content,
-- giving the author a live view of unpublished edits.
}
invariant IncrementalByContentHash {
-- Files are only written when content_hash changes
-- generatedFileHashes table tracks (projectId, relativePath, contentHash)
@@ -75,6 +113,17 @@ invariant MultiLanguageRoutes {
-- Each language subtree gets its own feeds and archives
}
invariant LanguageVariantSelection {
-- Every language tree renders each post in its own language when a
-- matching variant exists, falling back to the post's original language.
-- This applies uniformly to single pages, list pages (home/pagination,
-- category/tag/date archives), and feeds:
-- Main tree: a post whose language differs from main_language renders
-- its main_language translation when one exists (canonical variant).
-- Additional-language trees: posts resolve to that language's
-- translation; do_not_translate posts are excluded from those trees.
}
invariant CanonicalBaseUrlConfigured {
for generation in SiteGenerations:
generation.base_url != ""
@@ -98,16 +147,19 @@ rule GenerateCoreSectionPages {
ensures: FileGenerated("index.html")
ensures: FileGenerated("sitemap.xml")
-- Multi-language sitemap with hreflang alternates
ensures: FileGenerated("feed.xml")
-- RSS 2.0 feed
ensures: FileGenerated("rss.xml")
-- RSS 2.0 feed (same output name as the old bDS app; templates link /rss.xml)
ensures: FileGenerated("atom.xml")
-- Atom feed
ensures: FileGenerated("calendar.json")
-- Post dates for calendar widget
ensures: FileGenerated("404.html")
-- Not-found page rendered from the not-found template
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}/rss.xml", lang: lang))
ensures: FileGenerated(format("{lang}/atom.xml", lang: lang))
ensures: FileGenerated(format("{lang}/404.html", lang: lang))
}
-- Single section: one HTML page per published post
@@ -143,26 +195,46 @@ 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)))
let slug = slugify(t.name)
let page_count = ceil(posts_with_tag(t).count / generation.max_posts_per_page)
ensures: FileGenerated(format("tag/{slug}/index.html", slug: slug))
for page in page_range(2, page_count):
ensures: FileGenerated(format("tag/{slug}/page/{page}/index.html",
slug: slug, page: page))
}
-- Date section: year and month archives
-- Date section: year, month, and day archives
rule GenerateDateArchivePages {
when: GenerateSiteRequested(generation)
requires: date in generation.sections
for year in distinct_years(Posts):
let yp = ceil(posts_in_year(year).count / generation.max_posts_per_page)
ensures: FileGenerated(format("{year}/index.html", year: year))
for page in page_range(2, yp):
ensures: FileGenerated(format("{year}/page/{page}/index.html",
year: year, page: page))
for month in distinct_months(Posts, year):
let mp = ceil(posts_in_month(year, month).count / generation.max_posts_per_page)
ensures: FileGenerated(format("{year}/{month}/index.html",
year: year, month: month))
for page in page_range(2, mp):
ensures: FileGenerated(format("{year}/{month}/page/{page}/index.html",
year: year, month: month, page: page))
for day in distinct_days(Posts, year, month):
let dp = ceil(posts_in_day(year, month, day).count / generation.max_posts_per_page)
ensures: FileGenerated(format("{year}/{month}/{day}/index.html",
year: year, month: month, day: day))
for page in page_range(2, dp):
ensures: FileGenerated(format("{year}/{month}/{day}/page/{page}/index.html",
year: year, month: month, day: day, page: page))
}
-- Template rendering context
rule RenderPage {
when: PageRenderRequested(template, context)
-- LiquidJS rendering with full context:
-- Template rendering with full context:
-- posts, pagination, menus, tags, categories,
-- project metadata, i18n translations, theme settings
-- Macro expansion: [[slug param1=value1 ...]] in post content
@@ -196,7 +268,7 @@ invariant ArchiveDayBlocks {
-- ============================================================================
-- Pagefind builds a client-side full-text search index from generated HTML.
-- Uses the `pagefind` crate (library API), not a CLI subprocess.
-- Uses an embedded Pagefind library integration rather than a CLI subprocess.
-- Runs as the final step of the generation pipeline, after all HTML is written.
rule BuildSearchIndex {

View File

@@ -69,7 +69,7 @@ rule InitializeRepo {
has_lfs: true
)
ensures: GitignoreCreated(project)
-- .gitignore manages: thumbnails, generated html, node_modules, etc.
-- .gitignore manages generated artifacts, cached assets, dependency directories, etc.
ensures: LfsTrackingConfigured(project)
-- Git LFS auto-tracks image patterns (*.jpg, *.png, *.gif, etc.)
}

View File

@@ -126,8 +126,9 @@ rule ToggleAssistantSidebar {
value WindowTitleBar {
-- Platform-adaptive
-- menu_bar: rendered only on non-Mac platforms (6 groups: App, File, Edit, View, Window, Help)
-- macOS: native menu bar via muda crate (same 6 groups)
-- menu_bar: rendered only on non-Mac platforms (5 groups: File, Edit, View, Blog, Help)
-- macOS: native menu bar (same groups plus Window between Blog and Help;
-- wx auto Window menu disabled, app supplies its own for a stable order)
-- Keyboard: Alt opens mnemonics, Alt+letter opens group
-- Arrow keys navigate groups and items, Enter/Space activates
-- View group hides devTools toggle when not in dev mode
@@ -248,6 +249,8 @@ invariant PanelTabFallback {
-- Tasks tab: last 10 tasks, newest first, with progress/cancel.
-- Tasks with shared group_id are collapsible groups showing aggregate progress.
-- Output tab: log entries with copy-all button.
-- The panel opens on Output automatically when a blogmark transform fails
-- or a transform script writes output.
-- Post Links tab: backlinks (posts linking here) + outlinks (posts linked from here).
-- Each entry clickable, opens linked post as pinned tab.
-- Git Log tab: file-level git history for active post/media (up to 50 entries).

View File

@@ -8,6 +8,11 @@ use "./media.allium" as media
use "./script.allium" as script
use "./template.allium" as template
enum ProposalStatus {
pending
expired
}
entity McpServer {
transport: http | stdio
host: String -- 127.0.0.1 for HTTP
@@ -27,7 +32,7 @@ surface McpServerSurface {
entity Proposal {
kind: draft_post | propose_script | propose_template | propose_media_metadata | propose_post_metadata
status: pending | accepted | discarded | expired
status: ProposalStatus
entity_id: String
data: String
created_at: Timestamp
@@ -42,8 +47,6 @@ entity Proposal {
is_expired: expires_at <= now
transitions status {
pending -> accepted
pending -> discarded
pending -> expired
}
}
@@ -68,8 +71,7 @@ surface ProposalSurface {
config {
http_port: Integer = 4124
proposal_ttl_app: Duration = 30.minutes
proposal_ttl_cli: Duration = 8.hours
proposal_ttl: Duration = 30.minutes
}
surface McpAutomationSurface {
@@ -80,6 +82,9 @@ surface McpAutomationSurface {
McpToolInvoked("search_posts", params)
McpToolInvoked("count_posts", params)
McpToolInvoked("read_post_by_slug", slug, language)
McpToolInvoked("get_post_translations", post_id)
McpToolInvoked("get_media_translations", media_id)
McpToolInvoked("upsert_media_translation", params)
McpToolInvoked("draft_post", params)
McpToolInvoked("propose_script", params)
McpToolInvoked("propose_template", params)
@@ -87,8 +92,6 @@ surface McpAutomationSurface {
McpToolInvoked("propose_post_metadata", params)
AcceptProposalRequested(proposal)
DiscardProposalRequested(proposal)
InstallAgentConfigRequested(agent_kind)
UninstallAgentConfigRequested(agent_kind)
}
invariant LocalhostOnlyHttp {
@@ -161,6 +164,46 @@ surface CategoriesResource {
-- bds://categories
}
surface StatsResource {
facing viewer: McpClient
context blog: Blog
exposes:
blog.post_count
blog.media_count
blog.tag_count
blog.category_count
@guidance
-- bds://stats
}
surface PostMediaResource {
facing viewer: McpClient
context post: post/Post
exposes:
for m in post.media:
m.id
m.filename
m.title
m.alt
m.caption
m.tags
@guidance
-- bds://posts/{id}/media
-- Unknown post ids return not_found.
}
surface MediaImageResource {
facing viewer: McpClient
context media_item: media/Media
exposes:
media_item.mime_type
media_item.file_bytes
@guidance
-- bds://media/{id}/image
-- Returns blob content using the media MIME type.
-- Unknown media ids or missing files return not_found.
}
-- Read-only tools
rule CheckTerm {
@@ -201,6 +244,30 @@ rule ReadPostBySlug {
ensures: FullPostContent(post)
}
rule GetPostTranslations {
when: McpToolInvoked("get_post_translations", post_id)
-- Lists all available translations for a post.
ensures: PostTranslations(post_id)
}
rule GetMediaTranslations {
when: McpToolInvoked("get_media_translations", media_id)
-- Lists all available translated metadata for a media item.
ensures: MediaTranslations(media_id)
}
rule UpsertMediaTranslation {
when: McpToolInvoked("upsert_media_translation", params)
-- Creates or updates translated media metadata for a language.
ensures: media/UpsertMediaTranslationRequested(
params.media_id,
params.language,
params.title,
params.alt,
params.caption
)
}
-- Write tools (proposal-based)
rule DraftPost {
@@ -218,7 +285,7 @@ rule DraftPost {
entity_id: new_post.id,
data: "",
created_at: now,
expires_at: now + config.proposal_ttl_app,
expires_at: now + config.proposal_ttl,
draft_post: new_post,
proposed_script: null,
proposed_template: null,
@@ -244,7 +311,7 @@ rule ProposeScript {
entity_id: new_script.id,
data: "",
created_at: now,
expires_at: now + config.proposal_ttl_app,
expires_at: now + config.proposal_ttl,
draft_post: null,
proposed_script: new_script,
proposed_template: null,
@@ -270,7 +337,7 @@ rule ProposeTemplate {
entity_id: new_template.id,
data: "",
created_at: now,
expires_at: now + config.proposal_ttl_app,
expires_at: now + config.proposal_ttl,
draft_post: null,
proposed_script: null,
proposed_template: new_template,
@@ -289,7 +356,7 @@ rule ProposeMediaMetadata {
entity_id: params.media_id,
data: serialize(params),
created_at: now,
expires_at: now + config.proposal_ttl_app,
expires_at: now + config.proposal_ttl,
draft_post: null,
proposed_script: null,
proposed_template: null,
@@ -308,7 +375,7 @@ rule ProposePostMetadata {
entity_id: params.post_id,
data: serialize(params),
created_at: now,
expires_at: now + config.proposal_ttl_app,
expires_at: now + config.proposal_ttl,
draft_post: null,
proposed_script: null,
proposed_template: null,
@@ -335,7 +402,6 @@ rule AcceptProposal {
media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data))
if proposal.kind = propose_post_metadata:
post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data))
proposal.status = accepted
not exists proposal
}
@@ -348,7 +414,6 @@ rule DiscardProposal {
script/DeleteScriptRequested(proposal.proposed_script)
if proposal.kind = propose_template:
template/DeleteTemplateRequested(proposal.proposed_template)
proposal.status = discarded
not exists proposal
}
@@ -374,14 +439,25 @@ surface McpAgentKindSurface {
agent_kind.kind
}
surface McpSettingsSurface {
provides:
SettingsMCPAgentToggled(agent_kind)
@guidance
-- Agent configuration install/remove is exposed by the settings UI,
-- not by the MCP automation surface.
}
rule InstallAgentConfig {
when: InstallAgentConfigRequested(agent_kind)
when: SettingsMCPAgentToggled(agent_kind)
requires: not AgentConfigInstalled(agent_kind)
-- Writes stdio MCP server config into the agent's config file
ensures: AgentConfigInstalled(agent_kind)
}
rule UninstallAgentConfig {
when: UninstallAgentConfigRequested(agent_kind)
when: SettingsMCPAgentToggled(agent_kind)
requires: AgentConfigInstalled(agent_kind)
ensures: AgentConfigRemoved(agent_kind)
}

View File

@@ -9,8 +9,9 @@ surface MediaControlSurface {
facing _: MediaOperator
provides:
ImportMediaRequested(project, source_file)
ImportMediaRequested(source_path, project, metadata)
UpdateMediaRequested(media, changes)
ReplaceMediaFileRequested(media, new_source_path)
DeleteMediaRequested(media)
UpsertMediaTranslationRequested(media, language, title, alt, caption)
RebuildMediaFromFilesRequested(project)
@@ -121,22 +122,29 @@ invariant DateBasedMediaLayout {
}
rule ImportMedia {
when: ImportMediaRequested(project, source_file)
let uuid_name = generate_uuid() + extension(source_file)
when: ImportMediaRequested(source_path, project, metadata)
-- metadata is optional import context: title, alt, caption, author,
-- language, and tags may be supplied by the caller.
let uuid_name = generate_uuid() + extension(source_path)
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),
original_name: basename(source_path),
mime_type: detect_mime(source_path),
size: file_size(source_path),
width: detect_width(source_path),
height: detect_height(source_path),
title: metadata.title,
alt: metadata.alt,
caption: metadata.caption,
author: metadata.author,
language: metadata.language,
file_path: dest,
tags: {}
tags: metadata.tags
)
ensures: FileCopied(source_file, dest)
ensures: FileCopied(source_path, dest)
ensures: SidecarWritten(media)
ensures: ThumbnailsGenerated(media)
ensures: SearchIndexUpdated(media)
@@ -151,6 +159,28 @@ rule UpdateMedia {
ensures: SearchIndexUpdated(media)
}
rule ReplaceMediaFile {
when: ReplaceMediaFileRequested(media, new_source_path)
-- Replaces the binary at media.file_path with the new source file,
-- keeping the same path/id. Sidecar metadata (title/alt/etc.) is preserved.
let checksum = md5(read(new_source_path))
-- Identical content (checksum = media.checksum): no-op, nothing rewritten.
-- Otherwise the old file is backed up to {path}.bak (restored on failure,
-- removed on success) and the row is updated from the new file:
if checksum != media.checksum:
ensures: media.checksum = checksum
ensures: media.size = file_size(new_source_path)
ensures: media.updated_at = now
ensures: MediaDimensionsUpdated(media)
-- width/height re-read from the new image
ensures: SidecarWritten(media)
if media.is_image:
ensures: ThumbnailsRegenerated(media)
-- Synchronous (awaited), not fire-and-forget
ensures: SearchIndexUpdated(media)
-- See engine_side_effects.allium ReplaceMediaFileSideEffects
}
rule DeleteMedia {
when: DeleteMediaRequested(media)
ensures: not exists media
@@ -179,7 +209,7 @@ rule UpsertMediaTranslation {
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"):
for sidecar in scan_directory(project.public_dir + "/media", "*.meta"):
let parsed = parse_sidecar(sidecar)
ensures: Media.created(parsed)
-- or updated if already exists
@@ -196,3 +226,27 @@ invariant SidecarRoundtrip {
parse_sidecar(m.sidecar_path).caption = m.caption
parse_sidecar(m.sidecar_path).tags = m.tags
}
rule BatchImportProcessLinkImages {
when: BatchImportImagesRequested(project, post, file_paths, language)
requires: not OfflineMode
for source_path in file_paths where is_image(source_path):
let media = ImportMedia(source_path, project)
let analysis = AnalyzeImage(media, language)
ensures: MediaUpdated(media, analysis)
ensures: PostMediaLinked(media, post)
@guidance
-- Triggered from post editor quick action "Add Gallery Images".
-- AI results auto-applied without user confirmation.
-- After metadata is set, media is auto-translated to all configured blog languages.
-- Non-image files skipped entirely.
-- Concurrency limit from project metadata image_import_concurrency (default 4, min 1, max 8).
-- Toast per completed image + final summary toast.
-- On completion: [[gallery]] macro inserted into post content and post editor refreshed.
}
config {
batch_image_import_concurrency_default: Integer = 4
batch_image_import_concurrency_min: Integer = 1
batch_image_import_concurrency_max: Integer = 8
}

View File

@@ -1,7 +1,7 @@
-- allium: 1
-- bDS Media Processing Specification
-- Scope: core (Wave 1 — media import and processing)
-- Distilled from: ../bDS/src/main/engine/MediaEngine.ts,
-- Distilled from: ../bDS2/src/main/engine/MediaEngine.ts,
-- mediaProcessing.ts, thumbnail generation logic
--
-- This document specifies the exact media processing behavior:
@@ -14,10 +14,11 @@ surface MediaProcessingControlSurface {
facing _: MediaProcessingOperator
provides:
ImportMediaRequested(source_path, project)
ImportMediaRequested(source_path, project, metadata)
TagMediaRequested(media, tags)
DeleteMediaRequested(media)
ValidateMediaRequested(project)
RegenerateMissingThumbnailsRequested(project)
}
surface MediaProcessingRuntimeSurface {
@@ -91,8 +92,10 @@ config {
thumbnail_medium_width: Integer = 400
thumbnail_large_width: Integer = 800
thumbnail_ai_size: Integer = 448 -- 448x448 square crop, JPEG
thumbnail_format: String = "webp" -- All sizes except AI (encoder default quality)
thumbnail_format: String = "webp" -- All sizes except AI
thumbnail_quality: Integer = 80 -- WebP quality for small/medium/large
thumbnail_ai_format: String = "jpeg" -- AI thumbnail only
thumbnail_ai_quality: Integer = 85 -- JPEG quality for AI thumbnail
}
rule GenerateThumbnails {
@@ -103,36 +106,52 @@ rule GenerateThumbnails {
source: media.file_path,
destination: media.thumbnails.small,
width: config.thumbnail_small_width,
format: config.thumbnail_format
format: config.thumbnail_format,
quality: config.thumbnail_quality
)
ensures: ThumbnailGenerated(
source: media.file_path,
destination: media.thumbnails.medium,
width: config.thumbnail_medium_width,
format: config.thumbnail_format
format: config.thumbnail_format,
quality: config.thumbnail_quality
)
ensures: ThumbnailGenerated(
source: media.file_path,
destination: media.thumbnails.large,
width: config.thumbnail_large_width,
format: config.thumbnail_format
format: config.thumbnail_format,
quality: config.thumbnail_quality
)
ensures: ThumbnailGenerated(
source: media.file_path,
destination: media.thumbnails.ai,
size: config.thumbnail_ai_size,
format: config.thumbnail_ai_format
format: config.thumbnail_ai_format,
quality: config.thumbnail_ai_quality
)
}
rule RegenerateMissingThumbnails {
when: RegenerateMissingThumbnailsRequested(project)
-- Maintenance sweep over the project's raster images (images, excluding SVG),
-- oldest first. For each, any thumbnail file absent from disk is regenerated
-- from the original binary; images with a full set are skipped.
-- Runs as a background task reporting progress (current/total).
-- Returns counts: processed, generated, failed.
for media in project.media where is_image(media.mime_type) and not is_svg(media.mime_type):
if any_thumbnail_missing(media):
ensures: ThumbnailsRegenerated(media)
}
-- Thumbnail generation algorithm
value ThumbnailGeneration {
-- 1. Load source image
-- 2. Apply EXIF orientation correction (rotation, flip) so thumbnails display correctly
-- 3. Resize: small/medium/large preserve aspect ratio (width-constrained)
-- AI thumbnail is a 448x448 center crop (letterboxed on black background)
-- 4. Encode as WebP (encoder default quality) for small/medium/large
-- Encode as JPEG for AI thumbnail
-- 4. Encode as WebP quality 80 for small/medium/large
-- Encode as JPEG quality 85 for AI thumbnail
-- 5. Write to bucketed thumbnail path: thumbnails/{id[0:2]}/{id}-{size}.{ext}
--
-- No _source copy is made. Thumbnails regenerated from the original binary.
@@ -167,8 +186,8 @@ value ImageProcessing {
}
-- Processing rules:
-- 1. All thumbnails (except AI) are encoded as WebP (encoder default quality)
-- 2. AI thumbnail is encoded as JPEG (for vision model compatibility)
-- 1. All thumbnails (except AI) are encoded as WebP quality 80
-- 2. AI thumbnail is encoded as JPEG quality 85 (for vision model compatibility)
-- 3. Original format is preserved for full-size assets (no conversion)
-- 4. EXIF data is not stripped (thumbnails are re-encoded, so EXIF is naturally absent)
}
@@ -233,11 +252,13 @@ invariant MediaTranslationFileLayout {
-- ============================================================================
rule ImportMedia {
when: ImportMediaRequested(source_path, project)
when: ImportMediaRequested(source_path, project, metadata)
-- metadata is optional import context: title, alt, caption, author,
-- language, and tags may be supplied by the caller.
-- 1. Validate file type (must be supported image)
-- 2. Generate UUID v4 filename
-- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext}
-- 4. Write sidecar {binary_path}.meta
-- 4. Apply optional metadata and write sidecar {binary_path}.meta
-- 5. Generate four thumbnail sizes
-- 6. Index for search (FTS5)
ensures: media/Media.created(
@@ -247,6 +268,12 @@ rule ImportMedia {
size: file_size(source_path),
width: extract_width_from_header(source_path),
height: extract_height_from_header(source_path),
title: metadata.title,
alt: metadata.alt,
caption: metadata.caption,
author: metadata.author,
language: metadata.language,
tags: metadata.tags,
file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"),
sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"),
checksum: sha256(source_path)
@@ -345,7 +372,7 @@ invariant MediaSidecarFormat {
-- ============================================================================
config {
-- No file size limit on import (TypeScript app has no max_original_size check)
-- No file size limit on import
-- Original files are stored as-is (no compression, no resize)
-- Only thumbnails are generated from the original
strip_exif: Boolean = false -- Not explicitly stripped; re-encoding naturally omits it

View File

@@ -1,28 +1,30 @@
-- allium: 1
-- bDS Navigation Menu
-- Scope: core (read for rendering), extension Bucket F (menu editor UI)
-- Distilled from: src/main/engine/MenuEngine.ts
-- File-only model: no DB table. Loaded from meta/menu.opml into a
-- transient value, mutated in memory, written back to OPML on save.
surface MenuManagementSurface {
facing _: MenuOperator
provides:
UpdateMenuRequested(menu, items)
MenuLoadRequested(project_id)
UpdateMenuRequested(items)
SyncMenuFromFilesystemRequested(project_id)
}
value MenuItem {
kind: page | submenu | category_archive | home
label: String
slug: String?
children: List<MenuItem>? -- only for submenu kind
slug: String? -- pageSlug for page/home, categoryName for category_archive
children: List<MenuItem>? -- present only for submenu kind
}
entity Menu {
value Menu {
items: List<MenuItem>
-- Derived
home_items: items where kind = home
home_entry: home_items.first
home_entry: items.first -- always home after normalization
}
surface MenuSurface {
@@ -30,27 +32,42 @@ surface MenuSurface {
exposes:
menu.items.count
menu.home_items.count
menu.home_entry.label
}
invariant HomeAlwaysPresent {
-- The menu always has a Home entry, extracted and prepended
invariant HomeAlwaysFirst {
-- Normalization guarantees home is always the first item.
-- UpdateMenu strips any home entries from input, then prepends one.
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
-- meta/menu.opml is the sole persistent store (no DB table).
-- OPML outline attributes: text (label), type (kind),
-- pageSlug (slug for page/home), categoryName (slug for category_archive).
-- Nested <outline> elements represent submenu children.
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)
rule LoadMenu {
when: MenuLoadRequested(project_id)
-- Reads meta/menu.opml; if file missing, returns default (home-only) menu.
-- Normalizes: strips home entries from body, prepends canonical home.
ensures: MenuLoaded(project_id, normalize(parse_opml_or_empty(project_id)))
}
rule UpdateMenu {
when: UpdateMenuRequested(items)
-- Normalizes Home entry: strips all home items, prepends canonical home.
-- Writes normalized menu back to meta/menu.opml.
let without_home = items where kind != home
ensures: MenuFileWritten(normalize(without_home))
}
rule SyncMenuFromFilesystem {
when: SyncMenuFromFilesystemRequested(project_id)
-- Reloads menu from OPML, normalizes, writes back (round-trip repair).
ensures: MenuLoaded(project_id, _)
ensures: MenuFileWritten(_)
}

View File

@@ -5,6 +5,28 @@
use "./project.allium" as project
surface MetadataControlSurface {
facing _: MetadataOperator
provides:
UpdateProjectMetadataRequested(project, changes)
AddCategoryRequested(project, name)
RemoveCategoryRequested(project, name)
UpdateCategorySettingsRequested(project, category, settings)
SetPublishingPreferencesRequested(project, prefs)
AppStarted(project)
}
surface PublishingPreferencesSurface {
context prefs: PublishingPreferences
exposes:
prefs.ssh_host when prefs.ssh_host != null
prefs.ssh_user when prefs.ssh_user != null
prefs.ssh_remote_path when prefs.ssh_remote_path != null
prefs.ssh_mode
}
value CategoryRenderSettings {
render_in_lists: Boolean
show_title: Boolean

View File

@@ -14,6 +14,7 @@ surface MetadataMaintenanceSurface {
provides:
MetadataDiffRequested(project)
RebuildFromFilesystemRequested(project, entity_type)
RepairMetadataDiffItemRequested(project, direction, item)
}
value DiffField {
@@ -23,7 +24,8 @@ value DiffField {
}
value DiffReport {
entity_type: String -- post, media, script, template
entity_type: String -- post, post_translation, media,
-- media_translation, script, template, embedding
entity_id: String
differences: List<DiffField>
}
@@ -46,13 +48,46 @@ rule RunMetadataDiff {
if diffs.count > 0:
ensures: DiffReport.created(entity_type: "post", entity_id: post.id, differences: diffs)
-- Translation files only carry language-specific metadata. Shared status and
-- timestamp fields come from the canonical post and must not be reported as
-- missing when they are absent from the translation file.
for translation in project.post_translations:
let translation_file_data = parse_post_file(translation.file_path)
let translation_diffs = compare_translation_specific_fields(translation, translation_file_data)
if translation_diffs.count > 0:
ensures:
DiffReport.created(
entity_type: "post_translation",
entity_id: translation.id,
differences: translation_diffs
)
-- Detect orphan files (on disk but not in DB)
for file in scan_directory(project.effective_data_dir + "/posts", "*.md"):
for file in scan_directory(project.public_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
-- Same pattern for media sidecars (media), media translation sidecars
-- (media_translation), scripts, templates, and embeddings.
-- Embedding diffs compare the stored content_hash against the live post
-- content to detect vectors that need recomputation.
}
rule RepairMetadataDiffItem {
when: RepairMetadataDiffItemRequested(project, direction, item)
-- Resolves a single diff in one direction.
-- direction = file_to_db (filesystem wins) | db_to_file (database wins)
-- Dispatched per item.entity_type:
-- project | categories | category_meta | publishing -> project metadata sync/flush
-- post -> sync_post_from_file / rewrite_published_post
-- post_translation -> sync_post_translation_from_file / rewrite_published_post_translation
-- media -> sync_media_from_sidecar / sync_media_sidecar
-- media_translation -> sync_media_translation_from_sidecar / sync_media_translation_sidecar
-- script -> sync_script_from_file / sync_published_script_file
-- template -> sync_template_from_file / sync_published_template_file
-- embedding -> sync_post (file_to_db) / refresh_snapshot (db_to_file)
-- Unknown entity_type or direction -> unsupported error.
}
rule RebuildFromFilesystem {

View File

@@ -192,7 +192,7 @@ surface ConfirmDialogSurface {
modal.message
}
-- Native confirm dialogs (via rfd crate) are NOT modelled as values.
-- System confirm dialogs are NOT modelled as values.
-- They are simple yes/no system dialogs with a message string.
-- Used by: PostDelete, PostDiscard, TemplateDelete (with references).

View File

@@ -5,14 +5,20 @@
use "./project.allium" as project
enum PostStatus {
draft
published
archived
}
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}
-- Verify transliteration matches the established bDS behaviour for this set.
-- Uniqueness: tries base, then {slug}-2, {slug}-3, … (unbounded numeric suffix)
}
value PostFilePath {
@@ -40,13 +46,51 @@ value Frontmatter {
-- doNotTranslate (only when true), templateSlug, publishedAt
}
surface PostControlSurface {
facing _: PostOperator
provides:
CreatePostRequested(project, title, content, tags, categories, author, language, template_slug)
UpdatePostRequested(post, changes)
PublishPostRequested(post)
DeletePostRequested(post)
ArchivePostRequested(post)
DiscardPostChangesRequested(post)
SyncPostFromFileRequested(post)
ImportOrphanPostFileRequested(project, relative_path)
}
surface PostFilePathSurface {
context path: PostFilePath
exposes:
path.base_dir
path.year
path.month
path.slug
}
surface PostCanonicalUrlSurface {
context url: PostCanonicalUrl
exposes:
url.year
url.month
url.day
url.slug
}
surface FrontmatterSurface {
context _: Frontmatter
}
entity Post {
project: project/Project
title: String
slug: Slug
excerpt: String?
content: String?
status: draft | published | archived
status: PostStatus
author: String?
language: String?
do_not_translate: Boolean
@@ -59,6 +103,15 @@ entity Post {
updated_at: Timestamp
published_at: Timestamp?
-- Published snapshot: copy of title/content/tags/categories/excerpt as of
-- the last publish. Used by changes_affect_published_content to decide when
-- an edit reopens a published post to draft (see ReopenPublishedPost).
published_title: String?
published_content: String?
published_tags: String?
published_categories: String?
published_excerpt: String?
-- Relationships
translations: PostTranslation with canonical_post = this
linked_media: PostMediaLink with post = this
@@ -71,6 +124,10 @@ entity Post {
-- 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.
editor_body: if content != null: content else: read_markdown_body(file_path)
-- Resolver used by editors: prefer the in-DB draft content, else read
-- the markdown body from the post's file. Empty string when neither
-- exists. The same resolver applies to PostTranslation records.
transitions status {
draft -> published
@@ -104,21 +161,23 @@ 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)
ensures:
let new_post = 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: ""
)
new_post.status = draft
SearchIndexUpdated(new_post)
}
rule UpdatePost {
@@ -133,6 +192,13 @@ rule UpdatePost {
-- status auto-transitions back to draft
}
rule ReopenPublishedPost {
when: UpdatePostRequested(post, changes)
requires: post.status = published
requires: changes_affect_published_content(changes)
ensures: post.status = draft
}
rule PublishPost {
when: PublishPostRequested(post)
requires: post.status = draft or post.status = archived
@@ -164,9 +230,43 @@ rule DeletePost {
rule ArchivePost {
when: ArchivePostRequested(post)
requires: post.status = draft or post.status = published
ensures: post.status = archived
}
rule SyncPostFromFile {
when: SyncPostFromFileRequested(post)
requires: post.file_path != ""
-- Single-post reimport: re-reads the post's own .md file and upserts the DB
-- record from it (the filesystem is treated as truth for that one post).
-- Re-syncs the post's link graph. Errors out if the file is missing.
ensures: PostFieldsUpdated(post, parse_post_file(post.file_path))
ensures: PostLinksUpdated(post)
}
rule ImportOrphanPostFile {
when: ImportOrphanPostFileRequested(project, relative_path)
-- Imports a .md file that exists on disk but has no DB record (an orphan,
-- e.g. surfaced by RunMetadataDiff). Rejects non-markdown / missing files.
ensures:
let new_post = Post.created(parse_post_file(relative_path))
SearchIndexUpdated(new_post)
}
rule DiscardPostChanges {
when: DiscardPostChangesRequested(post)
requires: post.file_path != ""
-- Only posts with a published file on disk can be discarded;
-- a never-published draft has no file version to restore.
-- Re-reads the published .md file and upserts the DB record from it,
-- discarding unsaved draft edits.
ensures: post.content = null
ensures: post.status = published
ensures: PostLinksUpdated(post)
ensures: SearchIndexUpdated(post)
-- See engine_side_effects.allium DiscardPostChangesSideEffects
}
-- File format axioms
invariant FrontmatterRoundtrip {
@@ -185,5 +285,5 @@ invariant DateBasedFileLayout {
}
-- Slug freeze: once published_at is set, the slug is permanently frozen.
-- This matches TypeScript behaviour: is_slug_frozen = published_at != null
-- This follows the established bDS rule: is_slug_frozen = published_at != null
-- Even if the post reverts to draft, the slug cannot be changed.

View File

@@ -49,18 +49,24 @@ rule StopPreview {
}
-- Route resolution
-- Preview renders all posts (published + draft) on-demand via Liquid templates.
-- Content priority: DB content (draft edits) over published .md file content.
-- See invariant PreviewDraftOverlay below.
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
-- Finds post by slug+date regardless of status (published or draft).
-- Content resolved via editor_body: DB content if present, else .md file.
-- Renders 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)
-- Explicit draft preview by post_id (used by editor preview pane).
-- Renders draft content (from DB, not filesystem).
ensures: PreviewResponse(rendered_html)
}
@@ -68,6 +74,7 @@ rule ServeArchivePreview {
when: PreviewRequest(path)
requires: is_archive_path(path)
-- Category, tag, date archives with pagination
-- Includes both published and draft posts in listings.
ensures: PreviewResponse(rendered_html)
}
@@ -92,6 +99,28 @@ rule ServeLanguagePrefixedRoute {
ensures: PreviewResponse(translated_html)
}
invariant PreviewDraftOverlay {
-- Preview is the draft workspace: it shows what the blog *will* look like,
-- not what it currently looks like on the published site.
--
-- Post universe: all posts with status in {published, draft}.
-- Archived posts are excluded.
--
-- Content priority (per post):
-- 1. DB content field (draft edits not yet published) → used when non-nil
-- 2. Published .md file (last-published snapshot) → used when DB content is nil
-- 3. Empty string → fallback if neither exists
--
-- This means:
-- - A purely draft post (never published) renders from DB content.
-- - A published-then-edited post renders from DB content (the draft edits).
-- - A published post with no pending edits renders from its .md file.
--
-- Contrast with generation (see generation.allium GenerationPublishedOnly):
-- Generation uses *only* published .md file content, never DB draft content,
-- and excludes posts that have never been published.
}
invariant ThemeSwitching {
-- Preview supports live theme/mode switching via query params
-- ?theme=amber&mode=dark etc.

View File

@@ -8,6 +8,7 @@ surface ProjectControlSurface {
provides:
CreateProjectRequested(name, data_path)
OpenProjectRequested(folder_path)
SetActiveProjectRequested(project)
DeleteProjectRequested(project)
}
@@ -27,11 +28,37 @@ entity Project {
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
--
-- data_path is the project folder: the directory that CONTAINS
-- meta/project.json. It is DISCOVERED from the project.json location at
-- load time and is never written into project.json itself — so the folder
-- can be moved/renamed freely. The current location is remembered only as a
-- machine-local pointer (a project registry under private_dir), never
-- embedded in the portable project. See DataPathNotPersistedInProjectJson.
public_dir: data_path
-- All user-owned, portable, webserver-bound content lives here, under
-- the project folder:
-- posts (.md), media + thumbnails, templates/, scripts/,
-- meta/ (project.json, categories.json, category-meta.json,
-- publishing.json), tags.json, menu.opml, and generated html/ output.
-- See PublicContentLivesInProjectFolder.
private_dir: String
-- The OS per-user app-data directory. Holds app-internal,
-- machine-specific, regenerable artifacts ONLY — never inside the repo
-- or the project folder:
-- the SQLite database, the per-project embeddings index
-- (projects/{id}/embeddings.usearch + .meta.json sidecar), the
-- downloaded embedding-model cache, the project registry, and
-- window/UI state.
-- OS-specific base (resolved via :filename.basedir, app name "bds"):
-- macOS: ~/Library/Application Support/bds (:user_config)
-- Linux: $XDG_CONFIG_HOME/bds (default ~/.config/bds)
-- Windows: %APPDATA%\\bds
-- See PrivateArtifactsLiveInOsAppDir.
cache_dir: private_dir + "/projects/" + id
-- Per-project subtree of private_dir for that project's regenerable
-- artifacts (embeddings index + sidecar). Overridable in tests via the
-- :project_cache_root setting; never the repo or the project folder.
}
surface ProjectSurface {
@@ -48,8 +75,8 @@ surface ProjectSurface {
project.posts.count
project.media.count
project.tags.count
project.internal_base_dir
project.effective_data_dir
project.public_dir
project.private_dir
}
invariant SingleActiveProject {
@@ -73,8 +100,52 @@ rule CreateProject {
data_path: data_path,
is_active: false
)
ensures: StarterTemplatesCopied(project)
-- Bundled starter templates are copied into the new project
@guidance
-- data_path is the chosen project folder. CreateProject writes
-- meta/project.json into {data_path}/meta/ but never records data_path
-- inside it (DataPathNotPersistedInProjectJson). The default project's
-- folder is created at a per-user default content location on first
-- launch — never inside the application repo or private_dir.
}
rule DiscoverProjectDataPath {
-- Opening or loading a project folder deduces its data_path from the
-- on-disk location of meta/project.json rather than from any stored value,
-- keeping projects movable (DataPathNotPersistedInProjectJson).
when: OpenProjectRequested(folder_path)
-- folder_path contains meta/project.json
let project = Projects where data_path = folder_path
ensures: project.data_path = folder_path
}
invariant ProjectTemplatesDirectoryReservedForUserTemplates {
-- The project templates directory stores only user-managed templates.
-- Creating a project does not populate public_dir/templates with bundled defaults.
}
invariant PublicContentLivesInProjectFolder {
-- Every file the user owns or that must be served by the webserver lives
-- under public_dir (= the project folder containing meta/project.json):
-- posts, media + thumbnails, templates, scripts, the meta/ JSON files,
-- tags.json, menu.opml, and the generated html/ output. None of this
-- content is ever written to private_dir or into the application repo.
}
invariant PrivateArtifactsLiveInOsAppDir {
-- App-internal, machine-specific, regenerable artifacts — the SQLite
-- database, the per-project embeddings index and its sidecar, the
-- model cache, the project registry, and window/UI state — live ONLY
-- under private_dir (the OS per-user app-data directory). They are never
-- written into a project folder (public_dir) or into the application repo.
-- A regenerable artifact (e.g. the embeddings index) may be rebuilt from
-- the database when absent.
}
invariant DataPathNotPersistedInProjectJson {
-- meta/project.json never stores its own path or data_path. A project's
-- location is determined solely by where its meta/project.json file sits,
-- so the folder can be moved or renamed without editing any file. The app
-- remembers the current location as a machine-local pointer in private_dir.
}
rule SetActiveProject {

View File

@@ -5,12 +5,19 @@
use "./metadata.allium" as meta
enum PublishJobStatus {
pending
running
completed
failed
}
entity PublishJob {
ssh_host: String
ssh_user: String
ssh_remote_path: String
ssh_mode: scp | rsync
status: pending | running | completed | failed
status: PublishJobStatus
transitions status {
pending -> running
@@ -76,6 +83,7 @@ rule UploadSite {
rule StartPublishJob {
when: PublishJobStarted(project, job, credentials)
requires: job.status = pending
ensures: job.status = running
ensures: UploadTargetStarted(job, html, "html/", credentials.ssh_remote_path, credentials)
ensures: UploadTargetStarted(job, thumbnails, "thumbnails/", credentials.ssh_remote_path + "/thumbnails", credentials)

219
specs/rendering.allium Normal file
View File

@@ -0,0 +1,219 @@
-- allium: 1
-- bDS Rendering Subsystem
-- Scope: core — template rendering shared by preview and generation
-- Distilled from: lib/bds/rendering/{filters,labels,links_and_languages,
-- metadata,post_rendering,list_archive}.ex
-- The rendering subsystem turns a post/list/not-found record plus project
-- metadata into the assigns consumed by a Liquid template. It is shared by the
-- preview server (on-demand) and static site generation (published .md files).
-- Rendering language is the CONTENT language (post/project), never the UI locale.
use "./template.allium" as template
use "./template_context.allium" as template_context
use "./i18n.allium" as i18n
use "./post.allium" as post
-- ─── Custom Liquid filters ───────────────────────────────────
-- The three custom filters available in the Liquid subset (see
-- template.allium LiquidFilterSubset). Applied during template rendering.
surface RenderFilterSurface {
facing _: TemplateRenderer
provides:
I18nFilterApplied(value, language)
MarkdownFilterApplied(value, post_id, language)
SlugifyFilterApplied(value)
}
rule I18nFilter {
when: I18nFilterApplied(value, language)
-- {{ "Key" | i18n }} -> localized "render" domain string for `language`.
-- Empty/blank input returns empty string.
ensures: result = lgettext(language, "render", trim(value))
}
rule SlugifyFilter {
when: SlugifyFilterApplied(value)
-- {{ title | slugify }} -> URL-friendly slug (same Slug.slugify as posts).
ensures: result = slugify(value)
}
rule MarkdownFilter {
when: MarkdownFilterApplied(value, post_id, language)
-- {{ body | markdown }} pipeline, in order:
-- 1. Expand built-in [[...]] macros (see ExpandBuiltinMacros)
-- 2. Convert markdown to HTML (Earmark; errors degrade to partial HTML)
-- 3. Rewrite href/src URLs to canonical post/media paths (see RewriteUrls)
ensures: result = rewritten_html
}
-- ─── Built-in macros ─────────────────────────────────────────
-- Macros use double-bracket syntax [[name param="value" ...]], expanded in
-- markdown before HTML conversion. NOT Liquid tags. Each renders a bundled
-- macro template (macros/{name}) in an isolated Liquid subscope.
value BuiltinMacro {
name: String -- youtube | vimeo | gallery | photo_archive | tag_cloud
params: Map<String, String>
}
surface BuiltinMacroSurface {
context macro: BuiltinMacro
exposes:
macro.name
macro.params
}
rule ExpandBuiltinMacros {
when: MacroEncountered(macro, language, post_id)
-- Project-scoped macros (photo_archive, tag_cloud) resolve the owning
-- project id from the render context (carried in the Liquid context's
-- private data), so they populate correctly even when content is
-- pre-rendered outside a full page context.
-- Unknown macro names are left verbatim in the source.
if macro.name = "youtube" or macro.name = "vimeo":
-- Embeds video by `id`; localized default title when title param absent.
ensures: MacroTemplateRendered(format("macros/{name}", name: macro.name))
if macro.name = "gallery":
-- Image gallery from the post's linked image media (PostMedia links,
-- restored from media sidecars on rebuild). Linking on import is
-- independent of AI enrichment. Ordered newest-first (created_at desc);
-- lightbox group is "gallery-{post_id}"; title is caption|title|name.
-- `columns` clamped to 1..6 (default 3). Empty when no post_id/images.
ensures: MacroTemplateRendered("macros/gallery")
if macro.name = "photo_archive":
-- Month-grouped image archive for the project (optional year/month filter).
ensures: MacroTemplateRendered("macros/photo-archive")
if macro.name = "tag_cloud":
-- Weighted tag cloud from PUBLISHED post tag counts; per-tag colours
-- from Tag rows. Words carry {text, size, count, url}; the JSON is
-- HTML-escaped into the data-tag-cloud-words attribute so the client
-- runtime can JSON.parse it. `orientation` aliases normalize to
-- horizontal | mixed-hv | mixed-diagonal; width (320..1600, def 900)
-- and height (180..900, def 420) clamp to defaults when out of range.
ensures: MacroTemplateRendered("macros/tag-cloud")
}
invariant MacroIsolation {
-- Macro templates render in an isolated Liquid subscope so macro-local
-- assigns never leak into the surrounding template context.
}
-- ─── URL rewriting ───────────────────────────────────────────
rule RewriteUrls {
when: RenderedHtmlProduced(html, canonical_post_paths, canonical_media_paths)
-- Rewrites href= and src= attribute values in rendered HTML.
-- External/special URLs (scheme:, //, #) are left untouched.
-- Internal post references (/post/{slug}, /posts/{slug}, dated paths) map to
-- the post's canonical dated path; query/fragment suffixes are preserved.
-- Internal /media/YYYY/MM/{file} references map to canonical media paths.
ensures: AttributesRewritten(html)
}
-- ─── Links and languages ─────────────────────────────────────
value LinkContext {
href: String
title: String
display_slug: String
language: String
}
rule ResolveLanguagePrefix {
when: LanguagePrefixRequested(language, main_language)
-- "" for the main language (and nil/blank); "/{language}" otherwise.
if language = main_language:
ensures: prefix = ""
else:
ensures: prefix = format("/{lang}", lang: language)
}
rule CollectLinkContexts {
when: LinkContextsRequested(project, post_id, direction)
-- direction = incoming (backlinks) | outgoing.
-- One LinkContext per linked post that still exists; missing targets dropped.
-- href = canonical post path, language normalized to main when unset.
ensures: List<LinkContext>
}
-- ─── Render labels ───────────────────────────────────────────
value RenderLabels {
-- Localized strings for rendered/preview output, resolved in the "render"
-- gettext domain for the CONTENT language (not the UI locale). Includes
-- taxonomy, backlinks, archive, pagination, calendar, search, not-found,
-- and macro fallback labels. Month names resolved 1..12 per language.
}
invariant LabelsUseContentLanguage {
-- RenderLabels and the i18n filter resolve against the content/render
-- language, consistent with i18n.allium's split-localization rule.
}
-- ─── Post render assigns ─────────────────────────────────────
-- The full assigns map for a single post template, assembled from the post
-- record (Post or PostTranslation) and project metadata.
value PostRenderAssigns {
language: String
language_prefix: String
page_title: String?
pico_stylesheet_href: String
blog_languages: List<i18n/RenderLanguage>
alternate_links: List<AlternateLink> -- hreflang alternates for translations
menu_items: List<template_context/MenuItem>
post_categories: List<String>
post_tags: List<String>
tag_color_by_name: Map<String, String?>
backlinks: List<LinkContext>
canonical_post_path_by_slug: Map<String, String>
canonical_media_path_by_source_path: Map<String, String>
post_data_json_by_id: String -- PostData JSON for client widgets
post: template_context/PostContext -- includes incoming/outgoing links
labels: RenderLabels
calendar_initial_year: Integer?
calendar_initial_month: Integer?
}
value AlternateLink {
language: String
href: String
}
rule BuildPostAssigns {
when: PostAssignsRequested(project, assigns)
-- Loads the post/translation record, renders its markdown body (macros +
-- HTML + URL rewrite), collects incoming/outgoing links, and resolves all
-- metadata-derived assigns (menu, languages, alternates, tag colours,
-- calendar bounds, labels) for the post's language.
ensures: PostRenderAssigns
}
rule BuildNotFoundAssigns {
when: NotFoundAssignsRequested(project, assigns)
-- Assigns for the 404 page: page_title defaults to "404", no alternates,
-- but shares language/menu/blog_languages/stylesheet/labels with posts.
-- Consumed by the not-found template (see generation.allium 404.html).
ensures: NotFoundRenderAssigns
}
rule BuildListAssigns {
when: ListAssignsRequested(project, assigns)
-- Assigns for list/archive pages (home, category, tag, date archives):
-- paginated post list plus shared metadata-derived assigns.
ensures: ListRenderAssigns
}
invariant SharedRenderPathForPreviewAndGeneration {
-- Preview and generation produce identical HTML for the same input because
-- both build assigns through this subsystem and render via the same Liquid
-- subset. They differ only in content SOURCE (see preview.allium
-- PreviewDraftOverlay and generation.allium GenerationPublishedOnly).
}

View File

@@ -1,11 +1,31 @@
-- allium: 1
-- bDS SQLite Database Schema
-- bDS Persistence Data Contract
-- Scope: core (Wave 1 — exact compatibility contract)
-- Distilled from: ../bDS/src/main/database/schema.ts
-- Distilled from: ../bDS2/src/main/database/schema.ts
--
-- This document specifies the exact SQLite schema that the Rust
-- implementation must be able to read and write. It is the ground truth
-- for database compatibility.
-- This document specifies the persisted data model the rewrite must be able
-- to read and write. It is the ground truth for storage compatibility.
enum PostStatus {
draft
published
archived
}
enum PostTranslationStatus {
draft
published
}
enum TemplateStatus {
draft
published
}
enum ScriptStatus {
draft
published
}
-- ============================================================================
-- CORE ENTITIES
@@ -29,7 +49,7 @@ entity Post {
slug: String -- URL-friendly identifier
excerpt: String? -- Optional summary
content: String? -- Draft body (null when published)
status: draft | published | archived
status: PostStatus
author: String? -- Author name
created_at: Timestamp
updated_at: Timestamp
@@ -58,7 +78,7 @@ entity PostTranslation {
title: String
excerpt: String?
content: String? -- Draft body (null when published)
status: draft | published
status: PostTranslationStatus
created_at: Timestamp
updated_at: Timestamp
published_at: Timestamp?
@@ -119,7 +139,7 @@ entity Template {
enabled: Boolean
version: Integer -- Incremented on each update
file_path: String -- templates/{slug}.liquid
status: draft | published
status: TemplateStatus
content: String? -- Draft body (null when published)
created_at: Timestamp
updated_at: Timestamp
@@ -131,11 +151,11 @@ entity Script {
slug: String -- URL-safe identifier
title: String
kind: macro | utility | transform
entrypoint: String -- Default: "render" for macros
entrypoint: String -- Default: "render" for macros, "main" otherwise
enabled: Boolean
version: Integer -- Incremented on each update
file_path: String -- scripts/{slug}.lua
status: draft | published
file_path: String -- scripts/{slug}.{extension}
status: ScriptStatus
content: String? -- Draft body (null when published)
created_at: Timestamp
updated_at: Timestamp
@@ -184,8 +204,7 @@ entity GeneratedFileHash {
-- ============================================================================
entity PostSearchIndex {
-- SQLite FTS5 virtual table, not a real entity
-- Created via: CREATE VIRTUAL TABLE posts_fts USING fts5(...)
-- Full-text search index projection, not a user-authored entity
-- Indexed fields: title, excerpt, content, tags, categories
-- Plus all translation titles, excerpts, and content
post: Post
@@ -193,8 +212,7 @@ entity PostSearchIndex {
}
entity MediaSearchIndex {
-- SQLite FTS5 virtual table
-- Created via: CREATE VIRTUAL TABLE media_fts USING fts5(...)
-- Full-text search index projection
-- Indexed fields: title, alt, caption, original_name, tags
-- Plus all translation titles, alts, and captions
media: Media
@@ -221,16 +239,18 @@ entity ChatMessage {
content: String?
tool_call_id: String? -- For tool responses
tool_calls: String? -- JSON array of tool calls
cache_read_tokens: Integer?
cache_write_tokens: Integer?
created_at: Timestamp
}
entity AiProvider {
-- Provider catalog, populated from upstream model registry.
-- Read-only in Rust core; TypeScript app manages inserts.
-- Managed by the application and treated as read-only during normal use.
id: String -- PRIMARY KEY
name: String
env: String? -- Environment variable for API key
npm: String? -- NPM package reference (legacy)
package_ref: String? -- Legacy package reference
api: String? -- Base API URL
doc: String? -- Documentation URL
updated_at: Timestamp
@@ -261,7 +281,6 @@ entity AiModel {
max_output_tokens: Integer
interleaved: String? -- interleaved capability descriptor
status: String? -- active | deprecated | preview
provider_npm: String? -- provider-specific NPM reference (legacy)
updated_at: Timestamp
}
@@ -270,7 +289,7 @@ entity AiModelModality {
provider: AiProvider
model_id: String
direction: String -- "input" | "output"
modality: String -- "text" | "image" | "audio" | "video"
modality: String -- "text" | "image" | "audio" | "file" | "tool"
}
entity AiCatalogMeta {
@@ -287,7 +306,7 @@ entity EmbeddingKey {
post_id: String
project_id: String
content_hash: String -- SHA-256 of title+content
vector: String -- Base64-encoded Float32Array bytes (1536 bytes for 384-dim)
vector: String -- Encoded vector payload (1536 bytes for 384-dim)
}
entity DismissedDuplicatePair {
@@ -327,6 +346,305 @@ entity DbNotification {
created_at: Timestamp
}
surface ProjectRecordSurface {
context project: Project
exposes:
project.id
project.name
project.slug
project.description when project.description != null
project.data_path when project.data_path != null
project.created_at
project.updated_at
project.is_active
}
surface PostTranslationRecordSurface {
context translation: PostTranslation
exposes:
translation.id
translation.project_id
translation.translation_for
translation.language
translation.title
translation.excerpt when translation.excerpt != null
translation.content when translation.content != null
translation.status
translation.created_at
translation.updated_at
translation.published_at when translation.published_at != null
translation.file_path
translation.checksum when translation.checksum != null
}
surface MediaTranslationRecordSurface {
context translation: MediaTranslation
exposes:
translation.id
translation.project_id
translation.translation_for
translation.language
translation.title when translation.title != null
translation.alt when translation.alt != null
translation.caption when translation.caption != null
translation.created_at
translation.updated_at
}
surface TagRecordSurface {
context tag: Tag
exposes:
tag.id
tag.project_id
tag.name
tag.color when tag.color != null
tag.post_template_slug when tag.post_template_slug != null
tag.created_at
tag.updated_at
}
surface TemplateRecordSurface {
context template: Template
exposes:
template.id
template.project_id
template.slug
template.title
template.kind
template.enabled
template.version
template.file_path
template.status
template.content when template.content != null
template.created_at
template.updated_at
}
surface ScriptRecordSurface {
context script: Script
exposes:
script.id
script.project_id
script.slug
script.title
script.kind
script.entrypoint
script.enabled
script.version
script.file_path
script.status
script.content when script.content != null
script.created_at
script.updated_at
}
surface PostLinkRecordSurface {
context link: PostLink
exposes:
link.id
link.source_post_id
link.target_post_id
link.link_text when link.link_text != null
link.created_at
}
surface PostMediaLinkRecordSurface {
context link: PostMediaLink
exposes:
link.id
link.project_id
link.post_id
link.media_id
link.sort_order
link.created_at
}
surface SettingRecordSurface {
context setting: Setting
exposes:
setting.key
setting.value
setting.updated_at
}
surface GeneratedFileHashRecordSurface {
context record: GeneratedFileHash
exposes:
record.project_id
record.relative_path
record.content_hash
record.updated_at
}
surface PostSearchIndexRecordSurface {
context record: PostSearchIndex
exposes:
record.post
record.stemmed_content
}
surface MediaSearchIndexRecordSurface {
context record: MediaSearchIndex
exposes:
record.media
record.stemmed_content
}
surface ChatConversationRecordSurface {
context conversation: ChatConversation
exposes:
conversation.id
conversation.title
conversation.model when conversation.model != null
conversation.copilot_session_id when conversation.copilot_session_id != null
conversation.created_at
conversation.updated_at
}
surface ChatMessageRecordSurface {
context message: ChatMessage
exposes:
message.id
message.conversation_id
message.role
message.content when message.content != null
message.tool_call_id when message.tool_call_id != null
message.tool_calls when message.tool_calls != null
message.cache_read_tokens when message.cache_read_tokens != null
message.cache_write_tokens when message.cache_write_tokens != null
message.created_at
}
surface AiModelRecordSurface {
context model: AiModel
exposes:
model.provider
model.model_id
model.name
model.family when model.family != null
model.attachment
model.reasoning
model.tool_call
model.structured_output
model.temperature
model.knowledge when model.knowledge != null
model.release_date when model.release_date != null
model.last_updated_date when model.last_updated_date != null
model.open_weights
model.input_price when model.input_price != null
model.output_price when model.output_price != null
model.cache_read_price when model.cache_read_price != null
model.cache_write_price when model.cache_write_price != null
model.context_window
model.max_input_tokens
model.max_output_tokens
model.interleaved when model.interleaved != null
model.status when model.status != null
model.updated_at
}
surface AiModelModalityRecordSurface {
context modality: AiModelModality
exposes:
modality.provider
modality.model_id
modality.direction
modality.modality
}
surface AiCatalogMetaRecordSurface {
context meta: AiCatalogMeta
exposes:
meta.key
meta.value
}
surface EmbeddingKeyRecordSurface {
context key: EmbeddingKey
exposes:
key.label
key.post_id
key.project_id
key.content_hash
key.vector
}
surface DismissedDuplicatePairRecordSurface {
context pair: DismissedDuplicatePair
exposes:
pair.id
pair.project_id
pair.post_id_a
pair.post_id_b
pair.dismissed_at
}
surface ImportDefinitionRecordSurface {
context definition: ImportDefinition
exposes:
definition.id
definition.project_id
definition.name
definition.wxr_file_path when definition.wxr_file_path != null
definition.uploads_folder_path when definition.uploads_folder_path != null
definition.last_analysis_result when definition.last_analysis_result != null
definition.created_at
definition.updated_at
}
surface DbNotificationRecordSurface {
context notification: DbNotification
exposes:
notification.id
notification.entity_type
notification.entity_id
notification.action
notification.from_cli
notification.seen_at when notification.seen_at != null
notification.created_at
}
surface Fts5PostSchemaSurface {
context schema: Fts5PostSchema
exposes:
schema.fields
schema.stemmer_languages
}
surface Fts5MediaSchemaSurface {
context schema: Fts5MediaSchema
exposes:
schema.fields
schema.stemmer_languages
}
surface MigrationVersionSurface {
context _: MigrationVersion
}
-- ============================================================================
-- SCHEMA CONSTRAINTS AND INDEXES
-- ============================================================================

View File

@@ -1,16 +1,34 @@
-- allium: 1
-- bDS Scripting System
-- Scope: core (Wave 6 — Lua runtime and scripting docs)
-- Scope: core (Wave 6 — scripting behaviour and file contracts)
-- Distilled from: src/main/engine/ScriptEngine.ts, schema.ts
-- Scripting runtime is Lua (mlua crate). Behavioural contract preserved.
-- Lua is the normative scripting language for user-authored scripts in the
-- rewrite. The concrete embedding strategy remains an implementation choice;
-- only the behavioural contract is normative here.
config {
script_extension: String = "lua"
macro_timeout: Duration = 10.seconds
transform_max_toasts_per_script: Integer = 5
transform_max_toasts_total: Integer = 20
transform_max_toast_length: Integer = 300
blogmark_max_title_length: Integer = 200
blogmark_max_url_length: Integer = 2048
}
enum ScriptStatus {
draft
published
}
entity Script {
project_id: String
slug: String
title: String
kind: macro | utility | transform
entrypoint: String -- default: "render" for macros
entrypoint: String -- named Lua function used as the script entrypoint
enabled: Boolean
status: draft | published
status: ScriptStatus
content: String?
version: Integer
file_path: String
@@ -48,8 +66,9 @@ surface ScriptManagementSurface {
facing _: ScriptOperator
provides:
CreateScriptRequested(title, kind, content, entrypoint)
CreateAndPublishScriptRequested(title, kind, content, entrypoint)
CreateScriptRequested(project, title, kind, content, entrypoint)
CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
UpdateScriptRequested(script, changes)
PublishScriptRequested(script)
DeleteScriptRequested(script)
RunUtilityRequested(script)
@@ -58,60 +77,124 @@ surface ScriptManagementSurface {
RebuildScriptsFromFilesRequested(project)
}
surface ScriptRuntimeSurface {
facing _: ScriptRuntime
provides:
ValidateScript(source)
ExecuteScriptRequested(script, entrypoint, args, progress_sink)
@guarantee SandboxedExecution
-- User-authored Lua executes from a sandboxed runtime state.
-- Filesystem mutation, process control, package loading, and other
-- unrestricted host capabilities are unavailable unless explicitly
-- re-exposed by the host application.
@guarantee ExplicitHostCapabilities
-- Host-provided functions are exposed only through an explicit bds.*
-- capability table, never through ambient global access.
@guarantee MacroTimeout
-- Macro execution has a short timeout budget of config.macro_timeout.
@guarantee ManagedBatchExecution
-- Utility and transform scripts execute as managed jobs.
-- The contract does not define a fixed wall-clock limit for those
-- jobs because batch work can legitimately scale with project size.
-- Progress reporting, operator cancellation, and host orchestration
-- govern their lifecycle instead of a fixed timeout.
@guarantee ProgressFeedback
-- Long-running utility and transform scripts may emit progress updates
-- through explicit host APIs during execution.
-- Progress reporting is cooperative and flows through the supplied
-- progress sink rather than ambient global side effects.
@guarantee ScriptOutputRouting
-- print(…) calls and the explicit bds.app.log(…) API append lines to a
-- host-routed script output stream: the desktop app's Output panel,
-- stdout in the CLI, and the host logger when no sink is attached —
-- never the ambient console.
@guarantee BatchCancellation
-- Managed utility and transform jobs can be cancelled by the host
-- operator boundary.
}
invariant UniqueScriptSlug {
-- Slug uniqueness is scoped per project, not globally.
for a in Scripts:
for b in Scripts:
a != b implies a.slug != b.slug
a != b and a.project_id = b.project_id implies a.slug != b.slug
}
invariant ScriptFileLayout {
for s in Scripts where file_path != "":
s.file_path = format("scripts/{slug}.lua", slug: s.slug)
s.file_path = format("scripts/{slug}.{extension}", slug: s.slug, extension: config.script_extension)
}
-- Script files use .lua with standard --- YAML frontmatter
-- Script files use standard --- YAML frontmatter
rule CreateScript {
when: CreateScriptRequested(title, kind, content, entrypoint)
when: CreateScriptRequested(project, title, kind, content, entrypoint)
let slug = slugify(title)
-- Creates a draft script: content stored in DB, no file written yet
ensures: Script.created(
slug: slug,
title: title,
kind: kind,
content: content,
entrypoint: entrypoint ?? "render",
status: draft,
enabled: true,
version: 1,
file_path: ""
)
ensures:
let new_script = Script.created(
project_id: project.id,
slug: slug,
title: title,
kind: kind,
content: content,
entrypoint: entrypoint ?? if kind = macro: "render" else: "main",
status: draft,
enabled: true,
version: 1,
file_path: ""
)
new_script.status = draft
}
rule UpdateScript {
when: UpdateScriptRequested(script, changes)
ensures: ScriptFieldsUpdated(script, changes)
ensures: script.updated_at = now
ensures: script.version = script.version + 1
}
rule ReopenPublishedScript {
when: UpdateScriptRequested(script, changes)
requires: script.status = published
requires: script_changes_affect_published_output(changes)
ensures: script.status = draft
}
rule CreateAndPublishScript {
-- Alternative creation path: create + immediately publish (file written)
-- TypeScript: createScript() does this in one step
when: CreateAndPublishScriptRequested(title, kind, content, entrypoint)
-- Some implementations may expose this as a single user action
when: CreateAndPublishScriptRequested(project, title, kind, content, entrypoint)
let slug = slugify(title)
requires: ValidateScript(content) = valid
ensures:
let new_script = Script.created(
project_id: project.id,
slug: slug,
title: title,
kind: kind,
content: null,
entrypoint: entrypoint ?? "render",
entrypoint: entrypoint ?? if kind = macro: "render" else: "main",
status: published,
enabled: true,
version: 1,
file_path: format("scripts/{slug}.lua", slug: slug)
file_path: format("scripts/{slug}.{extension}", slug: slug, extension: config.script_extension)
)
ScriptFileWritten(new_script)
}
rule PublishScript {
when: PublishScriptRequested(script)
requires: script.status = draft
requires: ValidateScript(script.content) = valid
-- AST parsing must succeed
-- Lua parsing must succeed before a script can be published
ensures: script.status = published
ensures: ScriptFileWritten(script)
ensures: script.content = null
@@ -129,9 +212,17 @@ rule ExecuteMacro {
when: MacroExpansionRequested(script, template_context)
requires: script.kind = macro
requires: script.enabled = true
requires: script.entrypoint != ""
-- 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
-- Unknown macro names are resolved against enabled macro scripts by slug.
-- They receive named parameters plus template_context.env fields that
-- include isPreview, mainLanguage, languagePrefix, hook, source.kind,
-- and translations.
-- They return HTML and run sequentially with config.macro_timeout per
-- invocation.
-- Macro failures degrade to empty output for that invocation and do not
-- abort rendering of the surrounding page.
ensures: MacroOutputProduced(script, html_output)
}
@@ -139,7 +230,11 @@ rule ExecuteUtility {
when: RunUtilityRequested(script)
requires: script.kind = utility
requires: script.enabled = true
-- Runs on-demand from the UI, produces stdout output
requires: script.entrypoint != ""
-- Utility scripts commonly perform long-running data manipulation work.
-- They are manually started by an operator action, run as managed jobs,
-- may issue host-backed API calls, may emit progress during execution,
-- and may be cancelled by the operator.
ensures: UtilityOutputProduced(script, stdout)
}
@@ -147,19 +242,76 @@ 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):
-- Each transform can modify the data before post creation.
-- Execution uses the same managed job host API contract as other batch
-- scripts and may report progress while mass-processing remote or local
-- content.
let transforms = Scripts where project_id = data.project_id and kind = transform and enabled = true
for t in ordered_by(transforms, s => s.updated_at, s => s.slug, s => s.id):
requires: t.entrypoint != ""
ensures: TransformApplied(t, data)
@guarantee BlogmarkInputHardening
-- The deep link is sanitized before transforms run. Title and url have
-- control characters stripped and are trimmed. The url is kept only
-- when it is http(s) with a host, after removing any credentials and
-- fragment, and at most config.blogmark_max_url_length characters;
-- otherwise it is dropped so it can never reach the post body. The
-- title is capped at config.blogmark_max_title_length and falls back to
-- the url host when blank.
@guarantee BlogmarkDefaultBody
-- When the deep link supplies no content, the post body defaults to a
-- markdown link [title](url) to the bookmarked page, with the title
-- escaped, so transforms operate on the same candidate whether or not
-- an explicit body was provided. Explicit content always wins.
@guarantee BlogmarkProjectTargeting
-- A link's own project_id determines the target project. When it names a
-- project that exists and differs from the active one, the shell
-- switches to it before importing so the new post is shown in context.
-- When it names a project that does not exist in this install (e.g. a
-- bookmarklet copied from another instance) the import is refused with
-- an error — the post is never redirected into a different project.
-- With no link project_id, the active project is used; with no link
-- project_id and no active project the import is refused with a warning.
@guarantee BlogmarkColdStartDelivery
-- A deep link that arrives before the shell is ready (e.g. the
-- bookmarklet launching the app) is queued and replayed only after the
-- client has finished initialising and rehydrating its stored
-- workbench session, so the post is created and the editor it opens is
-- not overwritten by the session restore. A session restore arriving
-- after a deep link (e.g. when the link switched projects) preserves
-- the newly opened editor tab as the active tab.
@guarantee TransformTrigger
-- Transform scripts are triggered automatically by blogmark import.
-- Each script receives the current post candidate plus a context with
-- source='blogmark' and the originating URL.
@guarantee TransformPipelineContinuation
-- Transform errors are captured per script and do not roll back the
-- last valid post state produced by earlier transforms.
-- The pipeline continues with subsequent enabled transforms.
@guarantee TransformToastBudget
-- Transform scripts may emit toast feedback.
-- At most config.transform_max_toasts_per_script toasts are accepted
-- from any one transform, with a total budget of
-- config.transform_max_toasts_total across the pipeline.
-- Individual toast messages are truncated to
-- config.transform_max_toast_length characters.
@guidance
-- bds://new-post deep links from browser bookmarks
-- Max 5 toast notifications per script, 20 total
-- bds2://new-post deep links from browser bookmarks
-- (bds2:// scheme avoids clashing with the legacy app's bds:// scheme)
-- Ordering is deterministic: updated_at, then slug, then id
}
rule RebuildScriptsFromFiles {
when: RebuildScriptsFromFilesRequested(project)
for file in scan_directory(project.effective_data_dir + "/scripts", "*.lua"):
for file in scan_directory(project.public_dir + "/scripts", "*." + config.script_extension):
let parsed = parse_script_file(file)
ensures: Script.created(parsed)
}

View File

@@ -1,6 +1,6 @@
-- allium: 1
-- bDS Full-Text Search
-- Scope: core (Wave 1 — FTS5 in-app search with Snowball stemmers)
-- Scope: core (Wave 1 — in-app full-text search with Snowball stemmers)
-- Distilled from: src/main/engine/PostEngine.ts (FTS methods),
-- MediaEngine.ts (FTS methods), stemmer.ts
@@ -12,7 +12,7 @@ surface SearchControlSurface {
provides:
SearchPostsRequested(query, filters)
SearchMediaRequested(query)
SearchMediaRequested(query, filters)
}
surface SearchIndexRuntimeSurface {
@@ -24,8 +24,9 @@ surface SearchIndexRuntimeSurface {
}
value StemmerLanguage {
-- Snowball stemmers for 24 languages
-- ISO 639-1 to Snowball mapping
-- Snowball stemmers via library (Stemex)
-- Languages with a Snowball algorithm get real stemming;
-- others pass through unstemmed
-- Applied to both indexing and query processing
code: String
}
@@ -38,19 +39,29 @@ surface StemmerLanguageSurface {
}
entity PostSearchIndex {
-- SQLite FTS5 virtual table
-- Indexed fields: title, excerpt, content, tags, categories
-- Plus all translation titles, excerpts, and content
-- FTS5 virtual table with per-field stemmed columns
-- Each field is stemmed independently; translations are
-- stemmed with their own language stemmer and appended
-- to the corresponding field
post: post/Post
stemmed_content: String
title: String
excerpt: String
content: String
tags: String
categories: String
}
entity MediaSearchIndex {
-- SQLite FTS5 virtual table
-- Indexed fields: title, alt, caption, original_name, tags
-- Plus all translation titles, alts, and captions
-- FTS5 virtual table with per-field stemmed columns
-- Each field is stemmed independently; translations are
-- stemmed with their own language stemmer and appended
-- to the corresponding field
media: media/Media
stemmed_content: String
title: String
alt: String
caption: String
original_name: String
tags: String
}
invariant CrossLanguageStemming {
@@ -77,42 +88,80 @@ rule SearchPosts {
}
rule SearchMedia {
when: SearchMediaRequested(query)
when: SearchMediaRequested(query, filters)
-- Full-text search with optional filters:
-- language, tags, year, month, date range (from/to)
-- Returns paginated results with total count
let stemmed_query = stem(query, detect_language(query))
let matched = search_fts(MediaSearchIndex, stemmed_query)
let matched = search_fts(MediaSearchIndex, stemmed_query, filters)
ensures: SearchResults(
media: matched
media: matched,
total: matched.count,
offset: filters.offset,
limit: filters.limit
)
}
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))
-- Delete-and-reinsert: no in-place update for FTS5 rows
-- Each field is stemmed per-language; translations are stemmed
-- with their own language stemmer and joined into the same field
let lang = post.language
let translations = post.translations
let title = join_stemmed(
stem(post.title, lang),
for t in translations: stem(t.title, t.language)
)
let excerpt = join_stemmed(
stem(post.excerpt, lang),
for t in translations: stem(t.excerpt, t.language)
)
let content = join_stemmed(
stem(post.content, lang),
for t in translations: stem(t.content, t.language)
)
let tags = stem(join(post.tags, " "), lang)
let categories = stem(join(post.categories, " "), lang)
ensures: not exists PostSearchIndex{post: post}
ensures: PostSearchIndex.created(
post: post,
title: title,
excerpt: excerpt,
content: content,
tags: tags,
categories: categories
)
}
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))
-- Delete-and-reinsert: no in-place update for FTS5 rows
-- Each field is stemmed per-language; translations are stemmed
-- with their own language stemmer and joined into the same field
let lang = media.language
let translations = media.translations
let title = join_stemmed(
stem(media.title, lang),
for t in translations: stem(t.title, t.language)
)
let alt = join_stemmed(
stem(media.alt, lang),
for t in translations: stem(t.alt, t.language)
)
let caption = join_stemmed(
stem(media.caption, lang),
for t in translations: stem(t.caption, t.language)
)
let original_name = stem(media.original_name, lang)
let tags = stem(join(media.tags, " "), lang)
ensures: not exists MediaSearchIndex{media: media}
ensures: MediaSearchIndex.created(
media: media,
title: title,
alt: alt,
caption: caption,
original_name: original_name,
tags: tags
)
}

90
specs/server.allium Normal file
View File

@@ -0,0 +1,90 @@
-- allium: 1
-- Headless Server Mode (issue #26, phase 1)
-- Scope: extension (client/server split — SSH transport for TUI and GUI)
-- Distilled from: lib/bds/server.ex, lib/bds/application.ex, lib/bds/tui.ex
entity BootMode {
kind: desktop | server | tui
-- Resolved from the BDS_MODE environment variable at application start.
-- "server" is headless; "tui" is the headless server plus a TUI on the
-- launching terminal; anything else is desktop.
}
entity SshKeyMaterial {
dir: String -- <data dir>/ssh, next to the database
host_key: String -- ssh_host_rsa_key, generated on first boot
authorized_keys: String -- created empty (mode 600) on first boot
-- Invariant: key material lives in the private app data dir,
-- never in the repo or an application priv dir.
}
surface ServerRuntimeSurface {
facing _: ServerRuntime
provides:
ApplicationStarted(mode)
SshClientConnected()
GuiConnectRequested()
}
rule ModeResolution {
when: ApplicationStarted(mode)
-- BDS_MODE environment variable decides the mode once per boot.
-- Fallback (issue #33): resolved desktop mode boots as tui when no
-- graphical session is available, so the app always opens a UI
-- rather than crashing wx; a log line records the switch. Headless
-- means: non-Darwin unix without DISPLAY/WAYLAND_DISPLAY (plain ssh
-- included; ssh -X with a forwarded display keeps the GUI), or macOS
-- inside an ssh session (SSH_CONNECTION/SSH_CLIENT/SSH_TTY — macOS
-- never sets DISPLAY). Explicit server/tui modes and Windows are
-- never rewritten. Because the :desktop dependency boots wx in its
-- own application start, config/runtime.exs sets NO_WX=1 for every
-- non-desktop effective mode (BDS.Server.prepare_boot_env) so the
-- dependency starts inert before any BDS supervisor runs. In tui
-- mode prepare_boot_env also silences every other terminal writer —
-- the default logger handler moves to a rotating file in the private
-- app dir and Bumblebee's model-download progress bar is disabled —
-- because the TUI owns the terminal and each stray console line
-- scrolls the alternate screen up one row. Desktop/server modes and
-- SSH-served TUI sessions keep normal console logging.
ensures: BootMode.created(kind: mode)
}
rule DesktopBoot {
when: ApplicationStarted(mode: desktop)
-- Unchanged behavior: wx window shell, loopback HTTP endpoint.
ensures: DesktopWindowStarted()
}
rule ServerBoot {
when: ApplicationStarted(mode: server)
-- Headless: no wx children at all. Works on macOS, Windows, Linux.
ensures: LoopbackEndpointStarted()
-- HTTP endpoint stays bound to 127.0.0.1; never exposed directly
ensures: CliSyncWatcherStarted()
ensures: SshDaemonStarted()
-- ExRatatui.SSH.Daemon: public-key auth only (no passwords),
-- tcpip_tunnel_out enabled so GUI clients tunnel to the loopback
-- endpoint through the same SSH connection and the same keys
ensures: SshKeyMaterial.created()
-- host key generated and authorized_keys touched on first boot only
}
rule GuiRemoteConnection {
when: GuiConnectRequested()
-- Desktop "Connect to Server…": ssh connect with the same public keys
-- (client identity + known_hosts in the private ssh dir), then an OTP
-- TCP/IP tunnel to the server's loopback endpoint; the webview loads
-- the local tunnel end. Disconnect returns to the local shell URL.
ensures: WebviewTunneledToServer()
}
rule TuiSession {
when: SshClientConnected()
-- Each SSH client gets its own TUI app instance on the server;
-- only terminal cells cross the wire.
ensures: TuiAppMounted()
-- UI locale comes from the server-side UI language setting,
-- identical for every client
}

View File

@@ -338,7 +338,7 @@ surface ScriptListItemEntry {
@guarantee CreateDefaults
-- New scripts default to: kind=utility, content='print("new script")',
-- entrypoint='render', enabled=true.
-- entrypoint='main', enabled=true.
@guarantee LiveRefresh
-- Item list refreshes on scripts-changed event.
@@ -652,6 +652,9 @@ rule ImportListClick {
-- Full git interface, not the SidebarEntityList pattern.
-- Three possible states: loading, not_a_repo, active_repo.
-- Backend: BDS.Git provides status, diff, commit_all, history,
-- fetch, pull, push, prune_lfs_cache, remote_state, initialize_repo.
-- The sidebar must surface these capabilities directly.
-- State: not_a_repo
-- Remote URL text input + "Initialize Git" button.

View File

@@ -17,6 +17,7 @@ entity Task {
running -> completed
running -> failed
running -> cancelled
pending -> cancelled
}
}
@@ -36,6 +37,7 @@ surface TaskRuntimeSurface {
TaskWorkCompleted(task)
TaskWorkFailed(task, error_message)
ProgressReported(task, value, message)
FinishedTaskEvictionDue()
}
surface TaskSurface {
@@ -54,6 +56,8 @@ surface TaskSurface {
config {
max_concurrent: Integer = 3
progress_throttle: Duration = 250.milliseconds
finished_task_ttl: Duration = 1.hour
recent_finished_limit: Integer = 10
}
invariant MaxConcurrency {
@@ -95,7 +99,7 @@ rule FailTask {
rule CancelTask {
when: CancelTaskRequested(task)
requires: task.status = running or task.status = pending
-- AbortController-based cancellation
-- Cancellation uses a runtime-specific cancellation mechanism
ensures: task.status = cancelled
ensures: NextQueuedTaskStarted()
}
@@ -112,6 +116,23 @@ invariant ProgressThrottled {
-- At most one progress event per 250ms per task
}
invariant FinishedTaskRetention {
-- The status snapshot surfaces only the most recent finished tasks:
-- completed/failed/cancelled tasks beyond config.recent_finished_limit
-- (newest first) are not shown.
let finished = Tasks where status in {completed, failed, cancelled}
-- At most config.recent_finished_limit finished tasks are reported.
}
rule EvictFinishedTasks {
when: FinishedTaskEvictionDue()
-- Periodic sweep (every config.finished_task_ttl). A finished task whose
-- finished_at is older than config.finished_task_ttl is dropped from state.
for task in Tasks where status in {completed, failed, cancelled}:
if now - task.finished_at >= config.finished_task_ttl:
ensures: not exists task
}
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
rule RegisterExternalTask {
when: RegisterExternalTaskRequested(name)

View File

@@ -4,12 +4,18 @@
-- Distilled from: src/main/engine/TemplateEngine.ts, PageRenderer.ts, schema.ts,
-- bundled starter templates in src/main/engine/templates/
enum TemplateStatus {
draft
published
}
entity Template {
project_id: String
slug: String
title: String
kind: post | list | not_found | partial
enabled: Boolean
status: draft | published
status: TemplateStatus
content: String?
version: Integer
file_path: String
@@ -18,8 +24,8 @@ entity Template {
-- 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
referencing_posts: Posts where template_slug = this.slug and project_id = this.project_id
referencing_tags: Tags where post_template_slug = this.slug and project_id = this.project_id
transitions status {
draft -> published
@@ -28,11 +34,11 @@ entity Template {
}
surface TemplateManagementSurface {
facing operator: TemplateOperator
facing _: TemplateOperator
provides:
CreateTemplateRequested(title, kind, content)
CreateAndPublishTemplateRequested(title, kind, content)
CreateTemplateRequested(project, title, kind, content)
CreateAndPublishTemplateRequested(project, title, kind, content)
UpdateTemplateRequested(template, changes)
PublishTemplateRequested(template)
DeleteTemplateRequested(template)
@@ -40,9 +46,10 @@ surface TemplateManagementSurface {
}
invariant UniqueTemplateSlug {
-- Slug uniqueness is scoped per project, not globally.
for a in Templates:
for b in Templates:
a != b implies a.slug != b.slug
a != b and a.project_id = b.project_id implies a.slug != b.slug
}
invariant TemplateFrontmatter {
@@ -57,30 +64,56 @@ invariant TemplateFileLayout {
t.file_path = format("templates/{slug}.liquid", slug: t.slug)
}
invariant BundledDefaultTemplatesExistOutsideProjectData {
-- The application ships bundled default templates for:
-- single-post
-- post-list
-- not-found
-- plus supporting partials and macros.
-- These bundled templates are available for rendering even when the project
-- has no template files and no Template rows for those defaults.
}
invariant UserTemplateDirectoryOverridesBundledDefaults {
-- When a project template file or published Template row resolves the same slug
-- as a bundled template or partial, the project-owned template wins.
-- Bundled templates are fallback roots, not copied seed data.
}
invariant RebuildTemplatesIndexesOnlyProjectTemplates {
-- Rebuild-from-files scans only project.public_dir/templates.
-- Bundled defaults are render-time fallbacks and are not indexed into Templates
-- unless the user has created matching project files.
}
rule CreateTemplate {
when: CreateTemplateRequested(title, kind, content)
when: CreateTemplateRequested(project, title, kind, content)
let slug = slugify(title)
-- Creates a draft template: content stored in DB, no file written yet
ensures: Template.created(
slug: slug,
title: title,
kind: kind,
content: content,
status: draft,
enabled: true,
version: 1,
file_path: ""
)
ensures:
let new_template = Template.created(
project_id: project.id,
slug: slug,
title: title,
kind: kind,
content: content,
status: draft,
enabled: true,
version: 1,
file_path: ""
)
new_template.status = draft
}
rule CreateAndPublishTemplate {
-- Alternative creation path: create + immediately publish (file written)
-- TypeScript: createTemplate() does this in one step
when: CreateAndPublishTemplateRequested(title, kind, content)
-- Some implementations may expose this as a single user action
when: CreateAndPublishTemplateRequested(project, title, kind, content)
let slug = slugify(title)
requires: ValidateLiquid(content) = valid
ensures:
let new_template = Template.created(
project_id: project.id,
slug: slug,
title: title,
kind: kind,
@@ -100,10 +133,18 @@ rule UpdateTemplate {
ensures: template.version = template.version + 1
}
rule ReopenPublishedTemplate {
when: UpdateTemplateRequested(template, changes)
requires: template.status = published
requires: template_changes_affect_rendered_output(changes)
ensures: template.status = draft
}
rule PublishTemplate {
when: PublishTemplateRequested(template)
requires: template.status = draft
requires: ValidateLiquid(template.content) = valid
-- LiquidJS parser must accept the template
-- The template parser must accept the template
ensures: template.status = published
ensures: TemplateFileWritten(template)
-- Writes frontmatter + liquid to templates/{slug}.liquid
@@ -130,10 +171,16 @@ rule CascadeSlugUpdate {
rule RebuildTemplatesFromFiles {
when: RebuildTemplatesFromFilesRequested(project)
for file in scan_directory(project.effective_data_dir + "/templates", "*.liquid"):
for file in scan_directory(project.public_dir + "/templates", "*.liquid"):
let parsed = parse_template_file(file)
ensures: Template.created(parsed)
-- or updated if slug already exists
-- Prune stale published templates: a published Template whose file_path is
-- neither among the scanned files nor present on disk is deleted, clearing
-- its references first (posts' template_slug, tags' post_template_slug).
for template in project.templates where status = published and file_path != "":
if template.file_path not in scanned_files and not file_exists(template.file_path):
ensures: not exists template
}
-- Exact Liquid subset required (distilled from bundled starter templates)
@@ -158,11 +205,16 @@ invariant LiquidFilterSubset {
-- | default: fallback_value
-- | append: suffix_string
--
-- Custom filters (2):
-- Custom filters (3):
-- | 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)
-- | slugify — slugifies a string (used for tag/category URLs)
--
-- Enforced at publish/validation time (LiquidParser.validate); any other
-- filter is rejected even though Liquex would otherwise apply it as a
-- built-in standard filter.
--
-- NOT used: date, strip_html, truncate, split, join, size (as filter),
-- upcase, downcase, replace, remove, sort, map, where, first, last,
@@ -177,6 +229,10 @@ invariant LiquidOperatorSubset {
-- Special values: blank (nil/empty comparison)
-- Property access: dot notation (object.property), .size on arrays,
-- bracket notation for map lookups (map[key])
--
-- Enforced at publish/validation time (LiquidParser.validate); any other
-- comparison operator (!=, <, >=, <=, contains) is rejected even though
-- Liquex would otherwise evaluate it.
}
invariant LiquidRenderContext {

View File

@@ -1,7 +1,7 @@
-- allium: 1
-- bDS Liquid Template Context Specification
-- Scope: core (Wave 4 — rendering parity)
-- Distilled from: ../bDS/src/main/engine/GenerationRouteRendererFactory.ts,
-- Distilled from: ../bDS2/src/main/engine/GenerationRouteRendererFactory.ts,
-- PageRenderer.ts, BlogGenerationEngine.ts
--
-- This document specifies the exact data structure passed to Liquid templates
@@ -62,7 +62,7 @@ value RenderContext {
language: String -- Current language code
language_prefix: String? -- "/de" or "" depending on language
html_theme_attribute: String -- Theme class for <html> element
page_title: String -- Page title for <title> tag
page_title: String -- Blog title (description, else name) for the <title> tag; archive-specific labels come from archive_context
pico_stylesheet_href: String -- Path to Pico CSS theme
blog_languages: List<BlogLanguage>
alternate_links: List<AlternateLink>
@@ -127,7 +127,7 @@ value PostContext {
excerpt: String?
author: String?
language: String?
show_title: Boolean -- Always true for post pages
show_title: Boolean -- True for post pages; in list/archive pages it is false when any of the post's categories has "show title" disabled (e.g. asides)
published_at: Timestamp
created_at: Timestamp
updated_at: Timestamp
@@ -181,7 +181,7 @@ value LinkContext {
value ArchiveContext {
kind: category | tag | date
name: String?
name: String? -- Heading label: for categories the configured descriptive title (falls back to the category key), for tags the raw tag name
month: Integer?
year: Integer?
day: Integer?
@@ -234,26 +234,26 @@ value PaginationContext {
-- ============================================================================
-- LIQUID FILTER SPECIFICATION
-- Note: Allium does not have a 'filter' keyword. Filters are documented here
-- for reference but are implemented in the Liquid engine, not in Allium specs.
-- for reference but are implemented in the template engine, not in Allium specs.
-- ============================================================================
-- Built-in filters (from liquid-rust crate):
-- Built-in filters:
-- default: {{ value | default: "fallback" }}
-- escape: {{ text | escape }}
-- url_encode: {{ text | url_encode }}
-- append: {{ text | append: suffix }}
--
-- Custom filters (must be implemented in Rust Liquid engine):
-- Custom filters:
-- i18n: {{ "key" | i18n: language }} - translation lookup
-- markdown: {{ content | markdown: ... }} - markdown rendering with macro expansion
-- ============================================================================
-- BUILT-IN MACROS
-- Note: Allium does not have a 'macro' keyword. Macros are documented here
-- for reference but are implemented as Rust functions in bds-core/render.
-- for reference but are implemented by the rendering subsystem.
-- ============================================================================
-- Built-in macros (implemented as native Rust functions):
-- Built-in macros:
-- gallery: [[gallery images=post.linked_media columns=3]]
-- youtube: [[youtube id=dQw4w9WgXcQ]]
-- vimeo: [[vimeo id=123456789]]

View File

@@ -7,6 +7,11 @@
use "./post.allium" as post
use "./media.allium" as media
enum PostTranslationStatus {
draft
published
}
surface TranslationControlSurface {
facing _: TranslationOperator
@@ -22,6 +27,7 @@ surface TranslationRuntimeSurface {
provides:
PostPublished(post)
PublishTranslationRequested(translation)
TranslationEdited(translation, changes)
}
value SupportedLanguage {
@@ -42,7 +48,7 @@ entity PostTranslation {
title: String
excerpt: String?
content: String?
status: draft | published
status: PostTranslationStatus
file_path: String
checksum: String?
created_at: Timestamp
@@ -58,6 +64,18 @@ entity PostTranslation {
}
}
invariant TranslationFilesCarryFullMetadata {
-- Translation markdown files include status and timestamps alongside
-- language-specific fields. This allows each translation to be rebuilt
-- independently. On rebuild, missing fields fall back to canonical post
-- values for compatibility with legacy files.
for t in PostTranslations where file_path != "":
translation_file(t).has_fields(
id, translation_for, language, title, excerpt?,
status, created_at, updated_at, published_at
)
}
surface PostTranslationSurface {
context translation: PostTranslation
@@ -119,6 +137,7 @@ rule PublishPostTranslation {
rule PublishTranslation {
when: PublishTranslationRequested(translation)
requires: translation.status = draft
ensures: translation.status = published
ensures: translation.published_at = translation.published_at ?? now
ensures: TranslationFileWritten(translation)
@@ -126,6 +145,14 @@ rule PublishTranslation {
-- Content moves to filesystem
}
rule ReopenPublishedTranslation {
when: TranslationEdited(translation, changes)
requires: translation.status = published
requires: translation_edit_affects_published_content(changes)
ensures: translation.status = draft
ensures: translation.updated_at = now
}
rule DeletePostTranslation {
when: DeletePostTranslationRequested(translation)
ensures: not exists translation
@@ -159,3 +186,131 @@ invariant FtsIncludesTranslations {
for t in post.translations:
includes_text(search_index(post), t.title)
}
-- ===========================================================================
-- Auto-Translation System
-- Distilled from: lib/bds/posts/auto_translation.ex
--
-- Two entry points share one translation primitive:
-- 1. ScheduleAutoTranslation - reactive, fired only by an explicit manual
-- save (or publish) in the post editor — never by post creation or
-- auto-save. One background task per missing language produces a DRAFT
-- translation, then cascades to the post's linked media.
-- 2. FillMissingTranslations - batch maintenance action. Scans every
-- published post, AUTO-PUBLISHES the generated translations, fills linked
-- media, reports progress and returns a summary.
-- All AI work is gated by a resolvable endpoint and runs on background tasks.
-- ===========================================================================
config {
-- Background translations use the "AI" task group named per project.
auto_translation_task_group_name: String = "AI"
}
surface AutoTranslationControlSurface {
facing _: TranslationOperator
provides:
-- Reactive trigger: emitted only when a post is updated with
-- auto_translate enabled (the editor's manual save/publish path);
-- post creation and auto-save never emit it.
PostSavedForAutoTranslation(post)
-- Batch trigger: "fill missing translations" maintenance action.
FillMissingTranslationsRequested(project)
}
invariant AutoTranslationGatedByEndpoint {
-- No automatic translation runs unless an endpoint is resolvable for the
-- current mode. Airplane mode needs url+model; online additionally needs an
-- api_key. When unconfigured, scheduling is a silent no-op.
-- See ai.allium AirplaneModeGating for endpoint selection.
for post in Posts:
auto_translation_runs(post) implies endpoint_configured(post.project)
}
invariant AutoTranslationSkipsDoNotTranslate {
-- Posts flagged do_not_translate never schedule background translation,
-- and the batch scan rejects them before computing missing languages.
for post in Posts where post.do_not_translate:
not auto_translation_runs(post)
}
invariant AutoTranslationOnlyMissingLanguages {
-- The target set is the configured languages (main_language plus
-- blog_languages, normalized + de-duplicated) minus the post's source
-- language and any language that already has a translation.
for post in Posts:
auto_translation_targets(post) =
configured_languages(post.project)
- source_language(post)
- post.available_languages
}
rule ScheduleAutoTranslation {
when: PostSavedForAutoTranslation(post)
requires: not post.do_not_translate
requires: endpoint_configured(post.project)
-- One background task per missing language, each producing a DRAFT
-- translation (not auto-published) followed by a media cascade.
for language in auto_translation_targets(post):
ensures: BackgroundTaskSubmitted(
group: post.project,
group_name: config.auto_translation_task_group_name)
ensures: AutoTranslatePost(post, language, auto_publish: false)
ensures: AutoTranslateMediaCascade(post, language)
@guidance
-- Best-effort: missing metadata, unconfigured endpoint, or
-- do_not_translate all collapse to a silent success with no task.
}
rule AutoTranslatePost {
when: AutoTranslatePost(post, language, auto_publish)
requires: trim(editor_body(post)) != ""
-- Calls the AI endpoint with the post's source language, then upserts a
-- translation marked auto_generated. Publishes only in the batch path.
ensures:
let translation = UpsertPostTranslation(post, language)
if auto_publish:
translation.status = published
else:
translation.status = draft
@guidance
-- An empty body yields a no_content_to_translate error and no
-- translation is created.
}
rule AutoTranslateMediaCascade {
when: AutoTranslateMediaCascade(post, language)
-- After a post translation, each linked media (ordered by sort_order) gets
-- its own background task when its source language differs from the target
-- and it lacks a translation in that language.
for m in post.linked_media:
if m.language != "" and m.language != language and not (language in m.available_languages):
ensures: BackgroundTaskSubmitted(
group: post.project,
group_name: config.auto_translation_task_group_name)
ensures: media/UpsertMediaTranslation(m, language)
}
rule FillMissingTranslations {
when: FillMissingTranslationsRequested(project)
-- Batch maintenance. No-op (nothing_to_do) when there is at most one
-- configured language or nothing is missing. Otherwise scans published,
-- non-do_not_translate posts and their linked media.
requires: configured_languages(project).count > 1
for post in project.posts where status = published and not do_not_translate:
for language in auto_translation_targets(post):
ensures: AutoTranslatePost(post, language, auto_publish: true)
ensures: AutoTranslateMediaCascade(post, language)
ensures: ProgressReported(project)
ensures: FillMissingTranslationsCompleted(project)
@guidance
-- Returns a summary of counts: translated_posts, translated_media,
-- failed_count, warned_count, and nothing_to_do. nothing_to_do is true
-- when there is at most one configured language or nothing is missing.
-- Per-item failures increment failed_count and never abort the batch.
-- Progress runs through scanning (0.0-0.15) then per-item (0.15-1.0).
}

211
specs/tui.allium Normal file
View File

@@ -0,0 +1,211 @@
-- allium: 1
-- Terminal UI (issue #26, phase 4)
-- Scope: extension (second renderer over the shared UI core)
-- Distilled from: lib/bds/tui.ex, lib/bds/ui/sidebar.ex, lib/bds/ui/post_editor/*.ex
entity TuiState {
view: posts | media | templates | scripts | tags | settings | git
focus: sidebar | editor
selected_index: Integer
editing_post: Boolean
-- The editor drives the same BDS.UI.PostEditor workflow as the GUI:
-- canonical-language edits update the post, other languages update
-- translations; publish routes through the same publishing pipeline.
}
surface TuiSurface {
facing _: TuiRuntime
provides:
TuiKeyPressed(code, modifiers)
TuiEventReceived(entity, entity_id, action)
TuiSettingsChanged(key)
ShellTaskCompleted(route)
}
rule SidebarNavigation {
when: TuiKeyPressed(code: "up" | "down" | "j" | "k")
-- Selection moves over the flattened sidebar items and skips
-- section headers; data comes from BDS.UI.Sidebar.view, identical
-- to the GUI sidebar content.
ensures: TuiState.selected_index.updated()
}
rule OpenEntry {
when: TuiKeyPressed(code: "enter")
-- Posts open in the editor by default (title + textarea seeded from
-- the persisted form; syntax highlighting and word wrap on; ctrl+e
-- switches to the rendered preview). New empty posts created with
-- "n" open in the editor as well. Images open in the terminal image
-- preview. Other entities report that terminal editing is not
-- available yet.
ensures: TuiState.editing_post.updated()
}
rule SaveAndPublish {
when: TuiKeyPressed(code: "s" | "p", modifiers: "ctrl")
-- ctrl+s saves the draft, ctrl+p saves and publishes — both through
-- BDS.UI.PostEditor.Persistence, so files, embeddings, search, and
-- the event bus behave exactly as a GUI save.
ensures: PostPersisted()
}
rule EditorMode {
when: TuiKeyPressed(code: "e", modifiers: "ctrl")
-- ctrl+e toggles between the syntax-highlighted editor (the default
-- mode when opening a post) and the read-only rendered-Markdown
-- preview (rendered through tui-markdown so headings/emphasis/lists
-- are styled); keys in preview never edit content, and esc returns
-- to the sidebar from either mode.
ensures: PreviewToggled()
}
rule CodeEditorWrapping {
when: TuiState.editing_post.updated()
-- The post editor renders the buffer with syntax highlighting
-- (markdown with [[macro]] accents for posts, HTML+Liquid for
-- templates, Lua for scripts), soft-wraps long lines to the
-- viewport width, keeps the cursor's visual row visible while
-- scrolling, and highlights the cursor's source line. There is no
-- line-number gutter.
ensures: TuiState.updated()
}
rule AiQuickAction {
when: TuiKeyPressed(code: "g", modifiers: "ctrl")
-- One-shot AI post analysis (title/excerpt suggestions). Gated by
-- airplane mode: without a local endpoint the TUI shows a status
-- message instead of calling out.
ensures: AiSuggestionsRequestedOrRefused()
}
rule ProjectSwitcher {
when: TuiKeyPressed(code: "p")
-- "p" opens the projects overlay: all projects from the caching
-- database with the active one marked; enter activates the selected
-- project (BDS.Projects.set_active_project) and reloads the sidebar.
-- "o" switches to a folder-path prompt with bash-style tab
-- completion: tab completes a unique directory (trailing slash
-- appended), an ambiguous prefix completes to the longest common
-- prefix and lists the candidate folders; only directories are
-- offered, dotfolders only for a dot-prefixed input, and a leading
-- "~" expands to the home directory. A typed path is validated
-- to be an existing directory, then opened as a project
-- (BDS.Projects.create_project with data_path, named after the
-- folder) and activated; a full database rebuild is queued
-- automatically through the rebuild_database shell command so the
-- folder's content is loaded into the caching database. Invalid
-- paths keep the prompt open and report the error.
ensures: ProjectSwitchedOrOpened()
}
rule SidebarSearch {
when: TuiKeyPressed(code: "/")
-- "/" in sidebar focus opens a vi-style search prompt in the status
-- line that live-filters the current view through the same
-- BDS.UI.Sidebar filter params the GUI sidebar uses (posts, pages,
-- media). Plain words are a text search, "tag:x" and "category:x"
-- filter like the GUI chips, and an ISO date (yyyy-mm-dd) narrows
-- to that day — the day filter extends the shared sidebar queries.
-- Tokens combine with AND. Enter keeps the filter (shown appended
-- to the sidebar title), esc clears it; filters are per view.
ensures: SidebarFiltered()
}
rule CommandPrompt {
when: TuiKeyPressed(code: ":")
-- Vi-style prompt: ":" opens a filterable list of the parameterless
-- Blog-menu shell commands (metadata diff, validate site, force
-- render, rebuilds, reindex, translations, duplicates, upload,
-- browser preview URL) and runs the selection through the same
-- BDS.Desktop.ShellCommands backend as the GUI menu; typing ":?"
-- shows the full list as help. Overlays clear the cells beneath
-- them. Commands whose follow-up UI is GUI-only (validate
-- translations, find duplicates) are marked. Queued tasks report
-- progress in the status line until all tasks finish.
ensures: CommandListShownOrExecuted()
}
rule SettingsPanel {
when: TuiKeyPressed(code: "6")
-- "6" opens the settings panel (issue #29), matching the GUI panel
-- numbering: the sidebar lists the same preference sections as the
-- GUI settings editor (Project, Editor, Content, AI, Technology,
-- Publishing, Data, MCP, Style — from BDS.UI.Sidebar's settings
-- view). Enter on a section opens a section-specific editor in the
-- main area: a generic typed-field form from BDS.UI.SettingsForm.
-- Enter edits a text field in a status-line prompt, toggles a
-- boolean, or cycles an enum through its options; read-only info
-- rows are skipped by the selection. ctrl+s saves the section
-- through the same backends as the GUI editor (BDS.Metadata,
-- BDS.Settings, BDS.AI, BDS.MCP.AgentConfig) and reloads the form;
-- esc cancels the prompt, then closes the form. Category removal
-- and AI model discovery remain GUI-only.
ensures: SettingsFormShownEditedOrSaved()
}
rule TagsPanel {
when: TuiKeyPressed(code: "5")
-- "5" opens the tags view (issue #34), matching the GUI panel
-- numbering. The sidebar lists the same three sections as the GUI
-- tags editor (Tag Cloud, Create / Edit, Merge Tags — from
-- BDS.UI.Sidebar's tags nav view); enter opens the section in the
-- main area, all backed by BDS.UI.TagsPanel over the same BDS.Tags
-- operations as the GUI editor. Cloud lists tags with their post
-- usage counts ordered by usage; manage is alphabetical with "n"
-- (create prompt), enter (rename prompt), "c" (cycle colour through
-- the shared presets), "t" (cycle post template), "d" then "y"
-- (delete with post-count confirmation; any other key cancels) and
-- "s" (sync tags from post tags); merge marks tags with space and
-- "m" merges the marked tags into the selected one (at least two,
-- target included). Text prompts live in the status line; esc
-- cancels the prompt, then closes the panel. Domain tag events
-- reload an open panel, keeping it in sync with GUI and CLI writes.
ensures: TagsPanelShownOrEdited()
}
rule GitPanel {
when: TuiKeyPressed(code: "7")
-- "7" opens the git panel (issue #30) for content sync: the sidebar
-- lists the changed files from git status (code + path, branch with
-- ahead/behind counts in the title) with the commit history in its
-- lower half, like the GUI (short hash + subject; ↑ marks commits
-- that still need a push, ↓ remote-only ones), the main area shows a
-- scrollable whole-folder diff (staged + unstaged, capped) paged
-- with pgup/pgdn; enter on a file jumps the diff to it. "c" opens a
-- status-line commit prompt (git add -A + commit, empty messages
-- rejected), "u" pulls (ff-only) and "s" pushes — both run off the
-- UI process and report back as a status toast. Every action is
-- BDS.Git, the same backend as the GUI git panel. A project folder
-- that is not a git repository shows a message and refuses the
-- actions.
ensures: GitPanelShownOrSynced()
}
rule ReportPanels {
when: ShellTaskCompleted(route: "metadata_diff" | "site_validation")
-- Completed metadata diff and site validation tasks open a
-- scrollable report panel showing the differences. Enter applies
-- the whole report — metadata diff repairs all items from the
-- filesystem (file → db) and imports orphan files; site validation
-- applies the full report incrementally (render missing, remove
-- extra, re-render updated), matching the GUI defaults. Esc closes
-- the report without applying. Reports completed before this
-- session started never pop up.
ensures: ReportShownThenAppliedOrCancelled()
}
rule MultiClientSync {
when: TuiEventReceived(entity, entity_id, action)
-- Domain events refresh the sidebar; a post deleted elsewhere closes
-- the open editor. Keeps TUI sessions synchronized with GUI clients
-- and pipeline ingestion.
ensures: TuiState.updated()
}
rule ServerSideLocale {
when: TuiSettingsChanged(key: "ui.language")
-- The TUI re-reads the server-side UI language and re-renders; all
-- clients always share one language.
ensures: TuiRelocalized()
}

View File

@@ -35,9 +35,9 @@ surface UserNavigation {
-- ─── 1. Sidebar -> Editor flows ──────────────────────────────
-- All UI coordination flows through a shared reactive store (Zustand in TS,
-- Iced subscriptions in Rust). There are no direct calls between sidebar
-- and editor. Both read from and write to the store; changes propagate
-- All UI coordination flows through shared application state.
-- There are no direct calls between sidebar and editor.
-- Both read from and write to the same state model; changes propagate
-- reactively.
rule SidebarEntityClick {