Domain Rules & Business Invariants
Last Updated: February 2026 Context: Local-first Tauri desktop application with Rust backend and SQLite storage
Purpose
Section titled “Purpose”This document captures the critical business rules and domain invariants that govern the Inklings knowledge management system. These rules are enforced in the domain layer (Rust crates) regardless of storage mechanism.
Principle: Business logic enforces business rules. The database stores data; the application enforces integrity.
Page-Block Relationship Invariants
Section titled “Page-Block Relationship Invariants”Rule 1: Minimum Block Requirement
Section titled “Rule 1: Minimum Block Requirement”Rule: Every page MUST have at least one block at all times.
Enforcement:
- Creation: Creating a page atomically creates an initial empty block
- Deletion: Cannot delete the last block of a page
Rationale:
- UX Consistency: Pages without content are meaningless
- Rendering Simplification: Frontend can always assume at least one block exists
- State Machine Clarity: No “empty page” edge cases
Page Hierarchy Invariants
Section titled “Page Hierarchy Invariants”Rule 2: Cycle Prevention
Section titled “Rule 2: Cycle Prevention”Rule: Cannot move a page to become a descendant of itself (prevents cycles).
Enforcement: Check ancestry chain before move operations.
Example Violation:
Initial:A└── B └── C
User attempts: Move A to be child of CResult: Rejected - C is descendant of A
If allowed:C (new root)└── A └── B └── C ← CYCLE!Rationale: Maintains tree integrity, prevents infinite loops in traversal.
Rule 3: Cascade Deletion Behavior
Section titled “Rule 3: Cascade Deletion Behavior”Rule: Deleting a page deletes all its descendants.
UX Requirement: Frontend MUST warn user before deletion with descendant count.
Rationale:
- Simplicity: No orphaned pages to manage
- Predictability: Clear mental model for users
- Data Integrity: No dangling references
Workspace Invariants
Section titled “Workspace Invariants”Rule 4: Workspace Scoping
Section titled “Rule 4: Workspace Scoping”Rule: All entities belong to exactly one workspace. Pages belong directly to the workspace; blocks inherit through page relationship.
Adaptation for Local-First:
- Each workspace is a self-contained folder on disk with its own
inklings.db - No cross-workspace references
Rationale:
- Data Isolation: Clear boundaries per workspace
- Portability: Each workspace is self-contained
Rule 5: Flat Workspace Structure
Section titled “Rule 5: Flat Workspace Structure”Rule: Workspaces are flat containers (no nested workspaces).
Rationale:
- Simplicity: Follows established PKM patterns (Obsidian, Notion spaces)
- Clear Boundaries: Each workspace is independent
- UX Clarity: No “workspace within workspace” confusion
Users can have multiple workspaces, but they don’t nest.
Block Scoping Invariants
Section titled “Block Scoping Invariants”Rule 6: Block-Workspace Inheritance
Section titled “Rule 6: Block-Workspace Inheritance”Rule: Blocks inherit workspace context through their Page relationship (blocks don’t directly track workspace membership).
Rationale:
- Cleaner Domain Model: Blocks don’t “know” about workspaces
- Referential Integrity: Workspace context always valid via page
- Simplified Logic: No denormalization to maintain
Import Considerations
Section titled “Import Considerations”Import functionality must respect existing invariants:
| Existing Rule | Import Impact |
|---|---|
| Min 1 block per page | Empty imports still create initial block |
| Workspace scoping | All imported content belongs to target workspace |
| Flat workspaces | Subfolder structure not preserved as workspace hierarchy |
Design principles for import:
- Atomic: Import succeeds completely or fails cleanly
- Lossless: Wiki-links preserved as references (resolved or ghost)
- Passthrough: Unrecognized frontmatter keys preserved, not discarded
- Non-destructive: Conflicts prompt user, never silently overwrite
Validation Layers
Section titled “Validation Layers”Domain Layer
Section titled “Domain Layer”Entity-level validation (required fields, construction invariants):
impl Page { pub fn new(title: impl Into<String>) -> Self { let now = Utc::now(); // Atomically creates initial empty block (enforces min-1-block invariant) let initial_block = Block::new(1, ""); Self { id: Uuid::new_v4(), title: title.into(), page_type: PageType::default(), blocks: vec![initial_block], created_at: now, updated_at: now, // template, icon, frontmatter, type_assignments, layout: None/empty } }}Application Layer
Section titled “Application Layer”Business rule validation (invariants):
impl CreatePageUseCase { pub fn execute(&self, request: CreatePageRequest) -> Result<Page, BusinessRuleError> { // Validate business rules (e.g., cycle detection for moves) // ... }}Error Types
Section titled “Error Types”| Error Type | Layer | Example |
|---|---|---|
ValidationError | Domain | Invalid title length |
BusinessRuleError | Application | Cycle detected |
StorageError | Infrastructure | Database query failure |
Layout Invariants
Section titled “Layout Invariants”Rule 7: Layout Rectangle Validation
Section titled “Rule 7: Layout Rectangle Validation”Rule: Every named area in a CSS Grid layout template MUST form a contiguous rectangle.
Enforcement: Layout::new() validates the template by computing the bounding box for each named area and verifying
that the actual cell count equals the expected bounding box area: (max_row - min_row + 1) * (max_col - min_col + 1).
Additional constraints:
- Template must contain at least one area
- All rows must have the same column count
- Area names may only contain alphanumeric characters, hyphens, or underscores
- Templates are bounded to 20 rows, 20 columns, and 50 distinct area names
Example Violation:
a ba a <- "a" forms an L-shape, not a rectangle: REJECTEDRationale: CSS Grid grid-template-areas requires rectangular regions. Validating at the domain level prevents
invalid layouts from reaching storage or the frontend.
Tag Invariants
Section titled “Tag Invariants”Rule 8: Tag Name Validation
Section titled “Rule 8: Tag Name Validation”Rule: Tag and tag group names must be non-empty, at most 100 Unicode scalar values, and must not have leading or trailing whitespace.
Enforcement: Tag::new() and TagGroup::new() both validate via validate_label_name() before construction.
Additional constraints:
- Tag slugs are derived from names via
slugify()(lowercase, hyphens) - Tag colors must be valid 6-digit hex format (e.g.,
#ff5733)
Rationale: Consistent naming prevents display issues, slug collisions, and whitespace-related bugs in search and autocomplete.
Attachment Invariants
Section titled “Attachment Invariants”Rule 9: Attachment Filename Validation
Section titled “Rule 9: Attachment Filename Validation”Rule: Attachment filenames must be non-empty, at most 255 characters (Unicode char count), and must not contain path
separators (/ or \).
Enforcement: Attachment::new() validates filename, size, and extension before construction.
Additional constraints:
- File size must be greater than zero
- Only known-safe file extensions are allowed via an allowlist (images, documents, common office formats)
- Content hash (SHA-256) is stored for dedup
Rationale: Prevents path traversal attacks, empty uploads, and unknown file types from entering the system.
Block Content Type Invariants
Section titled “Block Content Type Invariants”Rule 10: Block Content Type Immutability
Section titled “Rule 10: Block Content Type Immutability”Rule: Block content type (Markdown or Image) is fixed at creation time and cannot be changed.
Enforcement: BlockContentType is set during block creation; no mutation method exists.
Additional constraints:
- Image blocks require valid
ImageBlockMetadata(attachment ID, scale, alignment) - Image captions must not exceed 500 characters
- Image link URLs must use
http://orhttps://scheme and not exceed 2048 bytes - Image scale percentage must be 1-100
Rationale: Changing a block’s fundamental type would invalidate its stored content (CRDT doc vs. image metadata). Type-specific rendering and FTS indexing depend on a stable content type.
Reference Invariants
Section titled “Reference Invariants”Rule 11: Wiki-Link References Track Resolution State
Section titled “Rule 11: Wiki-Link References Track Resolution State”Rule: References (wiki-links) are either resolved (pointing to an existing page) or unresolved (ghost links).
Resolution state is tracked explicitly via the resolved flag and target_page_id.
Enforcement: Reference::resolved() and Reference::unresolved() constructors enforce the correct combination of
fields. The UpdateReferencesUseCase re-resolves all links on content save.
Additional constraints:
- Renaming a page propagates slug changes to all referencing pages
- Deleting a page converts resolved references to ghost (unresolved) links
Rationale: Maintaining explicit resolution state enables ghost link detection, backlink queries, and rename propagation without full-text scanning.
Layout Definition Invariants
Section titled “Layout Definition Invariants”Rule 12: Builtin Layout Immutability
Section titled “Rule 12: Builtin Layout Immutability”Rule: Builtin layout definitions (is_builtin = true) cannot be updated or deleted by the user.
Enforcement: LayoutDefinition has is_builtin flag; application layer rejects mutations on builtins.
Additional constraints:
- Layout definition names must be non-empty, at most 100 characters, and not whitespace-only
- Descriptions must not exceed 500 characters
- Builtin IDs are deterministic (UUID v5 from name) and stable across restarts
Rationale: Builtins provide a consistent baseline; user-created definitions extend but never replace them.
Summary of Invariants
Section titled “Summary of Invariants”| Rule | Enforcement | Rationale |
|---|---|---|
| Min 1 block per page | Domain/Application | UX consistency |
| No page cycles | Application | Tree integrity |
| Cascade deletion | Application | No orphans |
| Workspace scoping | Domain design | Data isolation |
| Flat workspaces | Schema design | Simplicity |
| Block-workspace inheritance | Domain design | Clean model |
| Layout rectangle validation | Domain (Layout::new()) | CSS Grid compliance |
| Tag name validation | Domain (Tag::new()) | Naming consistency |
| Attachment filename validation | Domain (Attachment::new()) | Security, data integrity |
| Block content type immutability | Domain design | Stable rendering/indexing |
| Reference resolution tracking | Domain/Application | Ghost links, backlinks |
| Builtin layout immutability | Application | Consistent baseline |
| Type definition name validation | Domain (validate_name()) | Naming consistency |
| Property definition name valid. | Domain (validate_name()) | Naming consistency |
| Container rule depth constraint | Domain (closed enum) | Unambiguous propagation |
Type System Invariants
Section titled “Type System Invariants”Rule 13: Type Definition Name Validation
Section titled “Rule 13: Type Definition Name Validation”Rule: Type definition names must be non-empty and at most 100 Unicode characters.
Enforcement: TypeDefinition::validate_name() checks constraints before creation or update.
Rationale: Consistent naming prevents display issues and ensures type names fit within UI constraints.
Rule 14: Property Definition Name Validation
Section titled “Rule 14: Property Definition Name Validation”Rule: Property definition names must be non-empty and at most 100 Unicode characters.
Enforcement: PropertyDefinition::validate_name() checks constraints before creation or update.
Rationale: Same consistency guarantees as type names, plus property names appear in inline {{name:value}} syntax
where excessively long names would degrade readability.
Rule 15: Container Rule Depth Constraint
Section titled “Rule 15: Container Rule Depth Constraint”Rule: Container rules specify a depth (DirectChildren or Recursive) that controls how deep auto-type-assignment
propagates within a folder hierarchy.
Enforcement: ContainerRuleDepth is a closed enum — only the two valid variants can be constructed. Parsing from
storage rejects unknown values.
Rationale: Unconstrained depth semantics would create ambiguous behavior for nested folder structures.
Key Principle: Business rules are the most stable part of the system. They live in the domain and application layers, not the storage layer. Storage enforces data integrity; the application enforces business integrity.
Was this page helpful?
Thanks for your feedback!