Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions docs/superpowers/plans/2026-05-10-backend-platform-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Backend Platform Loop Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build the backend-only AppLens platform loop that proves module detection, Tune action proposal, approval-gated execution, and ledger recording work together without frontend changes or live system-changing jobs.

**Architecture:** Extend the existing backend primitives instead of adding a parallel harness. `ModuleStatusService` owns standardized module manifest/status shape, `BlackboardStore` owns append-only events plus query helpers, and a new platform loop service coordinates detect -> propose -> approve -> execute -> record through `TuneActionExecutor`.

**Tech Stack:** C#/.NET 10, xUnit, JSONL ledger, SQLite index, existing AppLens backend project.

---

### Task 1: Ledger Query Contract

**Files:**
- Modify: `src/AppLens.Backend/BlackboardStore.cs`
- Modify: `src/AppLens.Backend/BlackboardEvent.cs`
- Test: `tests/AppLens.Backend.Tests/BlackboardStoreTests.cs`

- [x] Add `BlackboardEventQuery` with optional filters for event type, module id, app id, correlation id, data state, and result limit.
- [x] Add `IBlackboardStore.QueryAsync(BlackboardEventQuery query, CancellationToken cancellationToken = default)`.
- [x] Test that querying by correlation id and event type returns only matching events in newest-first order.

### Task 2: Standardized Module Manifest Shape

**Files:**
- Modify: `src/AppLens.Backend/ModuleManifest.cs`
- Modify: `src/AppLens.Backend/ModuleStatusService.cs`
- Test: `tests/AppLens.Backend.Tests/ModuleStatusServiceTests.cs`

- [x] Add stable manifest metadata: schema version, module kind, storage roots, health checks, and typed action contracts.
- [x] Populate AppLens-LLM, Oracle, Mailbox, and AppLens-Zero with the same manifest shape.
- [x] Test that every manifest has the platform schema version, at least one storage root, at least one health check, and typed action contracts.

### Task 3: Tune Approval Lifecycle

**Files:**
- Create: `src/AppLens.Backend/PlatformLoopService.cs`
- Test: `tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs`

- [x] Add proposal and approval records for Tune actions.
- [x] Propose actions by writing `ActionProposed` ledger events.
- [x] Approve actions by writing `ActionApproved` ledger events with an approval grant id.
- [x] Execute only when an approval record is approved and references the proposal.
- [x] Record execution through `ActionExecuted` ledger events.

### Task 4: Integration Proof

**Files:**
- Test: `tests/AppLens.Backend.Tests/PlatformLoopServiceTests.cs`

- [x] Use fake module paths and a fake Tune runtime.
- [x] Prove detect -> propose -> approve -> execute -> record produces ledger events in one shared correlation id.
- [x] Prove rejected approvals do not call the runtime and record a blocked action.

### Task 5: Verification and PR

**Files:**
- Commit only backend feature files and tests.

- [x] Run `dotnet test .\AppLensDesktop.sln`.
- [x] Run `dotnet build .\AppLensDesktop.sln`.
- [ ] Commit the scoped backend platform loop work.
- [ ] Push `codex/backend-platform-next`.
- [ ] Open a draft PR against `main`.
170 changes: 170 additions & 0 deletions src/AppLens.Backend/BlackboardEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,175 @@ public sealed class BlackboardEvent
public string? SignerKeyId { get; init; }
public string? Signature { get; init; }

public static BlackboardEvent ForModuleDetected(
ModuleStatus status,
ModuleManifest manifest,
string correlationId) =>
new()
{
EventType = BlackboardEventType.ModuleDetected,
ModuleId = status.ModuleId,
AppId = status.AppId,
CorrelationId = correlationId,
DataState = status.Availability == ModuleAvailability.Available
? BlackboardDataState.Validated
: BlackboardDataState.Blocked,
Summary = $"{status.DisplayName} module status is {status.Availability}: {status.Reason}",
Payload = new Dictionary<string, string>
{
["module_id"] = status.ModuleId,
["app_id"] = status.AppId,
["display_name"] = status.DisplayName,
["availability"] = status.Availability.ToString(),
["reason"] = status.Reason,
["expected_source"] = status.ExpectedSource,
["next_action"] = status.NextAction,
["manifest_schema_version"] = manifest.SchemaVersion,
["module_kind"] = manifest.ModuleKind,
["capability_count"] = manifest.Capabilities.Count.ToString(),
["action_count"] = manifest.Actions.Count.ToString()
},
Provenance = new BlackboardProvenance
{
Source = "ModuleStatusService.GetStatuses",
Tool = "AppLens",
ToolVersion = typeof(ModuleStatusService).Assembly.GetName().Version?.ToString() ?? "unknown"
}
};

