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
7 changes: 7 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

Authoritative architecture and developer guides. Each covers one topic.

- [[RFC] Aevatar Chat — Multi-Channel Adapter Architecture](canon/aevatar-channel-architecture.md)
- [Aevatar Foundation](canon/architecture.md)
- [Workflow Chat API 能力说明(框架层)](canon/chat-api.md)
- [Connector 配置与执行逻辑](canon/connector.md)
Expand All @@ -30,6 +31,9 @@ Immutable records of architectural choices and their rationale.
- [Orleans Kafka Provider Backend Architecture](decisions/0003-kafka-transport.md)
- [Workflow 调度 Actor 化 & 多智能体协作演进方案](decisions/0006-multi-agent-evolution.md)
- [Aevatar Stream Forward 架构说明(2026-02-22)](decisions/0007-stream-forward.md)
- [Channel Runtime Multi-Token Credential Routing](decisions/0008-channel-runtime-multi-token-routing.md)
- [Channel Bot Callback Architecture — Lessons from Lark Integration](decisions/0009-channel-bot-callback-architecture.md)
- [Channel Phase 0 Persistent Provider Validation Result](decisions/0010-channel-phase0-provider-validation.md)

## History

Expand All @@ -44,11 +48,14 @@ Point-in-time design snapshots. Not authoritative — for context only.

### 2026-04

- [2026-04-09-scripting-authority-write-path-cqrs-closure](history/2026-04/2026-04-09-scripting-authority-write-path-cqrs-closure.md)
- [2026-04-17-nyxid-chat-registry-lifecycle](history/2026-04/2026-04-17-nyxid-chat-registry-lifecycle.md)
- [claude-code-architecture-learnings](history/2026-04/claude-code-architecture-learnings.md)
- [nyxid-chat-console-design](history/2026-04/nyxid-chat-console-design.md)

## Audit Scorecard

- [2026-04-08-architecture-audit-detailed.md](audit-scorecard/2026-04-08-architecture-audit-detailed.md)
- [FEATURE_APP_SERVICES_AUDIT.md](audit-scorecard/FEATURE_APP_SERVICES_AUDIT.md)

## Templates
Expand Down
41 changes: 41 additions & 0 deletions docs/decisions/0010-channel-phase0-provider-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
title: "Channel Phase 0 Persistent Provider Validation Result"
status: accepted
owner: eanzhao
---

# ADR-0010: Channel Phase 0 Persistent Provider Validation Result

## Context

Issue `#255` defined Phase 0 as a prerequisite for the Channel RFC. One acceptance item required a documented result for the persistent-provider validation harness before Channel runtime work could rely on durable inbox semantics.

The repository now contains a provider redelivery harness in [PersistentStreamProviderRedeliveryValidationTests](../../test/Aevatar.Foundation.Runtime.Hosting.Tests/PersistentStreamProviderRedeliveryValidationTests.cs), but the in-repo Orleans stream backends remain:

- `InMemory` for local development and deterministic tests
- `KafkaProvider` for the durable backend that exists in this repository today

There is still no EventHubs transport/provider implementation in the repo to validate directly.

## Decision

Phase 0 records the following validation result:

- `KafkaProvider` is the only durable backend validated in-repo today.
- EventHubs is not treated as implicitly equivalent to Kafka.
- Until an EventHubs backend exists in this repository and passes the same throw-vs-return redelivery harness, Channel runtime work must keep Kafka as the durable-provider fallback.

This is an explicit fallback outcome, not an EventHubs pass.

## Evidence

- PR `#267` added the redelivery harness and then narrowed its scope honestly to Kafka-only.
- The harness verifies both required semantics for the currently supported durable backend:
- `OnNextAsync` returns normally -> no redelivery
- `OnNextAsync` throws with propagated failure -> message is redelivered

## Consequences

