Setup
Every chapter in this guided tour adds code to the same server β one `dotnet run` starts them all. There are no databases to install, no Docker containers to orchestrate, no message brokers to configure. The SDK uses in-memory backends for everything: entities live in concurrent dictionaries, messages dispatch synchronously, files stay in byte arrays. Restart the server and the slate is wiped clean β perfect for experimentation.
This chapter orients you: the project structure, the server entry point, and the two tools you'll use to interact with the Library β the Bruno API client and its CLI.
What You'll Learn
- The project layout β each chapter lives in its own `NN-Name` directory with `Learning.md` as the narrative, subdirectories for code (`Capabilities/`, `Entities/`, `Subscriptions/`), and matching `.bru` files in `api-collection/NN-Name/`. The `Program.cs` at the root wires everything together using `#region NN-ChapterName` blocks β collapse all regions, expand one chapter, and you see only that chapter's wiring.
- How to start the server β `dotnet run` from the `Sdk.Sample` directory. The server listens on `http://localhost:5000`. All backends are InMemory: no database, no Docker, no Kafka. The startup is instant and every restart resets all state β you can experiment without fear of breaking anything.
- How to explore β two ways to send requests: the Bruno desktop app (load `api-collection/bruno.json` for a visual API explorer) and the Bruno CLI (`npx @usebruno/cli run "NN-ChapterName" --env local`). Every `.bru` file includes assertions β the CLI reports PASS/FAIL for each request, so you know immediately whether the chapter's concepts work as described.
The Server
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.Sample;
using Aletheia.Sdk.Aspects.DependencyInjection;
using Aletheia.Sdk.Capability.Messaging.DependencyInjection;
using Aletheia.Sdk.Web.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
var app = builder.Build();
app.Run();
- Aspects are a core functionality of Aletheia and are always required.
Known Gaps
- The overall Aletheia.Sdk.* requires lots of fine granular imports. This should be simplified and standardized.
- The import patterns are inconsistent imports across slices (for example see 01-Capabilities vs 02-Entities). This should be standardized and simplified.
- The methods for registering and activating the slices are inconsistent across slices (for example see 01-Capabilities vs 02-Entities). This should be standardized and simplified.
Capabilities
Every interaction with the Aletheia SDK begins the same way: a command arrives, a handler processes it, and a result is returned. This is the Capability pattern β the fundamental building block of the entire platform. There are no controllers, no minimal APIs, no REST conventions to memorize. You write a plain C# class that implements `ICapabilityHandler<TCommand, TResponse>`, decorate it with `[Capability]`, and the SDK discovers it, registers it in the DI container, and exposes it as an HTTP endpoint.
The Bruno collection for this chapter sends six requests across two handlers: `GreetHandler` validates the happy path (200) and empty-name rejection (422), while `ErrorDemoHandler` exercises the three domain error codes β `EMPTY_NAME`, `NOT_FOUND`, and `CONFLICT` β plus a success case to prove errors are the exception, not the rule. This establishes a pattern you'll see throughout the Library β every chapter's `.bru` files validate both the happy path and the error path.
What You'll Learn
- Capability pattern β the core interaction model of Aletheia: a command arrives, a handler executes, a result returns. This replaces controllers, action methods, and route attributes with a single, predictable pattern. Every capability in the SDK β entity CRUD, queries, custom business logic β follows this same shape.
- `ICapabilityHandler<TCommand, TResponse>` β the interface every capability implements. `TCommand` is the input (a plain class or record), `TResponse` is the output. The SDK's source generators discover all implementations at build time and register them automatically β you never write a controller or a route table by hand.
- `ExecutionResult<T>` β the unified result type that replaces exceptions for domain errors. `ExecutionResult.Ok(value)` for success, `ExecutionResult.Fail("ERROR_CODE", "Human message")` for validation failures. The HTTP layer maps `Ok` to 200 and `Fail` to 422 β structured error codes survive the wire so clients can react programmatically.
- Capability routing β `[Capability("sample.greet")]` defines the endpoint path. The string is the capability's unique identifier β it becomes the URL segment. `AddCapabilityHttp()` discovers all `[Capability]`-decorated handlers in the assembly, and `MapCapabilities()` exposes them in the ASP.NET pipeline. No route attributes, no `MapGet`/`MapPost` calls.
- Error modes β all domain errors return HTTP 422 with a structured `Code` field (`EMPTY_NAME`, `NOT_FOUND`, `CONFLICT`). Clients switch on the code, not the HTTP status. Infrastructure errors (genuinely unknown routes, duplicate identity) use 404 and 409 β every other failure is a 422 with a machine-readable code.
Error Modes
The Greet handler shows one kind of failure. Capabilities can return many β all mapped to 422 Unprocessable Entity. The Code field distinguishes them:
| Code | Meaning | When |
|---|---|---|
| `EMPTY_NAME` | Validation failure | Input doesn't meet requirements |
| `NOT_FOUND` | Resource missing | The requested entity doesn't exist |
| `CONFLICT` | State conflict | Operation conflicts with current state |
All domain errors use the same HTTP status. Clients switch on code, not the HTTP status code. 404 and 409 are reserved for infrastructure-level errors β genuinely unknown routes, or repository-level conflicts like duplicate identity. The ErrorDemo handler triggers each code on demand via a mode parameter.
The Code
The `GreetHandler` β accepts a name, returns a welcome message, rejects empty names
Capabilities/GreetCapability.cs
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Inbound command: ask the library to greet a visitor by name.
/// </summary>
public sealed record GreetCommand(string Name);
/// <summary>
/// Our library's greeting β the first capability everyone encounters.
/// </summary>
public sealed record GreetResponse(string Message);
[Capability("sample.greet")]
public sealed class GreetHandler : ICapabilityHandler<GreetCommand, GreetResponse>
{
public ValueTask<ExecutionResult<GreetResponse>> HandleAsync(
GreetCommand command,
CapabilityContext context,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(command.Name))
{
// Gracefully handle execution errors
return ValueTask.FromResult<ExecutionResult<GreetResponse>>(
new ExecutionResult<GreetResponse>.Fail(
new ExecutionError("EMPTY_NAME", "A name is required to enter the Library.")));
}
// Return successful execution result
var message = $"Welcome to the Library, {command.Name}. I am the keeper of all manuscripts.";
return ValueTask.FromResult<ExecutionResult<GreetResponse>>(
new ExecutionResult<GreetResponse>.Ok(new GreetResponse(message)));
}
}
The `ErrorDemoHandler` β triggers validation (422), not-found (404), and conflict (409) errors on demand
Capabilities/ErrorDemoCapability.cs
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Inbound command: choose an error mode to trigger.
/// </summary>
public sealed record ErrorDemoCommand(string Mode);
/// <summary>
/// Response when no error is triggered β the normal path.
/// </summary>
public sealed record ErrorDemoResponse(string Message);
/// <summary>
/// Demonstrates that all domain errors return 422 β the code field distinguishes them.
/// Mode "empty-name" β EMPTY_NAME, "missing" β NOT_FOUND, "conflict" β CONFLICT.
/// Mode "success" β 200 (normal response, proving errors are the exception path).
/// </summary>
[Capability("sample.error-demo")]
public sealed class ErrorDemoHandler : ICapabilityHandler<ErrorDemoCommand, ErrorDemoResponse>
{
public ValueTask<ExecutionResult<ErrorDemoResponse>> HandleAsync(
ErrorDemoCommand command,
CapabilityContext context,
CancellationToken cancellationToken = default)
{
return command.Mode switch
{
"empty-name" => ValueTask.FromResult<ExecutionResult<ErrorDemoResponse>>(
new ExecutionResult<ErrorDemoResponse>.Fail(
new ExecutionError("EMPTY_NAME", "A name is required to enter the Library."))),
"missing" => ValueTask.FromResult<ExecutionResult<ErrorDemoResponse>>(
new ExecutionResult<ErrorDemoResponse>.Fail(
new ExecutionError("NOT_FOUND", "The requested manuscript does not exist in the archives."))),
"conflict" => ValueTask.FromResult<ExecutionResult<ErrorDemoResponse>>(
new ExecutionResult<ErrorDemoResponse>.Fail(
new ExecutionError("CONFLICT", "Another scribe has already modified this manuscript."))),
_ => ValueTask.FromResult<ExecutionResult<ErrorDemoResponse>>(
new ExecutionResult<ErrorDemoResponse>.Ok(
new ErrorDemoResponse("No errors β the capability executed successfully."))),
};
}
}
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.Capability.Messaging.DependencyInjection;
using Aletheia.Sdk.Web.DependencyInjection;
using Aletheia.Sdk.Capability.DependencyInjection;
using Aletheia.Sdk.Capability.Http;
using Aletheia.Sdk.Capability.Http.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GreetHandler>();
builder.Services.AddCapabilityHttp();
var app = builder.Build();
app.MapCapabilities();
app.Run();
Known Gaps
- We should support more than 422 error codes from the SDK.
Try It
Greet Aletheia
POST/api/capabilities/sample/greetβ 200
Greet with Empty Name (rejected)
POST/api/capabilities/sample/greetβ 422
Error Demo β Invalid Input (422 with EMPTY_NAME)
POST/api/capabilities/sample/error-demoβ 422
Error Demo β Missing Resource (422 with NOT_FOUND)
POST/api/capabilities/sample/error-demoβ 422
Error Demo β Conflict (422 with CONFLICT)
POST/api/capabilities/sample/error-demoβ 422
Error Demo β Success (200)
POST/api/capabilities/sample/error-demoβ 200
Entities
Where capabilities model interactions, entities model things. An entity in Aletheia is a persistable object with a unique identity β think of it as a row in a database, but one that speaks RDF under the hood. You define an entity by decorating a plain C# class with `[Entity]` and `[Identity]`, and the SDK's source generators create everything: the repository backing store, the HTTP endpoints (POST, GET, PUT, DELETE), and the RDF graph mapping.
The Bruno collection walks through the full CRUD lifecycle: create a DataRecord with every supported scalar type populated, read it back, update it, list all records, delete it, and verify the deletion. Six requests that prove the generated endpoints work end-to-end β no hand-written controller code.
What You'll Learn
- `[Entity]` β marks a class as a persistable object in the Aletheia repository. The source generator creates the backing store implementation, identity management, and REST endpoints automatically. You write the class; the SDK writes the infrastructure. Entities are the nouns of your domain β the things that capabilities act upon.
- `[Identity]` β controls how each entity gets its unique IRI. `IdentityGenerator.Random` creates cryptographically random IRIs on creation, ensuring global uniqueness without a central ID server. The identity is an IRI (Internationalized Resource Identifier) β a URI that also serves as the RDF graph node identifier. Two other strategies exist β `PropertyBasedPlain` and `PropertyBasedEncoded` β both covered in detail in [[../03-Modelling/Learning|Chapter 03: Modelling]].
- `[Predicate]` β maps a CLR property to an RDF predicate, bridging the object-oriented and semantic-web worlds. Every standard scalar type is supported: `string`, `int`, `long`, `float`, `double`, `decimal`, `bool`, `Guid`, `DateOnly`, `DateTimeOffset`, and `Uri` β both nullable and non-nullable. The source generator handles serialization, so you work with plain C# types and the SDK translates to RDF triples.
- `[OperationEndpoints]` β instructs the source generator to create POST (create), GET by IRI (read), PUT (update), and DELETE (remove) HTTP endpoints for the entity. These are the standard CRUD operations. You can opt in or out per entity β some entities may only need read endpoints, others full CRUD.
- `MapOperations()` β exposes the generated CRUD endpoints in the ASP.NET pipeline. Called once in `Program.cs` after `AddEntityRepository` and the HTTP registration, it wires all `[OperationEndpoints]` entities into the server.
The Code
A demonstration entity exercising every supported scalar type β uses `Random` identity
Entities/DataRecord.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Sample entity used to demonstrate the full Aletheia platform stack with
/// <strong>every supported scalar CLR type</strong> and its nullable variant:
/// <c>string</c>, <c>bool</c>, <c>int</c>, <c>long</c>, <c>float</c>,
/// <c>double</c>, <c>decimal</c>, <c>DateOnly</c>, <c>DateTimeOffset</c>,
/// <c>Guid</c>, <c>Uri</c> β non-nullable and nullable β plus a
/// <c>List<string></c> collection to demonstrate list support.
/// <br/>
/// Uses <c>[OperationEndpoints]</c> + <c>MapOperations()</c> to expose five REST
/// endpoints under <c>api/entities/data-records</c> (POST, GET, PUT, DELETE).
/// </summary>
[Entity(Path = "data-records")]
[Identity(IdentityGenerator.Random)]
[OperationEndpoints]
public partial class DataRecord
{
// ββ Non-nullable scalars βββββββββββββββββββββββββββββββββββββββββββββββββ
[Predicate("label")]
public string Label { get; set; } = string.Empty;
[Predicate("active")]
public bool Active { get; set; }
[Predicate("count")]
public int Count { get; set; }
[Predicate("serial")]
public long Serial { get; set; }
[Predicate("ratio")]
public float Ratio { get; set; }
[Predicate("score")]
public double Score { get; set; }
[Predicate("amount")]
public decimal Amount { get; set; }
[Predicate("effectiveDate")]
public DateOnly EffectiveDate { get; set; }
[Predicate("timestamp")]
public DateTimeOffset Timestamp { get; set; }
[Predicate("correlationId")]
public Guid CorrelationId { get; set; }
[Predicate("reference")]
public Uri Reference { get; set; } = new Uri("https://www.aletheia.arkenforge.de/");
// ββ Nullable variants ββββββββββββββββββββββββββββββββββββββββββββββββββββ
[Predicate("notes")]
public string? Notes { get; set; }
[Predicate("flagged")]
public bool? Flagged { get; set; }
[Predicate("limit")]
public int? Limit { get; set; }
[Predicate("sequence")]
public long? Sequence { get; set; }
[Predicate("factor")]
public float? Factor { get; set; }
[Predicate("precision")]
public double? Precision { get; set; }
[Predicate("fee")]
public decimal? Fee { get; set; }
[Predicate("expiresOn")]
public DateOnly? ExpiresOn { get; set; }
[Predicate("archivedAt")]
public DateTimeOffset? ArchivedAt { get; set; }
[Predicate("alternateId")]
public Guid? AlternateId { get; set; }
[Predicate("canonicalUri")]
public Uri? CanonicalUri { get; set; }
}
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.Capability.Http;
using Aletheia.Sdk.Capability.Http.DependencyInjection;
using Aletheia.Sdk.Operations.Http;
using Aletheia.Sdk.Operations.Http.DependencyInjection;
using Aletheia.Sdk.Repository.DependencyInjection;
using Aletheia.Sdk.Repository.InMemory.DependencyInjection;
using Aletheia.Sdk.Repository.GraphDb.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
// ...
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GreetHandler>();
builder.Services.AddCapabilityHttp();
var repoBuilder = builder.Services.AddEntityRepository(builder.Configuration);
if (string.Equals(builder.Configuration["EntityRepository:Backend"], "GraphDb", StringComparison.OrdinalIgnoreCase))
repoBuilder.UseGraphDb();
else
repoBuilder.UseInMemory();
builder.Services.AddOperationEndpointsHttpFromAssemblyContaining<DataRecord>();
var app = builder.Build();
app.MapCapabilities();
app.MapOperations();
app.Run();
- The backend is chosen by the `EntityRepository:Backend` configuration key. Deployed in the
Known Gaps
- We should support List<T> besides scalar types in the SDK for entities. Currently, each List<T> must be modelled as entity relationship.
Try It
Create DataRecord
POST/api/entities/data-recordsβ 200
Read existing DataRecord
GET/api/entities/data-records?iri={{02-dataRecordIri}}β 200
Update existing DataRecord
PUT/api/entities/data-records?iri={{02-dataRecordIri}}β 200
List all DataRecords
GET/api/entities/data-recordsβ 200
Delete existing DataRecord
DELETE/api/entities/data-records?iri={{02-dataRecordIri}}β 200
Read deleted DataRecord (fails)
GET/api/entities/data-records?iri={{02-dataRecordIri}}β 404
Modelling
Entities gain meaning through their connections to other entities. Aletheia models relationships not with foreign keys or join columns, but with typed references β `EntityRef<T>` for 1:1 and 1:N relationships, `EntityRefCollection<T>` for N:M. These references store the target entity's IRI, not the entity itself, so the graph stays shallow until you choose to resolve a reference.
This chapter introduces four entity types that model a small library domain: a `Scribe` curates `Manuscript`s, each `Manuscript` belongs to `Genre`s through `CatalogEntry` join entities. The Bruno collection creates a scribe, creates a manuscript referencing that scribe, and retrieves the manuscript with its scribe relationship populated β proving that references survive round-trips through the HTTP API.
What You'll Learn
- `EntityRef<T>` β a typed reference to another entity, stored as the target's IRI. It doesn't load the target entity by default β you get the IRI immediately, and resolve to the full entity only when needed. This prevents accidental eager-loading of the entire object graph. Think of it as a type-safe foreign key that can optionally materialize into the full object.
- `EntityRefCollection<T>` β a collection of typed references for modelling 1:N relationships. A `Scribe` has an `EntityRefCollection<Manuscript>` β the collection stores the IRIs of all manuscripts curated by that scribe. Like `EntityRef<T>`, the collection doesn't load the referenced entities unless you ask it to.
- `[Owning]` β marks which side of a bidirectional relationship is the owner. The source generator uses this to wire inverse navigations automatically. When you set `manuscript.Scribe`, the scribe's `Manuscripts` collection is updated β and vice versa. Without `[Owning]`, relationships are unidirectional by default.
- Domain modelling β composing multiple entity types into a coherent domain. This chapter uses four types: `Scribe` (a person), `Manuscript` (a document), `Genre` (a category), and `CatalogEntry` (a join). Together they demonstrate the full spectrum of relationship patterns: 1:1, 1:N, and N:M.
- `CatalogEntry` β a join entity that links a `Manuscript` to a `Genre` with additional metadata (an `Author` property). This is the Aletheia pattern for N:M relationships: rather than implicit join tables, you create an explicit entity that carries the relationship's own data. The Bruno collection validates that creating a catalog entry persists both the link and the extra property.
- Identity strategies β chapter 02 introduced `[Identity]` with `Random`. This chapter completes the picture with all three strategies demonstrated side by side:
Entity Inheritance
Some Scribes wield magic. A MagicalScribe is still a Scribe β same name, same title, same manuscripts β plus a reserve of Mana that marks them apart.
The derived type inherits every property, every CRUD endpoint, and β critically β the identity strategy from its base. MagicalScribe gets a UUID IRI because Scribe uses IdentityGenerator.Random. It cannot choose a different strategy.
MagicalScribe gets its own routes at api/entities/magical-scribes, but it also appears in the scribes list β because a MagicalScribe *is* a Scribe at the RDF level.
β οΈ Watch for: The derived type must not set Path or [Identity] on [Entity] β the base type owns both. Use [Entity(PredicatePath = "...")] and [OperationEndpoints("magical-scribes")]. The ALETHEIA0007 analyzer rejects Path and [Identity] on derived types at build time.
The Code
A librarian who curates manuscripts β has a `Name`, `Title`, and a collection of `Manuscript`s
Entities/Scribe.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Entity.Contracts;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// A Scribe β a person who writes Manuscripts.
/// Demonstrates EntityRefCollection: one Scribe writes many Manuscripts.
/// </summary>
[Entity(Path = "scribes")]
[Identity(IdentityGenerator.Random)]
[OperationEndpoints]
public partial class Scribe
{
[Predicate("name")]
public string Name { get; set; } = string.Empty;
[Predicate("title")]
public string Title { get; set; } = string.Empty;
// An inverse relationship to the Scribe entity.
// It automatically is populated with the Manuscripts that reference this Scribe in their Scribes collection.
[Inverse(nameof(Manuscript.WrittenBy), "writtenBy")]
public partial EntityRefCollection<Manuscript> Wrote { get; }
}
A written work β has `Title`, `Author`, `Shelf`, `Year`, and references to its `Scribe` and `Genre`s
Entities/Manuscript.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Entity.Contracts;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// A Manuscript β the central entity of the Aletheia Study.
/// Represents a scroll entering the Great Library. Every new contributor
/// should start here to understand the Entity slice.
/// </summary>
[Entity(Path = "manuscripts")]
[Identity(IdentityGenerator.Random)]
[OperationEndpoints]
public partial class Manuscript
{
[Required]
[Predicate("title")]
public string Title { get; set; } = string.Empty;
[Predicate("author")]
public string Author { get; set; } = string.Empty;
[Owning("genre")]
public partial EntityRef<Genre>? Genre { get; set; }
[Predicate("shelf")]
public string Shelf { get; set; } = string.Empty;
[Predicate("year")]
public int Year { get; set; }
[Predicate("isSealed")]
public bool IsSealed { get; set; }
// A relationship to the Scribe entity.
// Due to the relation direction, a Manuscript cannot exist without a relationship to Scribes (someone has to write the manuscript).
// However, a Scribe can exist without a relationship to Manuscripts (maybe they haven't written anything yet).
[Owning("writtenBy")]
public partial EntityRefCollection<Scribe> WrittenBy { get; }
}
A literary category β referenced by manuscripts and catalog entries
Entities/Genre.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// A Genre β a fixed category of manuscripts. Demonstrates the [Enumeration] pattern:
/// sealed entity with public static readonly instances (SKOS-like named individuals).
/// The IRI is deterministic from the Key property via PropertyBasedPlain identity.
/// </summary>
[Entity(Path = "genres", PredicatePath = "genre")]
[Identity(IdentityGenerator.PropertyBasedPlain)]
[Enumeration]
[OperationEndpoints]
public partial class Genre
{
[IdentityPart(0)]
[Predicate("key")]
public partial string Key { get; init; }
[Predicate("displayName")]
public string DisplayName { get; set; } = string.Empty;
public static readonly Genre Philosophy = new() { Key = "philosophy", DisplayName = "Philosophy" };
public static readonly Genre History = new() { Key = "history", DisplayName = "History" };
public static readonly Genre Poetry = new() { Key = "poetry", DisplayName = "Poetry" };
public static readonly Genre Science = new() { Key = "science", DisplayName = "Science" };
public static readonly Genre Magic = new() { Key = "magic", DisplayName = "Magic" };
/// <summary>All five named genre individuals in declaration order.</summary>
public static IReadOnlyList<Genre> All { get; } = [Philosophy, History, Poetry, Science, Magic];
}
A join entity linking a `Manuscript` to a `Genre` with an `Author` β demonstrates N:M modelling
Entities/CatalogEntry.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// A Catalog Entry β demonstrates PropertyBasedPlain identity: the IRI is built
/// from Author + Title, producing human-readable, collision-safe identifiers
/// like /catalog-entries/plato-the-republic.
/// </summary>
[Entity(Path = "catalog-entries", PredicatePath = "catalogEntry")]
[Identity(IdentityGenerator.PropertyBasedPlain)]
[OperationEndpoints]
public partial class CatalogEntry
{
[IdentityPart(0)]
[Predicate("author")]
public partial string Author { get; init; }
[IdentityPart(1)]
[Predicate("title")]
public partial string Title { get; init; }
}
A `Scribe` who wields magic β inherits all properties and endpoints, adds only `Mana`
Entities/MagicalScribe.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// A MagicalScribe β a Scribe who wields arcane power.
/// Inherits every property, endpoint, and the identity strategy from <see cref="Scribe"/>.
/// Adds only <c>Mana</c> β their reserve of magical energy.
/// </summary>
/// <remarks>
/// The derived type must not set <c>Path</c> or <c>[Identity]</c> on <c>[Entity]</c> β
/// the base type owns both. <c>[Entity(PredicatePath = "magical-scribe")]</c> scopes
/// the new predicate under the inherited path, and
/// <c>[OperationEndpoints("magical-scribes")]</c> gives it dedicated CRUD routes.
/// <br/>
/// Because a MagicalScribe <em>is</em> a Scribe at the RDF level (multi-type projection),
/// a <c>GET api/entities/scribes</c> list also returns magical-scribe instances.
/// </remarks>
[Entity(PredicatePath = "magical-scribe")]
[OperationEndpoints("magical-scribes")]
public partial class MagicalScribe : Scribe
{
/// <summary>Reserve of magical energy β the one thing that sets them apart.</summary>
[Predicate("mana")]
public int Mana { get; set; }
}
Demonstrates `PropertyBasedEncoded` identity β same `Key` always produces the same opaque IRI
Entities/DeterministicRecord.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// A DeterministicRecord β same scalar shape as DataRecord, but uses
/// <see cref="IdentityGenerator.PropertyBasedEncoded"/> for deterministic GUIDv5 IRIs.
/// The IRI is a hash of the <c>Key</c> property under a namespace GUID,
/// so the same key always produces the same IRI β but the IRI reveals nothing
/// about the key value.
/// <br/>
/// Compare with <see cref="DataRecord"/> (Random UUIDv4) and
/// <see cref="Genre"/> (PropertyBasedPlain, human-readable IRI).
/// Together they demonstrate all three identity strategies.
/// </summary>
[Entity(Path = "deterministic-records")]
[Identity(IdentityGenerator.PropertyBasedEncoded)]
[OperationEndpoints]
public partial class DeterministicRecord
{
/// <summary>
/// The identity part β hashed into the GUIDv5 IRI.
/// Same key β same IRI every time. Different key β different IRI.
/// </summary>
[IdentityPart(0)]
[Predicate("key")]
public partial string Key { get; init; }
[Predicate("label")]
public string Label { get; set; } = string.Empty;
[Predicate("count")]
public int Count { get; set; }
[Predicate("active")]
public bool Active { get; set; }
}
Known Gaps
- Enumeration materialization does not happen automatically in startup into database; instead the code is read at runtime. This is not ideal for production scenarios.
- Enumeration implementation is error prone, as All() must be manually implemented and updated on change of enumeration values.
Try It
Create Scribe (to write a Manuscript)
POST/api/entities/scribesβ 200
List Genres (to select one to write a Manuscript about)
GET/api/entities/genresβ 200
Create Manuscript (to link later)
POST/api/entities/manuscriptsβ 200
Get Scribe by IRI
GET/api/entities/scribes?iri={{03-scribeIri}}β 200
Get Manuscript with Scribe reference populated
GET/api/entities/manuscripts?iri={{03-manuscriptIri}}β 200
Create Catalog Entry linking Manuscript and Genre
POST/api/entities/catalog-entriesβ 200
Create MagicalScribe (inherits from Scribe)
POST/api/entities/magical-scribesβ 200
Get MagicalScribe (inherited properties + mana)
GET/api/entities/magical-scribes?iri={{03-magicalScribeIri}}β 200
List Scribes includes MagicalScribe
GET/api/entities/scribesβ 200
Delete MagicalScribe
DELETE/api/entities/magical-scribes?iri={{03-magicalScribeIri}}β 200
Create DeterministicRecord (PropertyBasedEncoded identity)
POST/api/entities/deterministic-recordsβ 200
Create DeterministicRecord with Same Key (409 β identity conflict)
POST/api/entities/deterministic-recordsβ 409
Create DeterministicRecord with Different Key (different IRI)
POST/api/entities/deterministic-recordsβ 200
Lazy-Loading
Entity references are IRIs by default β lightweight, fast, and safe. But sometimes you need the full entity behind a reference. Aletheia uses lazy loading: resolve a reference on demand with `await`. You get the IRI immediately (no I/O), and only pay the cost of a lookup when you actually need the target entity.
The Bruno collection demonstrates the difference: a simple GET returns a Wing with its Curator unresolved (just the IRI), while the `wing-detail` capability explicitly resolves the curator via `await wing.Curator`. You'll also learn about the N+1 problem β why looping over entities and lazily resolving each one's relations is a performance trap β and why knowing your access patterns matters.
What You'll Learn
- `ILazyLoaded<T>` β wraps an entity reference with deferred resolution. Accessing the property gives you the IRI immediately (no I/O). Calling `await` on it triggers a repository lookup. This is the default behavior for all `EntityRef<T>` properties β they're lazy by design, preventing accidental graph traversal that could load thousands of entities.
- N+1 awareness β if you load 50 manuscripts and then lazily resolve each one's `Scribe` reference, you make 51 queries (1 for the manuscripts + 50 for the scribes). This is the N+1 problem. The sample doesn't trigger N+1 β it resolves a single curator β but understanding the pattern helps you recognize it. The `wing.Curator` reference stores an IRI; the `await` is what triggers the I/O. Doing that in a loop is the trap.
The Code
A Wing of the Library β has a lazy-loaded `Curator` (`EntityRef<Scribe>`) and a collection of `Manuscript`s
Entities/Wing.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Entity.Contracts;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// A Wing β a section of the Library, curated by a Scribe.
/// Demonstrates laziness of EntityRef (N:1 relation) β each Wing has one Curator.
/// </summary>
[Entity(Path = "wings")]
[Identity(IdentityGenerator.Random)]
[OperationEndpoints]
public partial class Wing
{
[Predicate("name")]
public string Name { get; set; } = string.Empty;
[Predicate("floor")]
public int Floor { get; set; }
[Owning("curator")]
public partial EntityRef<Scribe>? Curator { get; set; }
[Owning("manuscripts")]
public partial EntityRefCollection<Manuscript> Manuscripts { get; }
}
Resolves a Wing's curator lazily, demonstrating `await wing.Curator` with `EntitySession`
Capabilities/WingDetailCapability.cs
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Execution;
using Aletheia.Sdk.Repository.Contracts;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Ask Aletheia for the details of a Wing β she walks the relationships.
/// </summary>
public sealed record WingDetailCommand(string WingIri);
/// <summary>
/// The Wing's details, including its Curator's name and manuscript count.
/// </summary>
public sealed record WingDetailResponse(string WingName, string? CuratorName, int ManuscriptCount);
[Capability("sample.wing-detail")]
public sealed class WingDetailHandler : ICapabilityHandler<WingDetailCommand, WingDetailResponse>
{
private readonly IEntityStore _store;
public WingDetailHandler(IEntityStore store) => _store = store;
public async ValueTask<ExecutionResult<WingDetailResponse>> HandleAsync(
WingDetailCommand command,
CapabilityContext context,
CancellationToken cancellationToken = default)
{
var wing = await _store.LoadAsync<Wing>(command.WingIri, cancellationToken);
if (wing is null)
{
return new ExecutionResult<WingDetailResponse>.Fail(
new ExecutionError("WING_NOT_FOUND", $"No Wing found at '{command.WingIri}'."));
}
using var session = EntitySession.Begin(_store);
// Lazy-resolve the Curator via EntityRef<T>.GetAwaiter()
var curator = wing.Curator is not null ? await wing.Curator : null;
return new ExecutionResult<WingDetailResponse>.Ok(new WingDetailResponse(
wing.Name,
curator?.Name,
wing.Manuscripts is not null ? wing.Manuscripts.Iris.Count : 0));
}
}
Known Gaps
- Add explicit-include path once IEntityStore supports Include<T>(expression). Branch on a UseInclude flag β keep the lazy path below for the existing tests, add an include path that populates Curator upfront in a single round-trip (to solve the n-+1 problem). This is a common pattern in EF Core and other ORMs, and should be supported in Aletheia.Sdk.Entity as well.
Try It
Create Scribe
POST/api/entities/scribesβ 200
Create Manuscript
POST/api/entities/manuscriptsβ 200
Create Wing with Curator and Manuscripts
POST/api/entities/wingsβ 200
Get Wing by IRI
GET/api/entities/wings?iri={{04-wingIri}}β 200
Get Wing Detail β resolve lazy-loaded Curator
POST/api/capabilities/sample/wing-detailβ 200
Query
CRUD by IRI is precise but limited β you need to know the IRI to fetch an entity. Real applications need search: find manuscripts by title keyword, list scribes sorted by name, paginate through large result sets. Aletheia provides this through LINQ β the same `IQueryable<T>` interface familiar to every .NET developer, but backed by the repository instead of a database.
The Bruno collection for this chapter creates two manuscripts with "Nature" in their titles, then runs a catalog search that demonstrates `Where` filtering. The query is written in C# but executed against the repository β the LINQ provider translates predicates, ordering, and pagination into native repository operations.
What You'll Learn
- `IEntityStore.Query<T>()` β the entry point for querying entities. Returns `IQueryable<T>` β the standard .NET LINQ interface. You write `_store.Query<Manuscript>().Where(m => m.Title.Contains("Nature"))` and the SDK translates it into a repository query. No SQL, no custom query language, no string-based filters.
- `Sdk.Operations.Linq` β the LINQ-to-repository translation layer. It converts expression trees into repository-native query operations. Supported operators include `Where` (filtering), `OrderBy` / `OrderByDescending` (sorting), `Skip` / `Take` (pagination), and `Select` (projection). The translation happens at runtime β you get compile-time safety with repository-level execution.
- Query composition β queries compose naturally: `.Where(...).OrderBy(...).Skip(10).Take(20)` builds a single, efficient repository query. The provider doesn't fetch all entities and filter in memory β the filtering and pagination happen at the repository level, so large datasets don't cause memory pressure.
The Code
The `CatalogHandler` β searches manuscripts by keyword in the title using `_store.Query<Manuscript>().Where(...)`
Capabilities/CatalogCapability.cs
using System.Collections.Immutable;
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
using Aletheia.Sdk.Operations.Linq;
using Aletheia.Sdk.Repository.Contracts;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Search the Library's manuscripts by keyword.
/// </summary>
public sealed record CatalogCommand(string Keyword);
/// <summary>
/// A single manuscript match from the catalog search.
/// </summary>
public sealed record CatalogMatch(string Title, string Author, string Iri);
/// <summary>
/// Results of a catalog search.
/// </summary>
public sealed record CatalogResponse(IReadOnlyList<CatalogMatch> Matches);
[Capability("sample.catalog")]
public sealed class CatalogHandler : ICapabilityHandler<CatalogCommand, CatalogResponse>
{
private readonly IEntityStore _store;
public CatalogHandler(IEntityStore store) => _store = store;
public async ValueTask<ExecutionResult<CatalogResponse>> HandleAsync(
CatalogCommand command,
CapabilityContext context,
CancellationToken cancellationToken = default)
{
// Using Sdk.Operations.Linq to query the entity store for manuscripts that match the keyword in their title.
var matches = _store
.Query<Manuscript>()
.Where(m => m.Title != null && m.Title.Contains(command.Keyword))
.ToList();
return new ExecutionResult<CatalogResponse>.Ok(new CatalogResponse(
matches
.Select(m => new CatalogMatch(m.Title!, m.Author!, m.Iri))
.ToImmutableList()
));
}
}
Known Gaps
- LINQ currently is rather limited in its implementation (e.g. ToLowerInvariant() is not supported). Complex queries cannot be crafted easily.
Try It
Create Scribe
POST/api/entities/scribesβ 200
Create Manuscript
POST/api/entities/manuscriptsβ 200
Create another Manuscript
POST/api/entities/manuscriptsβ 200
Search Catalog β LINQ Where translated to repository
POST/api/capabilities/sample/catalogβ 200
Objects
Not all data fits in entity properties. Documents, images, PDFs, and other binary files need storage that's separate from the entity graph β you don't want a 50MB PDF serialized as an RDF literal. Aletheia's Object Storage slice decouples binary blobs from entity properties: entities carry object references (IRIs pointing to blobs), and the object store handles upload, download, and lifecycle independently.
The Bruno collection creates a `Seal` entity (a document with a name and description), uploads a file to its object storage, lists all seals to see the object reference, and downloads the file to verify its content survived the round-trip. In production, the in-memory store would be replaced with S3 or Azure Blob β the entity API stays identical.
What You'll Learn
- `[ObjectBearing]` β marks an entity as capable of carrying binary objects. The attribute takes a bucket name (e.g., `"seal-scrolls"`) that groups related files. The source generator creates upload and download HTTP endpoints for the entity β separate from the CRUD endpoints, so file operations don't interfere with property updates.
- Object storage architecture β files live outside the entity graph. An entity holds an `IObjectReference` (essentially a typed IRI to a blob), not the blob itself. This separation means entity queries stay fast regardless of file sizes, and objects can be stored on different infrastructure (cloud blob storage) than entities (database).
- `AddInMemoryObjectStorage()` β registers the in-memory object store for the sample. Files are stored as `byte[]` in a concurrent dictionary β fast, simple, and reset on server restart. In production, swap this for `AddS3ObjectStorage()` or `AddAzureBlobObjectStorage()` β the `IObjectStore` interface is identical, so entity code doesn't change.
- `MapObjectOperations()` β exposes the generated upload and download endpoints in the ASP.NET pipeline. Upload uses multipart form data; download returns the raw binary stream with the original content type. Both endpoints are discovered and wired automatically β no manual controller code.
Deleting Objects
The blob and the entity have independent lifecycles. Delete the object to remove the stored file β the Seal entity survives. Downloading a deleted object returns 404.
The Code
A `Seal` entity bearing an object β `[ObjectBearing("seal-scrolls")]` adds file storage to the standard entity endpoints
Entities/Seal.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Entity.Contracts;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// A Seal β a frozen record of the Library. Also demonstrates ObjectBearing:
/// each Seal can store a binary object of the sealed scroll.
/// </summary>
[Entity(Path = "seals")]
[Identity(IdentityGenerator.Random)]
[OperationEndpoints]
[ObjectBearing("seal-scrolls")]
public partial class Seal
{
[Predicate("name")]
public string Name { get; set; } = string.Empty;
[Predicate("description")]
public string Description { get; set; } = string.Empty;
[Owning("sealedManuscripts")]
public partial EntityRefCollection<Manuscript> SealedManuscripts { get; }
}
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.Repository.InMemory.DependencyInjection;
using Aletheia.Sdk.Repository.GraphDb.DependencyInjection;
using Aletheia.Sdk.ObjectStorage.Http;
using Aletheia.Sdk.ObjectStorage.Http.DependencyInjection;
using Aletheia.Sdk.ObjectStorage.InMemory.DependencyInjection;
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
// ...
repoBuilder.UseInMemory();
builder.Services.AddOperationEndpointsHttpFromAssemblyContaining<DataRecord>();
builder.Services.AddInMemoryObjectStorage();
builder.Services.AddObjectStorageHttpFromAssemblyContaining<Manuscript>();
var app = builder.Build();
// ...
app.MapOperations();
app.MapObjectOperations();
app.Run();
Try It
Create Seal
POST/api/entities/sealsβ 200
Upload File to Seal
PUT/api/objects/seals/content?iri={{06-sealIri}}β 200
| Content-Type | text/plain |
List Seals (with object metadata)
GET/api/entities/sealsβ 200
Download File from Seal
GET/api/objects/seals/content?iri={{06-sealIri}}β 200
Delete Object Blob (entity survives)
DELETE/api/objects/seals/content?iri={{06-sealIri}}β 200
Download Deleted Object (404)
GET/api/objects/seals/content?iri={{06-sealIri}}β 404
Transactions
Entity operations are atomic by default β creating a single entity either succeeds or fails. But real workflows often span multiple entities: register a manuscript and its catalog entry together, transfer a manuscript from one scribe to another, delete a scribe and all their manuscripts. If step 2 fails, step 1 must be undone. Aletheia provides transactional entity stores for exactly this β group multiple operations into a single all-or-nothing unit.
The Bruno collection demonstrates two transaction patterns: a successful registration followed by a duplicate rejection (the duplicate triggers a 409, and no orphan entity is left behind), and a deliberate duplicate-creation within a transaction that proves rollback works β neither duplicate survives.
What You'll Learn
- `ITransactionalEntityStore` β extends `IEntityStore` with transaction boundaries. Inject this instead of `IEntityStore` when your handler needs to group multiple entity operations atomically. The same DI registration serves both interfaces β the container provides the transactional implementation when you ask for it.
- `EntityOperations.BeginTransaction()` β starts a transaction scope. The pattern is a `using` block: `using var tx = _store.BeginTransaction();`. All entity operations within the block (creates, updates, deletes) are staged. When the block exits without exception, the transaction commits β all changes are persisted together. If an exception is thrown or the transaction is explicitly rolled back, nothing is persisted.
- Rollback on failure β if any operation inside a transaction fails β duplicate identity, constraint violation, or an explicit `tx.Rollback()` call β all preceding operations in that transaction are discarded. The repository never sees partial state. This is enforced at the store level, not the application level, so you can't accidentally leave orphan entities.
- Duplicate detection β identity uniqueness is enforced by the repository. Creating two entities with the same IRI (whether in the same transaction or across transactions) triggers a `DuplicateIdentityException`. Within a transaction, this causes a rollback. The Bruno collection tests this by creating two identical `CatalogEntry` entities and verifying neither persists.
The Code
Deliberately creates two identical `CatalogEntry` entities in a transaction β demonstrates rollback on duplicate identity
Capabilities/DuplicateCatalogEntryCapability.cs
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
using Aletheia.Sdk.Operations;
using Aletheia.Sdk.Repository.Transaction;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Register the same catalog entry twice to make a deliberate fail of the transaction.
/// </summary>
public sealed record DuplicateCatalogEntryCommand();
/// <summary>
/// The result of creating duplicate catalog entries. Should never be returned.
/// </summary>
public sealed record DuplicateCatalogEntryResponse();
[Capability("sample.duplicate-catalog-entry")]
public sealed class DuplicateCatalogEntryHandler : ICapabilityHandler<DuplicateCatalogEntryCommand, DuplicateCatalogEntryResponse>
{
private readonly ITransactionalEntityStore _store;
public DuplicateCatalogEntryHandler(ITransactionalEntityStore store) => _store = store;
public async ValueTask<ExecutionResult<DuplicateCatalogEntryResponse>> HandleAsync(
DuplicateCatalogEntryCommand command,
CapabilityContext context,
CancellationToken cancellationToken = default)
{
using var _ = EntityOperations.Use(_store);
var catalogEntry1 = new CatalogEntry { Author = "Plato", Title = "The Republic" };
var catalogEntry2 = new CatalogEntry { Author = "Plato", Title = "The Republic" };
await using var tx = EntityOperations.BeginTransaction();
tx.Create(catalogEntry1);
tx.Create(catalogEntry2); // This will cause a duplicate identity error when committing the transaction.
await tx.CommitAsync(cancellationToken);
return new ExecutionResult<DuplicateCatalogEntryResponse>.Ok(
new DuplicateCatalogEntryResponse()
);
}
}
Try It
Register Manuscript with Scribe β atomic transaction
POST/api/capabilities/sample/register-manuscriptβ 200
Register Duplicate Manuscript β rejected with 409
POST/api/capabilities/sample/register-manuscriptβ 422
Verify β no orphan entities persisted
GET/api/entities/scribesβ 200
Register the same catalog entries twice - must fail transaction and rollback
POST/api/capabilities/sample/duplicate-catalog-entryβ 409
Verify Rollback β catalog entries do not exist
GET/api/entities/catalog-entriesβ 200
Aspects
Validation logic scattered across handlers is hard to audit and easy to miss. Aletheia centralizes validation rules as Aspects β declarative constraints registered at startup and enforced automatically by the SDK pipeline. Aspects are defined using SHACL (Shapes Constraint Language), a W3C standard for RDF data validation, expressed as C# builder patterns that generate SHACL shapes at runtime.
There are three interception points: capability aspects guard handlers before execution, operation aspects guard entity CRUD operations, and query aspects filter or block repository queries. A fourth family β view aspects β never intercepts anything: it validates JSON form values on demand and reports findings back to the browser. The Bruno collection validates all four: a capability request that passes aspects (200), one that fails (422 with a structured error), an entity operation blocked by an aspect, a query whose results are filtered by matching aspects, and form values judged by the Manuscript view shape.
What You'll Learn
- `IAspectStore` β the central registry of all validation rules in the system. Aspects are registered during DI configuration (in `Program.cs` regions) and enforced at runtime by the aspect engine. The store holds SHACL shapes and evaluates them against commands, entities, and queries as they flow through the pipeline.
- Capability aspects β intercept capability handlers before execution. When a command arrives, the aspect engine checks it against all registered capability aspects. If a SHACL shape matches the command type and the command violates a constraint (e.g., `sh:minLength` on a string property), the handler never executes β the SDK returns 422 with the violation details. This keeps validation out of handler code entirely.
- Operation aspects β guard entity create, update, and delete operations. Before the repository persists a change, operation aspects validate the entity against registered SHACL shapes. A failed validation blocks the operation and returns an error. Unlike capability aspects which guard the command, operation aspects guard the entity's state β they run regardless of which capability triggered the operation.
- Query aspects β the most subtle of the three. Instead of blocking queries, query aspects filter them β they inject additional `Where` clauses into LINQ queries before execution. This enables patterns like multi-tenancy (automatically adding `Where(e => e.TenantId == currentTenant)`) or soft-delete filtering. They can also block queries entirely if a condition is not met.
- View aspects β the feedback family. They are never enforced in a pipeline: the exploration endpoints serve their Turtle source to the browser, and the view aspect engine validates JSON form values on demand (`POST api/entities/aspect-definitions/{iri}/validate`). Findings of every severity β `Violation`, `Warning`, `Info` β come back mapped to JSON paths (`title`, `pages`). The JSON key is the predicate: every `sh:path` lives in the canonical JSON namespace (`https://www.aletheia.arkenforge.de/json/`), so no application-specific predicate namespaces exist.
- SHACL shapes β the underlying constraint language. Shapes are defined using a C# builder API that mirrors SHACL's RDF vocabulary: `sh:minLength`, `sh:maxLength`, `sh:pattern` (regex), `sh:minInclusive`, `sh:maxInclusive`, and more. The builder generates SHACL RDF at runtime, which the aspect engine evaluates. This means aspects are data, not code β they could theoretically be loaded from a database or configuration file.
Object-Lock Aspect
Aspects aren't limited to entity properties β they guard object uploads too. The seal-lock aspect in Aspects/ObjectAspects.cs prevents re-uploading a seal's scroll: once the seal bears an object, the lock engages.
This is the same Operation Aspect mechanism shown above, applied to the [ObjectBearing] upload route. Instead of a SHACL shape with sh:property constraints, it uses a pure SPARQL WHERE clause β if the entity's objectKey is bound to any value, the aspect fires. Null means first upload (allowed); non-null means locked (rejected with 422).
Delete the blob (chapter 06) to release the lock, then re-upload freely.
β οΈ Watch for: The aspect IRI must be sent as the X-Aletheia-Operation-AspectIri header on the upload request. Without the header, the aspect engine doesn't know which aspects to evaluate β and the upload proceeds unguarded.
The Code
Registers SHACL-based aspects for capability handlers β e.g. catalog keyword minimum length
Aspects/CapabilityAspects.cs
using Aletheia.Sdk.Aspects.Abstractions;
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.Message;
public static class CapabilityAspects
{
public static void RegisterCapabilityAspects(this IAspectStore aspectStore)
{
// Registers a message aspect that validates catalog search keywords.
// The aspect uses SHACL (Shapes Constraint Language) to define the validation rules.
// The aspect is identified by the IRI "urn:aletheia:aspects:catalog-keyword-v1" and is associated with the CatalogCommand entity type.
// The validation rules specify that a catalog search keyword must be at least 3 characters long.
// The aspect is registered with the aspect store, making it available for use in the application.
var catalogKeywordAspect = new InlineTtlMessageAspect("urn:aletheia:aspects:catalog-keyword-v1", """
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix aletheia: <https://www.aletheia.arkenforge.de/> .
<urn:aletheia:aspects:catalog-keyword-shape>
a sh:NodeShape ;
sh:targetClass <urn:Aletheia.Sdk.Sample.CatalogCommand> ;
sh:property [
sh:path aletheia:Keyword ; sh:minLength 3 ;
sh:message "A catalog search keyword must be at least 3 characters." ;
] .
""");
aspectStore.RegisterMessage(catalogKeywordAspect);
aspectStore.RegisterCapabilityAspect(new CapabilityAspect
{
Iri = "urn:aletheia:aspects:capability:catalog-v1",
CommandAspectIri = "urn:aletheia:aspects:catalog-keyword-v1"
});
catalogKeywordAspect = new InlineTtlMessageAspect("urn:aletheia:aspects:catalog-keyword-v2", """
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix aletheia: <https://www.aletheia.arkenforge.de/> .
<urn:aletheia:aspects:catalog-keyword-shape>
a sh:NodeShape ;
sh:targetClass <urn:Aletheia.Sdk.Sample.CatalogCommand> ;
sh:property [
sh:path aletheia:Keyword ; sh:minLength 3 ;
sh:message "A catalog search keyword must be at least 3 characters." ;
] .
""");
aspectStore.RegisterMessage(catalogKeywordAspect);
aspectStore.RegisterCapabilityAspect(new CapabilityAspect
{
Iri = "urn:aletheia:aspects:capability:catalog-v2",
CommandAspectIri = "urn:aletheia:aspects:catalog-keyword-v2"
});
}
}
- The Catalog Keyword Aspect is demonstrated in 16-Authorization
Registers aspects that guard entity create/update/delete operations
Aspects/OperationAspects.cs
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.Operation;
public static class OperationAspects
{
public static void RegisterOperationAspects(this IAspectStore aspectStore)
{
// Registers an operation aspect that validates manuscripts before they are written to the repository.
// The aspect uses SHACL (Shapes Constraint Language) to define the validation rules.
// The aspect is identified by the IRI "urn:aletheia:aspects:manuscript-write-v1" and is associated with the Manuscript entity type.
// The validation rules specify that a manuscript must have a non-empty title.
// The aspect is registered with the aspect store, making it available for use in the application.
aspectStore.RegisterOperation(new InlineTtlOperationAspect("urn:aletheia:aspects:manuscript-write-v1", """
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix manuscripts: <https://www.aletheia.arkenforge.de/predicates/manuscripts/> .
<urn:aletheia:aspects:manuscript-shape>
a sh:NodeShape ;
sh:targetClass <https://www.aletheia.arkenforge.de/types/manuscripts> ;
sh:property [
sh:path manuscripts:title ; sh:minCount 1 ; sh:minLength 1 ;
sh:message "A manuscript must have a non-empty title." ;
] .
""", null)
);
}
}
Registers aspects that filter or block entity queries
Aspects/QueryAspects.cs
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.Query;
public static class QueryAspects
{
public static void RegisterQueryAspects(this IAspectStore aspectStore)
{
// Registers a query aspect for filtering manuscripts based on their seal status.
// The aspect uses SPARQL (Semantic Web Query Language) to define the query rules.
// The aspect is identified by the IRI "urn:aletheia:aspects:manuscript-query-v1" and is associated with the Manuscript entity type.
// The query rules specify that only manuscripts that are not sealed should be returned.
// The aspect is registered with the aspect store, making it available for use in the application.
var manuscriptQueryAspect = new InlineTtlQueryAspect("urn:aletheia:aspects:manuscript-query-v1",
"""
?manuscript <https://www.aletheia.arkenforge.de/predicates/manuscripts/isSealed> ?sealed .
FILTER(?sealed = false)
""",
null);
aspectStore.RegisterQuery(manuscriptQueryAspect);
}
}
Registers aspects that guard object storage uploads β the seal-lock aspect prevents re-upload
Aspects/ObjectAspects.cs
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.Operation;
/// <summary>
/// Object-level aspects β guards for [ObjectBearing] upload routes.
/// These apply to the object storage pipeline, not entity properties.
/// </summary>
public static class ObjectAspects
{
public static void RegisterObjectAspects(this IAspectStore aspectStore)
{
// Registers an object-lock aspect that prevents re-uploading a Seal's scroll.
// Once the Seal entity has an objectKey (a blob has been uploaded), the aspect
// blocks further uploads. Delete the blob first to unlock.
// Uses a pure SPARQL WHERE clause β no SHACL shape, just a graph pattern check.
aspectStore.RegisterOperation(new InlineTtlOperationAspect("urn:aletheia:aspects:operation:seal-lock-v1", null, """
?entityIri <https://www.aletheia.arkenforge.de/predicates/seals/objectKey> ?existingKey .
BIND(?entityIri AS ?focusNode)
BIND("This seal already bears a scroll. Delete the object before re-uploading." AS ?message)
"""));
}
}
Registers the Manuscript form view β the browser validates against it before submit; feedback, never enforcement
Aspects/ViewAspects.cs
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.View;
/// <summary>
/// Registers the frontend-purpose view aspects of the tutorial β the Manuscript
/// form shape the browser validates against before submitting.
/// <br/><br/>
/// Views are feedback, never enforcement: the capability pipeline never checks
/// them. The exploration endpoints serve their Turtle source, and the view
/// aspect engine validates JSON form values on demand
/// (<c>POST api/entities/aspect-definitions/{iri}/validate</c>), reporting
/// findings of every severity mapped to JSON paths.
/// <br/><br/>
/// Every <c>sh:path</c> is a predicate in the canonical JSON namespace
/// (<see cref="Aletheia.Sdk.Aspects.Abstractions.Aspect.JsonNamespace"/>) β its
/// local name is the JSON key.
/// </summary>
public static class ViewAspects
{
public static void RegisterViewAspects(this IAspectStore aspectStore)
{
// The Manuscript form shape: title required (violation when empty),
// pages a positive integer, authorNote optional but warned on when
// shorter than five characters.
aspectStore.RegisterView(new InlineTtlViewAspect("urn:aletheia:views:manuscript-form", """
@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix json: <https://www.aletheia.arkenforge.de/json/> .
<urn:aletheia:views:manuscript-form>
a sh:NodeShape ;
sh:targetClass <urn:aletheia:views:ManuscriptForm> ;
sh:property [
sh:path json:title ; sh:minCount 1 ; sh:minLength 1 ;
sh:datatype xsd:string ;
sh:message "view.manuscript.title" ;
] ;
sh:property [
sh:path json:pages ; sh:minCount 1 ;
sh:datatype xsd:integer ; sh:minInclusive 1 ;
sh:message "view.manuscript.pages" ;
] ;
sh:property [
sh:path json:authorNote ;
sh:datatype xsd:string ; sh:minLength 5 ;
sh:severity sh:Warning ;
sh:message "view.manuscript.authorNote" ;
] .
""")
);
}
}
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
app.MapObjectOperations();
CapabilityAspects.RegisterCapabilityAspects(app.Services.GetRequiredService<IAspectStore>());
QueryAspects.RegisterQueryAspects(app.Services.GetRequiredService<IAspectStore>());
OperationAspects.RegisterOperationAspects(app.Services.GetRequiredService<IAspectStore>());
ObjectAspects.RegisterObjectAspects(app.Services.GetRequiredService<IAspectStore>());
ViewAspects.RegisterViewAspects(app.Services.GetRequiredService<IAspectStore>());
app.Run();
Known Gaps
- Aspects should not define their own urls but should be first class entities
- The registration of aspects is non-speaking at the moment. We register CapabilityAspects and inside we have MessageAspects. The architectural purpose is clear. Capabilities, operations and queries are all called via some form of command (and each command is a message wrt. message driven design). But as developer it is difficult to follow.
- The ObjectAspects.cs demonstrates an example of checking the existing entity and its graph before applying the operation. This is a very important feature of the SDK. We should demonstrate this in the sample as well.
- Query aspects are not yet supported in the Aletheia SDK. This method is a placeholder for future implementation.
- The endpoints defined by AspectsEntityEndpointRouteBuilderExtensions are not compliant with the capability architecture β they bypass the capability dispatcher pipeline and do not follow entity operation conventions and is served directly from the aspect-definition surface.
Try It
Operation
Create Manuscript Without Title (should fail β aspect enforces min length)
POST/api/entities/manuscriptsβ 422
| X-Aletheia-Operation-AspectIri | urn:aletheia:aspects:manuscript-write-v1 |
Create Valid Manuscript (should succeed)
POST/api/entities/manuscriptsβ 200
| X-Aletheia-Operation-AspectIri | urn:aletheia:aspects:manuscript-write-v1 |
Capability
Search Catalog β short keyword rejected by Capability Aspect
POST/api/capabilities/sample/catalogβ 422
| X-Aletheia-Capability-AspectIri | urn:aletheia:aspects:capability:catalog-v1 |
Search Catalog β valid keyword passes Capability Aspect
POST/api/capabilities/sample/catalogβ 200
| X-Aletheia-Capability-AspectIri | urn:aletheia:aspects:capability:catalog-v1 |
Query
BUGGED: Create Sealed Manuscript (hidden by query aspect)
POST/api/entities/manuscriptsβ 200
| X-Aletheia-Operation-AspectIri | urn:aletheia:aspects:manuscript-write-v1 |
BUGGED: List Manuscripts β sealed ones are hidden
GET/api/entities/manuscriptsβ 200
| X-Aletheia-Query-AspectIri | urn:aletheia:aspects:manuscript-query-v1 |
Object
Create Seal for Lock Demo
POST/api/entities/sealsβ 200
First Upload β Lock Aspect Allows (no objectKey yet)
PUT/api/objects/seals/content?iri={{08-lockSealIri}}β 200
| X-Aletheia-Operation-AspectIri | urn:aletheia:aspects:operation:seal-lock-v1 |
Re-upload Blocked β Lock Aspect Engages (objectKey exists)
PUT/api/objects/seals/content?iri={{08-lockSealIri}}β 422
| X-Aletheia-Operation-AspectIri | urn:aletheia:aspects:operation:seal-lock-v1 |
View
Read Manuscript View Turtle (form shape served to the browser)
GET/api/entities/aspect-definitions/urn:aletheia:views:manuscript-form/viewβ 200
Validate Manuscript Form (conforming value β no findings)
POST/api/entities/aspect-definitions/urn:aletheia:views:manuscript-form/validateβ 200
Validate Manuscript Form (empty title β violation with JSON path)
POST/api/entities/aspect-definitions/urn:aletheia:views:manuscript-form/validateβ 200
Validate Manuscript Form (short author note β warning reported)
POST/api/entities/aspect-definitions/urn:aletheia:views:manuscript-form/validateβ 200
Events
Entities don't exist in isolation β when a manuscript is created, other parts of the system may need to react: index it for search, notify subscribers, trigger a workflow. Aletheia's messaging slice builds this directly into the repository: every entity mutation (create, update, delete) automatically publishes an event to a message bus. Subscribers consume these events asynchronously through a simple `IAsyncEnumerable` interface.
The Bruno collection walks through the full event lifecycle: create a `Manuscript` and verify an `EntityCreated` event was published, update it and see `EntityUpdated`, delete it and see `EntityDeleted`. An `EntityEventLog` singleton captures every event, and a capability handler exposes the log for verification.
What You'll Learn
- Entity messaging β the repository is the event source. When an entity is created, updated, or deleted, the repository publishes a typed event (`EntityCreated<T>`, `EntityUpdated<T>`, `EntityDeleted<T>`) to the message bus automatically. You don't write publish calls β the repository does it as part of the operation. This ensures events are never missed and always consistent with the entity state.
- `AddEntityMessaging<T>()` β registers a specific entity type for event publication. Each entity type needs its own call because each has a unique `TypeIri` (the RDF type identifier) that becomes part of the message topic. Only registered entity types publish events β entities without this registration mutate silently.
- `IMessageConsumer<TKey, TPayload>` β the subscriber interface. `ConsumeAsync(topic)` returns an `IAsyncEnumerable<Message<TKey, TPayload>>` β a streaming sequence of messages. Subscribers `await foreach` over the stream in a `BackgroundService`, processing each message as it arrives. The async enumerable never completes; it yields messages as long as the server is running.
- `EntityEventLog` β a thread-safe singleton that captures every entity event. The `ManuscriptSubscription` appends each event to the log, and the `EntityEventLogCapability` exposes the log via HTTP. This pattern β event sourcing into an in-memory log β is for verification in the sample; production systems would persist events to a database or event store.
- `BackgroundService` β the standard .NET pattern for long-running background work. `ExecuteAsync` is called when the server starts and runs until shutdown. Inside, the subscriber runs an `await foreach` over the message stream. The SDK handles serialization, topic management, and delivery β the subscriber only sees typed C# messages.
The Code
Queries the `EntityEventLog` β returns all events or events for a specific entity IRI
Capabilities/EntityEventLogCapability.cs
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Returns all evnets for a given entity IRI, or all events if no IRI is provided.
/// </summary>
/// <param name="Iri">Iri of the entity to retrieve events for. If null, all events are returned.</param>
public sealed record EntityEventLogCommand(string? Iri);
/// <summary>
/// Response carries the events
/// </summary>
public sealed record EntityEventLogResponse(IReadOnlyList<EntityEventLogEntry> Events);
[Capability("sample.entity-event-log")]
public sealed class EntityEventLogHandler : ICapabilityHandler<EntityEventLogCommand, EntityEventLogResponse>
{
private readonly EntityEventLog _log;
public EntityEventLogHandler(EntityEventLog log)
{
_log = log;
}
public async ValueTask<ExecutionResult<EntityEventLogResponse>> HandleAsync(
EntityEventLogCommand command,
CapabilityContext context,
CancellationToken cancellationToken = default)
{
if (command.Iri is null)
return new ExecutionResult<EntityEventLogResponse>.Ok(
new EntityEventLogResponse(_log.GetAll()));
return new ExecutionResult<EntityEventLogResponse>.Ok(
new EntityEventLogResponse(_log.GetByIri(command.Iri)));
}
}
A singleton log that captures every entity event as it's published
Subscriptions/EntityEventLog.cs
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Entry in the in-process entity event log used by the event demo.
/// </summary>
public sealed record EntityEventLogEntry(
string EntityIri,
string TypeName,
string Operation,
string BranchIri,
string Topic,
DateTimeOffset TimestampUtc);
/// <summary>
/// Thread-safe in-process log of entity change events.
/// Exposed via GET /api/diagnostics/entity-events.
/// </summary>
public sealed class EntityEventLog
{
private readonly List<EntityEventLogEntry> _entries = [];
private readonly object _lock = new();
public void Add(EntityEventLogEntry entry)
{
lock (_lock) _entries.Add(entry);
}
public IReadOnlyList<EntityEventLogEntry> GetAll()
{
lock (_lock) return [.. _entries];
}
public IReadOnlyList<EntityEventLogEntry> GetByIri(string iri)
{
lock (_lock) return [.. _entries.Where(e => e.EntityIri == iri)];
}
}
A `BackgroundService` that consumes `Manuscript` events and logs them
Subscriptions/ManuscriptSubscription.cs
using Aletheia.Sdk.Entity.Messaging;
using Aletheia.Sdk.Messaging;
using Microsoft.Extensions.Hosting;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Background service that listens for Manuscript changes and logs them.
/// Part of Chapter 07: The Whisper β every new Manuscript whispers through the Library.
/// </summary>
internal sealed class ManuscriptSubscription (
IMessageConsumer<string, EntityChangedEnvelope<Manuscript>> consumer,
EntityEventLog log) : BackgroundService
{
internal const string Topic = "aletheia.entities.manuscript.history";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var envelope in consumer.ConsumeAsync(Topic, stoppingToken).ConfigureAwait(false))
{
log.Add(new EntityEventLogEntry(
EntityIri: envelope.Payload.Iri.ToString(),
TypeName: envelope.Payload.TypeName,
Operation: envelope.Payload.Operation.ToString(),
BranchIri: envelope.Payload.BranchIri,
Topic: Topic,
TimestampUtc: envelope.TimestampUtc));
}
}
}
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.ObjectStorage.InMemory.DependencyInjection;
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Entity.Messaging.DependencyInjection;
using Aletheia.Sdk.Messaging.InMemory.DependencyInjection;
using Aletheia.Sdk.Branch.Http;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
builder.Services.AddMessagingInMemory();
builder.Services.AddEntityEvents();
builder.Services.AddEntityMessaging<Manuscript>(opts =>
opts.TypeIri = "https://www.aletheia.arkenforge.de/entities/Manuscript");
builder.Services.AddSingleton<EntityEventLog>();
builder.Services.AddHostedService<ManuscriptSubscription>();
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GreetHandler>();
builder.Services.AddCapabilityHttp();
// ...
Known Gaps
- Inconsistent methodology of AddEntityMessaging (compared to Capability or Entity registration).
- Free choice of TypeIri regarding the topic-naming of the event-bus (should be standardized and auto-derived)
- Using .AddEntityEvents() AFTER .AddBranchHttp() breaks the snapshotting guard. No exception is thrown, it breaks silently. A bugfix is required.
Try It
Create Manuscript (triggers an event)
POST/api/entities/manuscriptsβ 200
Update Manuscript (triggers an event)
PUT/api/entities/manuscripts?iri={{09-manuscriptIri}}β 200
Delete Manuscript (triggers an event)
DELETE/api/entities/manuscripts?iri={{09-manuscriptIri}}β 200
Check Entity Events
POST/api/capabilities/sample/entity-event-logβ 200
Branching
Sometimes you need to experiment without affecting the main timeline. Branching gives you isolated workspaces: create a named branch, make changes, and nobody outside the branch sees them until you merge. This is the same concept as git branches, but applied to entity data β create entities in a branch, query within the branch scope, and merge back when ready.
The Bruno collection demonstrates the full lifecycle: create a branch, create entities inside it (invisible from the default branch), list within the branch scope to confirm visibility, create a nested branch from a branch, merge back, and verify that merged entities appear in the parent. Branching is exercised entirely through HTTP β no dedicated chapter code, just the built-in branch endpoints.
What You'll Learn
- Branch isolation β a branch is a named scope that isolates all entity operations within it. Creating an entity in a branch makes it visible only to queries scoped to that branch. The default branch (sometimes called "main") sees only its own entities. This enables parallel workflows, experimentation, and draft-review-publish patterns without data duplication β entities exist once, with branch affiliation metadata.
- Default branch β the root timeline. Operations without an explicit branch scope write to the default branch. Most applications work exclusively in the default branch β branching is opt-in, not a mandatory indirection layer.
- Merge β integrate a branch's changes back into its parent. The merge operation replays the branch's entity mutations (creates, updates, deletes) into the parent's scope. If conflicts arise (e.g., the same entity was modified in both the branch and the parent), merge fails and the conflict must be resolved β the sample demonstrates a clean merge with no conflicts.
- Branch from branch β branches can nest arbitrarily. Create a branch from another branch, work in the nested branch, merge it back to its parent, then merge the parent to the default. This enables hierarchical workflows: feature branches off of epic branches, experiment branches off of feature branches.
Merge Conflicts
What happens when two changes collide? The merge engine detects conflicts at the entity level. The simplest demonstration: merging a branch into itself always fails with INVALID_MERGE.
β οΈ Watch for: Conflict detection is entity-level β two branches touching the same entity IRI blocks the second merge, regardless of which properties changed. In a real workflow, resolve conflicts by reconciling entity state, then retry.
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.Messaging.InMemory.DependencyInjection;
using Aletheia.Sdk.Branch.Http;
using Aletheia.Sdk.Branch.Http.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
// ...
builder.Services.AddSingleton<EntityEventLog>();
builder.Services.AddHostedService<ManuscriptSubscription>();
builder.Services.AddBranchHttp(builder.Configuration);
builder.Services.AddBranchHttpWithAspects(builder.Configuration);
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GreetHandler>();
builder.Services.AddCapabilityHttp();
// ...
ObjectAspects.RegisterObjectAspects(app.Services.GetRequiredService<IAspectStore>());
ViewAspects.RegisterViewAspects(app.Services.GetRequiredService<IAspectStore>());
app.UseBranchScope();
app.MapBranches();
app.Run();
- .AddBranchHttp() must be called before capability registration .AddCapabilityHttp(), otherwise internal declared capabilities will not be available (e.g. branch.merge)
Known Gaps
- Using both .AddBranchHttp() and .AddBranchHttpWithAspects() is inconsistent. We should standardize on one method for adding BranchHttp. Aspects are a core functionality and cannot be disabled anyways.
Try It
Create a Branch (alternate timeline)
POST/api/branchesβ 200
Create a Manuscript inside the Branch
POST/api/entities/manuscriptsβ 200
| X-Aletheia-BranchIri | {{10-branchIri}} |
List Manuscripts in the Branch (should show the branch data)
GET/api/entities/manuscriptsβ 200
| X-Aletheia-BranchIri | {{10-branchIri}} |
List Manuscripts in Default Branch (should NOT show branch data)
GET/api/entities/manuscriptsβ 200
Create a Sub-Branch (branch from a branch)
POST/api/branchesβ 200
Merge Branch Back to Default
POST/api/capabilities/branch/mergeβ 200
Verify Merged Data in Default
GET/api/entities/manuscriptsβ 200
Self-Merge Rejected (conflict detection)
POST/api/capabilities/branch/mergeβ 422
Snapshots
Branches let you work in parallel; snapshots let you freeze a moment forever. A snapshot captures the entire state of a branch at a point in time β every entity, every property, every relationship β into an immutable record. Once created, a snapshot can never be modified. It's a read-only window into the past, perfect for audits, version history, and compliance.
The Bruno collection creates a branch, populates it with entities, snapshots the branch, and then proves immutability by attempting to write to the snapshotted state β which the repository blocks. Listing entities within the snapshot shows exactly what existed at freeze time, unchanged by any subsequent mutations.
What You'll Learn
- Snapshot creation β capture a branch's complete entity state with a single API call. The snapshot records every entity, property, and relationship as they exist at that instant. Snapshots are first-class entities themselves β they have IRIs, can be listed, and can be referenced. Creating a snapshot is a point-in-time operation; entities created after the snapshot are not included.
- Snapshot immutability β the core guarantee. Once a snapshot exists, no operation β create, update, delete, merge β can modify its contents. The repository enforces this at the lowest level: any write targeting a snapshotted scope is rejected before it touches storage. Snapshots are append-only history β you can create new ones, but you can't change old ones.
- Snapshot listing β query entities as they existed at the snapshot moment. The snapshot provides a read-only view: you can list entities, read their properties, and traverse their relationships exactly as they were when the snapshot was taken. This is a point-in-time query β the current state of those entities may have changed, but the snapshot shows the frozen version.
- Snapshot guards β the enforcement mechanism. Guards are repository-level checks that intercept write operations and verify the target scope is not snapshotted. Guards run before any mutation, so there's no window where a write could sneak through. The Bruno collection tests this by attempting a write to a snapshotted branch and verifying the 4xx rejection.
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
app.UseBranchScope();
app.MapBranches();
app.MapSnapshots();
app.Run();
Try It
Create a Branch for a snapshot
POST/api/branchesβ 200
Add Manuscript to the branch before freezing
POST/api/entities/manuscriptsβ 200
| X-Aletheia-BranchIri | {{11-branchIri}} |
List data in the branch (isolated from default)
GET/api/entities/manuscriptsβ 200
| X-Aletheia-BranchIri | {{11-branchIri}} |
Create Snapshot from Branch
POST/api/snapshotsβ 201
Verify Snapshot Immutable β Mutation Rejected
PUT/api/entities/manuscripts?iri={{11-manuscriptIri}}β 422
| X-Aletheia-BranchIri | {{11-snapshotIri}} |
List data in snapshot β frozen point-in-time view
GET/api/entities/manuscriptsβ 200
| X-Aletheia-BranchIri | {{11-snapshotIri}} |
Trees
Entities model flat objects with relationships, but some domains are inherently hierarchical: organizational charts, file systems, taxonomies, bill of materials. Aletheia's Structure slice adds a tree data model on top of the entity system β nodes with parents, children, and arbitrary depth, with built-in capabilities for traversal and manipulation.
The Bruno collection creates a root node, adds children, creates usage relationships between nodes, retrieves the full tree with all descendants, and filters a tree by a flag condition. Like branching and snapshots, trees are exercised entirely through HTTP β the built-in endpoints handle the hierarchy logic.
What You'll Learn
- `Structure` slice β the SDK's hierarchical data model. A tree consists of nodes connected by parent-child edges. Each node can carry properties (key-value pairs), flags (boolean markers), and usage relationships (directed edges between arbitrary nodes). The tree is stored alongside entities β nodes have IRIs and can reference entities, bridging hierarchical and entity models.
- Tree capabilities β the Structure slice ships with built-in capability handlers: create a root node, add a child node, remove a node (and its subtree), move a node to a different parent, create a usage relationship, query a tree by flag, and retrieve the full hierarchy. These are standard `[Capability]` handlers β you can use them as-is or wrap them in your own capabilities.
- Tree configuration β `AddStructure()` initializes the tree engine and registers its internal services. `GetConfiguredTreeHandler` is a built-in capability that returns metadata about all configured trees in the system β useful for exploration and debugging.
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.Branch.Http.DependencyInjection;
using Aletheia.Sdk.Structure;
using Aletheia.Sdk.Structure.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
// ...
builder.Services.AddBranchHttp(builder.Configuration);
builder.Services.AddBranchHttpWithAspects(builder.Configuration);
builder.Services.AddStructure();
builder.Services.AddOperationEndpointsHttp(typeof(Node).Assembly);
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GetConfiguredTreeHandler>();
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GreetHandler>();
builder.Services.AddCapabilityHttp();
// ...
- .AddStructure() must be called before capability registration .AddCapabilityHttp(), otherwise internal declared capabilities will not be available (see also 10-Branching)
Known Gaps
- The Sdk already provides variable tree structures in an experimental state. Update this sample to demonstrate and test this behavior.
Try It
Create Root Node (Great Library)
POST/api/entities/structure-nodesβ 200
Create Child Node (Philosophy Wing)
POST/api/entities/structure-nodesβ 200
Create Usage Edge (Root β Child)
POST/api/entities/structure-usagesβ 200
Get Configured Tree
POST/api/capabilities/structure/configured-tree/getβ 200
Get Configured Tree with Flag Option
POST/api/capabilities/structure/configured-tree/getβ 200
Exploration
As your application grows, you'll want to know what's registered: which entity types exist, what capabilities are available, which aspects guard which operations. Aletheia supports runtime introspection β the SDK can describe its own configuration through standard entity endpoints. This is not documentation; it's live, queryable metadata that reflects the actual running system.
The Bruno collection sends three requests: list all aspect definitions, list all capability handler definitions, and list all entity type definitions. Each returns structured data β the same metadata the SDK uses internally to route requests, validate commands, and manage the repository.
What You'll Learn
- Runtime introspection β the SDK exposes its own configuration as entities. Aspect definitions, capability handler metadata, and entity type descriptors are all queryable through standard entity endpoints. This means you can build admin dashboards, generate API documentation, or validate configuration at runtime β all using the same `IEntityStore.Query<T>()` you already know.
- `AddAspectsEntity()` β registers an entity type that represents aspect definitions. Each instance describes one aspect: its name, target type (capability, operation, or query), SHACL shape details, and registration metadata. Querying this entity lists every aspect currently enforced by the system.
- `AddCapabilityEntity()` β registers an entity type for capability handler metadata. Takes an assembly reference β `AddCapabilityEntity(typeof(GreetHandler).Assembly)` β and the SDK scans that assembly for all `[Capability]`-decorated handlers. Each instance describes a handler: its capability ID, command type, response type, and route information.
- `AddEntityEntity()` β registers an entity type for entity type metadata. Similar to capability introspection, it scans an assembly for all `[Entity]`-decorated classes and exposes their metadata: entity name, identity type, properties, and whether they have operation endpoints enabled.
- `MapEntityEntity()` / `MapCapabilityEntity()` / `MapAspectsEntity()` β exposes the introspection endpoints in the ASP.NET pipeline. Each maps the corresponding introspection entity type to HTTP endpoints. They're called separately so you can expose some types of metadata but not others (e.g., expose capabilities and entities but keep aspects internal).
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.Structure;
using Aletheia.Sdk.Structure.DependencyInjection;
using Aletheia.Sdk.Aspects.Entity;
using Aletheia.Sdk.Capability.Entity;
using Aletheia.Sdk.Entity.Entity;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
// ...
builder.Services.AddInMemoryObjectStorage();
builder.Services.AddObjectStorageHttpFromAssemblyContaining<Manuscript>();
builder.Services.AddAspectsEntity();
builder.Services.AddCapabilityEntity(typeof(GreetHandler).Assembly);
builder.Services.AddEntityEntity(typeof(Manuscript).Assembly);
var app = builder.Build();
// ...
app.MapSnapshots();
app.MapEntityEntity();
app.MapCapabilityEntity();
app.MapAspectsEntity();
app.Run();
Known Gaps
- Stating the assemlies is a nice idea. However, can we make this "easier" in usage?
- The provided response is messy and still under construction. Handle with care and expect updates. It feels rather bulky and not "entity-centric".
Try It
List Aspect Definitions
GET/api/entities/aspect-definitionsβ 200
List Capability Definitions
GET/api/entities/capability-definitionsβ 200
List Entity Definitions
GET/api/entities/entity-definitionsβ 200
Background Processing
Not every request can complete in milliseconds. Long-running work β generating reports, processing uploads, sending notifications β shouldn't block an HTTP request. Aletheia's messaging slice supports fire-and-forget patterns: a capability publishes a message and returns immediately, while a background service picks up the message and does the heavy lifting.
The Bruno collection demonstrates the polling pattern: send an echo request and get back an immediate 202 Accepted with a result IRI, then poll that IRI until the background service has processed the echo and the result is ready. The simulated delay in the subscriber proves the work happens asynchronously β the initial request returns before processing completes.
What You'll Learn
- `IMessageProducer<TKey, TPayload>` β the publishing side of the messaging contract. The capability handler creates a message and calls `_producer.ProduceAsync(topic, key, payload)`. The call returns as soon as the message is accepted by the bus β it doesn't wait for a consumer to process it. This is the fire-and-forget pattern: the producer's responsibility ends when the message is queued.
- `IMessageConsumer<TKey, TPayload>` β the consuming side. Returns `IAsyncEnumerable<Message<TKey, TPayload>>` β an endless stream of messages from a topic. Consumers use `await foreach` inside a `BackgroundService.ExecuteAsync` to process messages as they arrive. The consumer controls concurrency: process one at a time, batch them, or spawn parallel tasks β the `IAsyncEnumerable` gives you full control.
- `BackgroundService` β the .NET base class for long-running hosted services. ASP.NET Core calls `ExecuteAsync` when the server starts and cancels the `CancellationToken` on shutdown. Inside, you typically run an `await foreach` over a message stream. The service is registered with `AddHostedService<T>()` and starts automatically β no manual thread management.
- Polling pattern β a common async workflow pattern. Step 1: the capability creates a pending `EchoResult` entity (status: `Processing`), publishes a message, and returns 202 Accepted with the result IRI. Step 2: the background subscriber consumes the message, does the work (with a simulated delay), and updates the result entity (status: `Ready`). Step 3: the client polls the result IRI until status changes. This decouples request from processing β the HTTP call never blocks on heavy work.
Async Capability Dispatch
Raw messaging works, but it breaks the capability contract β you publish a message, poll an entity, and reassemble the result yourself. The handler never receives a typed command.
IAsyncCapabilityDispatcher keeps the pattern intact. Call PublishAsync(command) to enqueue the capability on the message bus. A background pump (CapabilityCommandPumpService) picks it up, creates a DI scope, resolves the handler, calls HandleAsync, and publishes the reply. The capability contract β command β handler β result β is preserved across the async boundary.
The AsyncEcho handler demonstrates the pattern: same "echo a message, poll for result" flow as the raw messaging example above, but using the dispatcher instead of IMessageProducer directly. Compare the code β the dispatcher version has no topic strings and no MessageEnvelope construction.
β οΈ Watch for: AddCapabilityMessaging() in Program.cs scans all [Capability]-decorated handlers and wires producer, consumer, and pump services for each. This means every handler becomes async-dispatchable β including GreetHandler and EchoHandler from earlier chapters. Use the dispatcher when the work is naturally a capability β a command with a typed response. Use raw messaging only when the work doesn't fit the capability pattern or you need custom topic routing.
The Code
Accepts an echo request, creates a pending `EchoResult`, publishes a message, and returns immediately
Capabilities/EchoCapability.cs
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
using Aletheia.Sdk.Messaging;
using Aletheia.Sdk.Operations;
using Aletheia.Sdk.Repository;
using Aletheia.Sdk.Repository.Contracts;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Fire-and-forget: accept the request, store a pending result, return immediately.
/// </summary>
public sealed record EchoCommand(string Message);
/// <summary>
/// Response carries the result IRI β the caller polls this to check completion.
/// </summary>
public sealed record EchoResponse(string ResultIri, bool Accepted);
[Capability("sample.echo")]
public sealed class EchoHandler : ICapabilityHandler<EchoCommand, EchoResponse>
{
private readonly IEntityStore _store;
private readonly IMessageProducer<string, EchoResult> _producer;
public EchoHandler(IEntityStore store, IMessageProducer<string, EchoResult> producer)
{
_store = store;
_producer = producer;
}
public async ValueTask<ExecutionResult<EchoResponse>> HandleAsync(
EchoCommand command,
CapabilityContext context,
CancellationToken cancellationToken = default)
{
using var _ = EntityOperations.Use(_store);
// Create the pending result entity
var result = new EchoResult { OriginalMessage = command.Message, IsReady = false };
await EntityOperations.CreateAsync(result, cancellationToken);
// Publish to the echo topic for background processing
await _producer.ProduceAsync(new MessageEnvelope<EchoResult>(
"aletheia.echo.requests",
result.Iri,
result,
ExecutionScope.Current ?? new ExecutionCorrelation(),
DateTimeOffset.UtcNow), cancellationToken);
return new ExecutionResult<EchoResponse>.Ok(
new EchoResponse(result.Iri, true));
}
}
The `AsyncEchoHandler` β creates a pending `EchoResult`, dispatches via `IAsyncCapabilityDispatcher`, returns immediately
Capabilities/AsyncEchoCapability.cs
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Capability.Messaging;
using Aletheia.Sdk.Execution;
using Aletheia.Sdk.Operations;
using Aletheia.Sdk.Repository.Contracts;
namespace Aletheia.Sdk.Sample;
// ββ AsyncEcho (user-facing entry point) ββββββββββββββββββββββββββββββββββββββ
/// <summary>
/// Fire-and-forget via the async capability dispatcher.
/// The caller sends a message, gets back a result IRI immediately, and polls.
/// </summary>
public sealed record AsyncEchoCommand(string Message);
/// <summary>
/// Response carries the result IRI β the caller polls this to check completion.
/// </summary>
public sealed record AsyncEchoResponse(string ResultIri, bool Accepted);
[Capability("sample.async-echo")]
public sealed class AsyncEchoHandler : ICapabilityHandler<AsyncEchoCommand, AsyncEchoResponse>
{
private readonly IEntityStore _store;
private readonly IAsyncCapabilityDispatcher<ProcessEchoCommand, ProcessEchoResponse> _dispatcher;
public AsyncEchoHandler(
IEntityStore store,
IAsyncCapabilityDispatcher<ProcessEchoCommand, ProcessEchoResponse> dispatcher)
{
_store = store;
_dispatcher = dispatcher;
}
public async ValueTask<ExecutionResult<AsyncEchoResponse>> HandleAsync(
AsyncEchoCommand command,
CapabilityContext context,
CancellationToken cancellationToken = default)
{
using var _ = EntityOperations.Use(_store);
// Create the pending result entity
var result = new EchoResult { OriginalMessage = command.Message, IsReady = false };
await EntityOperations.CreateAsync(result, cancellationToken);
// Dispatch to the message bus β fire and forget
await _dispatcher.PublishAsync(
new ProcessEchoCommand(result.Iri, command.Message),
cancellationToken: cancellationToken);
return new ExecutionResult<AsyncEchoResponse>.Ok(
new AsyncEchoResponse(result.Iri, true));
}
}
The `ProcessEchoHandler` β runs on a background thread, updates the pending `EchoResult`
Capabilities/ProcessEchoCapability.cs
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
using Aletheia.Sdk.Operations;
using Aletheia.Sdk.Repository.Contracts;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Command dispatched via the message bus by <see cref="AsyncEchoHandler"/>.
/// Carries the result IRI so the background handler knows which EchoResult to update.
/// </summary>
public sealed record ProcessEchoCommand(string ResultIri, string Message);
/// <summary>
/// Response from the background processor β not surfaced to the original caller.
/// </summary>
public sealed record ProcessEchoResponse(string EchoMessage);
/// <summary>
/// Runs on a background thread via the CapabilityCommandPumpService.
/// Loads the pending EchoResult, marks it ready, and saves.
/// </summary>
/// <remarks>
/// This handler exists separately from <see cref="EchoHandler"/> because async
/// dispatch has different semantics: the caller owns the pending entity,
/// the handler only updates it. <see cref="EchoHandler"/> owns its entity β
/// it creates it. Different responsibilities, different capabilities.
/// </remarks>
[Capability("sample.process-echo")]
public sealed class ProcessEchoHandler : ICapabilityHandler<ProcessEchoCommand, ProcessEchoResponse>
{
private readonly IEntityStore _store;
public ProcessEchoHandler(IEntityStore store) => _store = store;
public async ValueTask<ExecutionResult<ProcessEchoResponse>> HandleAsync(
ProcessEchoCommand command,
CapabilityContext context,
CancellationToken cancellationToken = default)
{
var result = await _store.LoadAsync<EchoResult>(command.ResultIri, cancellationToken);
if (result is null)
{
return new ExecutionResult<ProcessEchoResponse>.Fail(
new ExecutionError("NOT_FOUND", $"EchoResult '{command.ResultIri}' not found."));
}
using var _ = EntityOperations.Use(_store);
result.EchoMessage = $"processed: {command.Message}";
result.IsReady = true;
await EntityOperations.UpdateAsync(result, cancellationToken);
return new ExecutionResult<ProcessEchoResponse>.Ok(
new ProcessEchoResponse(result.EchoMessage));
}
}
A pending result entity β created by the capability, updated by the subscription
Entities/EchoResult.cs
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Operations;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// An Echo Result β stores the outcome of an async echo request.
/// Created immediately with isReady=false, then updated by the background processor.
/// </summary>
[Entity(Path = "echo-results")]
[Identity(IdentityGenerator.Random)]
[OperationEndpoints]
public partial class EchoResult
{
[Predicate("originalMessage")]
public string OriginalMessage { get; set; } = string.Empty;
[Predicate("echoMessage")]
public string EchoMessage { get; set; } = string.Empty;
[Predicate("isReady")]
public bool IsReady { get; set; }
}
A `BackgroundService` that consumes echo messages, processes them (with simulated delay), and marks results as ready
Subscriptions/EchoSubscription.cs
using Aletheia.Sdk.Messaging;
using Aletheia.Sdk.Operations;
using Aletheia.Sdk.Repository.Contracts;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Background subscription that processes Echo requests asynchronously.
/// Listens on the echo topic, processes the message, and marks the result as ready.
/// </summary>
internal sealed class EchoSubscription(
IMessageConsumer<string, EchoResult> consumer,
IEntityStore store) : BackgroundService
{
internal const string Topic = "aletheia.echo.requests";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var envelope in consumer.ConsumeAsync(Topic, stoppingToken).ConfigureAwait(false))
{
var echo = envelope.Payload;
using var _ = EntityOperations.Use(store);
// Simulate async work β the Echo takes a moment to respond
await Task.Delay(200, stoppingToken);
var loaded = await store.LoadAsync<EchoResult>(echo.Iri, stoppingToken);
if (loaded is not null)
{
loaded.EchoMessage = $"Echo: {loaded.OriginalMessage}";
loaded.IsReady = true;
await EntityOperations.UpdateAsync(loaded, stoppingToken);
}
}
}
}
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
builder.Services.AddCapabilityEntity(typeof(GreetHandler).Assembly);
builder.Services.AddEntityEntity(typeof(Manuscript).Assembly);
builder.Services.AddHostedService<EchoSubscription>();
builder.Services.AddCapabilityMessaging();
var app = builder.Build();
// ...
- Enables async dispatch of capability handlers via the message bus. Requires AddMessagingInMemory() to already be registered.
Known Gaps
- We are unsure if this sample is a good sample regarding target architecture. Event-based subscribers should not contain any logic besides correct distribution to capabilities which do the logical part. We assume, that the Subscriptions/EchoSubscription.cs is not ideal in this architectural design pattern.
Try It
Send Echo Request β fire and forget
POST/api/capabilities/sample/echoβ 200
Poll Echo Result β check if ready
GET/api/entities/echo-results?iri={{14-resultIri}}β 200
Send Async Echo β fire and forget via dispatcher
POST/api/capabilities/sample/async-echoβ 200
Poll Async Echo Result β check if ready
GET/api/entities/echo-results?iri={{14-asyncResultIri}}β 200
AI
The Aletheia SDK is semantic, knowledge-graph-native β a natural fit for AI. This chapter wires the AI slice into the tutorial: a streaming chat endpoint that can call auto-generated tools (entity CRUD, capabilities, view validation), and a two-step scenario flow that mirrors the file-to-form use-case β a vision model reads an uploaded image, a builder model constructs the data, and the flow validates itself against the Manuscript view before anything reaches a form.
Everything runs against local models via Ollama. No cloud API key: the server talks to `http://localhost:11434`, using `llama3.2` for text and `moondream` for vision.
What You'll Learn
- `AddAI` / `AddAIOntology` / `AddAITools` β three registrations that bring the AI slice to life. `AddAI` registers `OpenAiCompatibleChatService` (any OpenAI-shaped endpoint) and the `ScenarioRunner`; `AddAIOntology` builds the system prompt from entity metadata; `AddAITools` auto-generates the flat tool set β entity CRUD tools, capability tools, `propose_plan`, and one `validate_<view>` tool per registered view aspect.
- One provider, many models β `AIOptions.Default` is the default connection; `AIOptions.ModelRoles` maps scenario roles to connections. The tutorial points both at Ollama: `Default` β `llama3.2`, `vision` β `moondream`, `builder` β `llama3.2`. DeepSeek, OpenAI, or any OpenAI-shaped endpoint works by changing `BaseUrl`/`Model`.
- Streaming chat over SSE β `POST /api/ai/chat` streams `ChatEvent` values as named SSE events (`event: token`, `event: tool_call`, `event: tool_result`, `event: done`), terminated by `data: [DONE]`. When the model calls a tool (e.g. `list_manuscripts`), the backend executes it and feeds the result back into the loop.
- Scenario flows β `POST /api/ai/flow` runs a registered `ScenarioDefinition`: each step binds a model role, receives the previous step's validated output, and must emit one JSON object satisfying its output schema. The runner validates (JSON Schema, then SHACL via `IViewAspectEngine`), retries within the step budget, and fails the flow on exhaustion β never emitting a known-invalid payload.
- Multimodal messages β a step's first message can carry `ChatContentPart` values (`Text` / `Image`). The flow request passes the uploaded image as a `data:` URL part; the vision role reads it.
- Self-validation β the builder step declares `ViewIri: urn:aletheia:views:manuscript-form`, so its output is judged against the chapter 08 view shape before the flow completes.
Compact output
The tutorial's AI requests use the compact form ("stream": false): one JSON document instead of an SSE token stream β useful when a program (or another agent) wants the finished answer, not the typewriter effect:
POST /api/ai/chatβ{ "text": "β¦", "toolCalls": [ { "name", "arguments", "result" } ], "error": null }POST /api/ai/flowβ{ "finalOutput": { β¦ }, "steps": [ { "index", "name", "output", "retries" } ], "error": null }
The endpoints still default to "stream": true β the token stream exists for the browser UI; the compact form exists for machine-to-machine calls.
The flow, step by step
manuscript-from-image runs two steps in sequence:
1. vision (moondream) receives the image as a content part and produces a plain-text caption describing the cover. 2. builder (llama3.2) receives the caption, extracts the structured form value, and the runner validates it against urn:aletheia:views:manuscript-form β the same view the browser validates against in chapter 08.
The vision step is a TextOutput step β vision models like moondream caption rather than emit strict JSON, so the runner passes their text straight to the builder instead of JSON-validating it. The builder is the one that produces the typed, validated output.
The request ships a readable cover (../assets/manuscript-cover.png, embedded as a data URL) showing _The First Manuscript, Pages: 42_ β so moondream can describe a real title and page count and the flow completes end-to-end.
The compact response reports one summary per completed step (name, output, retries), the final finalOutput, and an error when validation is exhausted or Ollama is unreachable.
The Code
Registers `manuscript-from-image` β a vision step (`moondream`) then a builder step (`llama3.2`) validated against the Manuscript view
Scenarios/AiScenarios.cs
using System.Text.Json;
using Aletheia.Sdk.AI.Scenarios;
namespace Aletheia.Sdk.Sample;
/// <summary>
/// Registers the tutorial's AI scenario flows β the file-to-form use-case as a
/// two-step flow: a vision model reads an uploaded image, then a builder model
/// constructs a Manuscript form value that the runner validates in-loop against
/// the manuscript-form view (chapter 08).
/// <br/><br/>
/// Step roles resolve through <c>AIOptions.ModelRoles</c> in appsettings.json β
/// <c>vision</c> points at the local <c>moondream</c> model, the builder falls back
/// to the default <c>llama3.2</c> model, both served by Ollama.
/// </summary>
public static class AiScenarios
{
/// <summary>The Manuscript form shape from chapter 08 β the flow validates against it.</summary>
public const string ManuscriptFormViewIri = "urn:aletheia:views:manuscript-form";
public static void RegisterAiScenarios(this ScenarioRegistry registry)
{
registry.Register(new ScenarioDefinition(
Key: "manuscript-from-image",
Description: "Read a scanned manuscript cover, then build a validated Manuscript form value.",
Steps:
[
new ScenarioStep(
Name: "vision",
ModelRole: "vision",
Instruction: "Read this scanned manuscript cover. " +
"Answer exactly: what title is printed on it, and what number is the page count? " +
"Reply with one short line only.",
OutputSchema: ManuscriptSchema(),
MaxRetries: 0,
TextOutput: true),
new ScenarioStep(
Name: "builder",
ModelRole: "builder",
Instruction: "The next message describes a manuscript cover. " +
"Extract the book title and the page count from it. " +
"Respond with a single JSON object with exactly the keys title (string) and pages (integer).",
OutputSchema: ManuscriptSchema(),
MaxRetries: 3,
ViewIri: ManuscriptFormViewIri),
]));
}
private static JsonElement ManuscriptSchema() => JsonSchema(
("title", "string"),
("pages", "integer"));
private static JsonElement JsonSchema(params (string Name, string Type)[] properties)
{
var props = new Dictionary<string, object>();
var required = new List<string>();
foreach (var (name, type) in properties)
{
props[name] = new { type };
required.Add(name);
}
return JsonSerializer.SerializeToElement(new
{
type = "object",
properties = props,
required,
});
}
}
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.Capability.Entity;
using Aletheia.Sdk.Entity.Entity;
using Aletheia.Sdk.AI.DependencyInjection;
using Aletheia.Sdk.AI.Http;
using Aletheia.Sdk.AI.Scenarios;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
// ...
builder.Services.AddCapabilityEntity(typeof(GreetHandler).Assembly);
builder.Services.AddEntityEntity(typeof(Manuscript).Assembly);
builder.Services.AddAI(builder.Configuration);
builder.Services.AddAIOntology();
builder.Services.AddAITools();
builder.Services.AddHostedService<EchoSubscription>();
builder.Services.AddCapabilityMessaging();
// ...
app.MapCapabilityEntity();
app.MapAspectsEntity();
AiScenarios.RegisterAiScenarios(app.Services.GetRequiredService<ScenarioRegistry>());
app.MapAiEndpoints();
app.Run();
- AddAI registers OpenAiCompatibleChatService + ScenarioRunner. The AI section of appsettings.json points Default at Ollama (http://localhost:11434) and the "vision" role at the llava model.
- Ontology context builds the system prompt from entity metadata (requires AddEntityEntity above).
- Tool registry auto-generates CRUD + capability + validate_<view> tools (requires the entity and capability registries above).
- Registers the tutorial's AI scenario flows before the first request.
- Hosts POST /api/ai/chat and POST /api/ai/flow as SSE endpoints.
Try It
Chat
Ask the Librarian (compact JSON)
POST/api/ai/chatβ 200
Flow
Manuscript from image (compact JSON)
POST/api/ai/flowβ 200
Authorization
Not every capability should be available to every caller. Aletheia's Authorization slice layers role-based access control on top of the aspect system: aspects are assigned to roles, roles are assigned to agents, and the token middleware establishes the caller's identity on every request. Unassigned aspects remain open (permissive by default), so you can secure incrementally β lock down what matters, leave the rest accessible.
The Bruno collection demonstrates the full authorization lifecycle: create an Agent (user), create a Role, assign the agent to the role, assign a catalog-search aspect to the role, attempt a catalog search without a token (403 Forbidden), and retry with a valid agent token (200 OK). The authorization model builds on concepts you already know β aspects, entities, and capabilities.
What You'll Learn
- Role-based authorization β the authorization model has three primitives: `Agent` (a user or service account), `Role` (a named collection of permissions), and `Aspect` (a permission to invoke a specific capability or operation). `AddRoleBasedAuthorization()` configures the engine. Authorization checks run after aspect validation but before the handler executes β if the caller lacks the required role, the handler never runs.
- Token middleware β `UseAgentTokenMiddleware()` sits in the ASP.NET pipeline and extracts an agent token from every request. The token identifies the caller β the middleware resolves it to an `Agent` entity and establishes the caller's identity for the duration of the request. All subsequent authorization checks use this identity.
- Permissive defaults β security is opt-in. If no aspect is assigned to the target capability or operation, no authorization check is performed β the request proceeds as if authorization didn't exist. This means you can add authorization to an existing application without breaking anything: unassigned capabilities remain open, assigned ones become gated. Lock down incrementally.
- `AddAuthorizationHttp()` β registers HTTP endpoints for managing the authorization model at runtime: create and manage agents, roles, and aspect-role assignments. These endpoints are themselves subject to authorization β in production, you'd protect them with an admin role.
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
using Aletheia.Sdk.AI.Http;
using Aletheia.Sdk.AI.Scenarios;
using Aletheia.Sdk.Authorization.DependencyInjection;
using Aletheia.Sdk.Authorization.Http.DependencyInjection;
using Aletheia.Sdk.Authorization.Entity;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAspects();
builder.Services.AddRoleBasedAuthorization();
builder.Services.AddAuthorizationHttp(builder.Configuration);
builder.Services.AddOperationEndpointsHttp(typeof(Agent).Assembly);
builder.Services.AddMessagingInMemory();
builder.Services.AddEntityEvents();
// ...
AiScenarios.RegisterAiScenarios(app.Services.GetRequiredService<ScenarioRegistry>());
app.MapAiEndpoints();
app.UseAgentTokenMiddleware();
app.Run();
- The registrations must be called at the very beginning of the service configuration block; otherwise it is not correctly applied. Unfortuonally, no exception is thrown when configured incorectly. Fix when you have time.
- Authorization is permissive. If an aspect is not assigned to a role, no authorization is required. This was an active design choice. No advanced security needs leads to no implementation effort. Security also becomes fully modelled using Sdk.Entity and thus profits of overall Sdk features.
- Providing the operation endpoints via an Assembly allows different implementations of Agent-Role interpretation as long as the Sdk.Authorization entities are used as base.
Known Gaps
- Currently, authentication is missing completely. Tokens are mocked and not received correctly. Architectural wise its clear that we need an IDP using OAuth2 / OIDC. However, we are not yet sure if this infrastructure or Sdk level.
- Calling .AddRoleBasedAuthorization() and .AddAuthorizationHttp() seems not lean. One call should be enough.
- Currently you can assign every type of aspect to a role; if you don't assigned the correct one (e.g. message instead of capability), no failure happens. Especially, as vocabulary regarding Aspects is already complicated (see 06-Aspects).
Try It
Create Agent (Alice)
POST/api/entities/authorization-agentsβ 200
Create Role (Head Librarian)
POST/api/entities/authorization-rolesβ 200
Assign Agent to Role
POST/api/entities/authorization-agent-role-assignmentsβ 200
Bind Role to Aspect
POST/api/entities/authorization-aspect-role-assignmentsβ 200
Search Catalog β without valid Agent token
POST/api/capabilities/sample/catalogβ 403
| X-Aletheia-Capability-AspectIri | urn:aletheia:aspects:capability:catalog-v2 |
Search Catalog β with valid Agent token
POST/api/capabilities/sample/catalogβ 200
| X-Aletheia-Capability-AspectIri | urn:aletheia:aspects:capability:catalog-v2 |
| Authorization | Bearer alice-secret |
Web Viewer
The Sdk.Web slice provides a generic Angular application that dynamically discovers Aletheia entities through introspection endpoints and presents a uniform CRUD interface. Every entity type gets the same UI treatment β list, view, create, update, delete β because the viewer learns everything at runtime from the platform itself. Add a new entity type to your application and the viewer adapts automatically.
What You'll Learn
- Dynamic entity discovery β the viewer calls `/api/entities/entity-definitions` at startup and renders a navigable list of every entity type known to the platform. No entity-specific code lives in the frontend; the UI is entirely data-driven.
- `AddWebInterface()` β registers the Sdk.Web static file host. Accepts an optional request path prefix (e.g. `"/viewer"`) so the Angular app can co-exist with other middleware at the root.
- `UseWebInterface()` β serves the compiled Angular application from the `wwwroot` directory and provides a client-side routing fallback. Must be the last middleware in the pipeline so API endpoints take priority over the SPA fallback.
- MSBuild Angular compilation β `dotnet build` on the `Sdk.Web` project automatically runs `npm install` and `ng build` (if Node.js is available). The compiled output lands in `wwwroot/` and is included as content. No separate build step needed.
Wiring
Program.cs β dimmed lines are from previous chapters, highlighted lines are new in this chapter
Program.cs
builder.Services.AddHostedService<EchoSubscription>();
builder.Services.AddCapabilityMessaging();
builder.Services.AddWebInterface("/viewer");
var app = builder.Build();
// ...
app.UseAgentTokenMiddleware();
app.UseWebInterface();
app.Run();
- Sdk.Web serves the Angular Viewing Gallery at /viewer/ β registered after all API endpoints so they take priority.
- Must be the last middleware β catches unmatched routes for Angular client-side routing.
GraphDB
Every entity you have created in this tour is an RDF graph β a web of subjectβpredicateβobject triples. In the earlier chapters that graph lived in a `ConcurrentDictionary`: instant, forgiving, and gone at restart. Production Aletheia persists that graph in a real triple store. The production-grade backend is Ontotext GraphDB β a remote RDF/SPARQL server that the `Repository.GraphDb` slice talks to over HTTP.
This chapter is the bridge out of the sandbox β and unlike every other chapter, the wiring it describes is now live. Deployed in the katharsis stack, the Sample points its entity repository at the GraphDB server behind `graphdb.katharsis.digital`. This chapter walks through the three things GraphDB needs before Aletheia can connect β a user, a repository, and the connection settings β all done in the GraphDB Workbench.
What You'll Learn
- GraphDB β an RDF/SPARQL triple store. Where the InMemory backend holds entities in a dictionary, GraphDB stores them as RDF triples in named graphs and answers SPARQL queries over HTTP. It is the persistent, production backend behind the `Repository.GraphDb` slice.
- Repository β a named, isolated graph space inside a GraphDB server. The SDK's `GraphDbOptions.RepositoryId` points at one of these. A single GraphDB server can host many repositories, each with its own data and settings.
- User β credentials GraphDB uses for authentication and authorization. The SDK sends them as HTTP Basic auth (`GraphDbOptions.Username` / `Password`). A user is granted permissions per repository (`READ_REPO_<id>`, `WRITE_REPO_<id>`).
- Settings β the connection contract between Aletheia and GraphDB, captured in `GraphDbOptions`: `BaseUrl` (where the server lives), `RepositoryId` (which repository), `Username`/`Password` (who to connect as), and `Timeout` (how long to wait).
The Setup Checklist
What you must do inside GraphDB before the SDK can connect β in order.
1. Start the server
In the katharsis stack the server already runs β its Workbench is at https://graphdb.katharsis.digital (behind Authentik), and the SDK reaches its REST API internally at http://graphdb:7200. To experiment locally instead:
docker run -d --name aletheia-graphdb -p 7200:7200 ontotext/graphdb:10.6.0
The default administrator credentials are admin / root β change them before anything real.
2. Create a repository
A repository is the named graph space Aletheia will read and write. Create it in the Workbench: https://graphdb.katharsis.digital β Setup β Repositories β Create new repository.
Fill in the settings as follows:
| Setting | Value | Notes |
|---|---|---|
| Repository ID | `aletheia` | Must match `GraphDb:RepositoryId` in the SDK |
| Repository type | GraphDB Repository | The default RDF store (`graphdb:SailRepository`) |
| Ruleset | RDFS-Plus (Optimized) | Enables basic inferencing; corresponds to `rdfsplus-optimized` |
| Read-only | off (unchecked) | The SDK writes entities, so the store must be writable |
3. Create a user
Create a dedicated application user β not the admin account β and grant it access to the aletheia repository only. The Workbench path is Setup β Users and Access β Create new user.
Fill in the settings as follows:
| Setting | Value | Notes |
|---|---|---|
| Username | `aletheia` | Matches `GraphDb__Username` in the deployment |
| Password | your secret | Matches `GraphDb__Password` in the deployment |
| Role | User | A regular user, not Admin or Repository manager |
| Repository access | `aletheia` β read + write | Grants `READ_REPO_aletheia` and `WRITE_REPO_aletheia` |
In the deployed Sample these credentials arrive via the GraphDb__Username and GraphDb__Password environment variables.
4. Configure the SDK settings
The SDK reads the connection from the GraphDb configuration section and applies it through UseGraphDb():
{
"EntityRepository": { "Backend": "GraphDb" },
"GraphDb": {
"BaseUrl": "http://graphdb:7200",
"RepositoryId": "aletheia"
}
}
The Username / Password are supplied by the environment β GraphDb__Username and GraphDb__Password β so no secret lives in the repository.
var repoBuilder = builder.Services.AddEntityRepository(builder.Configuration);
if (string.Equals(builder.Configuration["EntityRepository:Backend"], "GraphDb", StringComparison.OrdinalIgnoreCase))
repoBuilder.UseGraphDb();
else
repoBuilder.UseInMemory();
| Setting | Default | Purpose |
|---|---|---|
| `BaseUrl` | `http://localhost:7200` | GraphDB server address |
| `RepositoryId` | `aletheia` | Which repository to use |
| `Username` | β | Basic-auth user (omit for open servers) |
| `Password` | β | Basic-auth password |
| `Timeout` | 30 s | HTTP request timeout |
Local vs Deployed
The Sample's founding decision is zero infrastructure: dotnet run starts in under a second with no external state. So the backend stays config-driven β appsettings.json declares GraphDB as the deployed backend, while appsettings.Development.json pins local development and the dotnet test suite to InMemory. Run the server locally and the tutorial works exactly as before; deploy it into the katharsis stack and the same binary persists every entity to GraphDB at http://graphdb:7200. The deeper GraphDB integration (transactions, SPARQL query, snapshots) is exercised in Sdk.Integration, which flips to GraphDB with the same configuration key.