public static BlackboardEvent ForTuneActionProposed(TuneActionProposal proposal, TunePlanItem item) =>
new()
{
EventType = BlackboardEventType.ActionProposed,
ModuleId = "tune",
AppId = "applens-tune",
CorrelationId = proposal.CorrelationId,
CreatedAt = proposal.ProposedAt,
Summary = $"Tune action {item.ProposedAction.Kind} proposed for {item.ProposedAction.Target}.",
Payload = new Dictionary<string, string>
{
["proposal_id"] = proposal.ProposalId,
["plan_item_id"] = item.Id,
["kind"] = item.ProposedAction.Kind.ToString(),
["target"] = item.ProposedAction.Target,
["target_context"] = item.ProposedAction.TargetContext,
["execution_state"] = item.ProposedAction.ExecutionState.ToString(),
["risk"] = item.Risk.ToString(),
["requires_admin"] = item.RequiresAdmin.ToString(),
["description"] = item.ProposedAction.Description
},
Provenance = new BlackboardProvenance
{
Source = "PlatformLoopService.ProposeTuneActionAsync",
Tool = "AppLens-Tune",
ToolVersion = typeof(PlatformLoopService).Assembly.GetName().Version?.ToString() ?? "unknown"
},
PolicyResult = new BlackboardPolicyResult
{
Allowed = false,
BlockedReason = "Pending operator approval.",
RequiresApproval = true,
RequiresAdmin = item.RequiresAdmin,
RiskLevel = item.Risk.ToString().ToLowerInvariant(),
PolicyId = "applens-tune-approval-v1"
}
};

public static BlackboardEvent ForTuneActionApproved(TuneActionApproval approval, TuneActionProposal proposal) =>
new()
{
EventType = BlackboardEventType.ActionApproved,
ModuleId = "tune",
AppId = "applens-tune",
GrantId = approval.GrantId,
CorrelationId = proposal.CorrelationId,
CreatedAt = approval.DecidedAt,
DataState = approval.Approved ? BlackboardDataState.Validated : BlackboardDataState.Blocked,
Summary = approval.Approved
? $"Tune action proposal {proposal.ProposalId} approved."
: $"Tune action proposal {proposal.ProposalId} rejected.",
Payload = new Dictionary<string, string>
{
["approval_id"] = approval.ApprovalId,
["grant_id"] = approval.GrantId,
["proposal_id"] = proposal.ProposalId,
["approved"] = approval.Approved.ToString(),
["approved_by"] = approval.ApprovedBy,
["rationale"] = approval.Rationale
},
Provenance = new BlackboardProvenance
{
Source = "PlatformLoopService.ApproveTuneActionAsync",
Tool = "AppLens-Tune",
ToolVersion = typeof(PlatformLoopService).Assembly.GetName().Version?.ToString() ?? "unknown"
},
PolicyResult = new BlackboardPolicyResult
{
Allowed = approval.Approved,
BlockedReason = approval.Approved ? "" : approval.Rationale,
RequiresApproval = true,
RequiresAdmin = false,
RiskLevel = "low",
PolicyId = "applens-tune-approval-v1"
}
};

public static BlackboardEvent ForTuneActionExecuted(
TuneActionRecord action,
TuneActionProposal proposal,
TuneActionApproval approval,
string correlationId)
{
var allowed = action.Status == TuneActionStatus.Succeeded;
return new BlackboardEvent
{
EventType = BlackboardEventType.ActionExecuted,
ModuleId = "tune",
AppId = "applens-tune",
GrantId = approval.GrantId,
CorrelationId = correlationId,
CreatedAt = action.CompletedAt,
DataState = action.Status switch
{
TuneActionStatus.Succeeded => BlackboardDataState.Validated,
TuneActionStatus.Blocked => BlackboardDataState.Blocked,
TuneActionStatus.Failed => BlackboardDataState.Invalidated,
TuneActionStatus.RolledBack => BlackboardDataState.Invalidated,
_ => BlackboardDataState.Blocked
},
Summary = $"Tune action {action.Kind} for {action.Target} ended with {action.Status}.",
Payload = new Dictionary<string, string>
{
["proposal_id"] = proposal.ProposalId,
["approval_id"] = approval.ApprovalId,
["grant_id"] = approval.GrantId,
["action_id"] = action.Id,
["plan_item_id"] = action.PlanItemId,
["kind"] = action.Kind.ToString(),
["status"] = action.Status.ToString(),
["target"] = action.Target,
["message"] = action.Message,
["started_at"] = action.StartedAt.ToString("O"),
["completed_at"] = action.CompletedAt.ToString("O")
},
Provenance = new BlackboardProvenance
{
Source = "PlatformLoopService.ExecuteTuneActionAsync",
Tool = "AppLens-Tune",
ToolVersion = typeof(PlatformLoopService).Assembly.GetName().Version?.ToString() ?? "unknown"
},
PolicyResult = new BlackboardPolicyResult
{
Allowed = allowed,
BlockedReason = allowed ? "" : action.Message,
RequiresApproval = true,
RequiresAdmin = action.RequiresAdmin,
RiskLevel = action.RequiresAdmin ? "medium" : "low",
PolicyId = "applens-tune-approval-v1"
}
};
}