- Issue `#255` can treat the provider-validation acceptance item as documented with a fallback stance.
- Future EventHubs work must extend the harness for EventHubs explicitly and capture a fresh result before claiming EventHubs as a durable inbox provider.
- Channel Phase 1 implementations must not assume EventHubs checkpoint semantics from vendor docs or from Kafka behavior.
96 changes: 96 additions & 0 deletions src/Aevatar.Foundation.Abstractions/Credentials/AuthContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
namespace Aevatar.Foundation.Abstractions.Credentials;

/// <summary>
/// Stable auth intent that can cross module boundaries without carrying a raw secret.
/// The credential reference is late-bound and resolved only at the provider edge.
/// </summary>
public sealed record AuthContext
{
public AuthContext(
AuthPrincipal principal,
string? principalId = null,
string? credentialRef = null,
string? onBehalfOfUserId = null)
{
Principal = principal;
PrincipalId = Normalize(principalId);
CredentialRef = Normalize(credentialRef);
OnBehalfOfUserId = Normalize(onBehalfOfUserId);

Validate(Principal, PrincipalId, OnBehalfOfUserId);
}

public AuthPrincipal Principal { get; }

/// <summary>
/// Stable user identity when the principal is user-scoped.
/// </summary>
public string? PrincipalId { get; }

/// <summary>
/// Opaque late-bound reference. This is never the raw secret itself.
/// </summary>
public string? CredentialRef { get; }

/// <summary>
/// Audit target for delegated sends.
/// </summary>
public string? OnBehalfOfUserId { get; }

public bool UsesBotIdentity => Principal == AuthPrincipal.Bot;

public static AuthContext Bot(string? credentialRef = null) =>
new(AuthPrincipal.Bot, credentialRef: credentialRef);

public static AuthContext User(string principalId, string? credentialRef = null) =>
new(AuthPrincipal.User, principalId: principalId, credentialRef: credentialRef);

public static AuthContext OnBehalfOfUser(
string principalId,
string onBehalfOfUserId,
string? credentialRef = null) =>
new(
AuthPrincipal.OnBehalfOfUser,
principalId: principalId,
credentialRef: credentialRef,
onBehalfOfUserId: onBehalfOfUserId);

private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;

private static void Validate(
AuthPrincipal principal,
string? principalId,
string? onBehalfOfUserId)
{
switch (principal)
{
case AuthPrincipal.Bot:
if (principalId is not null)
throw new ArgumentException("Bot auth context cannot carry a principal id.", nameof(principalId));

if (onBehalfOfUserId is not null)
throw new ArgumentException("Bot auth context cannot carry an on-behalf-of user id.", nameof(onBehalfOfUserId));

break;
case AuthPrincipal.User:
if (principalId is null)
throw new ArgumentException("User auth context requires a principal id.", nameof(principalId));

if (onBehalfOfUserId is not null)
throw new ArgumentException("User auth context cannot carry an on-behalf-of user id.", nameof(onBehalfOfUserId));

break;
case AuthPrincipal.OnBehalfOfUser:
if (principalId is null)
throw new ArgumentException("Delegated auth context requires a principal id.", nameof(principalId));

if (onBehalfOfUserId is null)
throw new ArgumentException("Delegated auth context requires an on-behalf-of user id.", nameof(onBehalfOfUserId));

break;
default:
throw new ArgumentOutOfRangeException(nameof(principal), principal, "Unsupported auth principal.");
}
}
}
12 changes: 12 additions & 0 deletions src/Aevatar.Foundation.Abstractions/Credentials/AuthPrincipal.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Aevatar.Foundation.Abstractions.Credentials;

/// <summary>
/// Identifies which principal should be used when resolving an auth context.
/// The raw secret is still obtained later through <see cref="ICredentialProvider"/>.
/// </summary>
public enum AuthPrincipal
{
Bot = 0,
User = 1,
OnBehalfOfUser = 2,
}
3 changes: 3 additions & 0 deletions src/Aevatar.Foundation.Abstractions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- 定义 Agent/Actor/Runtime 的核心接口
- 定义事件发布、流、模块与持久化接口
- 定义框架级 connector 契约(`IConnector` / `IConnectorRegistry`)
- 定义框架级凭证解析契约(`AuthContext` / `ICredentialProvider`)
- 提供跨项目共享的 Proto 消息
- 提供少量基础工具类型(如 `AgentId`、时间工具、属性标记)

