Welcome to ALETHEIA

This is a hands-on tutorial that teaches the Aletheia SDK through 22 progressive chapters. Each chapter introduces one concept β€” from your first HTTP endpoint to authorization policies β€” with real code you can run and modify.

⚠️ Open (unenforced) mode only. Every Try It button on this page calls the API straight from your browser, and the page carries no identity β€” no bearer token, no simulation header. An Enforced host refuses any request that cannot prove who is asking, so these buttons answer 401/403 there whatever is running. Work through the tutorial against an Open host (dotnet run, ./run-local.sh); for Enforced mode use the Bruno collection instead (see Usage).


Setup

The Librarian unlocks the great oak doors. "Before you enter," she says, "you'll need a few things. Then the archives are yours to explore."

Every chapter in this guided tour adds code to the same server β€” one `dotnet run` starts them all (after a one-time admin UI build, see **Try It**). 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, entity events reach the in-process bus as the write happens, and files stay in byte arrays. Only one step stays a step of its own: a subscription's DELIVERY travels the durable outbox/inbox path and can settle after the response that caused it (chapter 10). 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; the admin UI lives at http://localhost:5000/aletheia/. 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.
  • Runtime release identity β€” the host opts into aletheia.runtime.info, served at POST /api/capabilities/aletheia/runtime/info. Its application and SDK versions come from build-stamped assembly metadata; unstamped local builds report development, never the project file's package default.
  • 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);
if (builder.Environment.IsDevelopment())
{
    LocalModelCredential.Apply(builder.Configuration, builder.Environment.ContentRootPath);
}
builder.Services.AddAspects();
 
var app = builder.Build();
 
app.Run();
  • A model CONNECTION is configuration; its credential is not. In Development the host
  • 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.

πŸ’‘ Wiring: The server uses #region NN-ChapterName blocks to group each chapter's registration. Look for the #region 00-Setup block β€” it initializes the core ASP.NET pipeline. Chapters add their services before app.Run().


Capabilities

The Librarian places a single scroll before you. "Before you learn the shelves," she says, "learn to ask a question and receive an answer."

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.

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);
if (builder.Environment.IsDevelopment())
// ...
}
builder.Services.AddAspects();
 
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GreetHandler>();
builder.Services.AddRuntimeInfo(options => options.ApplicationName = "Aletheia Sample");
builder.Services.AddBlobLifecycle();
builder.Services.AddSubscription();
builder.Services.AddCapabilityHttp();
 
var app = builder.Build();
 
app.MapCapabilities();
 
app.Run();
  • Register the opt-in runtime capability before AddCapabilityHttp() snapshots the handler catalog.
  • The blob lifecycle registers the server-owned record behind every stored blob, the `object.blob.cleanup` capability and the background pump that drains the queue. It goes before AddCapabilityHttp() like every other handler registration (root ADR-0010). It needs the operational graph, which this host configures for its outbox and subscriptions.
  • Subscription infrastructure registers its replay capability, so it must precede the catalog scan; chapter 10 declares the subscriptions below.

Known Gaps

  • We should support more than 422 error codes from the SDK.

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:

CodeMeaningWhen
`EMPTY_NAME`Validation failureInput doesn't meet requirements
`NOT_FOUND`Resource missingThe requested entity doesn't exist
`CONFLICT`State conflictOperation 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.

πŸ’‘ Wiring: This chapter's services are registered in ../Program.cs under #region 01-Capabilities. AddCapabilityHandlersFromAssemblyContaining<GreetHandler>() scans the assembly and registers every [Capability]-decorated handler. MapCapabilities() exposes them as HTTP endpoints.

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

The Librarian leads you deeper into the archives. "Capabilities ask questions," she says. "Entities are the answers that endure."

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 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), POST /query (collection 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&lt;string&gt;</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://aletheia.katharsis.digital/");
 
    // ── 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.Entity;
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);
if (builder.Environment.IsDevelopment())
// ...
builder.Services.AddSubscription();
builder.Services.AddCapabilityHttp();
 
if (builder.Configuration["Entity:BaseIri"] is { Length: > 0 } baseIri)
    EntityOptions.BaseIri = baseIri;
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 canonical base URL (EntityOptions.BaseIri) defaults to https://aletheia.katharsis.digital. Deployed apps override it per-app via the `Entity:BaseIri` configuration key β€” e.g. `Entity__BaseIri=https://<slug>.katharsis.digital` β€” so each app's entity IRIs live on its own subdomain without any code change. The environment variable overrides appsettings.json.
  • The backend is chosen by the `EntityRepository:Backend` configuration key. Deployed in the katharsis stack it is `GraphDb` (appsettings.json); local dev and tests override it to `InMemory` via appsettings.Development.json.

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.

πŸ’‘ Wiring: This chapter's services are registered in ../Program.cs under #region 02-Entities. AddEntityRepository configures the repository backend; UseInMemory() selects the in-memory store. AddOperationEndpointsHttpFromAssemblyContaining<DataRecord>() discovers and registers all [OperationEndpoints] entities in the assembly.

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

POST/api/entities/data-records/query→ 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

The Librarian spreads four scrolls across the table. "A manuscript alone is silent," she murmurs. "It speaks through its relationships."

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.

The manuscripts speak in many tongues. `Genre` and `Manuscript` carry their own names and descriptions β€” `[Label]` and `[Description]` β€” with English as the mandatory default and German as an optional translation. The Library serves these semantics to the browser and the AI from one source of truth, and the frontend may override any of them.

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:
  • [Label] / [Description] β€” entity semantics & i18n β€” attach human-readable names and descriptions to entity types, properties, and enumeration values. English is the mandatory default; other languages are optional. The backend stays the single source of truth and serves a layered, Transloco-conform dictionary at GET api/localization/{lang} β€” one map per definition surface, with English fallback per key inside its layer. Keys follow one mandatory scheme: entities.<entityPath>.label|description, entities.<entityPath>.properties.<PropertyName>.label|description (PropertyName = the C# property name, PascalCase), and entities.<entityPath>.values.<key>.label|description (key = the identity-part value). The frontend may override any of them through its own translation bundle.
  • Enumeration values as named individuals β€” Genre declares its five fixed categories as public static readonly fields, each carrying its own EN + DE [Label]/[Description]. The registry discovers them (key, IRI, display name, description, translations) and exposes them to the AI prompt, the localization dictionary, and the browser UI. ⚠️ Watch for: enumerations must use deterministic identity β€” Random is rejected by the ALETHEIA0009 analyzer.

The Code

A librarian who curates manuscripts β€” has a Name, Title, and a collection of Manuscripts

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, references to its Scribe and Genres, plus EN + DE [Label]/[Description] semantics

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]
[Label("Manuscript")]
[Label("de", "Handschrift")]
[Description("A scroll entering the Great Library.")]
[Description("de", "Eine Schriftrolle, die in die Große Bibliothek eintritt.")]
public partial class Manuscript
{
    [Required]
    [Predicate("title")]
    [Label("Title")]
    [Label("de", "Titel")]
    [Description("The title of the manuscript.")]
    [Description("de", "Der Titel der Handschrift.")]
    public string Title { get; set; } = string.Empty;
 
    [Predicate("author")]
    [Label("Author")]
    [Label("de", "Autor")]
    [Description("The author of the manuscript.")]
    [Description("de", "Der Autor der Handschrift.")]
    public string Author { get; set; } = string.Empty;
 
    [Owning("genre")]
    public partial EntityRef<Genre>? Genre { get; set; }
 
    [Predicate("shelf")]
    [Label("Shelf")]
    [Label("de", "Regal")]
    [Description("The shelf where the manuscript rests.")]
    [Description("de", "Das Regal, in dem die Handschrift ruht.")]
    public string Shelf { get; set; } = string.Empty;
 
    [Predicate("year")]
    [Label("Year")]
    [Label("de", "Jahr")]
    [Description("The year the manuscript was written.")]
    [Description("de", "Das Jahr, in dem die Handschrift verfasst wurde.")]
    public int Year { get; set; }
 
    [Predicate("isSealed")]
    [Label("Sealed")]
    [Label("de", "Versiegelt")]
    [Description("Whether the manuscript is sealed against further edits.")]
    [Description("de", "Ob die Handschrift gegen weitere Γ„nderungen versiegelt ist.")]
    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 β€” five named individuals (Philosophy…Magic) with EN + DE [Label]/[Description] semantics on the type, properties, and values

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]
[Label("Genre")]
[Label("de", "Gattung")]
[Description("A fixed category of manuscripts.")]
[Description("de", "Eine feste Kategorie von Handschriften.")]
public partial class Genre
{
    [IdentityPart(0)]
    [Predicate("key")]
    [Label("Key")]
    [Label("de", "SchlΓΌssel")]
    [Description("The stable URL-safe key forming the IRI suffix.")]
    [Description("de", "Der stabile, URL-sichere SchlΓΌssel, der den IRI-Suffix bildet.")]
    public partial string Key { get; init; }
 
    [Predicate("displayName")]
    [Label("Display name")]
    [Label("de", "Anzeigename")]
    [Description("The human-readable name of the genre.")]
    [Description("de", "Der lesbare Name der Gattung.")]
    public string DisplayName { get; set; } = string.Empty;
 
    [Label("Philosophy")]
    [Label("de", "Philosophie")]
    [Description("Texts exploring fundamental questions of existence, knowledge, and ethics.")]
    [Description("de", "Texte, die grundlegende Fragen nach Sein, Wissen und Ethik erkunden.")]
    public static readonly Genre Philosophy = new() { Key = "philosophy", DisplayName = "Philosophy" };
 
    [Label("History")]
    [Label("de", "Geschichte")]
    [Description("Chronicles of past events and their interpretation.")]
    [Description("de", "Chroniken vergangener Ereignisse und ihre Deutung.")]
    public static readonly Genre History = new() { Key = "history", DisplayName = "History" };
 
    [Label("Poetry")]
    [Label("de", "Dichtung")]
    [Description("Verse and lyrical composition.")]
    [Description("de", "Verse und lyrische Komposition.")]
    public static readonly Genre Poetry = new() { Key = "poetry", DisplayName = "Poetry" };
 
    [Label("Science")]
    [Label("de", "Wissenschaft")]
    [Description("Systematic study of the natural world.")]
    [Description("de", "Systematische Erforschung der natΓΌrlichen Welt.")]
    public static readonly Genre Science = new() { Key = "science", DisplayName = "Science" };
 
    [Label("Magic")]
    [Label("de", "Magie")]
    [Description("Texts of arcane and occult knowledge.")]
    [Description("de", "Texte arkanen und okkulten Wissens.")]
    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.

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.

πŸ’‘ Wiring: This chapter reuses the #region 02-Entities wiring β€” no additional registration is needed. The entity types are discovered automatically by AddOperationEndpointsHttpFromAssemblyContaining<DataRecord>().

Try It

Create Scribe (to write a Manuscript)

POST/api/entities/scribes→ 200

List Genres (to select one to write a Manuscript about)

POST/api/entities/genres/query→ 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

POST/api/entities/scribes/query→ 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

The Librarian gestures at a vast wing of the archives. "You wouldn't carry every scroll at once," she says. "You ask for the ones you need."

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 Manuscripts

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.

πŸ’‘ Wiring: This chapter's services are registered in ../Program.cs under #region 04-Lazy-Loading. The WingDetailHandler depends on IEntityStore β€” injected automatically by the DI container configured in #region 02-Entities.

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

The Librarian hands you a lens. "The archives are vast," she says. "This will help you find what you seek."

CRUD by IRI is precise but limited β€” you need to know the IRI before you can fetch an entity. Real applications need search: find manuscripts by title keyword, list scribes sorted by name, page through a result set larger than one screen. Aletheia answers on two faces of the same engine β€” LINQ in C#, and a query endpoint over HTTP.

In C#, `_store.Query<Manuscript>()` returns `IQueryable<T>` β€” the interface every .NET developer already knows. `.Where(...)`, `.OrderBy(...)`, `.Skip(...)`, `.Take(...)` compose into a single repository query: the provider translates the expression tree instead of pulling every entity into memory.

Over HTTP the collection path is `POST api/entities/{path}/query`, and the body is the same query written as JSON β€” `where`, `orderBy`, `offset`, `limit`, and `count`. A browser or a Bruno collection gets exactly the filtering, ordering, and paging the LINQ provider offers. `GET api/entities/{path}?iri=…` stays the point read; there is no bare list GET any more.

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.
  • POST api/entities/{path}/query β€” the HTTP face of the same query. GET …?iri=… reads one entity by IRI; the collection read is a POST whose JSON body carries the whole query. The response envelope is { items, totalCount, offset, limit } β€” totalCount is the size of the filtered set *before* paging.
  • The request body β€” where (a filter tree), orderBy ([{ "pred", "dir" }]), offset, limit, and count ("exact" computes totalCount, "none" skips it). Fields are optional; {} is a valid query for everything.
  • Filter groups β€” a leaf is { "pred", "op", "value" }. and, or, and not nest leaves into an expression tree, so a client can express the same predicates the LINQ provider accepts β€” without a custom query language.
  • reasoned β€” a leaf or orderBy entry may set "reasoned": true to filter or sort on a value a query aspect derived rather than on a stored property. The Sample demonstrates that in Chapter 08: Aspects with the derived accessStatus.

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.

The Query Endpoint

The query endpoint accepts one JSON object whose fields map one-to-one onto the LINQ operators from the previous section.

The request body

FieldShapeMeaning
`where`filter expressionThe filter tree. Omit it to match every entity of the type.
`orderBy``[{ "pred": …, "dir": "asc"\"desc", "reasoned": bool }]`The ordering chain, applied left to right (first entry primary).
`offset`integer β‰₯ 0Items to skip β€” `Skip(n)` in LINQ.
`limit`integer β‰₯ 0Maximum items to return β€” `Take(n)` in LINQ.
`count``"exact"` \`"none"`Whether the server also answers *how many* matched before paging (default `"exact"`).

An empty body {} is a valid query: every entity, no paging, exact count.

The filter operators

`op`Matches
`eq`, `ne`Equal, not equal.
`gt`, `gte`, `lt`, `lte`Greater/less than or equal β€” numbers, dates, and strings.
`in`One of the values in a JSON array.
`contains`, `startsWith`, `endsWith`Case-insensitive text matching.
`isNull`The predicate has no value. Carries no `value` field β€” `ne` and `isNull` are different questions.

Grouping

and, or, and not nest filter expressions arbitrarily. 09-query-combined.bru sends this body:

{
  "where": {
    "and": [
      {
        "or": [
          { "pred": "title", "op": "contains", "value": "Nature" },
          { "pred": "author", "op": "startsWith", "value": "Katharsis" }
        ]
      },
      { "not": { "pred": "isSealed", "op": "eq", "value": true } }
    ]
  },
  "orderBy": [ { "pred": "title", "dir": "desc" } ],
  "offset": 0,
  "limit": 2,
  "count": "exact"
}

A group object carries exactly one of and, or, or not β€” the trie must be unambiguous.

Reasoned predicates

Set "reasoned": true on a leaf or an orderBy entry to filter or sort on a value the server derived instead of a stored property. A reasoned predicate is not validated against the entity's vocabulary, and it must be requested through the query aspect that produces it: the derived accessStatus in Chapter 08: Aspects is available when the request carries the query-aspect header. On a reasoned leaf, contains/startsWith/endsWith compare the derived text β€” the same operators, a different source.

What comes back

{ items, totalCount, offset, limit } β€” offset and limit echo the request, and totalCount is null when count was "none". That single number is what turns a page into a paginated UI: request limit + 1 items, or read totalCount and compute the page count client-side.

The branch and aspect headers you met elsewhere work here unchanged: X-Aletheia-BranchIri scopes the query to a branch or snapshot, and X-Aletheia-Query-AspectIri applies a query aspect's filter and ##aspect:param:NAME## parameters β€” the collection's chapters 13 and 14 and the query-aspect requests of 08 all run on this POST route.

πŸ’‘ Wiring: This chapter adds no registration of its own. CatalogHandler is discovered by the assembly scan in ../Program.cs under #region 01-Capabilities, and it depends on IEntityStore β€” configured by #region 02-Entities. The HTTP query endpoint it mirrors is mapped by MapOperations() in #region 02-Entities.

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

Query - orderBy title descending

POST/api/entities/manuscripts/query→ 200

Query - filter titles containing "Nature"

POST/api/entities/manuscripts/query→ 200

Query - page one (offset 0, limit 1)

POST/api/entities/manuscripts/query→ 200

Query - page two (offset 1, limit 1)

POST/api/entities/manuscripts/query→ 200

Query - and/or/not combined with sort and page

POST/api/entities/manuscripts/query→ 200

Query - count none skips the total

POST/api/entities/manuscripts/query→ 200

Objects

The Librarian produces a wax seal from her desk. "Some knowledge," she says, pressing it into warm wax, "must be preserved exactly as it was written."

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).
  • ObjectStorage:Backend β€” the switch that keeps entity code ignorant of storage. InMemory is the local default; deployed, the sample selects S3, which binds AddS3ObjectStorage() to a MinIO server on the stack's private network. Aletheia.Sdk.ObjectStorage.S3 implements the same IObjectStore as the in-memory backend β€” one contract, two backends, and the entity API is identical either way.
  • 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.

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.ObjectStorage.S3.DependencyInjection;
 
var builder =  WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment())
// ...
    repoBuilder.UseInMemory();
builder.Services.AddOperationEndpointsHttpFromAssemblyContaining<DataRecord>();
 
builder.Services.AddObjectBearingEntity();
if (string.Equals(builder.Configuration["ObjectStorage:Backend"], "S3", StringComparison.OrdinalIgnoreCase))
    builder.Services.AddS3ObjectStorage(builder.Configuration);
else
    builder.Services.AddInMemoryObjectStorage();
builder.Services.AddObjectStorageHttpFromAssemblyContaining<Manuscript>();
 
var app = builder.Build();
 
// ...
 
app.MapOperations();
 
app.MapObjectOperations();
 
app.Run();
  • Projects the [ObjectBearing] scan into the read-only aletheia/object-bearings entity so MapOperations() serves the bucket matrix on the canonical exploration surface (order-independent relative to AddObjectStorageHttp above).
  • The blob backend follows the `ObjectStorage:Backend` key, exactly like `EntityRepository:Backend`: deployed in the katharsis stack it is `S3` (MinIO on the private network), local dev and tests override it to `InMemory`.

Introspecting the Object Bearings

[ObjectBearing("seal-scrolls")] binds an entity path to a bucket, but that binding was previously only visible in the attribute. AddObjectBearingEntity() projects the [ObjectBearing] scan that AddObjectStorageHttp() already performs into the read-only aletheia/object-bearings entity, so clients can discover which entity paths carry binaries and which store backs them.

Deleting Objects

The blob and the entity have independent lifecycles. Delete the object to detach it from the entity β€” the Seal entity survives. Downloading a detached object returns 404.

Detaching does not delete the bytes from the request that detached them. Whether they may go is a question about the whole repository β€” a snapshot or another branch may still carry the key β€” so the request writes down that cleanup is owed, and the platform's own lifecycle settles it afterwards. That is what AddBlobLifecycle() provides: a durable record per stored blob, written before the bytes are, so an upload interrupted at any point is discoverable rather than invisible, and a displaced key is never silently forgotten.

πŸ’‘ Wiring: This chapter's services are registered in ../Program.cs under #region 06-Objects. The ObjectStorage:Backend key selects the blob backend β€” AddS3ObjectStorage() deployed, AddInMemoryObjectStorage() locally; AddObjectStorageHttpFromAssemblyContaining<Manuscript>() discovers and registers all [ObjectBearing] entities.

Try It

Create Seal

POST/api/entities/seals→ 200

Upload File to Seal

PUT/api/objects/seals/content?iri={{06-sealIri}}β†’ 200
Content-Typetext/plain

List Seals (with object metadata)

POST/api/entities/seals/query→ 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

List Object Bearings

POST/api/entities/aletheia/object-bearings/query→ 200

Transactions

The Librarian opens a heavy ledger. "Every change to the archives," she says, turning to a fresh page, "is recorded here. All of it β€” or none of it."

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

Registers a Manuscript together with its Scribe in ONE transaction β€” a rejected or rolled-back registration leaves neither behind

Capabilities/RegisterManuscriptCapability.cs
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
using Aletheia.Sdk.Operations;
using Aletheia.Sdk.Repository.Transaction;
 
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// Register a Manuscript together with its Scribe in one atomic transaction.
/// If the transaction fails β€” or if errorScenario is "rollback" β€” neither enters the Library.
/// </summary>
public sealed record RegisterManuscriptCommand(string Title, string Author, string ScribeName, string? ErrorScenario = null);
 
/// <summary>
/// The result of registering a Manuscript β€” both the scroll and its creator.
/// </summary>
public sealed record RegisterManuscriptResponse(string ManuscriptIri, string ScribeIri, string ScribeName, bool Committed);
 
[Capability("sample.register-manuscript")]
public sealed class RegisterManuscriptHandler : ICapabilityHandler<RegisterManuscriptCommand, RegisterManuscriptResponse>
{
    private readonly ITransactionalEntityStore _store;
 
    public RegisterManuscriptHandler(ITransactionalEntityStore store) => _store = store;
 
    public async ValueTask<ExecutionResult<RegisterManuscriptResponse>> HandleAsync(
        RegisterManuscriptCommand command,
        CapabilityContext context,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(command.Title))
            return new ExecutionResult<RegisterManuscriptResponse>.Fail(
                new ExecutionError("EMPTY_TITLE", "A scroll must have a title."));
 
        if (string.IsNullOrWhiteSpace(command.ScribeName))
            return new ExecutionResult<RegisterManuscriptResponse>.Fail(
                new ExecutionError("EMPTY_SCRIBE", "A Scribe must have a name."));
 
        using var _ = EntityOperations.Use(_store);
 
        var scribe = new Scribe { Name = command.ScribeName, Title = "Registered Scribe" };
        var manuscript = new Manuscript { Title = command.Title, Author = command.Author };
 
        await using var tx = EntityOperations.BeginTransaction();
        tx.Create(scribe);
        tx.Create(manuscript);
        // Force IRI generation before linking (access triggers MaterializeIdentity)
        var manuscriptIri = manuscript.Iri;
        await scribe.Wrote.AddAsync(manuscript, cancellationToken);
        tx.Update(scribe);
 
        if (string.Equals(command.ErrorScenario, "rollback", StringComparison.OrdinalIgnoreCase))
        {
            // Deliberately abandon the transaction β€” no CommitAsync called.
            // The await using disposes without committing; both entities are lost.
            return new ExecutionResult<RegisterManuscriptResponse>.Fail(
                new ExecutionError("TX_ROLLBACK",
                    "Transaction rolled back deliberately. Neither scroll nor Scribe entered the Library."));
        }
 
        await tx.CommitAsync(cancellationToken);
 
        return new ExecutionResult<RegisterManuscriptResponse>.Ok(
            new RegisterManuscriptResponse(manuscript.Iri, scribe.Iri, scribe.Name, true));
    }
}

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()
        );
    }
}

πŸ’‘ Wiring: This chapter's services are registered in ../Program.cs under #region 07-Transactions. The handler injects ITransactionalEntityStore β€” the same DI registration as #region 02-Entities but requesting the transactional interface.

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

POST/api/entities/scribes/query→ 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

POST/api/entities/catalog-entries/query→ 200

Aspects

