# Einblick in die Rasepi-Architektur: Plugins, Action Guards und Pipelines

> Eine ausführliche technische Erklärung, wie das Plugin-System, die Action Guard Pipeline und die Block-Level Translation Engine von Rasepi tatsächlich funktionieren, mit echtem Code aus der Codebasis.

2026-03-06 · Tim Cadenbach · https://www.tcdev.de/de/blog/how-plugin-guardrail-and-pipeline-systems-work/

---

Die meisten Dokumentationsplattformen sprechen über "Erweiterbarkeit" wie Fluggesellschaften über "Beinfreiheit". Technisch vorhanden, praktisch enttäuschend. Ich wollte, dass die Architektur von Rasepi wirklich erweiterbar ist, ohne unvorhersehbar zu werden, also haben wir drei ineinandergreifende Systeme entwickelt: **Plugins** für Fähigkeiten, **Action Guards** für die Kontrolle und **Pipelines** für die deterministische Ausführung.

In diesem Beitrag erfährst du, wie jedes dieser Systeme in unserer aktuellen Codebasis funktioniert.

![Rasepi-Architektur: Plugins, Wachen und Pipelines arbeiten zusammen](https://www.tcdev.de/de/blog/img/architecture-pipeline.svg)

## Das Plugin-System: modular aufgebaut

Jedes Plugin in Rasepi implementiert `IPluginModule`, eine einzelne Schnittstelle, die erklärt, was das Plugin ist, welche Dienste es braucht und welche Routen es offenlegt:

```csharp
public interface IPluginModule
{
    PluginManifest Manifest { get; }
    void RegisterServices(IServiceCollection services);
    void MapRoutes(IEndpointRouteBuilder routes);
}
```

Der `PluginManifest` ist ein reiner Datenblock. Er beschreibt das Plugin, ohne etwas auszuführen:

```csharp
public sealed class PluginManifest
{
    public required string Id { get; init; }
    public required string Name { get; init; }
    public required string Version { get; init; }
    public string Description { get; init; }
    public string Category { get; init; }
    public IReadOnlyDictionary<string, string> UiContributions { get; init; }
    public bool HasSettings { get; init; }
    public bool HasEndpoints { get; init; }
    public IReadOnlyList<string> Dependencies { get; init; }
}
```

Beachte `UiContributions`. Dieses Wörterbuch ordnet Frontend-Erweiterungspunkte den Komponentennamen zu, damit das Vue-Frontend weiß, welche UI-Komponenten jedes Plugin beisteuert (eine Schaltfläche in der Symbolleiste, ein Seitenleisten-Panel, eine Einstellungsseite).

### Die Registrierung erfolgt in einer Zeile pro Plugin.

Beim Start registrieren wir Plugins über eine fließende API:

```csharp
var pluginRegistry = new PluginRegistry();

pluginRegistry
    .AddPlugin<WorkflowPluginModule>(builder.Services)
    .AddPlugin<RulesPluginModule>(builder.Services)
    .AddPlugin<RetentionPluginModule>(builder.Services)
    .AddPlugin<ClassificationPluginModule>(builder.Services);
```

Jeder Aufruf instanziiert das Modul, speichert es in der Registry und ruft `RegisterServices()` auf, um seine Abhängigkeiten zu verdrahten. Nachdem die App gebaut wurde, werden in einer einzigen Zeile alle Plugin-Routen zugeordnet:

```csharp
app.MapPluginRoutes(pluginRegistry);
```

Unter der Haube erhält jedes Plugin unter `/plugins/{pluginId}/` eine skalierte Routengruppe, auf die automatisch eine Autorisierung angewendet wird.

### Reales Beispiel: das Workflow-Plugin

Hier siehst du, wie ein echtes Plugin aussieht, das Modul Workflow & Genehmigungen:

```csharp
public sealed class WorkflowPluginModule : IPluginModule
{
    public const string PluginId = "workflow";

    public PluginManifest Manifest { get; } = new()
    {
        Id = PluginId,
        Name = "Workflow & Approvals",
        Version = "1.0.0",
        Description = "Adds approval workflows to entry publishing.",
        Category = "Workflow",
        HasSettings = true,
        HasEndpoints = true,
        UiContributions = new Dictionary<string, string>
        {
            ["entry.toolbar.publish"] = "WorkflowPublishButton",
            ["entry.sidebar.status"]  = "WorkflowStatusPanel",
            ["hub.admin.settings"]    = "WorkflowHubSettings",
        }
    };

    public void RegisterServices(IServiceCollection services)
    {
        services.AddScoped<IWorkflowService, WorkflowService>();
        services.AddScoped<IActionGuard, WorkflowPublishGuard>();
    }

    public void MapRoutes(IEndpointRouteBuilder routes)
    {
        WorkflowEndpoints.Map(routes);
    }
}
```

Die Kernplattform verweist niemals direkt auf `WorkflowService` oder `WorkflowPublishGuard`. Sie findet sie über den DI-Container. Das ist der Schlüssel zur Null-Kopplung. Die Kernapplikation berührt nie den Plugin-Code.

## Action Guards: die Kontrollschicht

Plugins fügen Fähigkeiten hinzu. Action Guards entscheiden, ob diese Fähigkeit oder eine Kernaktion ausgeführt werden darf. Sie sind synchrone Prüfer, die Operationen vor der Ausführung abfangen.

[Aktionswächter-Bewertungsablauf](https://www.tcdev.de/de/blog/img/action-guard-flow.svg)

Die Schnittstelle ist absichtlich minimal:

```csharp
public interface IActionGuard
{
    string PluginId { get; }
    string? ActionName { get; }  // null means guard ALL actions

    Task<ActionGuardResult> EvaluateAsync(
        ActionGuardContext context,
        IServiceProvider services,
        CancellationToken ct = default);
}
```

Wenn `ActionName` auf `null` steht, läuft die Wache für jede Aktion. Wenn sie auf etwas wie `"Entry.Publish"` gesetzt ist, fängt sie nur diese spezielle Aktion ab.

### Die Kontext- und Ergebnisverträge

Jeder Guard erhält einen typisierten Kontext mit dem Aktionsnamen, dem Tenant, dem User, der Entität und einer Property Bag:

```csharp
public sealed record ActionGuardContext(
    string ActionName,
    Guid TenantId,
    Guid UserId,
    Guid EntityId,
    IReadOnlyDictionary<string, object?> Properties)
{
    public T? Get<T>(string key) =>
        Properties.TryGetValue(key, out var v) && v is T typed
            ? typed : default;
}
```

Und jeder Guard liefert ein vorhersehbares Ergebnis: allow, deny oder allow-with-modifications:

```csharp
public sealed record ActionGuardResult
{
    public bool IsAllowed { get; init; }
    public string? ReasonCode { get; init; }
    public string? Message { get; init; }
    public IReadOnlyDictionary<string, object?>? Modifications { get; init; }

    public static ActionGuardResult Allow() =>
        new() { IsAllowed = true };

    public static ActionGuardResult Deny(
        string reasonCode, string message) =>
        new() { IsAllowed = false, ReasonCode = reasonCode, Message = message };
}
```

Das Feld `Modifications` ist wichtig. Ein Guard kann eine Aktion genehmigen, aber einen Teil des Inhalts umschreiben (zum Beispiel Geheimnisse vor der Veröffentlichung löschen).

### Kanonische Aktionsnamen

Wir definieren alle abfangbaren Aktionen als String-Konstanten, damit es keine Unklarheiten darüber gibt, worauf ein Guard abzielen kann:

```csharp
public static class ActionNames
{
    public static class Entry
    {
        public const string Create  = "Entry.Create";
        public const string Save    = "Entry.Save";
        public const string Publish = "Entry.Publish";
        public const string Delete  = "Entry.Delete";
        public const string Archive = "Entry.Archive";
        public const string Renew   = "Entry.Renew";
    }

    public static class Hub
    {
        public const string Create = "Hub.Create";
        public const string Delete = "Hub.Delete";
        public const string TransferOwnership = "Hub.TransferOwnership";
    }

    public static class Translation
    {
        public const string Create  = "Translation.Create";
        public const string Publish = "Translation.Publish";
    }
}
```

### Reales Beispiel: Veröffentlichen ohne Genehmigung blockieren

Das Workflow-Plugin registriert einen Guard, der `Entry.Publish` abfängt:

```csharp
public sealed class WorkflowPublishGuard : IActionGuard
{
    public string PluginId => WorkflowPluginModule.PluginId;
    public string? ActionName => ActionNames.Entry.Publish;

    public async Task<ActionGuardResult> EvaluateAsync(
        ActionGuardContext context,
        IServiceProvider services,
        CancellationToken ct = default)
    {
        var db = services.GetRequiredService<RasepiDbContext>();
        var entry = await db.Entries
            .AsNoTracking()
            .FirstOrDefaultAsync(e => e.Id == context.EntityId, ct);

        if (entry is null)
            return ActionGuardResult.Allow();

        var workflowService = services.GetRequiredService<IWorkflowService>();
        var check = await workflowService
            .CheckPublishAllowedAsync(entry.Id, entry.HubId);

        if (check.IsAllowed)
            return ActionGuardResult.Allow();

        return ActionGuardResult.Deny(
            "workflow.approval_required",
            check.Message ?? "Approval required before publishing.");
    }
}
```

Die Kernplattform weiß nichts über Genehmigungsworkflows. Sie ruft lediglich `Entry.Publish` über die Pipeline auf, und der Guard blockiert ihn, wenn der Workflow noch nicht abgeschlossen ist.

## Die Aktionspipeline: wo alles zusammenläuft

Der `ActionPipeline` ist der einzige Ausführungspfad für alle bewachten Vorgänge. Sie entscheidet, welche Guards gelten, wertet sie aus und führt die Aktion entweder aus oder blockiert sie.

```csharp
public sealed class ActionPipeline : IActionPipeline
{
    public async Task<ActionPipelineResult> ExecuteAsync(
        string actionName,
        ActionGuardContext context,
        Func<Task> action,
        CancellationToken ct = default)
    {
        var result = await EvaluateAsync(actionName, context, ct);
        if (!result.IsAllowed) return result;

        await action();  // All guards passed — execute

        return result;   // Return modifications for caller
    }
}
```

Die `EvaluateAsync`-Methode erledigt die schwere Arbeit:

```csharp
public async Task<ActionPipelineResult> EvaluateAsync(
    string actionName,
    ActionGuardContext context,
    CancellationToken ct = default)
{
    // 1. Which plugins are enabled for this tenant?
    var enabledPlugins = await _resolver.GetEnabledPluginIdsAsync();

    // 2. Which guards match this action?
    var applicable = _guards
        .Where(g => enabledPlugins.Contains(g.PluginId))
        .Where(g => g.ActionName == null || g.ActionName == actionName)
        .ToList();

    // 3. Evaluate each guard
    var denials = new List<ActionGuardResult>();
    var modifications = new List<ActionGuardResult>();

    foreach (var guard in applicable)
    {
        try
        {
            var guardResult = await guard.EvaluateAsync(context, _services, ct);
            if (!guardResult.IsAllowed)
                denials.Add(guardResult);
            else if (guardResult.Modifications?.Count > 0)
                modifications.Add(guardResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Guard threw. Treating as Allow.");
        }
    }

    // 4. Any denial blocks the whole action
    if (denials.Count > 0)
        return ActionPipelineResult.Blocked(denials);

    return modifications.Count > 0
        ? ActionPipelineResult.Allowed(modifications)
        : ActionPipelineResult.Allowed();
}
```

Hier gibt es drei wichtige Designentscheidungen:

1. **Per-Tenant-Auflösung.** Der `TenantPluginResolver` überprüft, welche Plugins jeder Tenant installiert und aktiviert hat. Ein Guard für ein deaktiviertes Plugin wird nie ausgeführt.
2. **Alle müssen passieren.** Wenn ein Guard verweigert, wird die Aktion blockiert. Dies ist eine bewusste Sicherheitsmaßnahme.
3. **Wächterfehler scheitern offen.** Wenn ein Wächter eine Ausnahme auslöst, wird sie protokolliert und als `Allow()` behandelt. Dadurch wird verhindert, dass ein fehlerhaftes Plugin die gesamte Plattform sperrt.

### Pro-Tenant-Plugin-Auflösung

Der Resolver fragt die Tabelle `TenantPluginInstallations` ab (die durch die globalen Abfragefilter von EF automatisch auf den aktuellen Tenant beschränkt wird):

```csharp
public sealed class TenantPluginResolver : ITenantPluginResolver
{
    public async Task<IReadOnlySet<string>> GetEnabledPluginIdsAsync(
        CancellationToken ct = default)
    {
        if (_cache is not null) return _cache;

        var ids = await _db.TenantPluginInstallations
            .Where(i => i.IsEnabled)
            .Select(i => i.PluginId)
            .ToListAsync(ct);

        _cache = ids.ToHashSet();
        return _cache;
    }
}
```

## Ereignisgesteuerte Seiteneffekte

Aktionen sind synchron. Seiteneffekte sind es nicht. Nachdem eine Aktion abgeschlossen ist, veröffentlicht der Dienst ein Domänenereignis:

```csharp
await _eventPublisher.PublishAsync(
    EventNames.Entry.Created, entry.Id, new { entry.OriginalLanguage });
```

Die Ereignisse werden in einen In-Memory-Kanal eingereiht und von einem `EventConsumerWorker` im Hintergrund verarbeitet. Der Worker leitet die Ereignisse an mehrere Systeme weiter:

- **Aktivitätsverfolgung.** Protokolliert, wer was wann getan hat.
- **Übersetzungsabrechnung.** Verfolgt die Kosten pro Anbieter
- **Plugin-Event-Handler.** Jedes Plugin kann Domain-Events abonnieren

Plugin-Ereignishandler implementieren `IPluginEventHandler`:

```csharp
public interface IPluginEventHandler
{
    string PluginId { get; }
    IReadOnlyList<string> SubscribedEvents { get; }

    Task HandleAsync(
        string eventName, Guid entityId,
        Guid? tenantId, Guid? userId,
        string payloadJson, IServiceProvider services,
        CancellationToken ct = default);
}
```

Der Worker ruft nur Handler auf, deren Plugin für den Tenant aktiviert ist. Das bedeutet, dass die Nebeneffekte von Plugin A niemals in einen Tenant gelangen, in dem nur Plugin B installiert ist.

## Die Übersetzungsmaschine auf Blockebene

Hier macht sich die Architektur am deutlichsten bemerkbar.

![Übersetzung auf Blockebene: nur geänderte Blöcke werden neu übersetzt](https://www.tcdev.de/de/blog/img/block-translation.svg)

Herkömmliche Plattformen übersetzen ganze Dokumente. Wir übersetzen einzelne **Blöcke**: Absätze, Überschriften, Listenelemente. Wenn ein Benutzer einen Absatz in einem Dokument mit 50 Blöcken bearbeitet, muss nur dieser Absatz neu übersetzt werden. Das ist der Grund für unsere 94%ige Kostenersparnis.

### Wie Blöcke aus TipTap JSON erstellt werden

Wenn ein Benutzer ein Dokument speichert, sendet der TipTap-Editor JSON wie folgt:

```json
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "attrs": { "blockId": "a1b2c3d4-..." },
      "content": [{ "type": "text", "text": "Hello world" }]
    }
  ]
}
```

Der `BlockTranslationService` parst dieses JSON und erstellt einzelne `EntryBlock` Datensätze:

```csharp
public async Task<List<EntryBlock>> CreateBlocksFromDocumentAsync(
    Guid entryId, string language, string contentJson,
    int version, Guid userId)
{
    var doc = JsonDocument.Parse(contentJson);
    var content = doc.RootElement.GetProperty("content");

    int position = 0;
    foreach (var node in content.EnumerateArray())
    {
        var blockType = node.GetProperty("type").GetString();
        var blockJson = JsonSerializer.Serialize(node);

        // Strip metadata attrs before hashing
        var hashInput = StripBlockMetaAttrs(blockJson);

        var block = new EntryBlock
        {
            Id = ExtractOrGenerateBlockId(node),
            EntryId = entryId,
            Language = language,
            Position = position++,
            BlockType = blockType,
            ContentJson = blockJson,
            ContentHash = CalculateContentHash(hashInput),
            IsNoTranslate = ExtractNoTranslateFlag(node),
            Version = version,
        };

        _context.EntryBlocks.Add(block);
    }

    await _context.SaveChangesAsync();
    return blocks;
}
```

### SHA256 Hashing für Stale-Erkennung

Der Inhalts-Hash ist das Herzstück der Stale Detection. Wir hashen den Blockinhalt (nachdem wir Metadatenattribute wie `blockId` und `deleted` entfernt haben) mit SHA256:

```csharp
private string CalculateContentHash(string content)
{
    using var sha256 = SHA256.Create();
    var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(content));
    return Convert.ToHexString(hashBytes);
}
```

Wenn sich ein Quellblock ändert, ändert sich auch sein Hash. Das System vergleicht dann den `SourceContentHash` jedes Übersetzungsblocks mit dem aktuellen Quell-Hash und markiert Unstimmigkeiten als `Stale`:

```csharp
public async Task MarkTranslationsAsStaleAsync(List<Guid> changedBlockIds)
{
    var affected = await _context.TranslationBlocks
        .Where(t => changedBlockIds.Contains(t.SourceBlockId))
        .ToListAsync();

    foreach (var translation in affected)
    {
        translation.Status = TranslationStatus.Stale;
        translation.UpdatedAt = DateTime.UtcNow;
    }

    await _context.SaveChangesAsync();
}
```

### Strukturanpassung

Übersetzer können die Blocktypen in verschiedenen Sprachen ändern. Eine englische Aufzählungsliste kann zu einer deutschen nummerierten Liste werden, eine kulturelle Präferenz. Das System verfolgt dies:

```csharp
var translation = new TranslationBlock
{
    SourceBlockId = sourceBlockId,
    Language = targetLanguage,
    BlockType = translatedBlockType,
    SourceBlockType = sourceBlock.BlockType,
    IsStructureAdapted = translatedBlockType != sourceBlock.BlockType,
    SourceContentHash = sourceBlock.ContentHash,
    Status = TranslationStatus.UpToDate,
};
```

### Übersetzungsanbieter als Plugins

Externe Übersetzungsdienste (DeepL, Google Translate, etc.) werden über `ITranslationProviderPlugin` eingebunden:

```csharp
public interface ITranslationProviderPlugin : IRasepiPlugin
{
    string[] GetSupportedLanguages();

    Task<string> TranslateAsync(
        string text, string sourceLanguage, string targetLanguage);

    Task<TranslationBatchResult> TranslateBatchAsync(
        Dictionary<string, string> texts,
        string sourceLanguage, string targetLanguage);
}
```

Die Batch-Methode erhält ein Wörterbuch mit den Block-IDs zum Inhalt, übersetzt sie alle und gibt die Übersetzungen mit einer abgerechneten Zeichenanzahl zurück. Da wir nur veraltete Blöcke und nicht das gesamte Dokument versenden, bleiben die Kosten minimal.

## Mieterisolierung: das unsichtbare Sicherheitsnetz

Jedes der oben beschriebenen Systeme läuft innerhalb einer strengen Tenant Isolation.

Der `TenantContextMiddleware` löst bei jeder Anfrage den Tenant aus dem JWT auf und verifiziert die Mitgliedschaft:

```csharp
public async Task InvokeAsync(
    HttpContext context, TenantContext tenantContext, RasepiDbContext db)
{
    var tenantIdClaim = context.User.FindFirstValue("tenant_id");
    var userIdClaim = context.User.FindFirstValue(ClaimTypes.NameIdentifier);

    // Populate scoped context
    tenantContext.TenantId = Guid.Parse(tenantIdClaim);
    tenantContext.UserId = Guid.Parse(userIdClaim);

    // Verify membership — fail closed
    var membership = await db.TenantMemberships
        .Where(m => m.TenantId == tenantContext.TenantId
                  && m.UserId == tenantContext.UserId)
        .FirstOrDefaultAsync();

    if (membership == null)
    {
        context.Response.StatusCode = 401;
        return;  // No membership = no access
    }
}
```

Die globalen Abfragefilter von Entity Framework stellen sicher, dass die Datenbankschicht die Filterung nach Tenant automatisch vornimmt, selbst wenn ein Entwickler sie vergisst:

```csharp
modelBuilder.Entity<Hub>()
    .HasQueryFilter(h => h.TenantId == _tenantContext.TenantId);
```

Das Ergebnis: `db.Hubs.ToListAsync()` gibt immer nur die Hubs des aktuellen Tenants zurück. Für Datenlecks muss der Abfragefilter aktiv umgangen werden, was in unserer Codebasis verboten ist.

## Das vollständige Bild

Wenn ein Nutzer bei einem Eintrag auf "Veröffentlichen" klickt, passiert folgendes:

1. **Die Anfrage wird eingegeben** Die Authentifizierung validiert den JWT, `TenantContextMiddleware` löst auf und verifiziert den Tenant.
2. **Controller ruft die Pipeline auf.** `IActionPipeline.ExecuteAsync("Entry.Publish", context, action)`
3. **Pipeline löst Wachen auf.** Fragt ab, welche Plugins der Tenant aktiviert hat, und wählt die entsprechenden Wachen aus.
4. **Guards bewerten.** Der Workflow-Guard prüft auf Genehmigungen, der Retention-Guard auf Richtlinien, der Rules-Guard validiert den Inhalt. Alle bestanden? Der Eintrag wird veröffentlicht.
5. **Ereignisse werden ausgelöst.** Das Ereignis `Entry.Published` wird in die Warteschlange gestellt. Ein Hintergrundworker protokolliert die Aktivitäten, aktualisiert die Übersetzungsabrechnung und ruft die Plugin-Event-Handler auf.
6. **Übersetzungen von Blöcken werden überprüft.** Veraltete Blöcke werden zur Neuübersetzung identifiziert.

Jede Ebene erledigt ihre Arbeit. Keine Schicht greift in eine andere ein. Das ist die Architektur.

> Wir haben sie nicht gebaut, weil Erweiterbarkeit gerade in Mode ist. Wir haben sie gebaut, weil eine Dokumentationsplattform, die sich nicht an die Arbeitsabläufe der einzelnen Teams anpassen kann, irgendwann durch eine ersetzt wird, die das kann. Und eine Plattform, die sich ohne Leitplanken anpasst, wird irgendwann etwas kaputt machen, das wichtig ist.
