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

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