The Gatekeeper stands at the archive entrance, a ledger of rules in hand. "Not everyone may enter," she intones. "And not everything may pass."

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 in C# as TTL/SPARQL text composed from generated, `BaseIri`-relative vocabulary constants (see **The Vocabulary** below).

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. Beyond filtering, query aspects can also enrich: a SPARQL `CONSTRUCT` derives implicit-knowledge facts and merges them into reads as ordinary JSON fields. The Bruno collection validates all of it: 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, form values judged by the Manuscript view shape, a read whose reasoned `accessStatus` is computed from the stored seal flag, and a collection query whose `where` filters on that derived value.

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. The shape is also the write contract: only the properties it mentions are saved or edited β€” body values for anything else are ignored, and a stored value an update does not mention is left untouched. 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.
  • Query-aspect enrichment β€” beyond filtering, a query aspect can carry a SPARQL CONSTRUCT that derives implicit-knowledge facts and merges them into reads as ordinary JSON fields. The Manuscript accessStatus is computed from isSealed by the construct β€” a "construction set" that packages the reasoning with the aspect. The result shape declares exactly the fields the read returns: the reasoned accessStatus and the stored isSealed beside it; properties the shape does not mention are not returned. Named twice over: the property carries its sh:name, and its value vocabulary is declared with sh:in β€” each value is a named individual (Restricted/EingeschrΓ€nkt, Public/Γ–ffentlich), so a read returns the value IRI and the localization dictionary renders its text (aspects.manuscript-access-v1.values.accessStatus.*). The derived values are read-only projections (never persisted). On GraphDB the same construct runs with ?infer=true so an OWL ruleset can contribute further inferred triples; the InMemory backend evaluates it structurally. The derived value is a first-class query field: a where leaf with "reasoned": true filters on it, and the SDK widens the server pass (the reasoned value doesn't exist in the store), enriches every candidate, then applies the reasoned leaf in memory β€” same operators, derived source.
  • Request-parameterized filtering β€” a query aspect's access gate can be parameterized per request (Aspects ADR-0010). A ##aspect:param:NAME## placeholder in the FilterWhere is resolved from a query-string parameter carried alongside the X-Aletheia-Query-AspectIri header, with the value percent-encoded before interpolation β€” a crafted value can neither break out of the IRI literal nor inject SPARQL. The placeholder sits inside a normal FilterWhere, so one aspect combines a runtime value with a static access gate in a single pass β€” e.g. list only the *unsealed* manuscripts of one genre. The filter is applied to the LIST type-scan, so a client can filter a collection server-side by a runtime value.
  • ?entityIri β€” the platform's first infused variable β€” the engine binds ?entityIri to the entity an operation is about: the record a create, update, or delete targets, and the subject a read's access gate, list filter, operation-context pass, or enrichment CONSTRUCT iterates. It is *infused* β€” no request parameter or header carries it, so a caller can neither claim nor override it, and the entity an aspect sees is always the one the platform already bound. Hand-written SPARQL is held to the same convention: the analyzer's ALETHEIA_1005 warns when a pass does not open with ?entityIri.
  • ?agentIri β€” the platform's second infused variable β€” a restriction can be about WHO is asking, not only about what is read. The engine binds ?agentIri to the acting agent (the Agent record behind the caller's token) wherever an aspect's SPARQL names it, exactly as it binds ?entityIri to the entity being read; no request parameter carries the identity, so a caller cannot claim one. The sample's manuscripts-for-agents-v1 read admits only a verified agent β€” 422 without a token, 200 with one. ⚠️ Watch for: this SPARQL engine compares SOFTLY against an UNBOUND variable, so a bare FILTER(?agentIri = <…>) PASSES for an anonymous caller. BOUND(?agentIri) is what refuses one, and naming a single agent combines the two: FILTER(BOUND(?agentIri) && ?agentIri = <https://…/authorization-agents/{uuid}>).
  • 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/aletheia/aspects/{iri}/validate). Findings of every severity β€” Violation, Warning, Info β€” come back mapped to JSON paths (title, genre, year). The JSON key is the predicate: every sh:path lives in the canonical JSON namespace (https://aletheia.katharsis.digital/json/), so no application-specific predicate namespaces exist.
  • View bindings β€” a view declares the aspects it may be used *with*: views:operationAspect for the writes it feeds and views:queryAspect for the reads that serve it. The declaration is a list and an allowed set: a request still names exactly one aspect in its header, and the set is what that header may name. It is opt-in β€” a view that names nothing is simply unbound β€” and it makes a screen's aspect IRI *checkable* instead of remembered.
  • SHACL shapes β€” the underlying constraint language. Shapes are written as TTL text using SHACL's RDF vocabulary β€” sh:minLength, sh:maxLength, sh:pattern (regex), sh:minInclusive, sh:maxInclusive, and more β€” composed from generated BaseIri-relative constants (see The Vocabulary below). The engine evaluates the resulting RDF. This means aspects are data, not code β€” they could theoretically be loaded from a database or configuration file.

The Code

Registers SHACL-based aspects for capability handlers β€” e.g. catalog keyword minimum length

Aspects/CapabilityAspects.cs
using Aletheia.Sdk.Aspects;
using Aletheia.Sdk.Aspects.Abstractions;
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.Message;
 
public static class CapabilityAspects
{
    // 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 message-property IRI has no entity vocabulary constant β€” kept as a full IRI literal.
    [AspectDefinition("urn:aletheia:aspects:catalog-keyword-v1", AspectDefinitionFamily.Message)]
    public static readonly IMessageAspect CatalogKeywordV1 = new InlineTtlMessageAspect("urn:aletheia:aspects:catalog-keyword-v1", Ttl.WithPrefixes($$"""
        <urn:aletheia:aspects:catalog-keyword-shape>
            a sh:NodeShape ;
            sh:targetClass <urn:Aletheia.Sdk.Sample.CatalogCommand> ;
            sh:property [
                sh:path <https://aletheia.katharsis.digital/Keyword> ;
                sh:name "Keyword"@en ;
                sh:minLength 3 ;
                sh:message "A catalog search keyword must be at least 3 characters." ;
            ] .
        """));
 
    [AspectDefinition("urn:aletheia:aspects:capability:catalog-v1", AspectDefinitionFamily.Capability)]
    public static readonly CapabilityAspect CatalogV1 = new()
    {
        Iri = "urn:aletheia:aspects:capability:catalog-v1",
        CommandAspectIri = "urn:aletheia:aspects:catalog-keyword-v1"
    };
 
    [AspectDefinition("urn:aletheia:aspects:catalog-keyword-v2", AspectDefinitionFamily.Message)]
    public static readonly IMessageAspect CatalogKeywordV2 = new InlineTtlMessageAspect("urn:aletheia:aspects:catalog-keyword-v2", Ttl.WithPrefixes($$"""
        <urn:aletheia:aspects:catalog-keyword-shape>
            a sh:NodeShape ;
            sh:targetClass <urn:Aletheia.Sdk.Sample.CatalogCommand> ;
            sh:property [
                sh:path <https://aletheia.katharsis.digital/Keyword> ;
                sh:name "Keyword"@en ;
                sh:minLength 3 ;
                sh:message "A catalog search keyword must be at least 3 characters." ;
            ] .
        """));
 
    [AspectDefinition("urn:aletheia:aspects:capability:catalog-v2", AspectDefinitionFamily.Capability)]
    public static readonly CapabilityAspect CatalogV2 = new()
    {
        Iri = "urn:aletheia:aspects:capability:catalog-v2",
        CommandAspectIri = "urn:aletheia:aspects:catalog-keyword-v2"
    };
}
  • The Catalog Keyword Aspect is demonstrated in 19-Authorization

Registers aspects that guard entity create/update/delete operations

Aspects/OperationAspects.cs
using Aletheia.Sdk.Aspects;
using Aletheia.Sdk.Aspects.Abstractions;
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.Operation;
using Aletheia.Sdk.Entity;
 
public static class OperationAspects
{
    // 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 shape mentions exactly the properties the operation governs: the title carries the
    // non-empty constraint, and every other path listed is the set the write saves β€” properties
    // NOT mentioned are neither saved nor edited. Operation aspects propose no sh:name at all:
    // the fields they constrain speak the entity's own vocabulary.
    [AspectDefinition("urn:aletheia:aspects:manuscript-write-v1", AspectDefinitionFamily.Operation)]
    public static readonly IOperationAspect ManuscriptWriteV1 = new InlineTtlOperationAspect("urn:aletheia:aspects:manuscript-write-v1", Ttl.WithPrefixes($$"""
        <urn:aletheia:aspects:manuscript-shape>
            a sh:NodeShape ;
            sh:targetClass <{{Vocabulary.Manuscript.Type}}> ;
            sh:property [
                sh:path <{{Vocabulary.Manuscript.Title}}> ;
                sh:minCount 1 ; sh:minLength 1 ;
                sh:message "A manuscript must have a non-empty title." ;
            ] ;
            sh:property [ sh:path <{{Vocabulary.Manuscript.Author}}> ] ;
            sh:property [ sh:path <{{Vocabulary.Manuscript.Genre}}> ] ;
            sh:property [ sh:path <{{Vocabulary.Manuscript.Shelf}}> ] ;
            sh:property [ sh:path <{{Vocabulary.Manuscript.Year}}> ] ;
            sh:property [ sh:path <{{Vocabulary.Manuscript.IsSealed}}> ] .
        """), null);
}

Registers aspects that filter entity queries, derive reasoned fields (enrichment), and require a verified agent (?agentIri)

Aspects/QueryAspects.cs
using Aletheia.Sdk.Aspects;
using Aletheia.Sdk.Aspects.Abstractions;
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.Query;
using Aletheia.Sdk.Entity;
 
public static class QueryAspects
{
    // Query aspect 1 β€” enrichment: derive a reasoned access status from the stored
    // seal flag and merge it into reads as an ordinary JSON field (`accessStatus`).
    // The result shape declares exactly the properties the aspect returns: the reasoned
    // `accessStatus` and the stored `isSealed` flag the reader needs alongside it. Properties
    // the shape does not mention are not returned. The CONSTRUCT is the "construction set" β€” a
    // SPARQL query that computes the implicit-knowledge fact. On GraphDB the same construct runs
    // with ?infer=true, so an OWL ruleset can contribute further inferred triples; the InMemory
    // backend evaluates it structurally.
    // `accessStatus` is a derived predicate with no entity property β€” kept as a full predicate IRI.
    // Its reasoned values are declared vocabulary, not magic strings: the shape lists them via
    // sh:in and names each (sh:name/sh:description), so the localization dictionary publishes
    // `aspects.manuscript-access-v1.values.accessStatus.restricted|public` and a reader resolves
    // the IRI a read returns through the same chain as every other label.
    [AspectDefinition("urn:aletheia:aspects:manuscript-access-v1", AspectDefinitionFamily.Query)]
    public static readonly IQueryAspect ManuscriptAccessV1 = new InlineTtlQueryAspect(
        "urn:aletheia:aspects:manuscript-access-v1",
        filterWhere: null,
        resultShapeTtl: Ttl.WithPrefixes($$"""
            <urn:aletheia:aspects:manuscript-access-shape>
                a sh:NodeShape ;
                sh:targetClass <{{Vocabulary.Manuscript.Type}}> ;
                sh:property [
                    sh:path <https://aletheia.katharsis.digital/predicates/manuscripts/accessStatus> ;
                    sh:name "Access status"@en ;
                    sh:in ( <urn:aletheia:aspects:manuscript-access:restricted> <urn:aletheia:aspects:manuscript-access:public> ) ;
                ] ;
                sh:property [ sh:path <{{Vocabulary.Manuscript.IsSealed}}> ] .
 
            <urn:aletheia:aspects:manuscript-access:restricted> sh:name "Restricted"@en, "EingeschrΓ€nkt"@de .
            <urn:aletheia:aspects:manuscript-access:public> sh:name "Public"@en, "Γ–ffentlich"@de .
            """),
        enrichmentConstruct: $$"""
            CONSTRUCT { ?entityIri <https://aletheia.katharsis.digital/predicates/manuscripts/accessStatus> ?status }
            WHERE {
              ?entityIri <{{Vocabulary.Manuscript.IsSealed}}> ?sealed .
              BIND(IF(?sealed, <urn:aletheia:aspects:manuscript-access:restricted>, <urn:aletheia:aspects:manuscript-access:public>) AS ?status)
            }
            """);
 
    // Query aspect 2 β€” request-parameterized filter + access gate in one aspect
    // (Aspects ADR-0010): list the manuscripts of one genre and hide sealed ones in
    // the same pass. The client sends `?genre=<genre-iri>` alongside the
    // `X-Aletheia-Query-AspectIri` header; the engine resolves the
    // `##aspect:param:genre##` placeholder (percent-encoding the value before
    // interpolation) and applies the WHOLE filter to the LIST type-scan β€” so a
    // crafted value can neither inject SPARQL nor break out of the IRI literal, and
    // the sealed gate is enforced for free on the same filter.
    // The subject variable MUST be ?entityIri β€” the engine binds it to each read
    // subject (mapped to ?s on the collection type-scan), so a filter using any other
    // variable would silently match nothing.
    [AspectDefinition("urn:aletheia:aspects:manuscript-by-genre-v1", AspectDefinitionFamily.Query)]
    public static readonly IQueryAspect ManuscriptByGenreV1 = new InlineTtlQueryAspect(
        "urn:aletheia:aspects:manuscript-by-genre-v1",
        $$"""
            ?entityIri <{{Vocabulary.Manuscript.Genre}}> <##aspect:param:genre##> .
            ?entityIri <{{Vocabulary.Manuscript.IsSealed}}> ?sealed .
            FILTER(?sealed = false)
        """,
        // The result shape declares the properties the aspect returns: title and the seal
        // flag the filter reasons about. Structural properties carry no sh:name β€” only the
        // reasoned properties of a query aspect are named.
        resultShapeTtl: Ttl.WithPrefixes($$"""
            <urn:aletheia:aspects:manuscript-by-genre-shape>
                a sh:NodeShape ;
                sh:targetClass <{{Vocabulary.Manuscript.Type}}> ;
                sh:property [ sh:path <{{Vocabulary.Manuscript.Title}}> ] ;
                sh:property [ sh:path <{{Vocabulary.Manuscript.IsSealed}}> ] .
            """));
 
    // Query aspect 3 β€” the platform's SECOND infused variable: `?agentIri`.
    // A read that only a VERIFIED AGENT may make. The engine binds `?agentIri` to the acting
    // agent β€” the `Agent` record behind the caller's token β€” wherever an aspect's SPARQL names
    // it, exactly as it binds `?entityIri` to the entity being read. The BOUND guard is what
    // refuses everyone else, and it is load-bearing rather than decoration: this SPARQL engine
    // compares SOFTLY against an unbound variable (`?agentIri = <…>` would PASS), so an
    // anonymous caller slips through a bare comparison. Naming ONE agent is the same gate with
    // the comparison added beside the guard:
    //   FILTER(BOUND(?agentIri) && ?agentIri = <https://…/authorization-agents/<uuid>>)
    // An Agent's IRI is a v5 UUID of its token (see Aletheia.Sdk.Authorization.Entity), which
    // is why the sample names the guard rather than one agent: a lesson should be readable.
    // The gate opens with a triple pattern whose subject is `?entityIri` because the platform's
    // analyzer requires that convention (ALETHEIA_1005) β€” the pattern is also the projection the
    // result shape returns.
    [AspectDefinition("urn:aletheia:aspects:manuscripts-for-agents-v1", AspectDefinitionFamily.Query)]
    public static readonly IQueryAspect ManuscriptsForAgentsV1 = new InlineTtlQueryAspect(
        "urn:aletheia:aspects:manuscripts-for-agents-v1",
        $$"""
            ?entityIri <{{Vocabulary.Manuscript.Title}}> ?title .
            FILTER(BOUND(?agentIri))
        """,
        resultShapeTtl: Ttl.WithPrefixes($$"""
            <urn:aletheia:aspects:manuscripts-for-agents-shape>
                a sh:NodeShape ;
                sh:targetClass <{{Vocabulary.Manuscript.Type}}> ;
                sh:property [ sh:path <{{Vocabulary.Manuscript.Title}}> ] .
            """));
}

Registers aspects that guard object storage uploads β€” the seal-lock aspect prevents re-upload

Aspects/ObjectAspects.cs
using Aletheia.Sdk.Aspects;
using Aletheia.Sdk.Aspects.Abstractions;
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.Operation;
using Aletheia.Sdk.Entity;
 
/// <summary>
/// Object-level aspects β€” guards for [ObjectBearing] upload routes.
/// These apply to the object storage pipeline, not entity properties.
/// </summary>
public static class ObjectAspects
{
    // 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.
    // The SHACL Local shape binds the aspect to the Seal entity type via
    // sh:targetClass and declares WHICH members it governs β€” Name and Description,
    // the two the reader sees. It constrains neither of them, so it never rejects:
    // a shape names what an aspect is about, and constrains only what it enforces.
    // The lock itself is the SPARQL context gate below: a graph pattern check on the
    // upload route, keyed on the objectKey the shape deliberately does NOT name.
    [AspectDefinition("urn:aletheia:aspects:operation:seal-lock-v1", AspectDefinitionFamily.Operation)]
    public static readonly IOperationAspect SealLockV1 = new InlineTtlOperationAspect("urn:aletheia:aspects:operation:seal-lock-v1", Ttl.WithPrefixes($$"""
        <urn:aletheia:aspects:seal-lock-shape>
            a sh:NodeShape ;
            sh:targetClass <{{Vocabulary.Seal.Type}}> ;
            sh:property [ sh:path <{{Vocabulary.Seal.Name}}> ] ;
            sh:property [ sh:path <{{Vocabulary.Seal.Description}}> ] .
        """), $$"""
        ?entityIri <{{Vocabulary.Seal.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 β€” and declares the aspects the form may be used with

Aspects/ViewAspects.cs
using Aletheia.Sdk.Aspects;
using Aletheia.Sdk.Aspects.Abstractions;
using Aletheia.Sdk.Aspects.Abstractions.Contracts;
using Aletheia.Sdk.Aspects.View;
 
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// 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/aletheia/aspects/{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.
/// <br/><br/>
/// The shape declares no <c>sh:targetClass</c>. A view is about a FORM: its
/// subject is the form itself, not an instance of an entity, so no class of the
/// domain is a meaningful context. The view engine types the validated JSON root
/// node with the shape's own IRI in that case β€” the constraints fire against the
/// payload's root either way. A view MAY name a target class; it is never required.
/// <br/><br/>
/// The form also says what it may be USED WITH. A view names the aspects it is
/// valid with β€” <see cref="Aletheia.Sdk.Aspects.Abstractions.Aspect.OperationAspectPredicate"/>
/// for the writes it feeds, <see cref="Aletheia.Sdk.Aspects.Abstractions.Aspect.QueryAspectPredicate"/>
/// for the reads that serve it. Each is a LIST and an allowed set, never the
/// choice itself: a request still names exactly one aspect in its header
/// (<c>X-Aletheia-Operation-AspectIri</c> / <c>X-Aletheia-Query-AspectIri</c>), and
/// the frontend reads the declaration to check that a screen which binds this
/// form uses an aspect the form actually declares. A form that outgrows one
/// aspect names another rather than losing the link; a view that names nothing
/// is simply unbound β€” declaring is opt-in, never an obligation.
/// </summary>
public static class ViewAspects
{
    // The Manuscript form shape β€” the three members a catalogue record of a manuscript really needs:
    // a TITLE (a string), a GENRE (a reference to a published enumeration) and a YEAR (an integer).
    // The three JSON shapes a form value can have are all here, and the genre is a closed list: a
    // model that fills this form must look the list up, with the names the platform publishes for
    // it, rather than invent a value. A genre outside the published list is a WARNING β€” worth
    // saying, not worth refusing.
    //
    // The bindings are read as: this form is WRITTEN through the manuscript operation aspect (whose
    // shape names exactly the fields the write saves, this form's title and genre among them), and
    // it is READ through two queries β€” the list filtered by the very genre this form captures, and
    // the agent-gated read that returns this form's title. A binding is a claim about reuse, and the
    // frontend's conformance test is what keeps a screen honest about it.
    [AspectDefinition("urn:aletheia:views:manuscript-form", AspectDefinitionFamily.View)]
    public static readonly IViewAspect ManuscriptForm = new InlineTtlViewAspect("urn:aletheia:views:manuscript-form", Ttl.WithPrefixes($$"""
        <urn:aletheia:views:manuscript-form>
            a sh:NodeShape ;
            <{{Aspect.OperationAspectPredicate}}> <urn:aletheia:aspects:manuscript-write-v1> ;
            <{{Aspect.QueryAspectPredicate}}> <urn:aletheia:aspects:manuscript-by-genre-v1> ;
            <{{Aspect.QueryAspectPredicate}}> <urn:aletheia:aspects:manuscripts-for-agents-v1> ;
            sh:property [
                sh:path <{{Aspect.JsonNamespace}}title> ; sh:name "Title"@en, "Titel"@de ;
                sh:minCount 1 ; sh:minLength 5 ;
                sh:datatype xsd:string ;
                sh:message "view.manuscript.title" ;
            ] ;
            sh:property [
                sh:path <{{Aspect.JsonNamespace}}genre> ; sh:name "Genre"@en, "Gattung"@de ;
                sh:minCount 1 ; sh:nodeKind sh:IRI ;
                sh:message "view.manuscript.genre" ;
            ] ;
            sh:property [
                sh:path <{{Aspect.JsonNamespace}}genre> ; sh:name "Genre"@en, "Gattung"@de ;
                sh:in ( {{PublishedGenres()}} ) ;
                sh:severity sh:Warning ;
                sh:message "view.manuscript.genre.unpublished" ;
            ] ;
            sh:property [
                sh:path <{{Aspect.JsonNamespace}}year> ; sh:name "Year"@en, "Jahr"@de ;
                sh:minCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 1000 ;
                sh:message "view.manuscript.year" ;
            ] .
        """));
 
    /// <summary>
    /// The published genres, as IRIs for the shape's <c>sh:in</c> list.
    /// <br/><br/>
    /// The list is read from the enumeration ITSELF β€” the instances' own IRIs β€” so adding a genre to
    /// <see cref="Genre"/> cannot leave the shape warning about a value the platform publishes. Each
    /// instance materializes its identity in Genre's static constructor, which reading a static field
    /// triggers.
    /// </summary>
    private static string PublishedGenres() => string.Join(
        " ",
        typeof(Genre)
            .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
            .Where(field => field.FieldType == typeof(Genre))
            .Select(field => field.GetValue(null) as Genre)
            .Where(genre => genre is not null)
            .Select(genre => $"<{genre!.Iri}>")
            .OrderBy(iri => iri, StringComparer.Ordinal));
}

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

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. Its shape declares the members the aspect is about β€” Seal.Name and Seal.Description β€” with sh:property entries and no constraints on them: a shape names what an aspect governs and constrains only what it enforces, so this one never rejects a write. The lock itself is the pure SPARQL WHERE clause below, keyed on objectKey, the member the shape deliberately does NOT name: 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.

πŸ’‘ Wiring: This chapter's services are registered in ../Program.cs under #region 08-Aspects. Five static methods register the five aspect registrations with the IAspectStore. AddAspects() in #region 00-Setup initializes the aspect engine β€” aspects are core functionality and always required.

The Vocabulary

Aspect TTL is *composed* from generated vocabulary constants β€” never hand-written IRI strings, never app-specific @prefix lines. Ttl.WithPrefixes(...) supplies the five standard prefixes (sh:, rdf:, rdfs:, xsd:, owl:), and the shape body interpolates the entity vocabulary:

// Vocabulary.Manuscript.Type  β†’ the Manuscript type IRI
// Vocabulary.Manuscript.Title β†’ the title predicate IRI
var shape = Ttl.WithPrefixes($$"""
    <urn:aletheia:aspects:manuscript-shape>
        a sh:NodeShape ;
        sh:targetClass <{{Vocabulary.Manuscript.Type}}> ;
        sh:property [
            sh:path <{{Vocabulary.Manuscript.Title}}> ;
            sh:minCount 1 ; sh:minLength 1 ;
            sh:message "A manuscript must have a non-empty title." ;
        ] ;
        sh:property [ sh:path <{{Vocabulary.Manuscript.Author}}> ] ;
        sh:property [ sh:path <{{Vocabulary.Manuscript.Genre}}> ] ;
        sh:property [ sh:path <{{Vocabulary.Manuscript.Shelf}}> ] ;
        sh:property [ sh:path <{{Vocabulary.Manuscript.Year}}> ] ;
        sh:property [ sh:path <{{Vocabulary.Manuscript.IsSealed}}> ] .
    """);

Every shape in this chapter is written that way β€” Aspects/OperationAspects.cs (operation), Aspects/QueryAspects.cs (SPARQL filter + enrichment), Aspects/ObjectAspects.cs (object lock), Aspects/ViewAspects.cs (form view) and Aspects/CapabilityAspects.cs (message).

The constants live in one generated Aletheia.Sdk.Entity.Vocabulary class, grouped per entity: Vocabulary.{Entity}.Type is the entity's type IRI and every other member of the group is one of its predicate IRIs β€” a group is self-contained, and a subtype carries the predicates it inherits.

Why interpolate instead of writing the IRI?

  • BaseIri-relative at runtime. Every generated constant is an expression over

EntityOptions.BaseIri, so a deployed app can override Entity__BaseIri and every shape moves with it. A static app @prefix β€” or a literal host inside the TTL β€” bakes the default host and silently points at the wrong graph.

  • Compile-time typo safety. Vocabulary.Manuscript.Titel does not compile; a mistyped IRI *string* would

only fail when the shape is evaluated.

  • No prefix boilerplate. The standard prefixes come from Ttl.Prefixes, and app-specific IRIs are

never declared as @prefix β€” a prefix cannot be BaseIri-relative, so app IRIs are always full tokens built from constants.

Documented exceptions. A few IRIs have no vocabulary constant and stay as full literals:

  • the derived accessStatus predicate and its value individuals β€” produced by the enrichment CONSTRUCT / declared by its shape, on no entity;
  • the urn: shape IRIs and the message shapes' target classes (e.g.

urn:Aletheia.Sdk.Sample.CatalogCommand) β€” stable identifiers, not BaseIri-relative;

  • a view's sh:targetClass β€” a form view declares none at all, so no constant is needed for it

(see Shape Naming);

  • the JSON-namespace view predicates β€” written <{{Aspect.JsonNamespace}}title> because the JSON key

*is* the predicate local name.

The rewrite is purely compositional: the emitted Turtle is semantically identical, and the Bruno suite under Try It is the regression net that proves it.

Shape Naming

Only property shapes are named, and only for the families that label their fields:

  • message aspects β€” the command shapes of capabilities β€” name every property,
  • view aspects name every property,
  • query aspects name only the reasoned properties their enrichment CONSTRUCT derives,
  • operation aspects name nothing β€” the fields they constrain speak the entity's own [Label]

vocabulary, which the admin already renders.

Node shapes are constraint carriers, never labels β€” no family requires a sh:name on them. Where a name is required, English is the mandatory default: an untagged literal counts as English (RDF's default language) and @en is the explicit form. Translations are optional literals beside it, and one shape can carry several.

<urn:aletheia:views:manuscript-form>
    a sh:NodeShape ;                          # node shapes carry no name at all
    sh:property [
        sh:path <...>/json/title ;
        sh:name "Title"@en, "Titel"@de ;      # required β€” view properties are all named
        sh:minCount 1 ; sh:minLength 5 ;
        sh:datatype xsd:string ;
        sh:message "view.manuscript.title" ;
    ] ;
    sh:property [
        sh:path <...>/json/genre ;
        sh:name "Genre"@en, "Gattung"@de ;
        sh:minCount 1 ; sh:nodeKind sh:IRI ;  # a REFERENCE to a published enumeration value
        sh:message "view.manuscript.genre" ;
    ] ;
    sh:property [
        sh:path <...>/json/genre ;
        sh:name "Genre"@en, "Gattung"@de ;
        sh:in ( <...>/genres/history <...>/genres/philosophy ) ;   # the published values
        sh:severity sh:Warning ;              # worth saying, not worth refusing
        sh:message "view.manuscript.genre.unpublished" ;
    ] .

The three literals never overlap: sh:name is the short display label, sh:description the long text, and sh:message the validation message (a translation key or a literal). No custom label vocabulary exists β€” this is the SHACL standard as shipped.

The contract is enforced twice. ALETHEIA_1008 fails the build for a literal registration whose required property shape has no English name, and the aspect store throws at registration for a shape that arrives from a file or a post-build Register* call β€” the same eager strictness the store already applies to duplicate aspect IRIs. Aspects/ViewAspects.cs is the bilingual reference, Aspects/QueryAspects.cs the reasoned-property reference, and Aspects/OperationAspects.cs shows a write shape with no sh:name at all.

A view says what it may be used with

A view is the one family the pipeline never consults, so nothing tells the platform which write or read a form belongs to β€” a screen may name any aspect IRI it likes. The view therefore declares it, through two platform predicates:

<urn:aletheia:views:manuscript-form>
    a sh:NodeShape ;
    <https://aletheia.katharsis.digital/views/operationAspect> <urn:aletheia:aspects:manuscript-write-v1> ;
    <https://aletheia.katharsis.digital/views/queryAspect> <urn:aletheia:aspects:manuscript-by-genre-v1> ;
    <https://aletheia.katharsis.digital/views/queryAspect> <urn:aletheia:aspects:manuscripts-for-agents-v1> ;
    sh:property [
        sh:path <...>/json/title ; ...
    ] .
  • A list, and an allowed set. A request still names exactly ONE aspect in its header

(X-Aletheia-Operation-AspectIri / X-Aletheia-Query-AspectIri); the declaration is the set that header may name. A form that outgrows one aspect names another instead of losing the link.

  • Opt-in, never mandatory. A view that names nothing is unbound and nothing fails β€” the

declaration is what makes a check *possible*, not a rule the platform imposes. The store, the engines and the write path are entirely unaware of it.

  • The sample's claim. manuscript-form binds one write β€” the operation aspect whose shape

names the fields this form captures β€” and two reads: the list filtered by the very genre the form captures, and the agent-gated read that returns its title.

  • What it buys. The aspect a screen binds becomes checkable instead of remembered. The exploration

endpoint serves this Turtle verbatim, so the frontend reads the declaration off the same shape it already loads, and asserts that the aspect a screen uses is one its view declares. The TTL is composed from the platform's own constants β€” Aspect.OperationAspectPredicate and Aspect.QueryAspectPredicate β€” never a hand-written IRI string. The first view request under Try It asserts the binding survives to the client.

The shape decides what is written and returned

A shape's sh:path set is not decoration β€” it is the contract of the aspect:

  • Operation aspects save only what they mention. A property the local shape does not declare is

neither saved nor edited: the request body's value for it is ignored on create, and a stored value is left untouched on update. Aspects/OperationAspects.cs mentions all six manuscript fields, so a full payload persists; a shape that mentioned only title would silently keep just the title.

  • Query aspects return only what they mention. A result shape that declares property shapes

projects the response to exactly those fields β€” stored properties outside the set are absent from the JSON, and a reasoned field is returned only when the shape declares it. A result shape with no property shapes (or no result shape at all) projects nothing and the aspect stays a pure gate.

What is being read β€” the entity the platform binds

?entityIri is the first of the two variables the engine owns, and the one every shape in this chapter already opens with. It is bound to the entity the operation is about β€” the record a create, update, or delete targets, and the subject a read's gate, filter, or enrichment pass iterates β€” in the same places the engine binds the agent below: the access gate, the list filter, the operation-context pass, and an enrichment CONSTRUCT.

It is therefore not a value a client can send. No header and no query-string parameter carries it, and a shape that named it in a request body would be refused as an unknown parameter. The aspect analyzer says the same thing to hand-written SPARQL: ALETHEIA_1005 warns when a pass's first subject variable is not ?entityIri, because a pass that starts anywhere else is no longer anchored to the record the platform resolved.

Who is asking β€” the agent the platform binds

?agentIri is the second of the two. A restriction may also name it, and the engine binds it to the acting agent β€” the Agent record behind the caller's token β€” in the same places it binds the entity: the access gate, the list filter, the operation context pass, and an enrichment construct. The identity is therefore never something a client sends; it is what the platform already knows about the caller.

Aspects/QueryAspects.cs registers the sample's agent-gated read. Read the gate as a sentence: *the manuscript must have a title, and there must be an agent* β€”

[AspectDefinition("urn:aletheia:aspects:manuscripts-for-agents-v1", AspectDefinitionFamily.Query)]
public static readonly IQueryAspect ManuscriptsForAgentsV1 = new InlineTtlQueryAspect(
    "urn:aletheia:aspects:manuscripts-for-agents-v1",
    $$"""
        ?entityIri <{{Vocabulary.Manuscript.Title}}> ?title .
        FILTER(BOUND(?agentIri))
    """,
    resultShapeTtl: …);

The collection walks both surfaces, with and without the token, which is where the difference between them shows:

RequestWhat the platform does
`Authorization: Bearer chapter08-reader`The token becomes the ambient agent; the engine resolves it to the agent's IRI and binds `?agentIri` β€” the gate finds its row, the list filter keeps its rows
no `Authorization` headerThere is no agent to bind, so the variable stays UNBOUND β€” the gate finds no row and the read is refused (422), while the list comes back `200` with zero items (a filter excludes rows; it never refuses a request)

⚠️ The soft comparison. With an unbound variable this SPARQL engine does not error β€” it compares softly: ?agentIri = <iri> passes and ?agentIri != <iri> is not true. A bare comparison therefore admits an anonymous caller. Always guard it: FILTER(BOUND(?agentIri) && ?agentIri = <…>) to admit exactly one agent, or FILTER(!BOUND(?agentIri) || ?agentIri != <…>) to refuse every other caller. An Agent's IRI is a v5 UUID of its token, which is why the sample names the guard instead of hard-coding one agent.

Compile-time Checks

This chapter's aspects are checked during the normal build. Aletheia.Sdk.Sample.csproj references the Aletheia.Sdk.Aspects.Analyzers Roslyn analyzer β€” it contributes no runtime assembly, only compile-time checks β€” so every new InlineTtl* registration is scanned as the chapter compiles:

CodeSeverityWhat it checks
`ALETHEIA_1001`ErrorAn `sh:targetClass` IRI whose `/types/` suffix names no `[Entity]` path or type segment
`ALETHEIA_1002`ErrorAn `sh:path` IRI whose `/predicates/{path}/{local}` tail names no entity predicate
`ALETHEIA_1003`ErrorThe same aspect IRI registered at a second creation site
`ALETHEIA_1004`WarningAn operation/query shape that declares no `sh:targetClass`
`ALETHEIA_1005`WarningA SPARQL pass whose first subject variable is not `?entityIri`
`ALETHEIA_1006`WarningA `##aspect:param:NAME##` name outside `[A-Za-z0-9_-]`
`ALETHEIA_1007`ErrorInline Turtle that is malformed (unbalanced `<`, unterminated string, no terminating `.`)
`ALETHEIA_1008`ErrorA property shape the family names (message/view: every property, query: the reasoned properties the CONSTRUCT derives) that declares no English `sh:name`

ALETHEIA_1000 (Info) is the live marker: it is reported at every registration whose IRI is a compile-time literal, proving the analyzer is loaded and firing. Those severities are the repo defaults declared in the root .editorconfig β€” the sample itself carries no suppressions and builds with zero aspect errors and zero aspect warnings.

πŸ’‘ The chapter composes its TTL from interpolated vocabulary constants, so the payload scanners skip these arguments (a documented limitation) while the literal aspect IRIs are always checked. The SampleAspectsRegressionTests guard in SDK/tests/Aspects.Analyzers.Tests/ feeds the same shapes as literals so the Turtle and SPARQL scans are exercised too.

Try It

Operation

Create Manuscript Without Title (should fail β€” aspect enforces min length)

POST/api/entities/manuscripts→ 422
X-Aletheia-Operation-AspectIriurn:aletheia:aspects:manuscript-write-v1

Create Valid Manuscript (should succeed)

POST/api/entities/manuscripts→ 200
X-Aletheia-Operation-AspectIriurn:aletheia:aspects:manuscript-write-v1

Capability

Search Catalog β€” short keyword rejected by Capability Aspect

POST/api/capabilities/sample/catalog→ 422
X-Aletheia-Capability-AspectIriurn:aletheia:aspects:capability:catalog-v1

Search Catalog β€” valid keyword passes Capability Aspect

POST/api/capabilities/sample/catalog→ 200
X-Aletheia-Capability-AspectIriurn:aletheia:aspects:capability:catalog-v1

Query

Query - create unsealed philosophy manuscript

POST/api/entities/manuscripts→ 200
X-Aletheia-Operation-AspectIriurn:aletheia:aspects:manuscript-write-v1

Query - create unsealed history manuscript

POST/api/entities/manuscripts→ 200
X-Aletheia-Operation-AspectIriurn:aletheia:aspects:manuscript-write-v1

Query - create SEALED philosophy manuscript (hidden by combined gate)

POST/api/entities/manuscripts→ 200
X-Aletheia-Operation-AspectIriurn:aletheia:aspects:manuscript-write-v1

Query - list philosophy: sealed one hidden, only The Republic

POST/api/entities/manuscripts/query?genre=https://aletheia.katharsis.digital/genres/philosophy→ 200
X-Aletheia-Query-AspectIriurn:aletheia:aspects:manuscript-by-genre-v1

Query - list history: only Annals

POST/api/entities/manuscripts/query?genre=https://aletheia.katharsis.digital/genres/history→ 200
X-Aletheia-Query-AspectIriurn:aletheia:aspects:manuscript-by-genre-v1

Query - unmatched genre returns empty

POST/api/entities/manuscripts/query?genre=https://aletheia.katharsis.digital/genres/science→ 200
X-Aletheia-Query-AspectIriurn:aletheia:aspects:manuscript-by-genre-v1

Query - crafted param cannot inject SPARQL

POST/api/entities/manuscripts/query?genre=urn%3Amanuscript%3A1%3E%20%3Curn%3Ainjected%3E%20%3Curn%3Aoops→ 200
X-Aletheia-Query-AspectIriurn:aletheia:aspects:manuscript-by-genre-v1

Query - create the agent the gated read needs

POST/api/entities/authorization-agents→ 200

Query - the agent reads the collection (the filter form)

POST/api/entities/manuscripts/query→ 200
X-Aletheia-Query-AspectIriurn:aletheia:aspects:manuscripts-for-agents-v1
AuthorizationBearer chapter08-reader

Query - without an agent the gate denies the read

GET/api/entities/manuscripts?iri={{agentGatedManuscriptIri}}β†’ 422
X-Aletheia-Query-AspectIriurn:aletheia:aspects:manuscripts-for-agents-v1

Query - the same read with an agent's token succeeds

GET/api/entities/manuscripts?iri={{agentGatedManuscriptIri}}β†’ 200
X-Aletheia-Query-AspectIriurn:aletheia:aspects:manuscripts-for-agents-v1
AuthorizationBearer chapter08-reader

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-AspectIriurn: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-AspectIriurn:aletheia:aspects:operation:seal-lock-v1

View

Read Manuscript View Turtle (form shape served to the browser)

GET/api/entities/aletheia/aspects/urn:aletheia:views:manuscript-form/view→ 200

Validate Manuscript Form (conforming value β€” no findings)

POST/api/entities/aletheia/aspects/urn:aletheia:views:manuscript-form/validate→ 200

Validate Manuscript Form (short title, no genre, year 0 β€” violations with JSON paths)

POST/api/entities/aletheia/aspects/urn:aletheia:views:manuscript-form/validate→ 200

Validate Manuscript Form (unpublished genre β€” warning reported alongside)

POST/api/entities/aletheia/aspects/urn:aletheia:views:manuscript-form/validate→ 200

Enrichment

Enrichment - create public manuscript

POST/api/entities/manuscripts→ 200
X-Aletheia-Operation-AspectIriurn:aletheia:aspects:manuscript-write-v1

Enrichment - read merges reasoned accessStatus

GET/api/entities/manuscripts?iri={{enrichedManuscriptIri}}β†’ 200
X-Aletheia-Query-AspectIriurn:aletheia:aspects:manuscript-access-v1

Enrichment - filter by reasoned accessStatus (restricted)

POST/api/entities/manuscripts/query→ 200
X-Aletheia-Query-AspectIriurn:aletheia:aspects:manuscript-access-v1

Events

A bell tolls deep within the Library. "Every creation, every change, every removal," the Librarian whispers, "rings through these halls."

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 09: Events β€” every new Manuscript rings 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.ObjectStorage.S3.DependencyInjection;
 
using Aletheia.Sdk.Entity.Messaging.DependencyInjection;
using Aletheia.Sdk.Messaging.InMemory.DependencyInjection;
using Aletheia.Sdk.Branch.Http;
 
var builder =  WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment())
// ...
}
builder.Services.AddAspects();
 
builder.Services.AddMessagingInMemory();
builder.Services.AddEntityEvents();
builder.Services.AddEntityMessaging<Manuscript>(opts =>
    opts.TypeIri = "https://aletheia.katharsis.digital/entities/Manuscript");
builder.Services.AddMessagingTopicEntity();
builder.Services.AddSingleton<EntityEventLog>();
builder.Services.AddHostedService<ManuscriptSubscription>();
 
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GreetHandler>();
builder.Services.AddRuntimeInfo(options => options.ApplicationName = "Aletheia Sample");
// ...
  • .AddEntityEvents() may be called before or after .AddBranchHttp(). The branches snapshotting guard is registered under a well-known keyed key and composed at provider-build time, so every call order yields the same stack.
  • Projects the registered entity-event topics into the read-only aletheia/messaging-topics entity so MapOperations() serves them on the canonical exploration surface (requires the AddEntityMessaging calls above; order-independent).

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)

Introspecting the Topic Matrix

Which entity types emit events, and to which topics, used to be visible only in the AddEntityMessaging() calls that configured them. AddMessagingTopicEntity() projects that configuration into the read-only aletheia/messaging-topics entity, so the topics the server actually emits to can be discovered over HTTP β€” the topic matrix is now part of the same definition surface as entities, aspects and capabilities.

Where This Pattern Grows Up

The pair you just used β€” produce a typed message, consume it in a BackgroundService β€” is the low-level form of every messaging pattern in the SDK. The repository publishes; your subscriber consumes; neither knows the other exists. It is also where the sharp edges live, which is why the tutorial returns to it twice:

  • [[../12-Async-Execution/Learning|12 β€” Async Execution]] takes the same "fire the work, let somebody

else do it" idea and keeps the CAPABILITY contract across it with IAsyncCapabilityDispatcher, so the handler still receives a typed command instead of a topic string.

  • [[../10-Subscription/Learning|10 β€” Subscription]] replaces the hand-written consumer with a declared

rule β€” an entity change binding to a capability β€” which is the same pattern one level up.

One property of this substrate is worth carrying forward: a message that arrives from the bus is processed under whatever identity the consuming loop runs as, not the caller's. An Open host never notices; an Enforced host does, and a consumer that assumes otherwise fails loudly (which is exactly what chapter 12's raw echo demonstrates).

πŸ’‘ Wiring: This chapter's services are registered in ../Program.cs under #region 09-Events. AddMessagingInMemory() sets up the in-memory message bus. AddEntityMessaging<Manuscript>() registers the Manuscript entity for event publication. The ManuscriptSubscription is registered as a hosted service.

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

List Messaging Topics

POST/api/entities/aletheia/messaging-topics/query→ 200

Subscription

A cart of scrolls arrives at the reading-room desk. The Librarian does not rise to stamp each one β€” she sets a small brass rule on the desk instead: "whenever a record is admitted, index it." Nobody carries the scroll to the index; the rule does.

Chapter 11 gave the Library a clock, so work could happen with nobody asking. This chapter gives it a rule about **events** β€” work that happens because something changed. A `SubscriptionDefinition` declares **which change** to watch and **which capability** to run; the change stream carries the event, and the capability does the work.

One rule shapes the command it sends: the event's data is poured into the capability's payload through `${…}` tokens, and each token expands to **raw JSON** β€” so a token is never wrapped in quotes.

What You'll Learn

  • AddSubscription<TEntity>() β€” one call per subscription. The entity type is the generic argument, so the pump that reads that type's change stream is registered exactly once however many subscriptions watch it, and the type on the definition can never drift from the type that actually emits the event. The name is the subscription's identity β€” it is a parameter, not a property, because the entity's IRI is content-addressed from it.
  • AddSubscription() β€” wires the registry, durable delivery worker, replay capability, capability catalog, and read-only aletheia/subscriptions entity surface. Register it after application handlers but before AddCapabilityHttp() or another catalog scan, so replay is included. It is idempotent; individual subscription declarations can follow later.
  • SubscriptionDefinition β€” the unit of declaration: an EntityTypeName, an Operations filter, a CapabilityIdentity, a CommandTemplateJson, an optional aspect IRI, and an enabled flag. Like a schedule it is code-side configuration, exposed read-only ([OperationEndpoints(IsReadOnly = true)] + [DefinitionBacked]) on the canonical exploration surface β€” there is no create endpoint, because a subscription is configuration, not content.
  • Command templates and ${…} tokens β€” the payload a capability receives is built from the event. event.iri, event.operation, event.typeName, event.typeIri, event.branchIri, event.timestampUtc, event.correlation.executionId, and event.dto.<property> are all addressable. Substitution is raw JSON: a token stands where a JSON *value* belongs, so ${event.dto.label} becomes "Indexed on arrival" β€” quoted by the JSON, never by you.
  • A subscription targets a capability, not an entity β€” the definition stays read-only, while separate inbox entities hold pending work, leases, retries, and dead letters. Everything the work does lives in the handler, so a subscribed execution travels the same aspect, authorization, and error pipeline as every other capability call.
  • Delayed execution does not preserve a credential β€” trusted subscriptions persist an identity snapshot of service/subject/delegation references, never the request JWT. Delivery restoration checks the current service Agent and live delegation on each attempt; expiry or revocation blocks the retry, and current permissions can only narrow the captured ceiling.
  • The transport is the host's choice β€” the pump consumes through IMessageConsumer<TKey,TValue>, so AddMessagingInMemory() gives in-process delivery and the Kafka implementation gives durable, cross-process delivery without a single change to the declaration.

The Code

The sample.index handler the subscription targets β€” appends to the ledger when the command carries an IRI, and reads it back when it does not, so one endpoint both indexes and lists

Capabilities/IndexHandler.cs
using System.Text.Json.Serialization;
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
using Microsoft.Extensions.Logging;
 
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// Inbound command for the index capability. With an <see cref="Iri"/> it records one indexed
/// record; without one it reads the ledger back, so the same endpoint both appends and lists.
/// </summary>
/// <remarks>
/// The three properties pin <see cref="JsonPropertyNameAttribute"/> because the two entry points
/// name them differently by default: the subscription's command template produces camelCase JSON,
/// which <c>ICapabilityCatalog.DispatchAsync</c> deserializes with <c>System.Text.Json</c> defaults
/// (case-sensitive, PascalCase), while a manual POST body arrives through ASP.NET Core's camelCase
/// serializer. Pinning <c>iri</c>, <c>operation</c> and <c>label</c> makes one payload shape valid
/// on both paths.
/// </remarks>
/// <param name="Iri">The entity IRI to index, or <c>null</c> to read the ledger.</param>
/// <param name="Operation">The mutation that triggered the dispatch.</param>
/// <param name="Label">The record's label, carried by the template's <c>${event.dto.label}</c> token.</param>
public sealed record IndexCommand(
    [property: JsonPropertyName("iri")] string? Iri = null,
    [property: JsonPropertyName("operation")] string? Operation = null,
    [property: JsonPropertyName("label")] string? Label = null);
 
/// <summary>The ledger the capability holds, together with its size.</summary>
/// <param name="TotalCount">The number of records indexed so far.</param>
/// <param name="Entries">Every indexed record, in arrival order.</param>
public sealed record IndexResponse(int TotalCount, IReadOnlyList<IndexEntry> Entries);
 
/// <summary>
/// The capability the <c>data-record-indexed</c> subscription targets β€” the Library indexes a record
/// as it arrives. It is not a data operation: it writes no entity and holds no state beyond the
/// ledger, so the same endpoint answers both the subscription and a manual POST.
/// </summary>
[Capability("sample.index")]
public sealed class IndexHandler : ICapabilityHandler<IndexCommand, IndexResponse>
{
    private readonly IndexLedger _ledger;
    private readonly TimeProvider _timeProvider;
    private readonly ILogger<IndexHandler> _logger;
 
    /// <summary>Creates the index handler.</summary>
    /// <param name="ledger">The ledger the indexed records are written to.</param>
    /// <param name="timeProvider">The clock the indexed instant is taken from.</param>
    /// <param name="logger">The log every dispatch is recorded in.</param>
    public IndexHandler(IndexLedger ledger, TimeProvider timeProvider, ILogger<IndexHandler> logger)
    {
        _ledger = ledger;
        _timeProvider = timeProvider;
        _logger = logger;
    }
 
    /// <inheritdoc/>
    public ValueTask<ExecutionResult<IndexResponse>> HandleAsync(
        IndexCommand command,
        CapabilityContext context,
        CancellationToken cancellationToken = default)
    {
        // No IRI means "read the ledger"; an IRI means "index this record". One capability, two
        // entry points, so the subscription and a manual probe cannot disagree about the shape.
        if (command.Iri is { Length: > 0 })
        {
            _ledger.Add(new IndexEntry(
                command.Iri,
                command.Operation ?? "Unknown",
                command.Label ?? string.Empty,
                _timeProvider.GetUtcNow()));
 
            _logger.LogInformation(
                "Indexed {Operation} of '{Iri}' (label: {Label}).",
                command.Operation ?? "Unknown",
                command.Iri,
                command.Label ?? "none");
        }
 
        var entries = _ledger.GetAll();
        return ValueTask.FromResult<ExecutionResult<IndexResponse>>(
            new ExecutionResult<IndexResponse>.Ok(new IndexResponse(entries.Count, entries)));
    }
}

The thread-safe in-process ledger the indexed records are appended to β€” the observable effect of the dispatch

IndexLedger.cs
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// One record the <c>sample.index</c> capability has indexed: the IRI the change reported, the
/// operation that caused it, the record's label, and the instant the capability ran.
/// </summary>
/// <param name="Iri">The IRI of the entity that changed.</param>
/// <param name="Operation">The mutation that triggered the dispatch, e.g. <c>Created</c>.</param>
/// <param name="Label">The label carried by the command template's <c>${event.dto.label}</c> token.</param>
/// <param name="AtUtc">The instant the capability ran.</param>
public sealed record IndexEntry(string Iri, string Operation, string Label, DateTimeOffset AtUtc);
 
/// <summary>
/// Thread-safe in-process ledger of the records the subscription has caused to be indexed.
/// Read through <c>POST /api/capabilities/sample/index</c> with no <c>iri</c>.
/// </summary>
public sealed class IndexLedger
{
    private readonly List<IndexEntry> _entries = [];
    private readonly object _lock = new();
 
    /// <summary>Appends one indexed record.</summary>
    /// <param name="entry">The entry to append.</param>
    public void Add(IndexEntry entry)
    {
        lock (_lock)
            _entries.Add(entry);
    }
 
    /// <summary>Every indexed record, in arrival order.</summary>
    /// <returns>A snapshot of the ledger.</returns>
    public IReadOnlyList<IndexEntry> GetAll()
    {
        lock (_lock)
            return [.. _entries];
    }
}

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.Subscription.DependencyInjection;
 
var builder =  WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment())
// ...
    builder.Services.AddInMemoryObjectStorage();
builder.Services.AddObjectStorageHttpFromAssemblyContaining<Manuscript>();
 
builder.Services.AddEntityMessaging<DataRecord>(opts =>
    opts.TypeIri = "https://aletheia.katharsis.digital/entities/DataRecord");
builder.Services.AddSingleton<IndexLedger>();
builder.Services.AddSubscription<DataRecord>("data-record-indexed", subscription =>
{
    subscription.Operations = "created";
    subscription.CapabilityIdentity = "sample.index";
    subscription.CommandTemplateJson = "{\"iri\": ${event.iri}, \"operation\": ${event.operation}, \"label\": ${event.dto.label}}";
});
 
var app = builder.Build();
 
// ...
  • A subscription binds an entity change to a capability execution: when a DataRecord is created, the named capability runs with a payload built from the event. It is the event-driven twin of a schedule β€” the declaration carries no logic, and everything the work does lives in the handler.
  • A subscription can only fire if the entity type actually publishes change events, so DataRecord is opted in here. AddEntityMessaging<>() is order-independent relative to AddEntityEvents() (region 09-Events) β€” both resolve at provider-build time.
  • AddSubscription() is registered with the capability handlers in region 01 so its replay capability is included in the catalog.
  • The entity type is the generic argument, so the pump that consumes that type's history topic is registered exactly once however many subscriptions target it.
  • CommandTemplateJson substitutes ${event.…} tokens as RAW JSON, so a token is never wrapped in quotes: ${event.dto.label} expands to the JSON string "Indexed on arrival".
  • AddSubscription() also projects the registered subscriptions into the read-only aletheia/subscriptions entity so MapOperations() (region 02-Entities) serves them on the canonical exploration surface; create, update and delete are deliberately absent.

From Event to Command

The path separates the business commit from delivery and capability completion:

 Business record + outbox rows commit together
     -> OutboxPublisher sends the frozen EntityChangedEnvelope<DataRecord>
     -> SubscriptionDispatchPump persists the resolved inbox command
     -> SubscriptionDeliveryWorker claims it and dispatches sample.index
     -> Completion is persisted; a failed attempt is retried or dead-lettered

The template is the whole contract between the event and the handler. It is validated eagerly at startup β€” an unknown operation token, an empty capability identity, or a template that is not a JSON object once its tokens are substituted fails the host's startup rather than the first event. A token the event does not carry (say ${event.dto.label} on a deleted record with a null payload) produces a dead-letter delivery retaining its diagnostic; it does not discard the event silently.

Delivery is asynchronous. The ledger request polls for the exact created IRI for up to five seconds, then checks its operation and label. A successful create acknowledges the business/outbox commit, not completion of the subscribed capability. Persistent GraphDB delivery rows survive a host restart; the local InMemory backend and this chapter's demonstration ledger do not.

Why Raw JSON

A token stands where a JSON value goes, not where a string goes:

{ "iri": ${event.iri}, "count": ${event.dto.count}, "dto": ${event.dto} }

${event.iri} becomes the JSON string "https://…" β€” its own quotes and all. Wrap the token in quotes and the value's quotes double: "${event.iri}" becomes ""https://…"", which is not JSON. That mistake is refused at startup, with a message telling you to drop the quotes; the count stays a number and the whole DTO can be embedded as an object, which a string-only substitution could not do.

πŸ’‘ Wiring: AddSubscription() runs in ../Program.cs under #region 01-Capabilities, after application handlers and before AddCapabilityHttp(). Region 21 registers the ledger, enables DataRecord events, and declares the subscription. The fixed operational graph comes from EntityRepository:OperationalGraphIri; MapOperations() serves the read-only definitions.

Try It

List Subscriptions

POST/api/entities/aletheia/subscriptions/query→ 200

Create DataRecord (triggers the subscription)

POST/api/entities/data-records→ 200

Check the Index Ledger

POST/api/capabilities/sample/index→ 200

Scheduling

The Librarian sets a small brass bell on the reading-room desk and winds it. "The Library keeps time," she says. "Some work must happen whether or not anyone asks β€” an audit at dawn, a digest on Monday, a heartbeat every minute. That work waited too long for a visitor to knock."

Chapter 18 taught the Library who is standing at the door. This chapter teaches it what to do when nobody is at the door at all. A `ScheduleDefinition` declares **when** the work runs and **which capability** performs it; the scheduler owns the clock, and the capability owns the logic.

One rule shapes everything else here: a fire is launched, never awaited. The bell rings, the work begins, and the hour keeps moving.

What You'll Learn

  • AddScheduling β€” one registration wires the whole mechanism: it seeds a ScheduleStore from a configure callback, scans the container for every capability handler the fires dispatch through, registers the executor, and starts a one-second ticker as a hosted service. The callback runs at composition time, so a malformed cron expression or time zone fails startup rather than a silent first tick.
  • ScheduleDefinition β€” the unit of declaration: a Name, a CronExpression, a TimeZone, a CapabilityIdentity, the CommandJson payload the fire dispatches, and the two timestamps the ticker maintains (LastFireUtc, NextFireUtc). Schedules are declared in code in v1 β€” there is no create endpoint, because a schedule is configuration, not content.
  • Cron shapes β€” CronSchedule accepts a five-field expression (minute hour day month weekday, e.g. * * * * *), a six-field expression, and Cronos macros (@hourly, @daily, @every_minute). Occurrences are always computed in the declared time zone, so 0 3 * * * with Europe/Berlin stays at 03:00 local time across a daylight-saving change instead of drifting to a fixed UTC instant.
  • A schedule targets a capability, not an entity β€” a schedule has no CRUD surface and owns no state beyond its timestamps. Everything a fire *does* lives in the capability handler, which keeps scheduled work inside the same aspect, authorization, and error pipeline as every other capability call. The same handler answers both the ticker and a manual POST.
  • Background identity is reference-only β€” a schedule policy may name a trusted service Agent and, where applicable, a delegation IRI. No access token is stored in the schedule. At fire time the SDK validates the current service identity, grant, expiry, and permitted aspects; retries must pass those checks again.
  • AddScheduleEntity() β€” projects the registered schedules into the read-only aletheia/schedules entity ([OperationEndpoints(IsReadOnly = true)] + [DefinitionBacked]), so MapOperations() serves them from the store on the canonical exploration surface. Only GET ?iri=… and POST /query exist; create, update, and delete are deliberately absent.
  • Ordering matters β€” AddScheduling calls AddCapabilityCatalog(), which scans the container for registered handlers. It must run after every AddCapabilityHandler<>(), or a schedule naming a later handler fails its first fire with CapabilityIdentityNotFoundException in the log.
  • ⚠️ Watch for: fires are concurrent β€” a due schedule is launched immediately on the thread pool and never awaited, so two occurrences of the same schedule may overlap by design, and a slow capability never delays the next fire. And a next fire in the past is a catch-up, not an error: it runs once immediately and resumes cadence from that moment β€” missed occurrences are never replayed.

The Code

The scheduling.heartbeat handler the schedule targets β€” logs the beat and reports firedAtUtc plus the beat number, with no state of its own

Capabilities/ScheduleHeartbeatHandler.cs
using System.Text.Json.Serialization;
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Execution;
using Microsoft.Extensions.Logging;
 
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// Inbound command: the note that travels with a heartbeat. The schedule seeds it from
/// <c>CommandJson</c>; a manual POST supplies its own.
/// </summary>
/// <remarks>
/// <see cref="Note"/> carries <see cref="JsonPropertyNameAttribute"/> because the two entry points
/// name it differently by default: the HTTP body arrives through ASP.NET Core's camelCase
/// serializer, while <c>ScheduleTargetExecutor</c> deserializes <c>CommandJson</c> with
/// <c>System.Text.Json</c> defaults. Pinning the wire name to <c>note</c> makes one payload shape
/// valid on both paths.
/// </remarks>
public sealed record ScheduleHeartbeatCommand([property: JsonPropertyName("note")] string? Note = null);
 
/// <summary>
/// The heartbeat's report: the instant it fired and its beat number within the UTC day.
/// </summary>
public sealed record ScheduleHeartbeatResponse(DateTimeOffset FiredAtUtc, int FireCount);
 
/// <summary>
/// The capability the <c>library-heartbeat</c> schedule targets β€” a scheduled job, not a data
/// operation: it writes nothing and answers with what the clock says.
/// </summary>
[Capability("scheduling.heartbeat")]
public sealed class ScheduleHeartbeatHandler : ICapabilityHandler<ScheduleHeartbeatCommand, ScheduleHeartbeatResponse>
{
    private readonly TimeProvider _timeProvider;
    private readonly ILogger<ScheduleHeartbeatHandler> _logger;
 
    /// <summary>Creates the heartbeat handler.</summary>
    /// <param name="timeProvider">The clock the schedule and the ticker also run on.</param>
    /// <param name="logger">The log the Library's heartbeat is recorded in.</param>
    public ScheduleHeartbeatHandler(TimeProvider timeProvider, ILogger<ScheduleHeartbeatHandler> logger)
    {
        _timeProvider = timeProvider;
        _logger = logger;
    }
 
    /// <inheritdoc/>
    public ValueTask<ExecutionResult<ScheduleHeartbeatResponse>> HandleAsync(
        ScheduleHeartbeatCommand command,
        CapabilityContext context,
        CancellationToken cancellationToken = default)
    {
        var firedAtUtc = _timeProvider.GetUtcNow();
 
        // The handler keeps no counter: the beat number is derived from the clock, so the handler stays
        // stateless β€” overlapping fires of the same schedule each report their own instant, and a
        // restart cannot leave the heartbeat counting from a stale value. For a per-minute schedule the
        // minute within the UTC day *is* the ordinal of the beat.
        var fireCount = (int)(firedAtUtc - firedAtUtc.Date).TotalMinutes;
 
        _logger.LogInformation(
            "Library heartbeat #{FireCount} at {FiredAtUtc} (note: {Note}).",
            fireCount,
            firedAtUtc.ToString("O"),
            command.Note ?? "none");
 
        return ValueTask.FromResult<ExecutionResult<ScheduleHeartbeatResponse>>(
            new ExecutionResult<ScheduleHeartbeatResponse>.Ok(
                new ScheduleHeartbeatResponse(firedAtUtc, fireCount)));
    }
}

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.Scheduling;
using Aletheia.Sdk.Scheduling.DependencyInjection;
 
using Aletheia.Sdk.Subscription.DependencyInjection;
 
// ...
    builder.Services.AddInMemoryObjectStorage();
builder.Services.AddObjectStorageHttpFromAssemblyContaining<Manuscript>();
 
builder.Services.AddScheduling(configure: store => store.Register(new ScheduleDefinition
{
    Name = "library-heartbeat",
    CronExpression = "* * * * *",
    TimeZone = "UTC",
    CapabilityIdentity = "scheduling.heartbeat",
    CommandJson = """{"note":"scheduled-heartbeat"}""",
}));
builder.Services.AddScheduleEntity();
 
builder.Services.AddEntityMessaging<DataRecord>(opts =>
    opts.TypeIri = "https://aletheia.katharsis.digital/entities/DataRecord");
// ...
  • The schedule targets a capability, not an entity: the work a fire performs lives in the handler, so the declaration carries only "when" (cron + zone) and "what to run" (capability identity + payload).
  • AddScheduling scans the container for every registered ICapabilityHandler<,>, so it must come after the handler registration in #region 01-Capabilities β€” a handler registered later is not in the catalog and the first fire fails with CapabilityIdentityNotFoundException.
  • Fires are concurrent: a due schedule launches on the thread pool and is never awaited, so two occurrences of library-heartbeat may overlap and a slow capability never delays the next fire.
  • CommandJson is deserialized with System.Text.Json defaults, which match on the property's wire name β€” ScheduleHeartbeatCommand pins that name to "note" so the scheduled payload and the manual POST body stay identical.
  • Projects the registered schedules into the read-only aletheia/schedules entity so MapOperations() (region 02-Entities) serves them on the canonical exploration surface; create, update and delete are deliberately absent.

Fires Are Concurrent

The scheduler is not a job runner. When a schedule comes due, the tick launches its execution on the thread pool and moves on: there is no per-schedule lock, no skip-while-running rule, and no in-flight tracking.

  • Two occurrences of the same schedule may overlap. With * * * * * and a capability that takes

longer than a minute, run *n* and run *n+1* are in flight at the same time β€” by design.

  • A slow or hanging capability never delays the next fire, and a target that faults cannot stop

the ticker: an unknown capability identity or an undeserializable payload is logged and skipped.

  • Because of that, a scheduled handler must be safe to run concurrently. The tutorial's heartbeat is

stateless for exactly this reason: it derives its beat number from the clock instead of counting in a field, so two overlapping fires cannot disagree about what the count is.

  • Timestamps advance only after a fire settles, so a tick that observes the very same due time while

an earlier fire is still in flight launches that occurrence again. The overlap resolves itself as soon as one of the in-flight fires records its outcome.

If you need "never two at once", the serialization belongs in the capability β€” a lock, a queue, or an entity-level guard β€” not in the schedule.

Catch-Up, Not Replay

A schedule is due when its NextFireUtc lies at or before the current instant. That makes a missed fire a catch-up: a host that was down, or a schedule declared with a next fire in the past, fires once immediately and then resumes its normal cadence, because the next occurrence is computed from the moment of the fire rather than from the occurrence that was missed.

Three missed occurrences therefore produce one immediate run, not three β€” and never a backlog. That is the honest trade: the scheduler promises the *next* beat, not the ones that were slept through. Enabled = false pauses a schedule without removing it; the ticker skips it and leaves both timestamps untouched.

πŸ’‘ Wiring: This chapter's services are registered in ../Program.cs under #region 11-Scheduling β€” deliberately after the capability handler registration in #region 01-Capabilities, because AddScheduling scans the container for the handlers its fires dispatch through. The read-only entity surface is served by MapOperations() (region 02-Entities); no extra endpoint mapping is needed.

Try It

List Schedules

POST/api/entities/aletheia/schedules/query→ 200

Invoke Heartbeat

POST/api/capabilities/scheduling/heartbeat→ 200

Async Execution

The Librarian gestures to a door marked "Staff Only." "Some work," she says, "doesn't need a visitor waiting at the counter."

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

  • IAsyncCapabilityDispatcher<TCommand, TResponse> β€” the pattern this chapter is about. PublishAsync(command) enqueues a capability on the message bus; a host-side pump (CapabilityCommandPumpService) then creates a DI scope, resolves the handler, calls HandleAsync, and publishes the reply. The capability contract β€” command β†’ handler β†’ result β€” survives the asynchronous boundary, which is what the raw flow below cannot do: no topic string to keep in sync, no MessageEnvelope to construct, and a typed command the handler cannot misread.
  • Polling pattern β€” the workflow both echo flows share, and the reason this chapter can teach one idea twice. Step 1: the capability creates a pending EchoResult (status Processing) and returns immediately with its IRI. Step 2: the background side does the work and flips the result to Ready. Step 3: the caller polls that IRI. The HTTP call never blocks on the heavy work. The Bruno requests walk both halves β€” 01/02 the raw flow, 03/04 the dispatcher.
  • The raw path β€” IMessageProducer<TKey, TPayload> / IMessageConsumer<TKey, TPayload> β€” the low-level pair chapter 09 introduced and this chapter's first flow uses directly. Publishing returns as soon as the bus accepts the message; a BackgroundService consumes an IAsyncEnumerable with await foreach. It is enough on its own, and it is what the dispatcher is built on β€” but it leaves the plumbing to you, which is precisely the work the rest of this chapter removes. Both flows stay in the sample so the difference can be read in code.
  • BackgroundService β€” the .NET base class for long-running hosted services, and the home of the raw consumer. ASP.NET Core calls ExecuteAsync when the server starts and cancels the CancellationToken on shutdown. The service is registered with AddHostedService<T>() and starts automatically β€” no manual thread management. One caution the sample learned the hard way: HostOptions STOPS the whole host when a hosted service throws, so a background loop must catch what it can and keep reading.
  • Execution provenance for delayed work β€” background authorization policies carry service-agent, subject-agent, and delegation IRIs, never a caller JWT. In Enforced hosts, the SDK resolves the references and checks the current grant before dispatch; persisted identity snapshots are not bearer credentials.

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;
using Microsoft.Extensions.Logging;
 
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,
    ILogger<EchoSubscription> logger) : 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))
        {
            try
            {
                await ProcessAsync(envelope.Payload, stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                return;
            }
            catch (Exception exception)
            {
                // A background subscriber must never take the host down with it β€” HostOptions stops the
                // whole process when a BackgroundService faults. In Enforced mode this delivery has no
                // service identity of its own (see 12-Async-Execution/Learning.md), so the read is
                // REFUSED: the echo stays pending, the refusal is logged, and the next message is read.
                logger.LogWarning(
                    exception,
                    "Echo delivery for {Iri} was refused; the echo stays pending. See 12-Async-Execution/Learning.md.",
                    envelope.Payload.Iri);
            }
        }
    }
 
    private async Task ProcessAsync(EchoResult echo, CancellationToken cancellationToken)
    {
        using var _ = EntityOperations.Use(store);
 
        // Simulate async work β€” the Echo takes a moment to respond
        await Task.Delay(200, cancellationToken);
 
        var loaded = await store.LoadAsync<EchoResult>(echo.Iri, cancellationToken);
        if (loaded is not null)
        {
            loaded.EchoMessage = $"Echo: {loaded.OriginalMessage}";
            loaded.IsReady = true;
            await EntityOperations.UpdateAsync(loaded, cancellationToken);
        }
    }
}

Wiring

Program.cs β€” dimmed lines are from previous chapters, highlighted lines are new in this chapter

Program.cs
    builder.Services.AddInMemoryObjectStorage();
builder.Services.AddObjectStorageHttpFromAssemblyContaining<Manuscript>();
 
builder.Services.AddHostedService<EchoSubscription>();
builder.Services.AddCapabilityMessaging();
 
builder.Services.AddScheduling(configure: store => store.Register(new ScheduleDefinition
{
// ...
  • 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.

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.

Authorization boundary: the raw echo job below runs without a service principal of its own, so in Enforced mode its delivery is refused β€” the identity check raises, the error is logged, and the echo stays pending. The subscriber catches it deliberately: HostOptions stops the whole host when a BackgroundService faults, and a refused delivery must never take the process down with it. That is the honest shape of hand-rolled messaging β€” IAsyncCapabilityDispatcher further down, and the subscriptions of 10 β€” Subscription, capture a background identity the *host* owns; a bare IMessageConsumer loop does not. Configure a trusted service Agent and a background policy in the host before treating the raw echo as Enforced-mode service work.

πŸ’‘ Wiring: This chapter's services are registered in ../Program.cs under #region 12-Async-Execution. The EchoSubscription is registered via AddHostedService<EchoSubscription>() β€” ASP.NET Core starts it automatically when the server boots.

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

Branching

The Librarian produces a second copy of the ledger β€” identical, yet separate. "Work in this one," she says, "and the original remains untouched until you're ready."

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.

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;
 
using Aletheia.Sdk.Scheduling;
using Aletheia.Sdk.Scheduling.DependencyInjection;
// ...
builder.Services.AddSingleton<EntityEventLog>();
builder.Services.AddHostedService<ManuscriptSubscription>();
 
builder.Services.AddBranchHttp(builder.Configuration);
 
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GreetHandler>();
builder.Services.AddRuntimeInfo(options => options.ApplicationName = "Aletheia Sample");
// ...
 
app.MapObjectOperations();
 
app.UseBranchScope();
app.MapBranches();
 
app.Run();
  • AddBranchHttp is the single branch-HTTP registration method; aspect enforcement on the management store and the branch.merge capability handler are always applied. AddBranchHttpWithAspects is obsolete and forwards to it.
  • .AddBranchHttp() must be called before capability registration .AddCapabilityHttp(), otherwise internal declared capabilities will not be available (e.g. branch.merge). That ordering is enforced at startup by CapabilityHandlerRegistrationOrderValidationService β€” a wrong order throws instead of silently dropping the handler.

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.

No code yet β€” this chapter is exercised entirely through the Bruno collection. The server wiring lives in ../Program.cs under #region 13-Branching: the single call AddBranchHttp registers branch endpoints, the branch store, aspect enforcement on it, and the branch.merge capability; UseBranchScope() + MapBranches() wire them into the pipeline. (AddBranchHttpWithAspects still exists but is obsolete and forwards to AddBranchHttp.)

πŸ’‘ Wiring: Branch HTTP must be registered before capability handlers (#region 01-Capabilities) β€” internal branch capabilities like branch.merge must be available for user capabilities to depend on them. A startup validator throws when AddCapabilityHttp() ran before a handler registration, so a wrong order fails loudly instead of silently dropping branch.merge.

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)

POST/api/entities/manuscripts/query→ 200
X-Aletheia-BranchIri{{10-branchIri}}

List Manuscripts in Default Branch (should NOT show branch data)

POST/api/entities/manuscripts/query→ 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

POST/api/entities/manuscripts/query→ 200

Self-Merge Rejected (conflict detection)

POST/api/capabilities/branch/merge→ 422

Snapshots

The Keeper places a sealed capsule on the shelf. "A moment, frozen," she says. "No quill shall touch it again."

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.
  • Authorization is not snapshot content β€” a branch snapshot freezes entity data, not bearer tokens or a grant's authority. Background work stores reference-only identity snapshots; the service identity and live delegation are checked again when the work executes.

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();

No code yet β€” this chapter is exercised entirely through the Bruno collection. The server wiring lives in ../Program.cs under #region 14-Snapshots: MapSnapshots() exposes the snapshot endpoints.

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)

POST/api/entities/manuscripts/query→ 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

POST/api/entities/manuscripts/query→ 200
X-Aletheia-BranchIri{{11-snapshotIri}}

Variant Trees

Experimental β€” the API surface may change.

The Librarian unfurls a vast parchment β€” a tree of knowledge, branches spreading in every direction. "Some knowledge," she says, "is not flat. It grows."

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;
 
using Aletheia.Sdk.Scheduling;
using Aletheia.Sdk.Scheduling.DependencyInjection;
// ...
 
builder.Services.AddBranchHttp(builder.Configuration);
 
builder.Services.AddStructure();
builder.Services.AddOperationEndpointsHttp(typeof(Node).Assembly);
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GetConfiguredTreeHandler>();
 
builder.Services.AddCapabilityHandlersFromAssemblyContaining<GreetHandler>();
builder.Services.AddRuntimeInfo(options => options.ApplicationName = "Aletheia Sample");
// ...
  • .AddStructure() must be called before capability registration .AddCapabilityHttp(), otherwise internal declared capabilities will not be available (see also 13-Branching)

Known Gaps

  • The Sdk already provides variable tree structures in an experimental state. Update this sample to demonstrate and test this behavior.

No dedicated chapter code yet β€” the server wiring lives in ../Program.cs under #region 15-Variant-Trees. AddStructure() initializes the tree engine, AddCapabilityHandlersFromAssemblyContaining() registers the built-in handler.

πŸ’‘ Wiring: Structure must be registered before capability handlers (#region 01-Capabilities) β€” internal tree capabilities must be available for user capabilities.

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

AI

Experimental β€” the API surface may change.

A candle flickers in an empty reading room. "This wing," the Librarian says quietly, "is where the Library learns to speak β€” and to see."

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 whose two tools reach the whole ontology (entity CRUD, capabilities, view validation), and a two-step scenario flow that mirrors the note-to-form use-case β€” a reader model answers what the note says in one line, a builder model constructs the data, and the flow validates itself against the Manuscript view before anything reaches a form.

Speech-to-text runs locally: `whisper.cpp` with the tiny English model, started by `./run-local.sh`. The two scenario roles (and chat) run against DeepSeek in the Development profile β€” one key, supplied outside the repository (see [[Usage]]). Nothing about the platform is vendor-specific: `appsettings.json` ships the same roles pointed at local Ollama for a key-free setup.

What You'll Learn

  • AddAI / AddAIOntology / AddAITools β€” three registrations that bring the AI slice to life. AddAI registers OpenAiCompatibleChatService (any OpenAI-shaped endpoint), WhisperTranscriptionService (ITranscriptionService), and the ScenarioRunner; AddAIOntology builds the system prompt from the ontology β€” entities, the aspects that govern them, and the views that shape them, told in the visitor's language; AddAITools registers the two tools the AI reaches everything through β€” the aletheia_ai dispatcher (entity CRUD, capabilities, view validation, plans) and the describe reader (one full definition on demand).
  • One provider, many models β€” AIOptions.Default is the default connection; AIOptions.ModelRoles maps scenario roles to connections; AIOptions.Whisper configures speech-to-text endpoints. The tutorial ships Ollama connections in appsettings.json (llama3.2 for both roles) and overrides them in appsettings.Development.json with DeepSeek (deepseek-chat, temperature 0, small token budgets) and a local whisper server.
  • Audio transcription with Whisper β€” POST /api/ai/transcribe receives multipart audio uploads and forwards them to any OpenAI-compatible /v1/audio/transcriptions endpoint, returning JSON transcripts with optional timestamped segments. Locally that endpoint is whisper.cpp (run-local.sh starts it with --inference-path /v1/audio/transcriptions, so the SDK's OpenAI-shaped URL reaches it).
  • 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. aletheia_ai with operation: list), 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 / Audio). An audio part is transcribed first (ITranscriptionService) and handed to the step as text; an image part travels to the model as a data: URL for a vision-capable role. The tutorial's own flows are text-only β€” every step works on a plain reading of the note β€” so no vision model is needed to run them.
  • 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.
  • Scenario introspection β€” AddAiScenarioEntity() (Aletheia.Sdk.AI.Http) projects the registered scenarios into the read-only aletheia/ai-scenarios entity, so clients can list and render scenarios through the same exploration surface as entity, aspect, and capability definitions.
  • Model-role introspection β€” AddAiModelRoleEntity() (same slice) projects AI:ModelRoles into the read-only aletheia/ai-model-roles entity, so a client can show which connection a step's modelRole resolves to: provider, purpose, endpoint host and port, and model name β€” never the API key.

The Code

Registers THREE scenarios over two roles: manuscript-form-from-note β€” ONE step (builder), validated against the Manuscript view β€” manuscript-record-from-note, which adds a SECOND step (writer) that writes the record through the manuscript-write operation aspect, and manuscript-questions, ONE text step (reader) that explores the platform and answers about what it read. The two note-to-form flows share their first step, and every flow is one scenario for every intake: a note may be typed or dictated

Scenarios/AiScenarios.cs
using System.Text.Json;
using Aletheia.Sdk.AI.Scenarios;
using Aletheia.Sdk.Aspects.Abstractions;
 
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// Registers the tutorial's AI scenarios β€” the note-to-form use-case, TWICE: once
/// as a form value the view accepts, and once all the way into the store, where
/// the write is judged by the operation aspect (chapter 08) before the record
/// exists β€” and once more for READING the library: a flow that answers questions
/// about the manuscripts that already exist.
/// <br/><br/>
/// Every step runs on a role named through <c>AIOptions.ModelRoles</c>: the two
/// note-to-form flows build and write on <c>builder</c>, and the questions flow
/// reads on <c>reader</c>. All of them are plain TEXT steps: no flow carries an
/// image, so no vision model is needed. The role that writes is the role that
/// reads, because the second step needs the same tool-calling ability as the first.
/// <br/><br/>
/// A note may be typed or DICTATED: an audio part is transcribed before the first
/// step runs and arrives as text, because speech-to-text is intake rather than a
/// step. Each flow NAMES the role that hears it β€” <see cref="IntakeRole"/> β€” so the
/// ear a flow uses is part of its declaration instead of a host detail nobody can
/// see. And the two note-to-form scenarios share the FIRST step β€” the same
/// instruction, the same schema, the same view β€” so a reader can see that writing a
/// record is one additional step, not a different flow.
/// </summary>
public static class AiScenarios
{
    /// <summary>The Manuscript form shape from chapter 08 β€” the flows validate against it.</summary>
    public const string ManuscriptFormViewIri = "urn:aletheia:views:manuscript-form";
 
    /// <summary>The operation aspect that judges a manuscript write (chapter 08).</summary>
    public const string ManuscriptWriteAspectIri = "urn:aletheia:aspects:manuscript-write-v1";
 
    /// <summary>
    /// The role that HEARS a dictated note, from <c>AIOptions.ModelRoles</c>. Every flow here names
    /// it as its intake, so the engine behind the transcript is declared rather than guessed: a
    /// reader of a flow's definition can see which model listens to it.
    /// </summary>
    public const string IntakeRole = "transcriber";
 
    /// <summary>
    /// The key of the flow that ANSWERS QUESTIONS about what the library already holds. Declared
    /// here because the tests and the chapter both name it.
    /// </summary>
    public const string QuestionsScenarioKey = "manuscript-questions";
 
    public static void RegisterAiScenarios(this ScenarioRegistry registry)
    {
        // ONE STEP: the note becomes a VALIDATED form value, and nothing is written.
        registry.Register(new ScenarioDefinition(
            Key: "manuscript-form-from-note",
            Description: "Read a note about a manuscript cover β€” typed, or dictated and transcribed β€” " +
                "and build a Manuscript form value the view accepts.",
            Steps:
            [
                new ScenarioStep(
                    Name: "builder",
                    ModelRole: "builder",
                    Instruction: FormInstruction(),
                    OutputSchema: ManuscriptSchema(),
                    MaxRetries: 3,
                    ViewIri: ManuscriptFormViewIri),
            ],
            IntakeRole: IntakeRole));
 
        // TWO STEPS: the same form, and then the RECORD β€” written through the operation aspect by the
        // platform's own write tool. The second step is what makes the flow a transaction rather than
        // a draft: the aspect's shape judges the record before it exists in the store.
        registry.Register(new ScenarioDefinition(
            Key: "manuscript-record-from-note",
            Description: "Read a note, build the Manuscript form value the view accepts, and write the " +
                "record through the manuscript-write operation aspect.",
            Steps:
            [
                new ScenarioStep(
                    Name: "builder",
                    ModelRole: "builder",
                    Instruction: FormInstruction(),
                    OutputSchema: ManuscriptSchema(),
                    MaxRetries: 3,
                    ViewIri: ManuscriptFormViewIri),
                new ScenarioStep(
                    Name: "writer",
                    ModelRole: "builder",
                    Instruction: WriterInstruction(),
                    OutputSchema: IriSchema(),
                    MaxRetries: 2),
            ],
            IntakeRole: IntakeRole));
 
        // ONE STEP, and it WRITES NOTHING: it answers. The step is given no output schema β€” the
        // answer is prose β€” and it runs on the reading role, whose job is to navigate the platform
        // and say what it found. Everything the earlier flows learned to BUILD, this one READS:
        // the entities, the aspects that frame them, the records already in the store.
        registry.Register(new ScenarioDefinition(
            Key: QuestionsScenarioKey,
            Description: "Answer questions about the manuscripts the library already holds β€” each " +
                "step explores the platform's own definitions and records through its tools, then " +
                "answers from what it read.",
            Steps:
            [
                new ScenarioStep(
                    Name: "reader",
                    ModelRole: "reader",
                    Instruction: QuestionsInstruction(),
                    // No schema: this step produces an ANSWER, not a value. `TextOutput` is exactly
                    // that contract β€” raw text in, raw text out, no JSON and no shape to validate.
                    OutputSchema: default,
                    MaxRetries: 2,
                    TextOutput: true),
            ],
            IntakeRole: IntakeRole));
    }
 
    /// <summary>
    /// The first step's instruction: it states the platform objects the form belongs to β€” the entity,
    /// the view that judges it, the operation aspect a write must also satisfy, and the query aspects a
    /// read MAY go through (with the no-operation aspect named for the case where none is picked) β€” and
    /// it says which member is an ENUMERATION, so the model looks the list up instead of inventing one.
    /// <br/><br/>
    /// The IRIs come from the aspect constants themselves, not from strings pasted here, so a renamed or
    /// moved aspect cannot leave the prompt describing something that no longer exists.
    /// </summary>
    private static string FormInstruction() =>
        string.Join(
            Environment.NewLine,
            [
                "You turn the user's note about a manuscript β€” typed, or dictated and transcribed into " +
                    "user voice instructions β€” into the Manuscript form value of the Aletheia Study.",
                "",
                "The platform objects this form belongs to:",
                "- Entity: Manuscript, entity path `manuscripts`.",
                $"- View aspect `{ViewAspects.ManuscriptForm.Iri}`: the shape that judges THIS step's " +
                    "output β€” the form itself. Three members, three JSON shapes:",
                "  - `title`, a string of at least five characters. Take it from the note and quote it " +
                    "as the note gives it.",
                "  - `genre`, the IRI of a PUBLISHED genre: a genre is an enumeration, so call " +
                    "`aletheia_ai` with operation `list` and target `genres`, and use a value it " +
                    "publishes, with the name it publishes for it in the reader's language. Never " +
                    "invent a genre IRI, and never translate a genre name yourself β€” the platform " +
                    "publishes the wording.",
                "  - `year`, an integer of at least 1000. Take it from the note; if the note names no " +
                    "year, OMIT the key rather than guessing one.",
                $"- Operation aspect `{OperationAspects.ManuscriptWriteV1.Iri}`: the ONE aspect that " +
                    "must be satisfied when a Manuscript is written. It matters to the step AFTER this " +
                    "one; here it only tells you what a record has to be.",
                $"- Query aspects `{QueryAspects.ManuscriptAccessV1.Iri}` and " +
                    $"`{QueryAspects.ManuscriptByGenreV1.Iri}`: what a READ may go through. Neither is " +
                    $"required β€” with no query aspect picked the platform's no-operation aspect " +
                    $"`{Aspect.NoOpIri}` applies and the read is simply unconstrained.",
                "",
                "Filling it:",
                "- The note is the only source of the title and the year: if it does not give one, leave " +
                    "that key out.",
                "- Ask the tools what the platform publishes before you answer, and judge the form " +
                    "against the view with the `validate` operation of `aletheia_ai`. Fix only what " +
                    "the note supports.",
            ]);
 
    /// <summary>
    /// The second step's instruction: it writes the RECORD, and it says which aspect the write must be
    /// judged by β€” the tool takes the aspect IRI, so the aspect's shape runs before the store sees the
    /// record. A write that named no aspect would be ungoverned, which is the one thing this step must
    /// never do.
    /// </summary>
    private static string WriterInstruction() =>
        string.Join(
            Environment.NewLine,
            [
                "You write the manuscript record from the form value above, using the platform's tools.",
                "",
                "- Call `aletheia_ai` with operation `create` and target `manuscripts`, putting EVERY " +
                    "member of the form value above in the payload under the entity's own keys " +
                    "(`Title`, `Genre`, `Year`). A record that lost one of them would not be the " +
                    "record the view validated, so `Genre` β€” the IRI β€” is passed too.",
                $"- Pass `aspectIri` = `{ManuscriptWriteAspectIri}` so the operation aspect judges the " +
                    "write BEFORE the record is stored. Never write without it: a write that names no " +
                    "aspect is ungoverned, and this flow exists to be governed.",
                "- The tool answers with the record it created. If it refuses, the refusal is the " +
                    "aspect's own finding: correct what the form value supports and never invent a value " +
                    "to satisfy it.",
                $"- End by answering with a single JSON object: `{{\"iri\": \"<the created record's IRI>\"}}`.",
            ]);
 
    /// <summary>The form the view judges: title and genre and year are all required by its shape.</summary>
    private static JsonElement ManuscriptSchema() => JsonSchema(
        ("title", "string", Required: true),
        ("genre", "string", Required: true),
        ("year", "integer", Required: true));
 
    /// <summary>
    /// The questions flow's instruction: EXPLORE, then answer.
    /// <br/><br/>
    /// The point of this flow is that nothing has to be enumerated for it. Every definition the
    /// platform publishes is served the same way, and every entity type's records are served by the
    /// same two tools, so the instruction can tell the model to LOOK β€” with the ontology it
    /// was given as the map, and the operations as the only vocabulary it needs to know. A prompt
    /// that listed the manuscripts, the genres and the authors would be a prompt that goes stale the
    /// moment a record is added; this one stays true because it describes how to ask, not what the
    /// answer is.
    /// </summary>
    private static string QuestionsInstruction() =>
        string.Join(
            Environment.NewLine,
            [
                "You answer questions about what the Aletheia Study already holds, using the " +
                    "platform's own tools. You never answer from memory: everything you say about a " +
                    "manuscript, a genre, an author or a link between them must come from something " +
                    "you READ in this run.",
                "",
                "The ontology in your system prompt is your map β€” the entity types, the aspects that " +
                    "frame them and the shapes that judge them. Use it to decide WHAT to ask for, " +
                    "then ask:",
                "- `aletheia_ai` is the one instrument: pass `operation` (`list`, `read`, `create`, " +
                    "`update`, `delete`, `execute`, `validate`, `plan`) and `target` (an entity path " +
                    "like `manuscripts`, a capability identity, or a view aspect IRI). `list` lists " +
                    "the records of one entity type; `read` reads one record by the IRI a list gave " +
                    "you. Every entity type is served the same way β€” there is nothing else to learn " +
                    "per type.",
                "- The same instrument runs the platform's own actions (`execute`) and checks a draft " +
                    "against the shape that would judge it (`validate`). `describe` returns one full " +
                    "definition β€” properties, labels, the shape β€” when you need the wording exactly.",
                "- The VIEW aspects tell you how a value is shaped and what the shape requires, the " +
                    "OPERATION aspects tell you what a write must satisfy, and the QUERY aspects tell " +
                    "you what a read may go through. All of them are published the same way the " +
                    "entities are, and all of them are worth reading before you claim how something " +
                    "is framed.",
                "",
                "How to answer:",
                "- Explore FIRST, then answer. Name the IRIs you actually read β€” a manuscript, a " +
                    "genre, an author, an aspect, the branch it lives on β€” instead of paraphrasing " +
                    "them, so the reader can follow the same link you followed.",
                "- Follow the LINKS: a manuscript's genre and author are references, so read the " +
                    "record they point at before you describe them.",
                "- Say what you did not find. An IRI you never read, a type with no records, an " +
                    "aspect this deployment does not publish β€” state it as the absence it is rather " +
                    "than filling the gap with something plausible.",
                "- Answer in prose in the reader's language. No JSON object, no schema: this flow " +
                    "produces an ANSWER, not a form value.",
            ]);
 
    /// <summary>What the writing step reports: the IRI of the record it created.</summary>
    private static JsonElement IriSchema() => JsonSchema(("iri", "string", Required: true));
 
    private static JsonElement JsonSchema(params (string Name, string Type, bool Required)[] properties)
    {
        var props = new Dictionary<string, object>();
        var required = new List<string>();
        foreach (var (name, type, isRequired) in properties)
        {
            props[name] = new { type };
            if (isRequired) required.Add(name);
        }
 
        return JsonSerializer.SerializeToElement(new
        {
            type = "object",
            properties = props,
            required,
        });
    }
}

The host's own read of the machine's one key: .env.local (gitignored) or the ambient DEEPSEEK_API_KEY, given to every role whose ApiKey is empty β€” Development only

LocalModelCredential.cs
using Microsoft.Extensions.Configuration;
 
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// The one model credential this machine holds, handed to the model roles the AI chapter binds.
/// <br/><br/>
/// A model connection β€” endpoint, model, limits β€” is configuration and belongs in
/// <c>appsettings*.json</c>. A credential is not: it is a fact about the machine, never about the
/// repository. So the host reads it from <c>.env.local</c> (gitignored; the tracked
/// <c>.env.local.example</c> shows the shape) or from the ambient <c>DEEPSEEK_API_KEY</c>, and
/// gives it to every role whose own <c>ApiKey</c> is still empty β€” which is what makes a plain
/// <c>dotnet run</c> work without a key in any tracked file, and what lets an explicitly
/// configured key (appsettings, user-secrets, environment) keep its say.
/// </summary>
internal static class LocalModelCredential
{
    /// <summary>The variable a reader is told to set β€” one value, every role that needs it.</summary>
    internal const string VariableName = "DEEPSEEK_API_KEY";
 
    /// <summary>The file a reader may keep instead, beside the project and gitignored.</summary>
    internal const string FileName = ".env.local";
 
    /// <summary>
    /// Give <see cref="VariableName"/> to every model role whose key is empty. Nothing happens
    /// when the machine provides no key: the roles stay keyless, the host still starts, and the
    /// AI endpoints answer 401 with the provider's own words.
    /// </summary>
    internal static void Apply(ConfigurationManager configuration, string contentRootPath)
    {
        var key = Read(contentRootPath);
        if (string.IsNullOrWhiteSpace(key))
        {
            return;
        }
 
        foreach (var role in configuration.GetSection("AI:ModelRoles").GetChildren())
        {
            var roleKey = $"AI:ModelRoles:{role.Key}:ApiKey";
            if (string.IsNullOrWhiteSpace(configuration[roleKey]))
            {
                configuration[roleKey] = key;
            }
        }
    }
 
    /// <summary>
    /// The key as the machine holds it: the ambient variable first (a shell that exports it means
    /// it), then the file. Quoted values are unquoted, comments and blank lines ignored β€” a
    /// reader who pastes the key with quotes around it still gets a usable value.
    /// </summary>
    internal static string? Read(string contentRootPath)
    {
        var ambient = Environment.GetEnvironmentVariable(VariableName);
        if (!string.IsNullOrWhiteSpace(ambient))
        {
            return Unquote(ambient);
        }
 
        var file = Path.Combine(contentRootPath, FileName);
        if (!File.Exists(file))
        {
            return null;
        }
 
        foreach (var line in File.ReadAllLines(file))
        {
            var trimmed = line.Trim();
            if (trimmed.Length == 0 || trimmed[0] is '#')
            {
                continue;
            }
 
            var separator = trimmed.IndexOf('=');
            if (separator <= 0)
            {
                continue;
            }
 
            if (!trimmed[..separator].Trim().Equals(VariableName, StringComparison.Ordinal))
            {
                continue;
            }
 
            var value = Unquote(trimmed[(separator + 1)..]);
            return value.Length == 0 ? null : value;
        }
 
        return null;
    }
 
    /// <summary>Trim whitespace and one layer of matching quotes β€” a shape a reader may type.</summary>
    private static string Unquote(string value)
    {
        var trimmed = value.Trim();
        if (trimmed.Length >= 2 &&
            (trimmed[0] is '"' or '\'') &&
            trimmed[^1] == trimmed[0])
        {
            return trimmed[1..^1].Trim();
        }
 
        return trimmed;
    }
}

Holds the credential reader to its rules: the file wins over the ambient variable, a missing file is not an error, and an empty value is no key at all

LocalModelCredentialTests.cs
using Microsoft.Extensions.Configuration;
 
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// The local model credential: where the host finds it, and what it does with it. These tests
/// write their own file into a temporary directory β€” never the developer's <c>.env.local</c> β€”
/// and restore the ambient variable afterwards.
/// </summary>
public sealed class LocalModelCredentialTests : IDisposable
{
    private const string Sample = "sk-not-a-real-key";
 
    private readonly string _root = Path.Combine(Path.GetTempPath(), "aletheia-cred-" + Guid.NewGuid().ToString("N"));
    private readonly string? _ambient = Environment.GetEnvironmentVariable(LocalModelCredential.VariableName);
 
    public LocalModelCredentialTests()
    {
        Directory.CreateDirectory(_root);
        Environment.SetEnvironmentVariable(LocalModelCredential.VariableName, null);
    }
 
    public void Dispose()
    {
        Environment.SetEnvironmentVariable(LocalModelCredential.VariableName, _ambient);
        Directory.Delete(_root, recursive: true);
    }
 
    private void WriteEnv(string contents) =>
        File.WriteAllText(Path.Combine(_root, LocalModelCredential.FileName), contents);
 
    private static ConfigurationManager Configuration(params (string Key, string Value)[] values)
    {
        var configuration = new ConfigurationManager();
        configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["AI:ModelRoles:reader:ApiKey"] = string.Empty,
            ["AI:ModelRoles:builder:ApiKey"] = string.Empty,
        });
        foreach (var (key, value) in values)
        {
            configuration[key] = value;
        }
 
        return configuration;
    }
 
    [Fact]
    public void Reads_the_key_from_the_env_file()
    {
        WriteEnv($"# a comment\n\n{LocalModelCredential.VariableName}={Sample}\n");
 
        Assert.Equal(Sample, LocalModelCredential.Read(_root));
    }
 
    [Fact]
    public void Ignores_another_variable_and_blank_values()
    {
        WriteEnv($"OTHER_KEY={Sample}\n{LocalModelCredential.VariableName}=\n");
 
        Assert.Null(LocalModelCredential.Read(_root));
    }
 
    [Fact]
    public void Unquotes_a_value_a_reader_typed_with_quotes()
    {
        WriteEnv($"{LocalModelCredential.VariableName}=\"{Sample}\"\n");
 
        Assert.Equal(Sample, LocalModelCredential.Read(_root));
    }
 
    [Fact]
    public void Prefers_the_ambient_variable_over_the_file()
    {
        WriteEnv($"{LocalModelCredential.VariableName}=from-file\n");
        Environment.SetEnvironmentVariable(LocalModelCredential.VariableName, "from-environment");
 
        Assert.Equal("from-environment", LocalModelCredential.Read(_root));
    }
 
    [Fact]
    public void Yields_nothing_when_the_machine_has_no_key()
    {
        Assert.Null(LocalModelCredential.Read(_root));
    }
 
    [Fact]
    public void Gives_the_key_to_every_role_that_has_none()
    {
        WriteEnv($"{LocalModelCredential.VariableName}={Sample}\n");
        var configuration = Configuration();
 
        LocalModelCredential.Apply(configuration, _root);
 
        Assert.Equal(Sample, configuration["AI:ModelRoles:reader:ApiKey"]);
        Assert.Equal(Sample, configuration["AI:ModelRoles:builder:ApiKey"]);
    }
 
    [Fact]
    public void Never_overwrites_a_role_that_already_has_a_key()
    {
        WriteEnv($"{LocalModelCredential.VariableName}={Sample}\n");
        var configuration = Configuration(("AI:ModelRoles:reader:ApiKey", "configured-elsewhere"));
 
        LocalModelCredential.Apply(configuration, _root);
 
        Assert.Equal("configured-elsewhere", configuration["AI:ModelRoles:reader:ApiKey"]);
        Assert.Equal(Sample, configuration["AI:ModelRoles:builder:ApiKey"]);
    }
 
    [Fact]
    public void Leaves_the_roles_alone_when_the_machine_has_no_key()
    {
        var configuration = Configuration();
 
        LocalModelCredential.Apply(configuration, _root);
 
        Assert.Equal(string.Empty, configuration["AI:ModelRoles:reader:ApiKey"]);
        Assert.Equal(string.Empty, configuration["AI:ModelRoles:builder:ApiKey"]);
    }
}

Holds the three PROMPTS to what they must not get wrong β€” the shared first step and its contract, the operation aspect the writer passes, the role that hears each flow, and the questions flow's obligation to explore and to name the IRIs it actually read

Scenarios/AiScenariosTests.cs
using System.Text.Json;
using Aletheia.Sdk.AI.Scenarios;
 
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// What the sample's scenarios TELL their models. A scenario is a prompt, and these tests hold the
/// prompt to what it must not get wrong: it names the platform objects a step works with (the entity,
/// the view that judges it, the operation aspect a write must satisfy, the no-operation aspect a read
/// falls back to), it says which member is an ENUMERATION the model must look up, and it forbids
/// invention β€” a value the note never gave is left out rather than guessed.
/// </summary>
public sealed class AiScenariosTests
{
    private const string ViewIri = "urn:aletheia:views:manuscript-form";
    private const string OperationAspectIri = "urn:aletheia:aspects:manuscript-write-v1";
    private const string AccessAspectIri = "urn:aletheia:aspects:manuscript-access-v1";
    private const string ByGenreAspectIri = "urn:aletheia:aspects:manuscript-by-genre-v1";
    private const string NoOpAspectIri = "https://aletheia.katharsis.digital/aspects/noop";
 
    private static ScenarioRegistry Registered()
    {
        var registry = new ScenarioRegistry();
        registry.RegisterAiScenarios();
        return registry;
    }
 
    [Fact]
    public void There_are_three_scenarios_one_that_forms_one_that_writes_and_one_that_answers()
    {
        var registry = Registered();
 
        // The form scenario is a DRAFT: it validates a value and stops. The record scenario is the
        // transaction: the same form, then the write the operation aspect judges. The questions
        // scenario is the READER: it answers about what is already there. One step, two steps and
        // one step respectively β€” and nothing else registered.
        Assert.Equal(
            new[] { "manuscript-form-from-note", "manuscript-questions", "manuscript-record-from-note" },
            registry.Scenarios.Keys.OrderBy(key => key, StringComparer.Ordinal).ToArray());
        Assert.Single(registry.Scenarios["manuscript-form-from-note"].Steps);
        Assert.Equal(2, registry.Scenarios["manuscript-record-from-note"].Steps.Count);
        Assert.Single(registry.Scenarios[AiScenarios.QuestionsScenarioKey].Steps);
    }
 
    [Fact]
    public void Every_flow_declares_the_role_that_hears_its_voice_notes()
    {
        // The ENGINE behind a transcript is part of a flow's declaration: a reader of the definition
        // can see which model listens to it, instead of the ear being a host detail. Here all three
        // flows dictate, so all three name the same role.
        foreach (var scenario in Registered().Scenarios.Values)
        {
            Assert.Equal(AiScenarios.IntakeRole, scenario.IntakeRole);
        }
    }
 
    [Fact]
    public void The_questions_step_reads_before_it_answers()
    {
        var scenario = Registered().Scenarios[AiScenarios.QuestionsScenarioKey];
        var step = scenario.Steps[0];
 
        // It runs on the READING role, and it produces an ANSWER: no view to validate against and no
        // output schema to fill, because prose is the output.
        Assert.Equal("reader", step.ModelRole);
        Assert.Null(step.ViewIri);
        // A text step carries no schema at all: the JSON element is undefined, and `TextOutput` is
        // what tells the runner that raw text IS the contract.
        Assert.Equal(JsonValueKind.Undefined, step.OutputSchema.ValueKind);
        Assert.True(step.TextOutput);
 
        // It is told to EXPLORE β€” the two tools and the aspect families, generically, because
        // every type is served the same way β€” and to answer only from what it read.
        Assert.Contains("aletheia_ai", step.Instruction);
        Assert.Contains("describe", step.Instruction);
        Assert.Contains("`validate`", step.Instruction);
        Assert.Contains("VIEW aspects", step.Instruction);
        Assert.Contains("OPERATION aspects", step.Instruction);
        Assert.Contains("QUERY aspects", step.Instruction);
        Assert.Contains("ontology in your system prompt", step.Instruction);
        Assert.Contains("Explore FIRST", step.Instruction);
 
        // Naming what it read is the whole difference between an answer and a plausible answer, and
        // an absence it did not find is stated rather than filled in.
        Assert.Contains("Name the IRIs you actually read", step.Instruction);
        Assert.Contains("Say what you did not find", step.Instruction);
    }
 
    [Fact]
    public void The_form_step_names_the_entity_the_view_and_the_aspects()
    {
        var step = Registered().Scenarios["manuscript-form-from-note"].Steps[0];
 
        Assert.Equal("builder", step.ModelRole);
        Assert.Equal(ViewIri, step.ViewIri);
 
        Assert.Contains("manuscripts", step.Instruction);
        Assert.Contains(ViewIri, step.Instruction);
        Assert.Contains(OperationAspectIri, step.Instruction);
        Assert.Contains(AccessAspectIri, step.Instruction);
        Assert.Contains(ByGenreAspectIri, step.Instruction);
        Assert.Contains(NoOpAspectIri, step.Instruction);
        Assert.Contains("`list`", step.Instruction);
        Assert.Contains("`genres`", step.Instruction);
    }
 
    [Fact]
    public void The_form_step_treats_the_genre_as_an_enumeration_the_platform_publishes()
    {
        var instruction = Registered().Scenarios["manuscript-form-from-note"].Steps[0].Instruction;
 
        // A genre is a closed list WITH published wording: the model looks both up.
        Assert.Contains("PUBLISHED genre", instruction);
        Assert.Contains("never translate a genre name", instruction);
        Assert.Contains("`year`, an integer", instruction);
    }
 
    [Fact]
    public void The_form_step_forbids_inventing_what_the_note_did_not_say()
    {
        var instruction = Registered().Scenarios["manuscript-form-from-note"].Steps[0].Instruction;
 
        Assert.Contains("OMIT", instruction);
        Assert.Contains("leave that key out", instruction);
        Assert.Contains("Fix only what the note supports", instruction);
    }
 
    [Fact]
    public void The_writer_step_writes_through_the_operation_aspect()
    {
        var step = Registered().Scenarios["manuscript-record-from-note"].Steps[1];
 
        Assert.Equal("writer", step.Name);
        Assert.Contains("`aletheia_ai`", step.Instruction);
        Assert.Contains("`create`", step.Instruction);
        Assert.Contains("`manuscripts`", step.Instruction);
        // The aspect IRI is what makes the write governed: naming it is the step's whole point.
        Assert.Contains(AiScenarios.ManuscriptWriteAspectIri, step.Instruction);
        Assert.Contains("Never write without it", step.Instruction);
        // ... and every member of the form goes into the record.
        Assert.Contains("EVERY member", step.Instruction);
        Assert.Contains("`Title`, `Genre`, `Year`", step.Instruction);
        // The step reports the created record's IRI.
        Assert.Contains("\"iri\"", step.Instruction);
    }
 
    [Fact]
    public void Both_scenarios_share_the_form_step_and_its_contract()
    {
        var registry = Registered();
        var form = registry.Scenarios["manuscript-form-from-note"].Steps[0];
        var first = registry.Scenarios["manuscript-record-from-note"].Steps[0];
 
        // Same instruction, same view, same contract: the record scenario does not re-teach the form.
        Assert.Equal(form.Instruction, first.Instruction);
        Assert.Equal(form.ViewIri, first.ViewIri);
        Assert.Equal(form.OutputSchema.GetRawText(), first.OutputSchema.GetRawText());
 
        // The form's members, and which are required β€” title, genre and year are all required by the
        // view, so the contract requires all three.
        var required = first.OutputSchema.GetProperty("required").EnumerateArray()
            .Select(value => value.GetString()).ToArray();
        Assert.Equal(new[] { "title", "genre", "year" }, required);
    }
 
    [Fact]
    public void The_writer_step_reports_the_iri_it_created()
    {
        var schema = Registered().Scenarios["manuscript-record-from-note"].Steps[1].OutputSchema;
 
        Assert.Equal("string", schema.GetProperty("properties").GetProperty("iri").GetProperty("type").GetString());
        Assert.Equal(
            new[] { "iri" },
            schema.GetProperty("required").EnumerateArray().Select(value => value.GetString()).ToArray());
    }
}

Starts the whisper server, says whether a key will be found, runs the host

../run-local.sh
#!/usr/bin/env bash
#
# Run the Sdk.Sample with everything its AI chapters need, LOCAL and self-contained:
#
#   1. whisper.cpp  β€” audio β†’ text (the tiny English model, 74 MB, on Metal)
#   2. the models   β€” the reader/builder roles from appsettings.Development.json (DeepSeek)
#   3. the key      β€” the HOST reads it from .env.local or the environment (never a tracked file)
#   4. the host     β€” dotnet run, with the environment the Development profile wants
#
# Usage:  ./run-local.sh [--url http://localhost:5301] [--no-whisper]
#
# The key comes from ./​.env.local (gitignored) if that file exists, else from the ambient
# environment, else from user-secrets β€” and the APPLICATION reads it either way, so a plain
# `dotnet run` finds it too. It is never echoed and never written to a tracked file. Nothing here
# needs the key to start: without it the AI endpoints answer 401 and everything else runs.
set -euo pipefail
cd "$(dirname "$0")"
 
WHISPER_MODEL="${WHISPER_MODEL:-/opt/homebrew/share/whisper.cpp/models/ggml-tiny.en.bin}"
WHISPER_PORT="${WHISPER_PORT:-8080}"
URL=""
WHISPER=1
 
while [ $# -gt 0 ]; do
  case "$1" in
    --url) URL="${2:-}"; shift 2 ;;
    --no-whisper) WHISPER=0; shift ;;
    *) echo "unknown option: $1" >&2; exit 2 ;;
  esac
done
 
# ── the key: the HOST reads it, this script only says whether it will find one ─────────────────
# The application reads `.env.local` (or the ambient DEEPSEEK_API_KEY) itself and hands the one
# value to every role that has none β€” see 16-AI/LocalModelCredential.cs. So plain `dotnet run`
# behaves exactly like this script, and nothing here has to pass a credential on.
if [ -f .env.local ] && grep -qE '^DEEPSEEK_API_KEY=.+' .env.local 2>/dev/null; then
  echo "βœ” DeepSeek key found in .env.local"
elif [ -n "${DEEPSEEK_API_KEY:-}" ]; then
  echo "βœ” DeepSeek key found in the environment"
elif dotnet user-secrets list 2>/dev/null | grep -q 'ApiKey'; then
  echo "βœ” DeepSeek key found in user-secrets"
else
  echo "⚠  No DeepSeek key found. The reader/builder roles will answer 401."
  echo "   One line in Sdk.Sample/.env.local (gitignored; see .env.local.example):"
  echo "     DEEPSEEK_API_KEY=sk-…"
  echo "   or outside the repository entirely:"
  echo "     dotnet user-secrets set \"AI:ModelRoles:builder:ApiKey\" \"sk-…\""
  echo "     dotnet user-secrets set \"AI:ModelRoles:reader:ApiKey\"  \"sk-…\""
fi
 
# ── whisper.cpp: the tiny model, serving the OpenAI path the SDK calls ───────────────────
if [ "$WHISPER" = 1 ]; then
  # A GET, never a POST: the transcription route waits for a body, so probing it with an
  # empty POST hangs instead of answering.
  if curl -sf -m 3 -o /dev/null "http://127.0.0.1:${WHISPER_PORT}/"; then
    echo "βœ” whisper-server already listening on 127.0.0.1:${WHISPER_PORT}"
  elif [ ! -x /opt/homebrew/bin/whisper-server ]; then
    echo "⚠  whisper-server is not installed β€” skipping (brew install whisper.cpp)"
  elif [ ! -f "$WHISPER_MODEL" ]; then
    echo "⚠  whisper model missing: $WHISPER_MODEL"
    echo "   curl -L -o '$WHISPER_MODEL' https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin"
  else
    echo "β–Έ starting whisper-server (${WHISPER_MODEL##*/}) on 127.0.0.1:${WHISPER_PORT}"
    # --inference-path: this server speaks /inference by default, and the SDK (like every
    # OpenAI client) posts to /v1/audio/transcriptions. Serving that path removes the need
    # for a translation shim in front of it.
    whisper-server --model "$WHISPER_MODEL" --host 127.0.0.1 --port "$WHISPER_PORT" \
      --convert --inference-path /v1/audio/transcriptions \
      >/tmp/aletheia-whisper.log 2>&1 &
    for _ in $(seq 1 60); do
      if curl -sf -m 2 -o /dev/null "http://127.0.0.1:${WHISPER_PORT}/"; then break; fi
      sleep 0.5
    done
    echo "  (log: /tmp/aletheia-whisper.log)"
  fi
fi
 
# ── the host (Development profile: local whisper, DeepSeek roles) ────────────────────────
# --no-launch-profile: launchSettings.json pins the URL to http://localhost:5000, and that
# would win over ASPNETCORE_URLS β€” including a --url somebody passed.
echo "β–Έ dotnet run${URL:+" ($URL)"}"
if [ -n "$URL" ]; then
  ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS="$URL" dotnet run --no-launch-profile
else
  ASPNETCORE_ENVIRONMENT=Development dotnet run
fi

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.AI.DependencyInjection;
using Aletheia.Sdk.AI.Http;
using Aletheia.Sdk.AI.Http.DependencyInjection;
using Aletheia.Sdk.AI.Scenarios;
 
using Aletheia.Sdk.Scheduling;
using Aletheia.Sdk.Scheduling.DependencyInjection;
// ...
    builder.Services.AddInMemoryObjectStorage();
builder.Services.AddObjectStorageHttpFromAssemblyContaining<Manuscript>();
 
builder.Services.AddAI(builder.Configuration);
builder.Services.AddAIOntology();
builder.Services.AddAITools();
builder.Services.AddAiScenarioEntity();
builder.Services.AddAiModelRoleEntity();
 
builder.Services.AddHostedService<EchoSubscription>();
builder.Services.AddCapabilityMessaging();
// ...
 
app.MapSnapshots();
 
AiScenarios.RegisterAiScenarios(app.Services.GetRequiredService<ScenarioRegistry>());
app.MapAiEndpoints();
 
app.Run();
  • AddAI registers OpenAiCompatibleChatService + ScenarioRunner. The AI section of appsettings.json is the portable default (Ollama at http://localhost:11434 for both roles); appsettings.Development.json overrides it locally with DeepSeek (https://api.deepseek.com, deepseek-chat) and whisper.cpp at http://127.0.0.1:8080. Keys are never configured in a tracked file β€” see run-local.sh.
  • Ontology context builds the system prompt from entity metadata (requires AddEntityEntity above).
  • Tool registry registers the aletheia_ai dispatcher and the describe reader (requires the entity and capability registries above).
  • Projects the registered scenarios into the read-only aletheia/ai-scenarios entity so MapOperations() serves them on the canonical exploration surface (requires AddAI above).
  • Projects the AI:ModelRoles configuration into the read-only aletheia/ai-model-roles entity (endpoint host and model only β€” never the API key), so a client can show which model a scenario step actually talks to. Reads the AI options AddAI binds 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.
  • AI scenarios are exposed as aletheia/ai-scenarios through the canonical exploration surface.

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" } ], "transcript": "…", "transcriptEngine": { "role", "provider", "endpoint", "model" }, "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 step

manuscript-form-from-note runs ONE step (builder): it reads the note and produces the form value, and the runner validates it against urn:aletheia:views:manuscript-form β€” the same view the browser validates against in chapter 08. One step, because reading a short note is not work a second model call should do: the step that understands the note is the step that fills the form.

#decision !entity/SDK/Sample/decisions/010-a-second-scenario-writes-the-record-under-the-operation-aspect#^statement

manuscript-record-from-note runs the SAME first step and then a SECOND one (writer): the form value has to exist before anything can be written, so the flow is a draft and a transaction over one shared beginning rather than two flows. The second step writes the RECORD β€” it calls the platform's own aletheia_ai tool with operation create, target manuscripts, and the operation aspect's IRI, so the aspect's shape judges the record BEFORE the store sees it. That is what makes the step a step and not a formatting detail: without the aspect a write would be ungoverned, and the flow exists to be governed. Both steps run on the builder role, because the step that writes needs the same tool-calling ability as the step that reads. (A flow MAY have several steps, and a step MAY be a TextOutput step β€” a caption that is passed on as text instead of validated against a schema. The writer is neither: it is a validated step whose schema is the IRI of the record it made.)

The SAME step handles every intake. A typed note arrives as text in the payload; a DICTATED one arrives as an audio part, and the runner transcribes it with Whisper (ITranscriptionService) and injects the transcript as a text part β€” *user voice instructions* β€” before the step runs. Nothing in the scenario changes, and there is deliberately no second scenario for it: speech-to-text is intake, not a step. Nor is the model kept from the tools it needs: the builder role sets SupportsTools: true, and the runner only states the tool rules when the role it resolves actually supports function calling β€” a model told to "look it up" when it cannot would only learn to pretend.

What the runner HEARD is reported as well, because a reader of a voice request has no other way to see it: the stream carries a transcribed event BEFORE the first step and naming none, and the compact result carries the same text as transcript β€” or the reason nothing could be heard (no transcription service, a refused request) while the step still runs on whatever it was given.

WHO heard it is reported too. A transcript with no author reads as if the words had appeared by themselves, so every flow here NAMES its ear β€” IntakeRole: "transcriber", declared beside its steps and projected as the scenario's intakeRole. The name is resolved the same way a step's modelRole is: the flow's role wins, else the host's transcription-purpose role, else the AI:Whisper endpoint reported with no role name (because there is no role). The engine travels on the event and on the compact result as transcriptEngine β€” role, provider, endpoint host and port, model β€” and a flow that names a role the host does not configure FAILS honestly instead of being quietly rerouted to a different model than it asked for.

#decision !entity/SDK/AI/decisions/020-an-intake-is-a-declared-model-role#^statement

manuscript-questions runs ONE step (reader) and it WRITES NOTHING: it answers. The step is a TextOutput step β€” no schema, because the output is prose β€” and it is told HOW to look rather than what to find. That is the whole design: nothing about the flow is per-question.

#decision !entity/SDK/Sample/decisions/011-a-third-scenario-explores-before-it-answers#^statement

The instruction names the two TOOLS, not the entities: aletheia_ai routes every operation β€” list/read serve every entity type the same way, execute runs the platform's own actions, validate judges a draft against a view β€” and describe serves any definition. It names the three aspect families the same way β€” VIEW aspects say how a value is shaped, OPERATION aspects what a write must satisfy, QUERY aspects what a read may go through β€” because all of them are published like the entities are. The ontology in the system prompt is the map; the two tools are the only vocabulary the prompt needs, and neither goes stale when a record is added or an entity type is registered.

The answers are held to what was READ: name the IRIs you actually read (a manuscript, a genre, an author, an aspect, the branch it lives on) instead of paraphrasing them, follow the links a record carries, and say what you did NOT find rather than filling the gap with something plausible. The reader role therefore sets SupportsTools: true in both appsettings files: a step told to explore has to be able to call, and the runner only states the tool rules when the resolved role supports function calling.

What the scenario tells its models

The step's instruction is not "extract a title, a genre and a year". It states the platform objects the form belongs to, and it takes those IRIs from the aspect constants themselves (ViewAspects.ManuscriptForm.Iri, OperationAspects.ManuscriptWriteV1.Iri, QueryAspects.*, Aspect.NoOpIri) so a renamed aspect cannot leave the prompt describing something that no longer exists:

  • the entity Manuscript (path manuscripts);
  • the view aspect that judges this step's output β€” the form itself;
  • the one operation aspect that must be satisfied *beside* the view when a Manuscript is written: its shape requires a non-empty title, so a form without one is refused twice over β€” and the SECOND step passes that aspect's IRI to the write tool, which is how the aspect gets to judge the record;
  • the query aspects a read MAY go through β€” and the no-operation aspect (https://aletheia.katharsis.digital/aspects/noop) for the case where none is picked. That last part matters: with no query aspect the platform applies no-op, the read is simply unconstrained, and nothing is missing when the note names no genre.

A model that knows the ontology NAVIGATES it: in a live run it calls aletheia_ai with operation list for the entity path genres, reads the real genre IRIs, and only then answers. The SDK ships those two tools β€” the dispatcher reaches every entity, capability, and view aspect, and the reader returns any definition β€” and offers them to the model through function calling, so a client can show every call and every answer β€” the admin playground (Web) lists them beside the step that made them.

The other half of the instruction is a prohibition. The note is the ONLY source of a title or a year, so a value it does not give is *left out*: never a plausible number, never an improved title. The platform says the same thing to every step β€” in its shared rules, and again in the retry message, so a finding like year: minCount 1 is not read as pressure to fabricate. The consequence is deliberate: a note that names a title but no year ends RED with that finding, because a green validation on invented data is the one failure nothing downstream can detect. The compact response then carries no finalOutput and an error naming what the view refused β€” and the value that was refused travels with the failure, so a client can show the form that was judged rather than only the sentence about it.

The GENRE is the third shape of the same rule read the other way round. It is an ENUMERATION, so the instruction says to call aletheia_ai with operation list for genres and use a value it publishes β€” with the name the platform publishes for it in the reader's language β€” and never to invent an IRI or translate a label by hand. The model is not told the values; it is told that the values exist and where to ask, which is the difference between a navigation and a guess.

The note request ships The note reads: 'The First Manuscript', 42 pages. in its payload; the voice request ships ../assets/sample-audio.wav (embedded as a base64 data URL) saying the same thing, so both flows end at the same title and can be compared; the record request ships a note that names a title AND a genre AND a year (The Sea Chart, philosophy, 1502), because a write needs a record the aspect will accept. A vision-shaped flow would pass an image part the same way β€” the SDK carries Image parts to whatever role is bound to read them, which is why the tutorial's own scenarios stay runnable without a vision model.

The compact response reports one summary per completed step (name, output, retries), the final finalOutput, what the audio intake heard as transcript, and an error when validation is exhausted or the model server is unreachable.

Scenario introspection

Scenarios are not a private AI concern: AddAiScenarioEntity() projects every registered ScenarioDefinition into the read-only aletheia/ai-scenarios entity, served by MapOperations() alongside the entity, aspect, and capability definitions.

GET /api/entities/aletheia/ai-scenarios

Each item carries the scenario key, description, intakeRole (the role that HEARS it, empty when it declares none), stepCount, definitionOrigin, and an embedded steps array (name, modelRole, instruction, viewIri, maxRetries, textOutput, hasOutputSchema). The raw JSON Schema is deliberately not projected β€” hasOutputSchema is the interpreted signal a client needs to render "typed output", matching the platform's rule that introspection exposes interpreted metadata, not internals. Clients can therefore discover and render scenarios without hardcoding keys such as manuscript-form-from-note.

Model-role introspection

A step names a role, not a model, and the role only resolves at runtime against AIOptions.ModelRoles β€” host configuration, not definition data. AddAiModelRoleEntity() projects those configured connections into the read-only aletheia/ai-model-roles entity, so a client can name the model a step actually talks to:

GET /api/entities/aletheia/ai-model-roles

Each item carries the role name, the provider discriminator, the purpose (Chat or Transcription β€” the axis that says which role HEARS), the endpoint β€” host and port only β€” the model, and definitionOrigin. The API key never travels, and the base URL is reduced to its host and port, so no scheme, path, query, or credential reaches the wire. A role the host never configured yields no item: the runner falls back to the Default connection for it, and the client renders that absence instead of guessing.

πŸ’‘ Wiring: AddAI must run after the entity and capability registries (#region 17-Exploration) so the tool registry and ontology prompt can see the domain. MapAiEndpoints() hosts POST /api/ai/chat and POST /api/ai/flow; the AI slice itself stays free of ASP.NET Core β€” Aletheia.Sdk.AI.Http is the carrier.

Try It

Chat

Ask the Librarian (compact JSON)

POST/api/ai/chat→ 200

Flow

Manuscript form from note (compact JSON)

POST/api/ai/flow→ 200

Manuscript from voice (compact JSON)

POST/api/ai/flow→ 200

Manuscript record from note (compact JSON β€” written under the operation aspect)

POST/api/ai/flow→ 200

Manuscript questions (compact JSON β€” answers from what it read)

POST/api/ai/flow→ 200

Audio

Transcribe Audio with Whisper

POST/api/ai/transcribe→ 200

Ai-Scenarios

List AI Scenarios

POST/api/entities/aletheia/ai-scenarios/query→ 200

Exploration

The Librarian hands you a mirror. "The Library," she says, "can tell you about its own shelves β€” if you know how to ask."

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 four requests: list all aspect definitions, list all capability handler definitions, list all entity type definitions, and assert that the SPARQL source of the sample aspects round-trips through the definition surface β€” alongside the interpreted `shapeModel`/`sparqlModel` the server derives from it. 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.
  • AddAspectsHttp() β€” registers the aspect-definition surface and the endpoint descriptors that serve it: an entity type whose instances describe one aspect each β€” its name, target type (capability, operation, or query), SHACL shape details, and registration metadata. Querying it lists every aspect currently enforced by the system. It chains AddAspectsEntity(), which registers the projection itself; a non-web consumer calls that one alone and stays free of ASP.NET Core.
  • 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.
  • Authorization is discoverable too β€” platform registration exposes Agent, Role, role assignments, resource grants, Delegation, delegation audit records, and the read-only Genesis marker through the same entity-definition catalog. These are platform entities, not a separate admin-only schema.
  • One canonical HTTP surface β€” the definition types (EntityDefinition, EntityOperationDefinition, AspectDefinition, CapabilityDefinition) are ordinary read-only entities: [Entity(Path = …)] + [OperationEndpoints(IsReadOnly = true)] + [DefinitionBacked]. app.MapOperations() serves them at api/entities/{path} exactly like any other entity β€” nothing maps introspection routes by hand any more. The AddXxxEntity() calls register their projections with the operations pipeline; no MapXxxEntity() call exists.
  • [DefinitionBacked] β€” definition entities are not written to the entity store. The marker tells MapOperations() to serve list/read from the registry's read-only projection (collection reads via POST api/entities/{path}/query; resolved through the keyed IDefinitionEntityLookup) while keeping the standard { items, totalCount } envelope, the ?iri= semantics (400 INVALID_IRI / 404 NOT_FOUND), and a natural 405 for every CUD verb.
  • Two non-CRUD leftovers β€” the localization dictionary (app.MapLocalization()) and the aspect view/validate pair (app.MapAspectDefinitionAux()) stay thin route extensions in their own HTTP slices β€” Aletheia.Sdk.Localization.Http and Aletheia.Sdk.Aspects.Http: they return non-entity payloads (a dictionary; raw Turtle with ETag; a validation report) and the capability dispatcher cannot express a GET route with an {iri} path parameter.
  • SPARQL source as definition fields β€” an aspect definition carries the SPARQL text it was declared with: an operation aspect exposes contextWhere (+ hasContextWhere), a query aspect exposes filterWhere (+ hasFilterWhere) and enrichmentConstruct (+ hasResultShapeTtl), alongside the SHACL localShapeTtl/resultShapeTtl. The text round-trips verbatim β€” ##aspect:param:…## placeholders included β€” so an admin can render SPARQL the way it renders Turtle. This mirrors viewTtl: the source is served for display only; the client never parses it (009-single-server-side-interpretation). The interpreted shapeModel and sparqlModel ride alongside the raw text.
  • Interpretation as definition fields (shapeModel/sparqlModel) β€” the server parses once, in C#, with dotNetRDF, and serves the result as ordinary fields on the same definition entity: shapeModel carries the sh:targetClass list and each property's structured constraints (kind, parameters, stable labelKey such as constraint.minLength); sparqlModel carries the referenced predicates, the params extracted from ##aspect:param:NAME## placeholders, the boundVariables, the constructHeadPredicates a CONSTRUCT template writes, and the usesEntityIri/usesFocusNode flags. No frontend ever regex-parses TTL or SPARQL again β€” there is no separate aggregate endpoint; the interpretation is entity data.

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.Aspects.Http;
using Aletheia.Sdk.Aspects.Http.DependencyInjection;
using Aletheia.Sdk.Capability.Entity;
using Aletheia.Sdk.Entity.Entity;
using Aletheia.Sdk.Localization;
using Aletheia.Sdk.Localization.Http;
 
using Aletheia.Sdk.AI.DependencyInjection;
using Aletheia.Sdk.AI.Http;
// ...
    builder.Services.AddInMemoryObjectStorage();
builder.Services.AddObjectStorageHttpFromAssemblyContaining<Manuscript>();
 
builder.Services.AddAspectsHttp();
builder.Services.AddCapabilityEntity(typeof(GreetHandler).Assembly);
builder.Services.AddEntityEntity(typeof(Manuscript).Assembly);
builder.Services.AddPlatformLocalization();
 
builder.Services.AddAI(builder.Configuration);
builder.Services.AddAIOntology();
// ...
 
app.MapSnapshots();
 
app.MapLocalization();
app.MapAspectDefinitionAux();
 
AiScenarios.RegisterAiScenarios(app.Services.GetRequiredService<ScenarioRegistry>());
app.MapAiEndpoints();
// ...
  • The platform translation dictionary β€” every registered definition surface contributes its layer (entities, view/query/write aspects) behind the MapLocalization() route below.
  • SPARQL source (context/filter WHERE, enrichment CONSTRUCT) is served as fields on aletheia/aspects.
  • The definition surfaces are ordinary read-only entities now. MapOperations() (region 02-Entities) serves api/entities/aletheia/entities, aletheia/entity-operations, aletheia/aspects and aletheia/capabilities from the [DefinitionBacked] registry projections β€” no dedicated mapper. The matrix is complete: aletheia/messaging-topics (region 09-Events), aletheia/object-bearings (region 06-Objects) and the entity-definition surfaces are served the same way. Only two non-CRUD, non-entity routes remain slice-local: the localization dictionary and the aspect view/validate pair.

Known Gaps

  • Stating the assemlies is a nice idea. However, can we make this "easier" in usage?

No dedicated chapter code yet β€” the server wiring lives in ../Program.cs under #region 17-Exploration. Three AddXxxEntity() calls register definition projections; the authorization resources registered by chapter 16 are discovered through the same canonical MapOperations() surface, plus the two non-CRUD leftover mappers.

πŸ’‘ Wiring: Each registration requires an assembly reference β€” AddCapabilityEntity(typeof(GreetHandler).Assembly) tells the system which assembly to scan for [Capability]-decorated handlers.

Try It

List Aspects

POST/api/entities/aletheia/aspects/query→ 200

List Capabilities

POST/api/entities/aletheia/capabilities/query→ 200

List Entities

POST/api/entities/aletheia/entities/query→ 200

Aspect SPARQL source exposure

POST/api/entities/aletheia/aspects/query→ 200

Authentication

At the front door, the Librarian asks who vouched for the visitor, not what name is printed on the envelope. A signed token carries an issuer and subject; together they remain the visitor's identity even when a display name changes.

An interactive application uses OIDC Authorization Code with PKCE; a background worker uses client credentials. Both present a JWT whose RS256 signature, issuer, audience, and lifetime are validated against trusted metadata. Credentials stay in user-secrets or deployment secret configuration, never in the sample or Bruno collection.

A service may additionally name a live delegation with the SDK's execution header. The platform accepts it from a verified principal; the delegated aspects are rechecked at execution time and are never copied into a token or a scheduled snapshot.

What You'll Learn

  • OIDC for people β€” browser clients use Authorization Code + PKCE and send the resulting access token as Authorization: Bearer. The API trusts metadata only from the configured Authority/MetadataAddress and requires the configured Audience; an unsigned bearer-shaped string remains anonymous in Open mode and is rejected when JWT validation is configured.
  • Stable principal projection β€” the validated (iss, sub) pair is projected to an Agent using a stable key. Profile names are display data, not identity; first-request order never grants administrator rights.
  • Service credentials β€” workers use OAuth2 client credentials. Authentication:ServiceToken:ClientSecret is supplied through secret configuration. Authentication:Jwt:ServiceSubjects explicitly identifies trusted service subjects; ServiceClientIds is an optional additional client-claim restriction, never sufficient proof by itself. AddJwtServiceTokenClient() is registered only when its endpoint, client ID, and secret are present.
  • Delegation β€” X-Aletheia-Delegation-Iri selects a live delegation, accepted from any identity the grant names β€” a verified JWT or a development declaration, so a delegation can be exercised locally without an identity provider. The live grant, its optional window, the delegate agent, and the permitted aspects are checked for each request. The grant IS the permission: the subject must hold the aspect it lends and the delegate needs nothing of its own beyond being named, so a freshly created grant works on the next elected request. A refused selection answers 403 and records a delegation-selection audit row rather than quietly serving the caller as itself. A refused grant WRITE answers 422 INVALID_DELEGATION and says which rule it broke β€” a window that has already closed (delegation_window_closed, naming the instant), a start after the expiry, an unnamed agent, or an aspect no request could carry. Both window ends are optional, and a date-only pick reads as midnight: a grant meant to stay open-ended is written with the expiry cleared.
  • Safe local tests β€” the sample's default Open mode needs no identity provider. Development simulation is explicitly unverified. The Authorization.Http TestServer suite creates an ephemeral RSA key and local metadata document to exercise Enforced mode without calling an IdP or remote model.
  • Browser metadata per surface β€” the browser's half of authentication is a document, not a config read: each browser surface publishes its own at its own path, and one Authentication:Browser section defines the one public client they share. A second surface overrides only the two values that genuinely differ β€” where it comes back to.
  • A local identity without simulation β€” a host that has not moved to platform authorization still gets a canonical agent from a Development bearer token, through the same trusted projection service a verified identity uses, and only in Development.

The Code

Wiring

Program.cs β€” dimmed lines are from previous chapters, highlighted lines are new in this chapter

Program.cs
}
builder.Services.AddAspects();
 
if (simulationEnabled)
{
    builder.Services.AddAgentSimulation(builder.Configuration);
    builder.Services.PostConfigure<GenesisOptions>(options =>
    {
        if (!string.IsNullOrWhiteSpace(options.InitialAdministratorIssuer)
            && options.InitialAdministratorIssuer != AgentSimulationOptions.Issuer)
            throw new InvalidOperationException("Simulation Genesis must use the isolated development issuer, not a real JWT issuer.");
        options.InitialAdministratorIssuer = AgentSimulationOptions.Issuer;
    });
}
 
builder.Services.AddMessagingInMemory();
builder.Services.AddEntityEvents();
// ...
 
var app = builder.Build();
 
if (jwtConfigured)
    app.UseJwtAuthentication();
 
app.MapCapabilities();
 
// ...
  • agent.whoami is registered by AddPlatformAuthorizationHttp() with the authorization control plane.
  • The standard JWT handler validates signature, issuer, audience, and lifetime before the identity reaches authorization.

Browser Runtime Configuration

The admin bundle loads public OIDC settings from /aletheia/assets/authentication.json, served by the existing UseWebInterface() pipeline. It is disabled by default. Enable it with host configuration, for example:

Authentication__Browser__Enabled=true
Authentication__Browser__Authority="https://identity.example.test/tenant"
Authentication__Browser__ClientId="public-admin-client"
Authentication__Browser__RedirectUri="https://app.example.test/aletheia/auth/callback"
Authentication__Browser__PostLogoutRedirectUri="https://app.example.test/aletheia/"
Authentication__Browser__ApiBaseUrl="https://app.example.test/api"
Authentication__Browser__Scope="openid profile email"

Authority, ClientId, redirect URLs, API base URL, and optional Scope are public browser metadata; no client secret or other configuration section is serialized. Redirect and post-logout URLs must share an origin. URLs use HTTPS, except loopback URLs in Development. API token validation remains separately configured with Authentication:Jwt:Authority (or MetadataAddress) and Authentication:Jwt:Audience; the browser ClientId is not necessarily that API audience.

The document is served in every configuration, enabled or not: a disabled section answers {"enabled":false}, and that answer is what tells a bundle not to sign anybody in. A no-store cache header rides along, because a stage's configuration is not a cacheable asset. run-production-open.sh and run-production-enforced.sh start the sample with the enabled shape; the Development launchers leave it off.

The document belongs to the surface that serves it

UseWebInterface() publishes the document under its own mount path β€” /aletheia/assets/authentication.json for the admin, because the admin is mounted at /aletheia/. The same path at the root answers 404: this is not a site-wide asset but the browser half of ONE surface's sign-in.

One client, two surfaces

A host that serves a second browser surface β€” an application at / beside the admin at /aletheia/, both using the same public client β€” publishes that surface's document itself, with UseBrowserAuthentication:

// The admin at /aletheia/: the wing publishes <mount>/assets/authentication.json
// from Authentication:Browser, and its callbacks already point under /aletheia/.
app.UseWebInterface();

// The application at /: the SAME client, but it returns HERE, not to the admin.
app.UseBrowserAuthentication(
    "/assets/authentication.json",
    redirectUri: "https://app.example.test/auth/callback",
    postLogoutRedirectUri: "https://app.example.test/");

Only the two values that genuinely differ are passed. The issuer, client id, API base URL and scope stay in Authentication:Browser, because two copies of a client id are two things that can drift β€” and the drift is silent: the browser would ask the provider for a client the API does not validate.

RuleWhat it prevents
The pair is supplied together or not at allA surface that overrides one URL and inherits the other from a different origin
The pair shares an originThe provider matches both strictly; a reader who lands on another origin returns without the session
The document is served even when disabledA bundle that gets a 404 and cannot tell "no sign-in here" from "misconfigured host"
Always `no-store`A stage's configuration cached by a browser and served to the next stage

The sample serves one surface, so it takes the default form and chapter request 05-browser-document.bru exercises it; 06-browser-document-surface-scope.bru shows that the root does not answer the admin's document. The reference for the overload is SDK/src/Web/Usage.md.

Simulating an Identity (Local Development)

With no identity provider in front, a request may declare the agent it acts as instead of proving it:

X-Aletheia-Agent-Simulation: {"token":"alice","name":"Alice Example","email":"alice@example.com"}

Only token is required. In a Debug SDK build running in Development, the platform maps it to an isolated development issuer/subject principal and applies the same Enforced role checks as JWT requests. It remains explicitly simulated, never authenticated; it cannot assume the identity of an actual provider's user.

The sample registers simulation only when no trusted JWT is configured and the build/environment permit it. The SDK refuses unsafe combinations:

  • In Debug + Development, both Open and Enforced may use simulation; chapter 16 shows explicit Genesis setup for x.
  • In Release builds or outside Development, simulation is unavailable regardless of authorization mode or legacy Enabled settings. Enforced startup needs real JWT configuration.
  • In production frontend builds, the switch is absent and its interceptor sends no simulation header, even when runtime dev mode is enabled.

A declaration cannot claim membership β€” groups and entitlements have no wire form here, because a locally green build must not be reachable by faking an entitlement. A bearer value is only trusted when the sample is configured to validate it against the configured OIDC authority and audience.

A local identity without simulation

Simulation is this sample's local identity because the host registers AddPlatformAuthorizationHttp(). A host that still composes its own store β€” UseAgentTokenMiddleware() on a bearer token, plus UseAgentSync() β€” has no simulation, but its Development token is still made usable: when the request carries a token no verifier vouched for, UseAgentSync() builds the canonical principal with DevelopmentAgentIdentity.Create(token) (issuer /aletheia/development/agent-token, subject = the token), projects it through the same trusted service a verified identity goes through, and binds that canonical token and execution identity for the rest of the request. A gate that asks ?agentIri therefore answers on such a host β€” locally through this bridge, deployed through the verifier β€” without anything declared on the wire.

Outside Development the bridge does not exist: the unverified token passes through untouched and no record is written β€” which is why a deployment must configure Authentication:Jwt rather than rely on a bearer string. The decision is one line: platform authorization registered β†’ nothing to do; legacy store composition β†’ wrap the bearer middleware with UseAgentSync().

Deployed Behaviour

The Bruno requests above run against the local Open-mode server. For a real deployment, configure standard OIDC JWT validation:

1. Set Authentication__Jwt__Authority (or Authentication__Jwt__MetadataAddress) and Authentication__Jwt__Audience from the identity provider's registered API configuration. 2. Configure Authorization__Mode=Enforced and a one-time Authorization__Genesis__Key, InitialAdministratorIssuer, and InitialAdministratorSubject through deployment configuration. 3. The API validates the JWT and derives identity from signed iss and sub; agent.whoami reports the verified identity.

To enable it in the katharsis stack:

# Configure these as deployment environment variables/secrets; never commit real values.
Authentication__Jwt__Authority="<trusted OIDC authority>"
Authentication__Jwt__Audience="<registered API audience>"
Authentication__Jwt__ServiceSubjects__0="<verified worker subject>"
Authorization__Mode="Enforced"
Authorization__Genesis__Key="<per-environment stable key>"
Authorization__Genesis__InitialAdministratorIssuer="<bootstrap administrator issuer>"
Authorization__Genesis__InitialAdministratorSubject="<bootstrap administrator subject>"
Authentication__ServiceToken__TokenEndpoint="<provider token endpoint>"
Authentication__ServiceToken__ClientId="<worker client id>"
Authentication__ServiceToken__ClientSecret="<deployment secret>"

The service first authenticates using its own credential; the host projects that verified identity into a Service Agent. Configure Authorization__Background__ServiceAgentIri with that Agent's IRI before using it for delayed execution. A queued delivery retains identity references and a protected snapshot reference, not the service secret or the caller's JWT. Restarting a worker revalidates that snapshot, current roles, and any live delegation; deleting the delegation stops subsequent execution.

Local browser testing uses a separate local OIDC provider and isolated application store. Register the exact loopback callback shown by the configured Authentication:Browser:RedirectUri, use PKCE, and set Authentication:Jwt:RequireHttpsMetadata=false only for that loopback Development provider. Never repoint local tests at the deployed authorization graph. Test administrator login, denied roleless access, service delegation, deletion while work is queued, and a restart against the same durable store before deployment.

How this stack is provisioned. The host is an OIDC *client*, not a host behind forward-auth: the edge forwards every request, and the API validates the Bearer token. One PUBLIC OAuth2 application per stage (aletheia-staging, aletheia-prod) carries authorization_code + PKCE for the browser and client_credentials for the background service bot; there is no proxy provider and no Traefik authentication middleware. The provider invariants (unique name equal to the client id and the API audience, issuer_mode=per_provider, sub_mode=user_uuid, mandatory signing key, strict redirect URIs) and the service accounts whose app passwords mint machine tokens are recorded in deployment/auth/README.md.

To exercise acceptance behavior without any remote service, run dotnet test SDK/tests/Authorization.Http.Tests/Aletheia.Sdk.Authorization.Http.Tests.csproj --no-restore --nologo -v minimal --filter FullyQualifiedName~PlatformAuthorizationEndToEndTests from the repository root.

πŸ’‘ Wiring: ../Program.cs registers AddJwtAuthentication() only when a trusted authority (or metadata address) and audience exist, then runs UseJwtAuthentication() before API mappings. Authentication:ServiceToken may enable the SDK token client; its secret comes only from external secret configuration. Without JWT configuration, Debug + Development hosts may use simulation in either Open or Enforced mode, as shown in chapter 16.

Try It

Who Am I β€” anonymous (no identity headers)

POST/api/capabilities/agent/whoami→ 200

Who Am I β€” unsigned bearer is anonymous

POST/api/capabilities/agent/whoami→ 200
AuthorizationBearer this-is-not-a-signed-jwt

Who Am I β€” declared (simulated) agent

POST/api/capabilities/agent/whoami→ 200
X-Aletheia-Agent-Simulation{"token":"alice","name":"Alice Example","email":"alice@example.com"}

Who Am I β€” malformed declaration

POST/api/capabilities/agent/whoami→ 400
X-Aletheia-Agent-Simulationalice

Browser authentication β€” the admin's document

GET/aletheia/assets/authentication.json→ —

Browser authentication β€” the document belongs to its surface

GET/assets/authentication.json→ —

Authorization

A new register appears beside the doorway: one key for the Library, one name for the first administrator, and a record of the moment authority began.

Authorization distinguishes an open library from an enforced one. Genesis creates the initial administrator exactly once, and resource-scoped grants decide which operation may proceed. A Debug host in Development can exercise these same rules with explicitly simulated principals; production builds require real credentials.

The requests below exercise the ordinary entity and capability surfaces in the sample's Open mode. The separate in-process acceptance suite generates its own RSA key, trusts only its local test metadata, and proves the Enforced-mode administrator, denial, and execution-selection paths without an identity provider or remote graph store.

What You'll Learn

  • Modes and scoped no-ops β€” AddPlatformAuthorizationHttp() discovers resources and their actions, installs per-resource no-op aspects, and registers the control plane. Open mode preserves access for actions without grants; Enforced mode requires a live grant for the requested resource/action.
  • Verified identity and Genesis β€” AddJwtAuthentication() validates a configured authority or metadata address, audience, signature, and lifetime before UseJwtAuthentication() binds identity. Authorization:Genesis:* identifies the one-time administrator by stable issuer and subject; profile names and first-visitor order do not establish trust.
  • Administration, delegation, and audit β€” platform entities represent grants, delegations, and immutable audit entries. Delegated execution is selected by a verified caller naming a live delegation, and each request rechecks the grant and its aspect scope.
  • Safe local practice β€” the Sample defaults to Open mode. In Debug + Development, simulation projects an isolated development principal and exercises Enforced role checks as well. The response still reports simulated: true and authenticated: false; production artifacts cannot enable this path.
  • A background service identity β€” a scheduled fire and a subscription delivery have no caller, so they execute as a principal the HOST owns: Authorization:Background:ServiceAgentIri names it, and the capture is refused in Enforced mode when the host owns no Service agent (background_service_identity_missing β€” a delivery is parked authorization-blocked, a fire is refused every tick). AddSampleServiceBot() seeds that agent, the role it holds, and the aspect assignments background work needs; in Development it is a mock.
  • Domain rights are separate, and the sample grants them to its operator β€” Genesis gives the administrator the *platform* permissions and nothing else, which is why a fresh Enforced host refuses an administrator's first entity write. Authorization:ServiceBot:GrantOperatorDomainRights (on by default in Development) assigns the administrator the application's own discovered permissions, so every other chapter works unchanged in Enforced mode.

The Code

The background service identity: the Authorization:ServiceBot options, the (iss, sub) resolution that names it, and the one-time startup seed β€” the service agent, its Service Bot role, and the operator's domain rights

ServiceBotBootstrap.cs
using System.Text;
using System.Text.Json;
using Aletheia.Sdk.Aspects.Abstractions;
using Aletheia.Sdk.Authorization;
using Aletheia.Sdk.Authorization.Entity;
using Aletheia.Sdk.Authorization.Http;
using Aletheia.Sdk.Authorization.Http.DependencyInjection;
using Aletheia.Sdk.Capability;
using Aletheia.Sdk.Entity;
using Aletheia.Sdk.Entity.Contracts;
using Aletheia.Sdk.Execution;
using Aletheia.Sdk.Repository;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aletheia.Sdk.Sample;
 
/// <summary>
/// The sample's background service identity: the account a <b>scheduled fire</b> and a
/// <b>subscription delivery</b> execute as.
/// </summary>
/// <remarks>
/// <para>
/// Bound from the <c>Authorization:ServiceBot</c> configuration section.
/// </para>
/// <para>
/// Background work never runs as the caller who caused it. When it is enqueued the host captures a
/// reference-only identity β€” which agent executes, which agent's rights are evaluated β€” and an
/// Enforced host can only do that when it OWNS a service agent (<c>Kind = "Service"</c>). Without
/// one the capture is denied: the delivery is parked <c>authorization-blocked</c> and the scheduled
/// fire answers <c>background_service_identity_missing</c>. The <c>Authorization:Background</c>
/// section names that agent; this section decides whether the sample seeds it and what it may do.
/// </para>
/// </remarks>
public sealed class ServiceBotOptions
{
    /// <summary>The configuration section this options type is bound from.</summary>
    public const string ConfigurationSection = "Authorization:ServiceBot";
 
    /// <summary>
    /// Whether the sample seeds the service identity at startup.
    /// </summary>
    /// <remarks>
    /// Unspecified means "per environment": <see langword="true"/> in Development, where the identity
    /// is a mock, and <see langword="false"/> everywhere else, where a host that wants a service
    /// identity opts in explicitly. A null value is not a third behaviour β€” it is the absence of a
    /// decision, which this default supplies.
    /// </remarks>
    public bool? Enabled { get; set; }
 
    /// <summary>
    /// The service principal's issuer, which must equal the <c>iss</c> claim of the credential the
    /// service authenticates with.
    /// </summary>
    /// <remarks>
    /// Defaults to <c>Authentication:Jwt:Authority</c>, then (in Development) to the isolated
    /// development simulation issuer. The value is compared byte-for-byte, because it is one half of
    /// the stable <c>(iss, sub)</c> pair the agent's identity is derived from β€” a trailing slash that
    /// differs from the claim produces a different agent, not a near miss.
    /// </remarks>
    public string? Issuer { get; set; }
 
    /// <summary>
    /// The service principal's subject, which must equal the <c>sub</c> claim of the credential the
    /// service authenticates with.
    /// </summary>
    /// <remarks>
    /// Defaults to the mock subject in Development. Elsewhere it is required: the host must say which
    /// verified principal is its background worker. Read the real value off a token the service
    /// already issued (<c>iss</c> and <c>sub</c>) rather than guessing it.
    /// </remarks>
    public string? Subject { get; set; }
 
    /// <summary>
    /// The service account whose app password authenticates the machine token request.
    /// </summary>
    /// <remarks>
    /// A deployment names this and <see cref="Password"/> instead of a pre-encoded client secret:
    /// Authentik expects a machine request to carry `base64("&lt;account&gt;:&lt;app password&gt;")` as its
    /// `client_secret`, and composing that in the host keeps an encoded credential out of every
    /// deployment's secret store. See <see cref="ServiceBotRegistration.ComposeServiceTokenSecret"/>.
    /// </remarks>
    public string? Username { get; set; }
 
    /// <summary>
    /// The service account's app password β€” an Authentik <b>Token</b> with intent `app password`,
    /// which the account can revoke on its own. NOT a provider client secret.
    /// </summary>
    public string? Password { get; set; }
 
    /// <summary>The display name recorded on the seeded agent.</summary>
    public string DisplayName { get; set; } = "Service Bot";
 
    /// <summary>
    /// The capability identities the service identity is granted, as execute permissions.
    /// </summary>
    /// <remarks>
    /// Defaults to the sample's two background capabilities: the data-record subscription's target
    /// (<c>sample.index</c>) and the scheduled tick (<c>scheduling.heartbeat</c>). Each entry becomes
    /// an <c>AspectRoleAssignment</c> for that capability's execute aspect β€” the resource/action
    /// specific no-op aspect, never a universal bypass.
    /// </remarks>
    public string[] Capabilities { get; set; } = [];
 
    /// <summary>
    /// Whether the Genesis administrator additionally receives every one of the application's own
    /// permissions, so the tutorial's chapters can be exercised in Enforced mode.
    /// </summary>
    /// <remarks>
    /// Unspecified means "per environment" exactly as <see cref="Enabled"/> does. This is the sample's
    /// own authority policy, not an SDK behaviour: the platform deliberately grants its administrator
    /// only the <b>platform</b> permissions and leaves domain rights to the application, which is why
    /// a fresh Enforced host refuses an administrator's first entity write. Turning this on makes the
    /// tutorial's Open-mode examples work unchanged in Enforced mode, and turning it off leaves a host
    /// where every domain right must be granted explicitly.
    /// </remarks>
    public bool? GrantOperatorDomainRights { get; set; }
}
 
/// <summary>A resolved service principal: the stable <c>(iss, sub)</c> pair its agent is derived from.</summary>
/// <param name="Issuer">The issuer the service's credential carries.</param>
/// <param name="Subject">The subject the service's credential carries.</param>
internal readonly record struct ServiceBotIdentity(string Issuer, string Subject)
{
    /// <summary>
    /// The agent IRI this identity owns.
    /// </summary>
    /// <remarks>
    /// Derived, never stored: the agent's identity parts are its token, so the IRI is the same value
    /// whether the record was seeded here or projected from a real credential moments later β€” which
    /// is what lets the host point <c>Authorization:Background:ServiceAgentIri</c> at an agent that
    /// has not been written yet.
    /// </remarks>
    internal string AgentIri => new Agent { Token = Agent.TokenForPrincipal(Issuer, Subject) }.Iri;
}
 
/// <summary>Registers the sample's background service identity.</summary>
public static class ServiceBotRegistration
{
    /// <summary>
    /// Composes the `client_secret` a machine token request must send from a named service account and
    /// its app password.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Authentik authenticates a `client_credentials` request with a service account's APP PASSWORD,
    /// presented as `client_secret = base64("&lt;account&gt;:&lt;app password&gt;")`. Nothing else in the
    /// host cares about the encoding, so this composes it once from the two values a deployment can
    /// name plainly β€” no deployment has to store an encoded credential, and no configuration file ever
    /// carries one.
    /// </para>
    /// <para>
    /// Call it BEFORE registering the SDK's token client, which reads the composed value. An explicitly
    /// configured `Authentication:ServiceToken:ClientSecret` always wins, so a host that speaks to a
    /// provider with an ordinary confidential client keeps working unchanged.
    /// </para>
    /// </remarks>
    /// <param name="configuration">The host configuration, mutated in memory.</param>
    public static void ComposeServiceTokenSecret(this IConfiguration configuration)
    {
        ArgumentNullException.ThrowIfNull(configuration);
 
        var section = configuration.GetSection(ServiceBotOptions.ConfigurationSection);
        if (string.IsNullOrWhiteSpace(section["Username"]) || string.IsNullOrWhiteSpace(section["Password"]))
            return;
        if (!string.IsNullOrWhiteSpace(configuration["Authentication:ServiceToken:ClientSecret"]))
            return;
 
        configuration["Authentication:ServiceToken:ClientSecret"] = Convert.ToBase64String(
            Encoding.UTF8.GetBytes($"{section["Username"]}:{section["Password"]}"));
    }
 
    /// <summary>
    /// Seeds the service identity an Enforced host needs for background work, and points
    /// <c>Authorization:Background:ServiceAgentIri</c> at it.
    /// </summary>
    /// <param name="services">The service collection.</param>
    /// <param name="configuration">The host configuration.</param>
    /// <param name="environment">The host environment, which decides the development defaults.</param>
    /// <returns>The same service collection for chaining.</returns>
    public static IServiceCollection AddSampleServiceBot(
        this IServiceCollection services,
        IConfiguration configuration,
        IHostEnvironment environment)
    {
        ArgumentNullException.ThrowIfNull(services);
        ArgumentNullException.ThrowIfNull(configuration);
        ArgumentNullException.ThrowIfNull(environment);
 
        var section = configuration.GetSection(ServiceBotOptions.ConfigurationSection);
        services.Configure<ServiceBotOptions>(section);
 
        // Open mode asks none of these questions: a capture with no host service agent falls back to
        // the anonymous identity and every catalogued association is permissive without a role. Only an
        // Enforced host needs a service agent, so only an Enforced host pays for one.
        if (!string.Equals(configuration["Authorization:Mode"], AuthorizationMode.Enforced.ToString(),
                StringComparison.OrdinalIgnoreCase))
            return services;
 
        var settings = section.Get<ServiceBotOptions>() ?? new ServiceBotOptions();
        var seedServiceIdentity = settings.Enabled ?? environment.IsDevelopment();
        var grantOperatorDomainRights = settings.GrantOperatorDomainRights ?? environment.IsDevelopment();
        if (seedServiceIdentity || grantOperatorDomainRights)
        {
            if (seedServiceIdentity)
            {
                ServiceBotIdentityResolver.Validate(settings, configuration, environment);
                services.PostConfigure<BackgroundAuthorizationOptions>(background =>
                    // Resolved lazily: the agent IRI is derived from the CANONICAL base IRI, and a deployment
                    // may override `Entity:BaseIri` after this registration has already run.
                    background.ServiceAgentIri ??= ServiceBotIdentityResolver.Resolve(settings, configuration, environment).AgentIri);
            }
 
            // Registered through a factory because the token client is OPTIONAL: it exists only when the
            // host configures its own client credentials, which is a verification convenience, not a
            // precondition for seeding.
            services.AddHostedService(provider => new ServiceBotStartupService(
                provider.GetRequiredService<IOptions<ServiceBotOptions>>(),
                provider.GetRequiredService<IOptions<GenesisOptions>>(),
                provider.GetRequiredService<IConfiguration>(),
                provider.GetRequiredService<IHostEnvironment>(),
                provider.GetRequiredService<AuthorizationControlStore>(),
                provider.GetRequiredService<VerifiedAgentService>(),
                provider.GetRequiredService<AuthorizationControlEntityStore>(),
                provider.GetRequiredService<IAuthorizationResourceCatalog>(),
                provider.GetService<IServiceTokenClient>(),
                provider.GetRequiredService<ILogger<ServiceBotStartupService>>()));
        }
 
        return services;
    }
}
 
/// <summary>Resolves the service principal from configuration, with the development mock as the default.</summary>
internal static class ServiceBotIdentityResolver
{
    /// <summary>The mock subject a Development host seeds when <c>Subject</c> is not configured.</summary>
    internal const string DevelopmentSubject = "aletheia-service-bot";
 
    /// <summary>Resolves the service principal, or throws when configuration cannot establish one.</summary>
    /// <param name="settings">The bound options.</param>
    /// <param name="configuration">The host configuration, for the JWT authority default.</param>
    /// <param name="environment">The host environment.</param>
    /// <returns>The resolved identity.</returns>
    /// <exception cref="InvalidOperationException">
    /// Thrown when no issuer or subject can be established. An Enforced host that cannot name its
    /// background worker must say so at startup rather than discover it as a blocked delivery.
    /// </exception>
    internal static ServiceBotIdentity Resolve(
        ServiceBotOptions settings,
        IConfiguration configuration,
        IHostEnvironment environment)
    {
        var issuer = FirstNonEmpty(settings.Issuer, configuration["Authentication:Jwt:Authority"]);
        if (issuer is null && environment.IsDevelopment())
            issuer = AgentSimulationOptions.Issuer;
        var subject = FirstNonEmpty(settings.Subject);
        if (subject is null && environment.IsDevelopment())
            subject = DevelopmentSubject;
 
        if (issuer is null || subject is null)
            throw new InvalidOperationException(
                $"{ServiceBotOptions.ConfigurationSection} requires Issuer and Subject outside Development. " +
                "Set Issuer to the credential's `iss` claim (it defaults to Authentication:Jwt:Authority) and " +
                "Subject to its `sub` claim β€” the same pair Authentication:Jwt:ServiceSubjects must declare. " +
                "Both are compared byte-for-byte, because together they are the agent's identity.");
 
        return new ServiceBotIdentity(issuer, subject);
    }
 
    /// <summary>Answers whether a service principal can be established, without deriving one.</summary>
    /// <param name="settings">The bound options.</param>
    /// <param name="configuration">The host configuration, for the JWT authority default.</param>
    /// <param name="environment">The host environment.</param>
    /// <exception cref="InvalidOperationException">Thrown when the identity cannot be established.</exception>
    internal static void Validate(ServiceBotOptions settings, IConfiguration configuration, IHostEnvironment environment) =>
        _ = Resolve(settings, configuration, environment);
 
    private static string? FirstNonEmpty(params string?[] values)
    {
        foreach (var value in values)
            if (!string.IsNullOrWhiteSpace(value))
                return value;
        return null;
    }
}
 
/// <summary>
/// Writes the sample's background identity into the control graph once, at startup: the service
/// agent, the roles it needs, and the administrator's own domain rights.
/// </summary>
/// <remarks>
/// <para>
/// It runs as an ordinary operator would β€” the seeded writes are authorized as the Genesis
/// administrator, through the same guarded control store an HTTP write traverses β€” so the bootstrap
/// cannot grant anything the administrator could not have granted itself. The agent record is the one
/// exception, and deliberately so: it is written by the trusted projection service
/// (<see cref="VerifiedAgentService"/>), which is the only writer an agent identity is allowed.
/// </para>
/// <para>
/// Idempotent: every record's IRI is content-addressed from its identity parts, so a restart finds
/// what the previous run wrote and writes nothing. With the InMemory backend the whole control graph
/// is fresh per process, which is what makes the launchers disposable.
/// </para>
/// </remarks>
internal sealed class ServiceBotStartupService(
    IOptions<ServiceBotOptions> settings,
    IOptions<GenesisOptions> genesis,
    IConfiguration configuration,
    IHostEnvironment environment,
    AuthorizationControlStore control,
    VerifiedAgentService agents,
    AuthorizationControlEntityStore guarded,
    IAuthorizationResourceCatalog catalog,
    IServiceTokenClient? tokenClient,
    ILogger<ServiceBotStartupService> logger) : IHostedService
{
    /// <inheritdoc/>
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        var options = settings.Value;
        var marker = await FindGenesisMarkerAsync(cancellationToken).ConfigureAwait(false);
        if (marker is null)
        {
            // Genesis writes its marker before this service runs. Its absence means the host is not
            // actually bootstrapped and there is no administrator to authorize the seeding as.
            throw new InvalidOperationException(
                "The service identity cannot be seeded before Genesis has written its marker.");
        }
 
        var seedServiceIdentity = options.Enabled ?? environment.IsDevelopment();
        var grantOperatorDomainRights = options.GrantOperatorDomainRights ?? environment.IsDevelopment();
        var seeded = new List<string>();
 
        // Both halves write as the Genesis administrator β€” the actor these permissions exist for.
        using var identityScope = ExecutionIdentityScope.Use(
            new ExecutionIdentityContext(marker.AdministratorAgentIri, marker.AdministratorAgentIri));
 
        if (seedServiceIdentity)
        {
            var identity = ServiceBotIdentityResolver.Resolve(options, configuration, environment);
            await VerifyAgainstTokenEndpointAsync(identity, cancellationToken).ConfigureAwait(false);
            var agent = await agents.ProjectAsync(new Agent
            {
                Token = Agent.TokenForPrincipal(identity.Issuer, identity.Subject),
                Issuer = identity.Issuer,
                Subject = identity.Subject,
                Kind = "Service",
                DisplayName = options.DisplayName,
            }, cancellationToken).ConfigureAwait(false);
 
            var role = new Role { Key = $"service-bot:{genesis.Value.Key}", DisplayName = options.DisplayName };
            var granted = await EnsureRoleAsync(role, ServiceAspects(options), cancellationToken).ConfigureAwait(false);
            await EnsureAgentAssignmentAsync(agent.Iri, role.Iri, cancellationToken).ConfigureAwait(false);
            seeded.Add($"'{role.Key}' ({granted.Count} aspect(s)) to the background agent '{agent.Iri}' " +
                $"({identity.Issuer} / {identity.Subject}); Authorization:Background:ServiceAgentIri is '{identity.AgentIri}'");
        }
 
        if (grantOperatorDomainRights)
        {
            var operatorRole = new Role { Key = $"sample-operator:{genesis.Value.Key}", DisplayName = "Sample Operator" };
            var domain = OperatorAspects();
            await EnsureRoleAsync(operatorRole, domain, cancellationToken).ConfigureAwait(false);
            await EnsureAgentAssignmentAsync(marker.AdministratorAgentIri, operatorRole.Iri, cancellationToken).ConfigureAwait(false);
            seeded.Add($"'{operatorRole.Key}' ({domain.Count} domain permission(s)) to the administrator '{marker.AdministratorAgentIri}'");
        }
 
        logger.LogInformation("Seeded the sample's authorization: {Seeded}.", string.Join("; ", seeded));
    }
 
    /// <inheritdoc/>
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
 
    /// <summary>
    /// Proves the configured identity is the one the host's own client credentials produce.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Only a host that configured <c>Authentication:ServiceToken</c> reaches this check. The token is
    /// minted by the configured token endpoint for this host's own client credentials, and its
    /// <c>iss</c> and <c>sub</c> claims are read purely to COMPARE β€” the seeded agent comes from
    /// configuration either way, so an endpoint that answered with something unexpected cannot widen
    /// anything. A mismatch is fatal because it is exactly the case where background work would run as
    /// a principal no deployment named; the message prints both pairs, so one edit fixes it.
    /// </para>
    /// <para>
    /// A token the endpoint cannot mint is a WARNING, not a failure: the identity is declared in
    /// configuration, and an identity provider that is briefly unreachable must not stop a host from
    /// starting the work it already knows how to identify.
    /// </para>
    /// </remarks>
    private async ValueTask VerifyAgainstTokenEndpointAsync(ServiceBotIdentity identity, CancellationToken cancellationToken)
    {
        if (tokenClient is null)
            return;
 
        string token;
        try
        {
            token = await tokenClient.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (Exception error) when (error is not OperationCanceledException)
        {
            logger.LogWarning(error,
                "The service token could not be minted, so the configured service identity was not verified against it; " +
                "background work uses issuer '{Issuer}' and subject '{Subject}'.",
                identity.Issuer, identity.Subject);
            return;
        }
 
        var claims = ReadJwtClaimPair(token);
        if (claims is null)
        {
            logger.LogWarning("The service token carries no readable 'iss'/'sub' pair; the configured service identity was not verified.");
            return;
        }
 
        var (issuer, subject) = claims.Value;
        if (!string.Equals(issuer, identity.Issuer, StringComparison.Ordinal)
            || !string.Equals(subject, identity.Subject, StringComparison.Ordinal))
            throw new InvalidOperationException(
                $"The service token endpoint minted a token for ('{issuer}', '{subject}'), but " +
                $"{ServiceBotOptions.ConfigurationSection} declares ('{identity.Issuer}', '{identity.Subject}'). " +
                "Background work would run as one principal while the deployment names another. Set Issuer and Subject to " +
                "the token's 'iss' and 'sub' claims β€” the same pair Authentication:Jwt:ServiceSubjects must declare.");
 
        logger.LogInformation(
            "The service token endpoint confirmed the configured service identity ('{Issuer}', '{Subject}').", issuer, subject);
    }
 
    /// <summary>Reads a JWT's <c>iss</c> and <c>sub</c> claims, without verifying its signature.</summary>
    /// <remarks>
    /// Deliberately signature-blind. The token arrived from the host's own token endpoint over TLS, in
    /// exchange for the host's own client secret, and is used only to compare against configuration β€”
    /// never to establish the agent, which configuration alone establishes.
    /// </remarks>
    private static (string Issuer, string Subject)? ReadJwtClaimPair(string jwt)
    {
        var parts = jwt.Split('.');
        if (parts.Length < 2)
            return null;
        try
        {
            var payload = parts[1].Replace('-', '+').Replace('_', '/');
            payload = payload.PadRight((payload.Length + 3) / 4 * 4, '=');
            using var document = JsonDocument.Parse(Convert.FromBase64String(payload));
            var issuer = document.RootElement.TryGetProperty("iss", out var issuerClaim) ? issuerClaim.GetString() : null;
            var subject = document.RootElement.TryGetProperty("sub", out var subjectClaim) ? subjectClaim.GetString() : null;
            return string.IsNullOrWhiteSpace(issuer) || string.IsNullOrWhiteSpace(subject)
                ? null
                : (issuer, subject);
        }
        catch (Exception error) when (error is FormatException or JsonException)
        {
            return null;
        }
    }
 
    /// <summary>
    /// The execute permissions background work needs: one resource/action-specific no-op aspect per
    /// configured capability, resolved from the canonical base IRI the catalog itself uses.
    /// </summary>
    private static List<string> ServiceAspects(ServiceBotOptions options)
    {
        string[] capabilities = options.Capabilities.Length == 0
            ? ["sample.index", "scheduling.heartbeat"]
            : options.Capabilities;
        var baseIri = EntityOptions.BaseIri.TrimEnd('/');
 
        return capabilities
            .Select(capability => ResourceAuthorizationAspectDescriptor.NoOpFor(
                EntityOptions.BaseIri,
                $"{baseIri}/capabilities/{new CapabilityIdentity(capability).ToRoutePath()}",
                AuthorizationAction.Execute).Iri)
            .Distinct(StringComparer.Ordinal)
            .ToList();
    }
 
    /// <summary>
    /// Every permission the application OWNS β€” its entities and its capabilities, never the
    /// platform's control graph and never a public permission, which needs no assignment.
    /// </summary>
    private List<string> OperatorAspects() =>
        catalog.Permissions
            .Where(permission => !permission.IsPlatformOwned && !permission.AllowsAnonymous)
            .Select(permission => permission.AspectIri)
            .Distinct(StringComparer.Ordinal)
            .ToList();
 
    private async ValueTask<List<string>> EnsureRoleAsync(Role role, List<string> aspects, CancellationToken cancellationToken)
    {
        await EnsureAsync(role, cancellationToken).ConfigureAwait(false);
        foreach (var aspect in aspects)
            await EnsureAsync(new AspectRoleAssignment { AspectIri = aspect, RoleIri = role.Iri }, cancellationToken)
                .ConfigureAwait(false);
        return aspects;
    }
 
    private ValueTask EnsureAgentAssignmentAsync(string agentIri, string roleIri, CancellationToken cancellationToken) =>
        EnsureAsync(new AgentRoleAssignment { AgentIri = agentIri, RoleIri = roleIri }, cancellationToken);
 
    /// <summary>Writes the record when it does not already exist, authorized as the bound administrator.</summary>
    private async ValueTask EnsureAsync<T>(T entity, CancellationToken cancellationToken)
        where T : class, IEntity
    {
        if (await control.LoadAsync<T>(entity.Iri, cancellationToken).ConfigureAwait(false) is not null)
            return;
        await guarded.SaveAsync(entity, WriteMode.Create, cancellationToken).ConfigureAwait(false);
    }
 
    private async ValueTask<Genesis?> FindGenesisMarkerAsync(CancellationToken cancellationToken)
    {
        await foreach (var marker in control.QueryAsync<Genesis>(cancellationToken).ConfigureAwait(false))
            if (string.Equals(marker.Key, genesis.Value.Key, StringComparison.Ordinal))
                return marker;
        return null;
    }
}

Wiring

Program.cs β€” dimmed lines are from previous chapters, highlighted lines are new in this chapter

Program.cs
using Aletheia.Sdk.AI.Http.DependencyInjection;
using Aletheia.Sdk.AI.Scenarios;
 
using Aletheia.Sdk.Authorization;
using Aletheia.Sdk.Authorization.DependencyInjection;
using Aletheia.Sdk.Authorization.Http;
using Aletheia.Sdk.Authorization.Http.DependencyInjection;
using Aletheia.Sdk.Authorization.Entity;
 
using Aletheia.Sdk.Scheduling;
using Aletheia.Sdk.Scheduling.DependencyInjection;
// ...
}
builder.Services.AddAspects();
 
builder.Services.AddPlatformAuthorizationHttp(builder.Configuration);
var jwtAuthority = builder.Configuration["Authentication:Jwt:Authority"];
var jwtMetadataAddress = builder.Configuration["Authentication:Jwt:MetadataAddress"];
var jwtAudience = builder.Configuration["Authentication:Jwt:Audience"];
var jwtConfigured = !string.IsNullOrWhiteSpace(jwtAudience)
    && (!string.IsNullOrWhiteSpace(jwtAuthority) || !string.IsNullOrWhiteSpace(jwtMetadataAddress));
#if DEBUG
var simulationEnabled = !jwtConfigured && builder.Environment.IsDevelopment();
#else
var simulationEnabled = false;
#endif
if (jwtConfigured)
{
    builder.Services.AddJwtAuthentication(builder.Configuration);
}
else if (!simulationEnabled
    && string.Equals(builder.Configuration["Authorization:Mode"], "Enforced", StringComparison.OrdinalIgnoreCase))
{
    throw new InvalidOperationException("Enforced authorization requires JWT configuration, or a Debug build running Development agent simulation.");
}
var serviceToken = builder.Configuration.GetSection("Authentication:ServiceToken");
builder.Configuration.ComposeServiceTokenSecret();
if (!string.IsNullOrWhiteSpace(serviceToken["TokenEndpoint"])
    && !string.IsNullOrWhiteSpace(serviceToken["ClientId"])
    && !string.IsNullOrWhiteSpace(serviceToken["ClientSecret"]))
{
    builder.Services.AddJwtServiceTokenClient(builder.Configuration);
}
builder.Services.AddSampleServiceBot(builder.Configuration, builder.Environment);
 
if (simulationEnabled)
{
// ...
if (jwtConfigured)
    app.UseJwtAuthentication();
 
if (simulationEnabled)
    app.UseAgentSimulation();
 
app.MapCapabilities();
 
// ...
  • One registration discovers resources and installs scoped no-op aspects, Genesis, and control-plane endpoints before the capability catalog is built. Open mode is the local default; enforced deployments need a trusted JWT authority and audience.
  • A deployment names the service account and its APP PASSWORD; the encoded `client_secret` a machine token request must carry is composed here, before the token client reads it β€” so no secret store holds an encoded credential, and a host with an ordinary confidential client (an explicit Authentication:ServiceToken:ClientSecret) is left alone.
  • Background work β€” a scheduled fire, a subscription delivery β€” executes as a SERVICE identity the HOST owns, never as the caller who caused it. This seeds that identity at startup (a mock in Development) and points Authorization:Background:ServiceAgentIri at it. Without one an Enforced host cannot capture a background identity at all: the delivery is parked `authorization-blocked` and a scheduled fire is refused. Configure it through `Authorization:ServiceBot`; see 19-Authorization/Learning.md.
  • The simulation may declare an identity only in Development, where Authorization:Mode defaults to Open.

Known Gaps

  • 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 08-Aspects).

No chapter-local code β€” the host wiring lives in ../Program.cs under #region 19-Authorization and #region 18-Authentication. Offline acceptance tests live in SDK/tests/Authorization.Http.Tests/PlatformAuthorizationEndToEndTests.cs.

FileRole
`ServiceBotBootstrap.cs`The background service identity: the `Authorization:ServiceBot` options, the `(iss, sub)` resolution that names it, and the one-time startup seed β€” the service agent, its `Service Bot` role, and the operator's domain rights
`../Program.cs` `#region 19-Authorization``AddPlatformAuthorizationHttp()` before every capability handler, then `AddSampleServiceBot()` β€” the only two calls an Enforced host needs beyond its JWT configuration

πŸ’‘ Wiring: AddPlatformAuthorizationHttp() runs before capability catalog registration. A configured JWT provider uses AddJwtAuthentication() and UseJwtAuthentication(). Without JWT configuration, a Debug host in Development uses AddAgentSimulation() and UseAgentSimulation() in either authorization mode. The sample supplies the simulation issuer to Genesis and rejects a real-provider Genesis issuer on that branch. AddSampleServiceBot() follows it, and registers the seeding hosted service *after* Genesis, because it authorizes its own writes as the administrator Genesis just created.

Try It

Direct Agent creation is denied anonymously

POST/api/entities/authorization-agents→ 403

Create Role (Head Librarian)

POST/api/entities/authorization-roles→ 200

List Agent-Role Assignments

POST/api/entities/authorization-agent-role-assignments/query→ 200

List Aspect-Role Assignments

POST/api/entities/authorization-aspect-role-assignments/query→ 200

Search Catalog β€” Open mode, anonymous caller

POST/api/capabilities/sample/catalog→ 200

Search Catalog β€” placeholder bearer is not identity

POST/api/capabilities/sample/catalog→ 200
AuthorizationBearer this-is-not-a-signed-jwt

Administration

The Librarian leads you to a grand stained-glass window. "This," she says, "is the Viewing Gallery β€” it shows you every manuscript in the Library, no matter what wing it lives in, no matter when it was catalogued. The Gallery doesn't need to know what it's looking at. It asks the shelves, and the shelves answer."

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/aletheia/entities 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.
  • Authorization entities are platform resources β€” the live catalog includes Agents, Roles, grants, Delegations, audit records, and Genesis. In the admin authorization grouping, Delegations follow Roles; no separate endpoint or client-side entity registry is needed.
  • AddWebInterface() β€” registers the Sdk.Web static file host. Accepts an optional request path prefix (e.g. "/aletheia") 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("/aletheia");
 
builder.Services.AddScheduling(configure: store => store.Register(new ScheduleDefinition
{
// ...
AiScenarios.RegisterAiScenarios(app.Services.GetRequiredService<ScenarioRegistry>());
app.MapAiEndpoints();
 
app.UseWebInterface();
 
app.Run();
  • Sdk.Web serves the Angular admin at /aletheia/ β€” registered after all API endpoints so they take priority.
  • Must be the last middleware β€” catches unmatched routes for Angular client-side routing.

Open the Administration

The viewer is one click away from every chapter: the sidebar carries a permanent Administration β†— entry, and here is the same door β€” open the Administration.

The link is relative on purpose. The tutorial and the admin are served by the same host, so ./aletheia/ resolves against whatever origin the reader loaded the shell from β€” http://localhost:5000/aletheia/ locally, .katharsis.digital/aletheia/ deployed β€” and no absolute host is ever baked into the generated page.

How this chapter is verified

This chapter has no Bruno collection, and that is deliberate rather than an omission. The .bru runner speaks HTTP; everything worth asserting here happens in a browser. A request could prove that /aletheia/index.html is served, which tells you nothing about whether the viewer rendered a shelf from the live introspection feed.

The proof is the Playwright suite in Sdk.Frontend/e2e/, run against the built admin and a deterministic fixture backend:

cd Sdk.Frontend
npm run build:admin
npx playwright test            # production bundle

admin.spec.ts is the chapter's real test: it drives the definition browser, every aspect family, the write playgrounds, the Atlas, and the visual baselines. authentication-lifecycle.spec.ts proves the authentication journey this chapter turns on β€” sign-in, identity switch, sign-out, and what happens to a credential the platform refuses. See Test-Coverage for the chapter's entry in the coverage map.

No dedicated chapter code β€” the server wiring lives in ../Program.cs under #region 20-Administration. AddWebInterface("/aletheia") registers the host; UseWebInterface() serves the Angular app as the last middleware.

πŸ’‘ Wiring: UseWebInterface() must be called after all API endpoint mappings (MapCapabilities(), MapOperations(), etc.) so API routes take priority over the Angular client-side routing fallback.


GraphDB

The Librarian leads you out of the reading room and down to the vault. "Everything so far has been sand," she says. "It takes the shape you give it, and the tide erases it overnight."

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:

# GraphDB 11.x needs a license file (it logs "License could not be loaded" and answers 500 without
# one). Mount yours, or use a 10.x tag for a license-free local look β€” that is what the SDK's own
# test fixture runs.
docker run -d --name aletheia-graphdb -p 7200:7200 \
  -v "$PWD/graphdb.license:/opt/graphdb/home/conf/graphdb.license" \
  ontotext/graphdb:11.5.0

A license file is obtained from Ontotext (a free tier exists for development); without it, use ontotext/graphdb:10.7.3, which runs license-free and is what the SDK test suite starts. The default administrator credentials are admin / root β€” change them before anything real.

⚠️ Two versions, on purpose. The deployed katharsis stack and this chapter's recipe run 11.5.0 (licensed). The SDK's own GraphDB test fixture runs 10.7.3 β€” the last license-free line β€” because a licence-free host cannot run 11.x at all; a licensed one tests 11.5.0 through ALETHEIA_GRAPHDB_IMAGE=ontotext/graphdb:11.5.0. SDK/src/Repository.GraphDb/Test-Coverage.md records the measured evidence (and the 768m heap a 2 GB container VM needs).

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:

SettingValueNotes
Repository ID`aletheia`Must match `GraphDb:RepositoryId` in the SDK
Repository typeGraphDB RepositoryThe default RDF store (`graphdb:SailRepository`)
RulesetRDFS-Plus (Optimized)Enables basic inferencing; corresponds to `rdfsplus-optimized`
Read-onlyoff (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:

SettingValueNotes
Username`aletheia`Matches `GraphDb__Username` in the deployment
Passwordyour secretMatches `GraphDb__Password` in the deployment
RoleUserA regular user, not Admin or Repository manager
Repository access`aletheia` β†’ read + writeGrants `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();
SettingDefaultPurpose
`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 sHTTP 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.

πŸ’‘ Wiring: #region 02-Entities in ../Program.cs reads EntityRepository:Backend and calls UseGraphDb() when it is GraphDb; #region 21-GraphDB documents the GraphDb section. appsettings.json points at http://graphdb:7200; appsettings.Development.json pins local dev and the test suite to InMemory.