Domain Rules & Business Invariants
Status: Accepted Reference epics: INK-826, INK-828, INK-830, INK-836 ADRs: ADR-017, ADR-018, ADR-019
Last Updated: April 2026 Context: Local-first Tauri desktop application with a Rust domain and a Python sidecar agent runtime, 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, agent, or caller.
Principle: Business logic enforces business rules. The database stores data; the application enforces integrity. The agent reasons; the domain validates.
The rules divide into two families:
- World-model invariants (Rules 1–7): what it means for content to enter, live in, and change within a workspace’s world. Every write, regardless of caller, must satisfy these. Cross-link up to systems/world for the concepts these rules encode.
- Content-shape invariants (Rules 8–22): the structural rules about pages, blocks, tags, references, types, and layouts that the application has always enforced.
World-Model Invariants
Section titled “World-Model Invariants”Rule 1: Submit-Boundary-Only Writes
Section titled “Rule 1: Submit-Boundary-Only Writes”Rule: Every write that modifies workspace-visible content must be expressed as a WorldWrite value constructed in the domain. There is no back door.
What counts as workspace-visible content: pages, blocks, frontmatter, tags, page-tag assignments, page-type assignments, attachments, references, derivation links, deviation records, revalidation flags, and anything else attributed to a participant in the event log. Agent scratchpads, LangGraph checkpoints, and runtime-internal memory tiers are not workspace-visible and do not cross the boundary.
Enforcement:
- Application use cases that mutate workspace state accept
WorldWritevalues, not loose arguments. WorldWrite::new()is the only constructor. It requires origin, lifecycle, and (where applicable) derivation sources, deviation context, and retroactive-revision impact as part of its shape.- Tool implementations never populate these fields themselves; they are supplied by the caller (the Tauri command, the Rust MCP adapter, the import pipeline, the task runner), which knows the caller identity and call context.
- The MCP adapter constructs a
WorldWriteon behalf of every boundary-crossing tool call based on registration metadata. Python-native tools that cross the boundary declare the same metadata and flow through the same construction path.
Rationale: The world model’s guarantees about provenance, derivation, deviation, and retroactive revision are only meaningful if every write participates in them. Routing through a single construct makes that participation a property of the type system, not a convention. See submit-boundary and ADR-017.
Rule 2: Origin Immutability
Section titled “Rule 2: Origin Immutability”Rule: Origin is set when content is first submitted and does not change for the lifetime of that content.
Four origin values (see provenance): Authored, Imported, AgentProduced, Observed.
Enforcement:
Originis stored on every write that creates workspace-visible content.- Subsequent writes that modify the same content produce new
WorldWritevalues, but they do not alter the origin of the entity being modified; they record the origin of the modification itself in the event log. - Application use cases that replace content (for example, a retirement + recreate flow) are expressed as retirement of the old entity plus creation of a new one; both carry their own origin.
Rationale: Origin is a factual record of where content entered the workspace. Once set, it does not shift. Honest attribution depends on this — the agent cannot cite an authored page as agent-produced, and imported content cannot be relabeled authored to hide its provenance. See ADR-018.
Rule 3: Lifecycle Transitions
Section titled “Rule 3: Lifecycle Transitions”Rule: Lifecycle status on workspace content transitions only through a closed set of valid moves.
Four lifecycle values (see provenance): Draft, Candidate, Canonical, Retired.
Valid transitions:
Draft → Candidate | Canonical | RetiredCandidate → Canonical | Draft | RetiredCanonical → Retired | CandidateRetired → Canonical (explicit author un-retire only)Enforcement:
Lifecycleis stored on every write that creates workspace-visible content.- Lifecycle changes are themselves world writes; the domain validates the old-to-new transition against the table above and rejects invalid moves.
Canonical ← AgentProducedtransitions require explicit author assent — the agent cannot self-promote its own inferences from Candidate to Canonical. The domain enforces this by checking the participant identity on the world-write against the origin of the target content.
Rationale: Lifecycle is a trajectory, not a fact. The valid-moves table encodes the author-controlled progression from in-progress to settled. Disallowing silent agent self-promotion is the architectural expression of the author-is-authority principle; see world-agent #author-authority.
Rule 4: Agent Writes Are Agent-Produced
Section titled “Rule 4: Agent Writes Are Agent-Produced”Rule: When the World Agent produces content, the resulting WorldWrite carries origin: AgentProduced. The agent cannot submit content labeled with any other origin.
Enforcement:
- The MCP adapter, when constructing a
WorldWriteon behalf of a sidecar-initiated tool call, populates origin from the caller identity. Sidecar-initiated calls always resolve toAgentProduced. - The domain rejects any
WorldWritewhere the declared origin disagrees with the caller identity — for example, an agent-initiated call that tries to claimAuthored. - Imports run from an import pipeline, not the agent, and produce
origin: Imported. If the agent initiates an import via MCP, the import pipeline executes it and the resulting writes carryorigin: Imported— the pipeline identity, not the agent identity, attaches to the writes.
Rationale: Origin honesty is a domain invariant, not a tool convention. If an agent could label its own output as authored, the derived signals (standing, weight) and the zone distinctions would stop carrying meaning. See ADR-018 and the author-authority framing in world-agent.
Rule 5: Deviation on Conflict
Section titled “Rule 5: Deviation on Conflict”Rule: When a WorldWrite would submit content that conflicts with existing canonical content, the domain produces a DeviationRecord alongside (or instead of) applying the write.
Four conflict types (see deviation-records):
| Conflict | What triggers it | Outcome |
|---|---|---|
| Retrieved-vs-asserted | Agent’s grounded answer disagrees with existing canonical content | Deviation recorded; write is blocked unless caller is the author |
| Inference-becomes-stale | Agent-produced content no longer reconciles with current canonical sources | Deviation recorded; write queued for triage |
| Correction-reveals-gap | Author correction implies the world’s structure was insufficient | Deviation recorded; author’s write applies; triage flag surfaces the gap |
| Canonical-vs-canonical | Two canonical entities are mutually inconsistent | Deviation recorded; both entities remain canonical until author reconciles |
Enforcement:
- The domain checks every non-author
WorldWritefor conflict against existing canonical content before applying. - Conflicts always produce a
DeviationRecord. The record is queryable, timestamped, typed, and keyed into the workspace content graph — but it is not itself a workspace entity and carries no origin, lifecycle, or derivation. - The author can always write through conflict: author-initiated writes that disagree with existing canonical content apply, and the existing content is marked superseded with a pointer to the deviation record that reflected the disagreement.
Rationale: The world model surfaces its own seams rather than hiding them. A deviation is an artifact of the model noticing inconsistency; suppressing that artifact would break the author’s ability to see where the world needs reconciliation. See ADR-019.
Rule 6: Derivation-Link Constraints
Section titled “Rule 6: Derivation-Link Constraints”Rule: DerivationLink records are valid only when all three constraints hold:
- Both ends are workspace entities. Derivation is entirely intra-workspace. The
sourceandderivedfields reference workspace IDs; external citations are not derivation links (they are attachment metadata or imported content withorigin: Imported). - No cycles. A derivation link from A to B is rejected if any directed path already exists from B to A. Cycle detection runs on the derivation subgraph at write time.
- The derived entity has
origin: AgentProducedor is explicitly author-recorded. Authored content can record its own derivations, but the domain will not create derivation links implicitly on authored writes.
Enforcement:
WorldWritevalues that include derivation sources validate all three constraints at construction time.- The
DerivationLinktable enforces workspace-ID foreign keys at storage; cycle detection runs in the application layer before the write is applied. - See derivation-links for the read paths and how derivations feed standing.
Rationale: Derivation is a directed dependency graph and its properties (traversal, standing derivation, retroactive revision) depend on acyclicity and workspace-locality.
Rule 7: Capability-Denied Is Not Deviation
Section titled “Rule 7: Capability-Denied Is Not Deviation”Rule: When a participant attempts a write the capability system denies, the result is a capability error, not a DeviationRecord.
Enforcement:
- The capability check runs on every
WorldWritebefore the domain validates conflict. If the participant lacks the capability the write requires, the domain returnsDomainError::CapabilityDeniedand the write does not enter the conflict-detection path. - No
DeviationRecordis produced for capability denials. - Participants still have capability sets when the underlying action is conceptually valid; absence is a capability-granting concern, not a conflict the world model needs to surface.
Rationale: Deviation records mean “the world noticed an inconsistency.” Capability denial means “this participant was not allowed to do this.” Mixing the two would populate the deviation inbox with access-control noise and dilute its signal. See world-agent #participation-in-scheduled-work for the participant model and ADR-019 for the deviation-vs-error distinction.
Content-Shape Invariants
Section titled “Content-Shape Invariants”Rule 8: Minimum Block Requirement
Section titled “Rule 8: 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
Rule 9: Cycle Prevention (Page Hierarchy)
Section titled “Rule 9: Cycle Prevention (Page Hierarchy)”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 10: Cascade Deletion Behavior
Section titled “Rule 10: 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
Rule 11: Workspace Scoping
Section titled “Rule 11: 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, no cross-workspace derivation, no cross-workspace agent behavior
Rationale:
- Data isolation: clear boundaries per workspace
- World closure: each workspace is a world; closure is a property of the design, not a limitation. See world-agent (one World Agent per workspace).
- Portability: each workspace is self-contained
Rule 12: Flat Workspace Structure
Section titled “Rule 12: 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.
Rule 13: Block-Workspace Inheritance
Section titled “Rule 13: 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
Rule 14: Layout Rectangle Validation
Section titled “Rule 14: 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.
Rule 15: Tag Name Validation
Section titled “Rule 15: 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.
Rule 16: Attachment Filename Validation
Section titled “Rule 16: 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.
Rule 17: Block Content Type Immutability
Section titled “Rule 17: 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.
Rule 18: Wiki-Link References Track Resolution State
Section titled “Rule 18: 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.
Rule 19: Builtin Layout Immutability
Section titled “Rule 19: 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.
Rule 20: Type Definition Name Validation
Section titled “Rule 20: 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 21: Property Definition Name Validation
Section titled “Rule 21: 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 22: Container Rule Depth Constraint
Section titled “Rule 22: 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.
Import Considerations
Section titled “Import Considerations”Import functionality must respect every invariant in this document, with these specific interactions:
| Rule | Import Impact |
|---|---|
| Submit-boundary-only writes | Import pipeline constructs WorldWrite values with origin: Imported |
| Origin immutability | Imported content’s origin is set at import time and remains Imported |
| Lifecycle transitions | Imports land as lifecycle: Draft by default; bulk-canonicalize is an explicit author action |
| Agent writes are agent-produced | Agent-initiated imports still produce origin: Imported (the pipeline, not the agent, authors the write) |
| Deviation on conflict | Imports that conflict with existing canonical content produce deviation records per Rule 5 |
| 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 produce deviation records, never silently overwrite
See architecture/data-flow/import (rewritten in Wave 3) for the full import pipeline and systems/content/import-system for the system-level description.
Validation Layers
Section titled “Validation Layers”Domain Layer
Section titled “Domain Layer”Entity-level validation (required fields, construction invariants):
impl WorldWrite { /// Construct a world write for content creation. Rejects malformed /// combinations of origin, lifecycle, and derivation at construction. pub fn create( participant: ParticipantId, origin: Origin, lifecycle: Lifecycle, content: WorldWriteContent, derivation: Vec<DerivationSource>, ) -> Result<Self, WorldWriteError> { /* ... */ }}
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, } }}Application Layer
Section titled “Application Layer”Business rule validation (invariants), capability checks, conflict detection, deviation-record production:
impl CreatePageUseCase { pub fn execute(&self, write: WorldWrite) -> Result<CreatePageOutcome, BusinessRuleError> { // Capability check (Rule 7) self.capabilities.require(&write.participant, Capability::WriteContent)?; // Conflict detection; may produce DeviationRecord (Rule 5) let conflicts = self.conflict_detector.check(&write)?; // Cycle detection for moves (Rule 9) // ... }}Error Types
Section titled “Error Types”| Error Type | Layer | Example |
|---|---|---|
ValidationError | Domain | Invalid title length, malformed WorldWrite |
BusinessRuleError | Application | Cycle detected, invalid lifecycle transition |
CapabilityDenied | Application | Participant lacks required capability (Rule 7) |
WorldWriteError | Domain | Origin/lifecycle mismatch, cyclic derivation |
StorageError | Infrastructure | Database query failure |
Summary of Invariants
Section titled “Summary of Invariants”| Rule | Family | Enforcement | Rationale |
|---|---|---|---|
| 1. Submit-boundary-only writes | World | Domain (WorldWrite) | Provenance machinery, not intention |
| 2. Origin immutability | World | Domain + event log | Honest attribution |
| 3. Lifecycle transitions | World | Domain transition table | Author-controlled progression |
| 4. Agent writes are agent-produced | World | Application (caller-identity check) | Origin honesty for agent-produced content |
| 5. Deviation on conflict | World | Application + DeviationRecord | World notices its own seams |
| 6. Derivation-link constraints | World | Domain (construction) + application (cycle detection) | Directed acyclic dependency graph |
| 7. Capability-denied ≠ deviation | World | Application (capability check precedes conflict) | Access control is not inconsistency |
| 8. Min 1 block per page | Content | Domain/Application | UX consistency |
| 9. No page cycles | Content | Application | Tree integrity |
| 10. Cascade deletion | Content | Application | No orphans |
| 11. Workspace scoping | Content | Domain design | Data isolation + world closure |
| 12. Flat workspaces | Content | Schema design | Simplicity |
| 13. Block-workspace inheritance | Content | Domain design | Clean model |
| 14. Layout rectangle validation | Content | Domain (Layout::new()) | CSS Grid compliance |
| 15. Tag name validation | Content | Domain (Tag::new()) | Naming consistency |
| 16. Attachment filename validation | Content | Domain (Attachment::new()) | Security, data integrity |
| 17. Block content type immutability | Content | Domain design | Stable rendering/indexing |
| 18. Reference resolution tracking | Content | Domain/Application | Ghost links, backlinks |
| 19. Builtin layout immutability | Content | Application | Consistent baseline |
| 20. Type definition name validation | Content | Domain (validate_name()) | Naming consistency |
| 21. Property definition name validation | Content | Domain (validate_name()) | Naming consistency |
| 22. Container rule depth constraint | Content | Domain (closed enum) | Unambiguous propagation |
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; the domain enforces invariants that cannot be expressed except through type constructors. The submit boundary is the strongest of these — a workspace modification is literally not expressible in the domain except through a WorldWrite value.
Was this page helpful?
Thanks for your feedback!