Expand All @@ -26,6 +27,7 @@ Aevatar.Foundation.Abstractions/
├── Attributes/
├── EventModules/
├── Connectors/
├── Credentials/
├── Context/
├── Propagation/
├── Persistence/
Expand All @@ -45,6 +47,7 @@ Aevatar.Foundation.Abstractions/
- `IEventContext`:模块上下文的共性根接口
- `IEventModule<TContext>`:可插拔事件处理模块(含优先级)
- `IConnector` / `IConnectorRegistry`:命名 connector 调用契约与注册表
- `AuthContext` / `ICredentialProvider`:principal-aware 凭证引用与延迟解析契约
- `IStateStore<TState>` / `IEventStore`:状态与事件持久化契约

## Proto 说明
Expand Down
78 changes: 78 additions & 0 deletions test/Aevatar.Foundation.Abstractions.Tests/AuthContextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using Aevatar.Foundation.Abstractions.Credentials;
using Shouldly;

namespace Aevatar.Foundation.Abstractions.Tests;

public sealed class AuthContextTests
{
[Fact]
public void Bot_Factory_CreatesBotContext()
{
var context = AuthContext.Bot("secrets://bot/default");

context.Principal.ShouldBe(AuthPrincipal.Bot);
context.PrincipalId.ShouldBeNull();
context.CredentialRef.ShouldBe("secrets://bot/default");
context.OnBehalfOfUserId.ShouldBeNull();
context.UsesBotIdentity.ShouldBeTrue();
}

[Fact]
public void User_Factory_CreatesUserContext()
{
var context = AuthContext.User("user-123", "secrets://user/123");

context.Principal.ShouldBe(AuthPrincipal.User);
context.PrincipalId.ShouldBe("user-123");
context.CredentialRef.ShouldBe("secrets://user/123");
context.OnBehalfOfUserId.ShouldBeNull();
context.UsesBotIdentity.ShouldBeFalse();
}

[Fact]
public void OnBehalfOfUser_Factory_CreatesDelegatedContext()
{
var context = AuthContext.OnBehalfOfUser(
principalId: "workflow-runner",
onBehalfOfUserId: "owner-42",
credentialRef: "secrets://workflow/owner-42");

context.Principal.ShouldBe(AuthPrincipal.OnBehalfOfUser);
context.PrincipalId.ShouldBe("workflow-runner");
context.OnBehalfOfUserId.ShouldBe("owner-42");
context.CredentialRef.ShouldBe("secrets://workflow/owner-42");
}

[Fact]
public void Constructor_NormalizesWhitespaceOnlyOptionals()
{
var context = new AuthContext(AuthPrincipal.Bot, credentialRef: " ");

context.CredentialRef.ShouldBeNull();
}

[Fact]
public void Constructor_RejectsBotPrincipalId()
{
Should.Throw<ArgumentException>(() => new AuthContext(
AuthPrincipal.Bot,
principalId: "bot-id"))
.ParamName.ShouldBe("principalId");
}

[Fact]
public void Constructor_RejectsMissingUserPrincipalId()
{
Should.Throw<ArgumentException>(() => new AuthContext(AuthPrincipal.User))
.ParamName.ShouldBe("principalId");
}

[Fact]
public void Constructor_RejectsMissingDelegatedAuditTarget()
{
Should.Throw<ArgumentException>(() => new AuthContext(
AuthPrincipal.OnBehalfOfUser,
principalId: "workflow-runner"))
.ParamName.ShouldBe("onBehalfOfUserId");
}
}
Loading