public static BlackboardEvent ForScanCompleted(AuditSnapshot snapshot, string? correlationId = null) =>
new()
{
Expand Down Expand Up @@ -132,6 +301,7 @@ public sealed class BlackboardPolicyResult
[JsonConverter(typeof(JsonStringEnumConverter<BlackboardEventType>))]
public enum BlackboardEventType
{
ModuleDetected,
CapabilityObserved,
EvidenceCaptured,
ReportGenerated,
Expand Down
53 changes: 53 additions & 0 deletions src/AppLens.Backend/BlackboardStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,21 @@ public interface IBlackboardStore

Task<List<BlackboardEvent>> ReadAllAsync(CancellationToken cancellationToken = default);

Task<List<BlackboardEvent>> QueryAsync(BlackboardEventQuery query, CancellationToken cancellationToken = default);

Task<int> GetIndexedEventCountAsync(CancellationToken cancellationToken = default);
}

public sealed class BlackboardEventQuery
{
public BlackboardEventType? EventType { get; init; }
public string? ModuleId { get; init; }
public string? AppId { get; init; }
public string? CorrelationId { get; init; }
public BlackboardDataState? DataState { get; init; }
public int? Limit { get; init; }
}

public sealed class BlackboardStore : IBlackboardStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
Expand Down Expand Up @@ -72,6 +84,47 @@ public async Task<List<BlackboardEvent>> ReadAllAsync(CancellationToken cancella
return events;
}

public async Task<List<BlackboardEvent>> QueryAsync(
BlackboardEventQuery query,
CancellationToken cancellationToken = default)
{
var events = (await ReadAllAsync(cancellationToken).ConfigureAwait(false)).AsEnumerable();

if (query.EventType is not null)
{
events = events.Where(evt => evt.EventType == query.EventType);
}

if (!string.IsNullOrWhiteSpace(query.ModuleId))
{
events = events.Where(evt => string.Equals(evt.ModuleId, query.ModuleId, StringComparison.OrdinalIgnoreCase));
}

if (!string.IsNullOrWhiteSpace(query.AppId))
{
events = events.Where(evt => string.Equals(evt.AppId, query.AppId, StringComparison.OrdinalIgnoreCase));
}

if (!string.IsNullOrWhiteSpace(query.CorrelationId))
{
events = events.Where(evt => string.Equals(evt.CorrelationId, query.CorrelationId, StringComparison.OrdinalIgnoreCase));
}

if (query.DataState is not null)
{
events = events.Where(evt => evt.DataState == query.DataState);
}

events = events.OrderByDescending(evt => evt.CreatedAt);

if (query.Limit is > 0)
{
events = events.Take(query.Limit.Value);
}

return events.ToList();
}

public static string SerializeEvent(BlackboardEvent evt) =>
JsonSerializer.Serialize(evt, SerializerOptions);

Expand Down
32 changes: 32 additions & 0 deletions src/AppLens.Backend/ModuleManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ namespace AppLens.Backend;

public sealed class ModuleManifest
{
public const string PlatformSchemaVersion = "1.0";

public string SchemaVersion { get; init; } = PlatformSchemaVersion;
public string AppId { get; init; } = "";
public string DisplayName { get; init; } = "";
public string ModuleId { get; init; } = "";
public string ModuleKind { get; init; } = "";
public string Version { get; init; } = "1.0";
public string RiskLevel { get; init; } = "low";
public List<string> Capabilities { get; init; } = [];
Expand All @@ -14,6 +18,34 @@ public sealed class ModuleManifest
public List<string> Privacy { get; init; } = [];
public string StatusContract { get; init; } = "";
public List<string> ReportRoots { get; init; } = [];
public List<ModuleStorageRoot> StorageRoots { get; init; } = [];
public List<ModuleHealthCheck> HealthChecks { get; init; } = [];
public List<ModuleActionContract> Actions { get; init; } = [];
}

public sealed class ModuleStorageRoot
{
public string Name { get; init; } = "";
public string Path { get; init; } = "";
public string PrivacyState { get; init; } = "raw_private";
public string Description { get; init; } = "";
}

public sealed class ModuleHealthCheck
{
public string Name { get; init; } = "";
public string Kind { get; init; } = "file";
public string Target { get; init; } = "";
public bool Required { get; init; } = true;
}

public sealed class ModuleActionContract
{
public string Name { get; init; } = "";
public string Permission { get; init; } = "read";
public bool RequiresApproval { get; init; } = true;
public bool SystemChanging { get; init; }
public string Description { get; init; } = "";
}

public sealed class ModuleStatus
Expand Down
Loading
Loading