From 8d84ed2bb61944fc7c837630a2585dfcf1a98d67 Mon Sep 17 00:00:00 2001
From: Nene7ko_ <1604009816@qq.com>
Date: Tue, 11 Aug 2026 23:49:07 +0800
Subject: [PATCH 1/3] docs: propose layered error ownership
---
.../decisions/0020-layered-error-ownership.md | 39 +++++++++++++++++++
1 file changed, 39 insertions(+)
create mode 100644 docs/decisions/0020-layered-error-ownership.md
diff --git a/docs/decisions/0020-layered-error-ownership.md b/docs/decisions/0020-layered-error-ownership.md
new file mode 100644
index 0000000..fec9b40
--- /dev/null
+++ b/docs/decisions/0020-layered-error-ownership.md
@@ -0,0 +1,39 @@
+# ADR 0020: Layered Error Ownership
+
+- Status: Proposed for issue #116
+- Date: 2026-08-11
+- Decision owner: Platform Architecture
+- Related: `nekiro-sdk-go#3`, `nekiro-sdk-go#4`, `NeKiro-Samples#11`, and
+ `NeKiro-Samples#12`
+
+## Context
+
+Core domain packages, cross-process contracts, and Agent Runtime lifecycle
+code need different error semantics. Treating them as one global taxonomy
+would couple independent owners and would make dependency details easier to
+leak across trust boundaries.
+
+The Runtime lifecycle also repeats setup, serving, lease observation, and
+shutdown handling in each Sample. The SDK needs to provide that lifecycle
+mechanism without taking ownership of Runtime-specific configuration,
+handlers, transport security, or policy.
+
+## Proposed Direction
+
+This ADR will define:
+
+1. owner-local typed errors for Core domain packages;
+2. stable wire semantics through versioned contracts;
+3. explicit error translation at process and protocol boundaries;
+4. safe Runtime lifecycle stages owned by `nekiro-sdk-go/agent/host`; and
+5. the rejection of a global catch mechanism or flattened Core error
+ taxonomy.
+
+The final decision and consequences will be completed on the draft pull
+request before it is marked ready for review.
+
+## Fallback Delta
+
+Fallback delta: removed 0, retained 0, added 0, net +0.
+
+Added fallback evidence: none.
From f6c5219f13c2b91f4a1e8d3c48f32c9e12aa80c1 Mon Sep 17 00:00:00 2001
From: Nene7ko_ <1604009816@qq.com>
Date: Tue, 11 Aug 2026 23:59:36 +0800
Subject: [PATCH 2/3] docs: clarify layered errors and quickstart
---
README.md | 390 ++++++++----------
README.zh-CN.md | 376 ++++++++---------
.../decisions/0020-layered-error-ownership.md | 89 +++-
3 files changed, 422 insertions(+), 433 deletions(-)
diff --git a/README.md b/README.md
index cee630e..30012e4 100644
--- a/README.md
+++ b/README.md
@@ -155,60 +155,111 @@ Neither runtime receives the other's direct target address.
## Quick start
-This example runs the real **Runtime B -> Router -> Runtime A** path. Runtime A
-first publishes a lease to Nacos. Router observes the instance through a
-snapshot and continuous watch. Runtime B then calls A by Agent ID and
-capability; it never receives A's address.
+### 1. Run the real product path
+
+Start here if you want to see the complete **Register -> Discover -> Install ->
+Invoke -> Record** loop. The immutable
+[NeKiro-Stack](https://github.com/NeKiro-project/NeKiro-Stack) prepares exact
+Core, SDK, Samples, and transport revisions, starts PostgreSQL and secured
+Nacos, and runs the backend acceptance. Git, Go 1.26+, Docker, Bash (Git Bash
+or WSL also works on Windows), and network access are required.
+
+```bash
+git clone https://github.com/NeKiro-project/NeKiro-Stack.git
+cd NeKiro-Stack
+
+work_root=$(mktemp -d)
+backend_env="$work_root/backend.env"
+prepared_env="$work_root/prepared.env"
+
+./scripts/write-ci-env.sh backend "$backend_env" "$(pwd)" nekiro-quickstart
+set -a
+source "$backend_env"
+set +a
+
+./scripts/prepare.sh "$(pwd)/components.json" "$work_root/checkouts" "$prepared_env"
+set -a
+source "$prepared_env"
+set +a
+
+go run ./cmd/nacos-secure-fixture generate "$NEKIRO_E2E_TLS_ROOT"
+docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \
+ --file compose.yaml \
+ --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \
+ --profile router-nacos-secure \
+ up --detach --wait --wait-timeout 120
+go test -tags=e2e -run '^TestInvokeToRecordAcceptance$' -count=1 ./tests/backend
+
+docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \
+ --file compose.yaml \
+ --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \
+ --profile router-nacos-secure \
+ --profile runtime-registration \
+ --profile watch-refresh \
+ down --volumes --remove-orphans
+```
+
+The final test proves all of the following in one run:
+
+| Check | Evidence |
+| --- | --- |
+| Register | Runtime A publishes a ready, exact-Release instance lease to Nacos. |
+| Discover | Router reads the initial snapshot and watches the instance lifecycle. |
+| Invoke | Runtime B reaches A by Agent ID and capability through Router only. |
+| Nested invoke | A -> B and B -> A both re-enter Router; neither runtime receives the other's address. |
+| Record | Ledger contains correlated parent/child metadata with `root_task_id` and `trace_id`. |
+| Recovery | Removal fails closed and a replacement instance becomes routable. |
+
+### 2. See the call topology
```text
Runtime A --lease--> Nacos --snapshot/watch--> Router
Runtime B --Agent ID + capability--> Router --> Runtime A
|
+--> Invocation Ledger
+
+Runtime A <---- managed A2A ----> Router <---- managed A2A ----> Runtime B
```
-The production code is in
-[NeKiro-Samples](https://github.com/NeKiro-project/NeKiro-Samples). The two
-complete `package main` programs below run inside that module. They use the
-Core [`registry`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry)
-and [`registry/nacos`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry/nacos)
-packages through a thin Samples adapter:
+Consumers never resolve or dial a Provider address. Core owns the registration,
+heartbeat, lease, and deregistration semantics through
+[`registry`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry) and
+[`registry/nacos`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry/nacos).
+The Samples adapter only maps explicit `RUNTIME_A_*` / `RUNTIME_B_*` settings,
+builds the secured HTTP transport, and connects lease readiness to the process
+lifecycle. The endpoint ownership challenge is a separate trusted-publication
+check. Since the examples use Samples `internal` packages, copy the pattern
+into another Agent module instead of importing those packages directly.
-```text
-Runtime main
- -> Samples environment + TLS/mTLS adapter
- -> Core registry models + registry/nacos Registrar
- -> Nacos
-```
+The complete production sources are [Runtime A main](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-a/cmd/runtime-a/main.go),
+[Runtime B main](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/cmd/runtime-b/main.go),
+and [B -> A nested invocation](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/nested.go).
-Core owns the registration, heartbeat, lease, and deregistration semantics.
-The Samples adapter only maps explicit `RUNTIME_A_*` / `RUNTIME_B_*`
-deployment settings, builds the secured HTTP transport, and connects the lease
-to process readiness and shutdown. The endpoint ownership challenge shown in
-the programs is a separate trusted-publication check, not part of instance
-registration. Because the programs import Samples `internal` packages, copy
-their integration pattern into another Agent module rather than importing
-those packages directly.
+### 3. Copy the complete Runtime mains
-Runtime A starts its managed A2A endpoint, registers an exact instance, keeps
-the lease alive, and deregisters during shutdown:
+The programs below are complete `package main` entrypoints. They keep Runtime
+configuration, Router authentication, TLS/mTLS, challenge proof, and handlers
+in Samples, while [`agent/host`](https://pkg.go.dev/github.com/NeKiro-project/nekiro-sdk-go/agent/host)
+owns serving, lease observation, bounded shutdown, and deregistration.
+
+
+Runtime A: register, serve, watch the lease, and deregister
```go
package main
import (
"context"
- "errors"
"log"
"net/http"
"os"
- "os/signal"
"syscall"
"time"
"github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof"
"github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration"
runtimea "github.com/NeKiro-project/NeKiro-Samples/runtime-a"
+ agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host"
)
func main() {
@@ -218,104 +269,82 @@ func main() {
}
func run() error {
- config, err := runtimea.LoadConfig(os.LookupEnv)
- if err != nil {
- return err
- }
- registrationConfig, err := nacosregistration.Load(
- os.LookupEnv, "RUNTIME_A", config.AgentID, config.InstanceID,
- )
+ return runWithLookup(os.LookupEnv)
+}
+
+func runWithLookup(lookup func(string) (string, bool)) error {
+ config, err := runtimea.LoadConfig(lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime A configuration", err)
}
- registrationClient, err := nacosregistration.NewHTTPClient(registrationConfig)
+ registrationConfig, err := nacosregistration.Load(lookup, "RUNTIME_A", config.AgentID, config.InstanceID)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime A registration configuration", err)
}
- registration, err := nacosregistration.New(registrationConfig, registrationClient)
- if err != nil {
- return err
+ var registration agenthost.Registration
+ var readiness runtimea.Readiness = ready(true)
+ if registrationConfig.Mode == nacosregistration.ModeNacos {
+ registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig)
+ if clientErr != nil {
+ return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A Nacos transport", clientErr)
+ }
+ runtimeRegistration, err := nacosregistration.New(registrationConfig, registrationClient)
+ if err != nil {
+ return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A registration", err)
+ }
+ registration = runtimeRegistration
+ readiness = runtimeRegistration
}
-
handler, err := runtimea.NewHandler(config, http.DefaultClient)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "create Runtime A handler", err)
}
- application, err := challengeproof.NewHandler(
- runtimea.NewHTTPHandlerWithReadiness(handler, registration),
- os.LookupEnv,
- )
+ application, err := challengeproof.NewHandler(runtimea.NewHTTPHandlerWithReadiness(handler, readiness), lookup)
if err != nil {
- return err
- }
- if err := registration.Register(context.Background()); err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "configure Runtime A endpoint challenge", err)
}
-
- ctx, stop := signal.NotifyContext(
- context.Background(), os.Interrupt, syscall.SIGTERM,
- )
- defer stop()
- server := &http.Server{Addr: config.ListenAddress, Handler: application}
- serverErrors := make(chan error, 1)
- go func() {
- err := server.ListenAndServe()
- if errors.Is(err, http.ErrServerClosed) {
- err = nil
- }
- serverErrors <- err
- }()
- registrationErrors := make(chan error, 1)
- go func() { registrationErrors <- registration.Run(ctx) }()
-
- var runErr error
- registrationStopped := false
- select {
- case <-ctx.Done():
- case runErr = <-serverErrors:
- case runErr = <-registrationErrors:
- registrationStopped = true
+ shutdownTimeout := 5 * time.Second
+ if registrationConfig.RequestTimeout > 0 {
+ shutdownTimeout = registrationConfig.RequestTimeout
}
- stop()
-
- shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- shutdownErr := server.Shutdown(shutdownContext)
- if !registrationStopped {
- select {
- case registrationErr := <-registrationErrors:
- runErr = errors.Join(runErr, registrationErr)
- case <-shutdownContext.Done():
- runErr = errors.Join(runErr, shutdownContext.Err())
- }
+ runtimeHost, err := agenthost.New(agenthost.Config{
+ Address: config.ListenAddress,
+ Handler: application,
+ Registration: registration,
+ ShutdownTimeout: shutdownTimeout,
+ Signals: []os.Signal{os.Interrupt, syscall.SIGTERM},
+ })
+ if err != nil {
+ return err
}
- deregisterErr := registration.Deregister(shutdownContext)
- return errors.Join(runErr, shutdownErr, deregisterErr)
+ return runtimeHost.Run(context.Background())
}
+
+type ready bool
+
+func (value ready) Ready() bool { return bool(value) }
```
-B registers the same way, validates the Router-issued credential on its
-incoming A2A endpoint, and creates its handler with the Router URL and Agent
-token. When B receives the sample's `nested` request, `NewConfiguredHandler`
-uses the public Agent SDK to target A by Agent ID and capability. B never
-resolves or dials A itself.
+
+
+
+Runtime B: authenticate Router calls and invoke A by capability
```go
package main
import (
"context"
- "errors"
"log"
"net/http"
"os"
- "os/signal"
"syscall"
"time"
"github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof"
"github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration"
runtimeb "github.com/NeKiro-project/NeKiro-Samples/runtime-b"
+ agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host"
"github.com/NeKiro-project/nekiro-sdk-go/agent/routerauth"
)
@@ -326,147 +355,80 @@ func main() {
}
func run() error {
- address, err := runtimeb.ListenAddressFromEnvironment(os.LookupEnv)
- if err != nil {
- return err
- }
- config, err := runtimeb.LoadConfig(os.LookupEnv)
+ return runWithLookup(os.LookupEnv)
+}
+
+func runWithLookup(lookup func(string) (string, bool)) error {
+ address, err := runtimeb.ListenAddressFromEnvironment(lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime B listen address", err)
}
- authenticationConfig, err := routerauth.LoadConfig(os.LookupEnv)
+ authenticationConfig, err := routerauth.LoadConfig(lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime B authentication configuration", err)
}
- registrationConfig, err := runtimeb.LoadRegistrationConfig(
- os.LookupEnv, config.AgentID, config.InstanceID,
- )
+ config, err := runtimeb.LoadConfig(lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime B configuration", err)
}
- registrationClient, err := nacosregistration.NewHTTPClient(registrationConfig)
+ registrationConfig, err := runtimeb.LoadRegistrationConfig(lookup, config.AgentID, config.InstanceID)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime B registration configuration", err)
}
- registration, err := runtimeb.NewNacosRegistration(
- registrationConfig, registrationClient,
- )
- if err != nil {
- return err
+ var registration agenthost.Registration
+ var readiness runtimeb.Readiness = ready(true)
+ if registrationConfig.Mode == runtimeb.RegistrationModeNacos {
+ registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig)
+ if clientErr != nil {
+ return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B Nacos transport", clientErr)
+ }
+ runtimeRegistration, err := runtimeb.NewNacosRegistration(registrationConfig, registrationClient)
+ if err != nil {
+ return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B registration", err)
+ }
+ registration = runtimeRegistration
+ readiness = runtimeRegistration
}
-
handler, err := runtimeb.NewConfiguredHandler(config, http.DefaultClient)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "create Runtime B handler", err)
}
- execution, err := runtimeb.NewHTTPHandlerWithAuthAndReadiness(
- handler, authenticationConfig, registration,
- )
+ execution, err := runtimeb.NewHTTPHandlerWithAuthAndReadiness(handler, authenticationConfig, readiness)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "configure Runtime B authentication", err)
}
- application, err := challengeproof.NewHandler(execution, os.LookupEnv)
+ application, err := challengeproof.NewHandler(execution, lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "configure Runtime B endpoint challenge", err)
}
- if err := registration.Register(context.Background()); err != nil {
- return err
- }
-
- ctx, stop := signal.NotifyContext(
- context.Background(), os.Interrupt, syscall.SIGTERM,
- )
- defer stop()
- server := &http.Server{Addr: address, Handler: application}
- serverErrors := make(chan error, 1)
- go func() {
- err := server.ListenAndServe()
- if errors.Is(err, http.ErrServerClosed) {
- err = nil
- }
- serverErrors <- err
- }()
- registrationErrors := make(chan error, 1)
- go func() { registrationErrors <- registration.Run(ctx) }()
-
- var runErr error
- registrationStopped := false
- select {
- case <-ctx.Done():
- case runErr = <-serverErrors:
- case runErr = <-registrationErrors:
- registrationStopped = true
+ shutdownTimeout := 5 * time.Second
+ if registrationConfig.RequestTimeout > 0 {
+ shutdownTimeout = registrationConfig.RequestTimeout
}
- stop()
-
- shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- shutdownErr := server.Shutdown(shutdownContext)
- if !registrationStopped {
- select {
- case registrationErr := <-registrationErrors:
- runErr = errors.Join(runErr, registrationErr)
- case <-shutdownContext.Done():
- runErr = errors.Join(runErr, shutdownContext.Err())
- }
+ runtimeHost, err := agenthost.New(agenthost.Config{
+ Address: address,
+ Handler: application,
+ Registration: registration,
+ ShutdownTimeout: shutdownTimeout,
+ Signals: []os.Signal{os.Interrupt, syscall.SIGTERM},
+ })
+ if err != nil {
+ return err
}
- deregisterErr := registration.Deregister(shutdownContext)
- return errors.Join(runErr, shutdownErr, deregisterErr)
+ return runtimeHost.Run(context.Background())
}
-```
-
-The handler extracts `PlatformContext` only after `routerauth` verifies the
-Router credential, then calls `agentsdk.Client.Invoke` with
-`TargetAgentID: "runtime-a"`. See the complete
-[B -> A invocation implementation](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/nested.go).
-
-### Run the complete scenario
-
-The immutable [NeKiro-Stack](https://github.com/NeKiro-project/NeKiro-Stack)
-supplies the parts intentionally omitted above: trusted Card/Release
-publication, Workspace installation and permissions, Router credentials,
-PostgreSQL, secured Nacos, exact component revisions, and Ledger assertions.
-The following commands require Git, Go 1.26+, Docker, Bash, and network access:
-
-```bash
-git clone https://github.com/NeKiro-project/NeKiro-Stack.git
-cd NeKiro-Stack
-
-work_root=$(mktemp -d)
-backend_env="$work_root/backend.env"
-prepared_env="$work_root/prepared.env"
-
-./scripts/write-ci-env.sh backend "$backend_env" "$(pwd)" nekiro-quickstart
-set -a
-source "$backend_env"
-set +a
-
-./scripts/prepare.sh "$(pwd)/components.json" "$work_root/checkouts" "$prepared_env"
-set -a
-source "$prepared_env"
-set +a
-go run ./cmd/nacos-secure-fixture generate "$NEKIRO_E2E_TLS_ROOT"
-docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \
- --file compose.yaml \
- --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \
- --profile router-nacos-secure \
- up --detach --wait --wait-timeout 120
-go test -tags=e2e -run '^TestInvokeToRecordAcceptance$' -count=1 ./tests/backend
+type ready bool
-docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \
- --file compose.yaml \
- --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \
- --profile router-nacos-secure \
- --profile runtime-registration \
- --profile watch-refresh \
- down --volumes --remove-orphans
+func (value ready) Ready() bool { return bool(value) }
```
-A passing acceptance proves that A registered, Router discovered its exact
-Release-scoped instance, B reached A only through Router, and the parent/child
-records share `root_task_id` and `trace_id` in Ledger. It also exercises A -> B,
-instance removal, fail-closed routing, and replacement recovery.
+
+
+B verifies the Router-issued credential before extracting `PlatformContext`.
+The `nested` handler then calls the public Agent SDK with
+`TargetAgentID: "runtime-a"`; it never reads a Nacos endpoint or dials A
+directly.
### Develop Core
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 63d87de..8f4ee80 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -135,54 +135,109 @@ Runtime B -> Router -> Runtime A
## 快速开始
-下面的例子会运行真实的 **Runtime B -> Router -> Runtime A** 链路。Runtime A
-先向 Nacos 发布租约,Router 通过初始 snapshot 和持续 watch 观察到该实例。Runtime B
-只使用 Agent ID 和 capability 调用 A,不会获得 A 的地址。
+### 1. 先运行真实产品链路
+
+如果你想先看到 NeKiro 跑起来,从这里开始。固定精确组件版本的
+[NeKiro-Stack](https://github.com/NeKiro-project/NeKiro-Stack) 会准备 Core、SDK、
+Samples 和 transport,启动 PostgreSQL 与安全 Nacos,并验证完整的 **Register ->
+Discover -> Install -> Invoke -> Record** 闭环。需要 Git、Go 1.26+、Docker、Bash
+(Windows 可以使用 Git Bash 或 WSL)和网络访问。
+
+```bash
+git clone https://github.com/NeKiro-project/NeKiro-Stack.git
+cd NeKiro-Stack
+
+work_root=$(mktemp -d)
+backend_env="$work_root/backend.env"
+prepared_env="$work_root/prepared.env"
+
+./scripts/write-ci-env.sh backend "$backend_env" "$(pwd)" nekiro-quickstart
+set -a
+source "$backend_env"
+set +a
+
+./scripts/prepare.sh "$(pwd)/components.json" "$work_root/checkouts" "$prepared_env"
+set -a
+source "$prepared_env"
+set +a
+
+go run ./cmd/nacos-secure-fixture generate "$NEKIRO_E2E_TLS_ROOT"
+docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \
+ --file compose.yaml \
+ --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \
+ --profile router-nacos-secure \
+ up --detach --wait --wait-timeout 120
+go test -tags=e2e -run '^TestInvokeToRecordAcceptance$' -count=1 ./tests/backend
+
+docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \
+ --file compose.yaml \
+ --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \
+ --profile router-nacos-secure \
+ --profile runtime-registration \
+ --profile watch-refresh \
+ down --volumes --remove-orphans
+```
+
+最后一条测试会一次性证明:
+
+| 检查 | 验收证据 |
+| --- | --- |
+| Register | Runtime A 向 Nacos 发布 ready、精确 Release 的实例 lease。 |
+| Discover | Router 读取 initial snapshot,并持续 watch 实例生命周期。 |
+| Invoke | Runtime B 只通过 Router,按 Agent ID 和 capability 到达 A。 |
+| Nested invoke | A -> B 与 B -> A 都会再次进入 Router;双方都拿不到对方地址。 |
+| Record | Ledger 中父子 metadata 共享 `root_task_id` 与 `trace_id`。 |
+| Recovery | 实例移除后路由失败关闭,替换实例上线后恢复。 |
+
+### 2. 看懂调用拓扑
```text
Runtime A --lease--> Nacos --snapshot/watch--> Router
Runtime B --Agent ID + capability--> Router --> Runtime A
|
+--> Invocation Ledger
+
+Runtime A <---- managed A2A ----> Router <---- managed A2A ----> Runtime B
```
-生产实现位于 [NeKiro-Samples](https://github.com/NeKiro-project/NeKiro-Samples)。
-下面两个完整的 `package main` 程序需要放在该模块内运行。它们通过一层很薄的
-Samples adapter 使用 Core 的
+Consumer 不会自行解析或直连 Provider 地址。Core 通过
[`registry`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry) 和
-[`registry/nacos`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry/nacos):
+[`registry/nacos`](https://pkg.go.dev/github.com/NeKiro-project/NeKiro/registry/nacos)
+拥有注册、heartbeat、lease 与 deregistration 语义。Samples adapter 只映射显式的
+`RUNTIME_A_*` / `RUNTIME_B_*` 配置,构造安全 HTTP transport,并把 lease readiness
+接入进程生命周期。Endpoint ownership challenge 是另一条可信发布校验。由于示例
+使用 Samples `internal` package,其他 Agent 模块应复制这种接入模式,而不能直接
+import 这些包。
-```text
-Runtime main
- -> Samples 环境变量 + TLS/mTLS adapter
- -> Core registry model + registry/nacos Registrar
- -> Nacos
-```
+完整生产源码见 [Runtime A main](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-a/cmd/runtime-a/main.go)、
+[Runtime B main](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/cmd/runtime-b/main.go)
+和 [B -> A 嵌套调用](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/nested.go)。
-注册、heartbeat、lease 和 deregister 语义都由 Core 负责。Samples adapter 只把
-显式的 `RUNTIME_A_*` / `RUNTIME_B_*` 部署配置映射为 Core model,构造安全 HTTP
-transport,并将 lease 接入进程 readiness 与 shutdown。程序中的 endpoint ownership
-challenge 是一条独立的可信发布校验,不属于实例注册。由于程序引用了 Samples 的
-`internal` package,其他 Agent 模块应复用这种接入模式,而不能直接 import 这些包。
+### 3. 复制完整 Runtime main
-Runtime A 启动托管 A2A endpoint,注册精确实例,持续维护 lease,并在退出时注销:
+下面是完整的 `package main` 入口。Runtime 配置、Router 鉴权、TLS/mTLS、challenge
+proof 和 handler 仍由 Samples 拥有;
+[`agent/host`](https://pkg.go.dev/github.com/NeKiro-project/nekiro-sdk-go/agent/host)
+统一负责 HTTP serving、lease observation、有界 shutdown 和 deregistration。
+
+
+Runtime A:注册、启动、观察 lease,并在退出时注销
```go
package main
import (
"context"
- "errors"
"log"
"net/http"
"os"
- "os/signal"
"syscall"
"time"
"github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof"
"github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration"
runtimea "github.com/NeKiro-project/NeKiro-Samples/runtime-a"
+ agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host"
)
func main() {
@@ -192,103 +247,82 @@ func main() {
}
func run() error {
- config, err := runtimea.LoadConfig(os.LookupEnv)
- if err != nil {
- return err
- }
- registrationConfig, err := nacosregistration.Load(
- os.LookupEnv, "RUNTIME_A", config.AgentID, config.InstanceID,
- )
+ return runWithLookup(os.LookupEnv)
+}
+
+func runWithLookup(lookup func(string) (string, bool)) error {
+ config, err := runtimea.LoadConfig(lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime A configuration", err)
}
- registrationClient, err := nacosregistration.NewHTTPClient(registrationConfig)
+ registrationConfig, err := nacosregistration.Load(lookup, "RUNTIME_A", config.AgentID, config.InstanceID)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime A registration configuration", err)
}
- registration, err := nacosregistration.New(registrationConfig, registrationClient)
- if err != nil {
- return err
+ var registration agenthost.Registration
+ var readiness runtimea.Readiness = ready(true)
+ if registrationConfig.Mode == nacosregistration.ModeNacos {
+ registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig)
+ if clientErr != nil {
+ return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A Nacos transport", clientErr)
+ }
+ runtimeRegistration, err := nacosregistration.New(registrationConfig, registrationClient)
+ if err != nil {
+ return agenthost.Wrap(agenthost.StageRegistration, "create Runtime A registration", err)
+ }
+ registration = runtimeRegistration
+ readiness = runtimeRegistration
}
-
handler, err := runtimea.NewHandler(config, http.DefaultClient)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "create Runtime A handler", err)
}
- application, err := challengeproof.NewHandler(
- runtimea.NewHTTPHandlerWithReadiness(handler, registration),
- os.LookupEnv,
- )
+ application, err := challengeproof.NewHandler(runtimea.NewHTTPHandlerWithReadiness(handler, readiness), lookup)
if err != nil {
- return err
- }
- if err := registration.Register(context.Background()); err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "configure Runtime A endpoint challenge", err)
}
-
- ctx, stop := signal.NotifyContext(
- context.Background(), os.Interrupt, syscall.SIGTERM,
- )
- defer stop()
- server := &http.Server{Addr: config.ListenAddress, Handler: application}
- serverErrors := make(chan error, 1)
- go func() {
- err := server.ListenAndServe()
- if errors.Is(err, http.ErrServerClosed) {
- err = nil
- }
- serverErrors <- err
- }()
- registrationErrors := make(chan error, 1)
- go func() { registrationErrors <- registration.Run(ctx) }()
-
- var runErr error
- registrationStopped := false
- select {
- case <-ctx.Done():
- case runErr = <-serverErrors:
- case runErr = <-registrationErrors:
- registrationStopped = true
+ shutdownTimeout := 5 * time.Second
+ if registrationConfig.RequestTimeout > 0 {
+ shutdownTimeout = registrationConfig.RequestTimeout
}
- stop()
-
- shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- shutdownErr := server.Shutdown(shutdownContext)
- if !registrationStopped {
- select {
- case registrationErr := <-registrationErrors:
- runErr = errors.Join(runErr, registrationErr)
- case <-shutdownContext.Done():
- runErr = errors.Join(runErr, shutdownContext.Err())
- }
+ runtimeHost, err := agenthost.New(agenthost.Config{
+ Address: config.ListenAddress,
+ Handler: application,
+ Registration: registration,
+ ShutdownTimeout: shutdownTimeout,
+ Signals: []os.Signal{os.Interrupt, syscall.SIGTERM},
+ })
+ if err != nil {
+ return err
}
- deregisterErr := registration.Deregister(shutdownContext)
- return errors.Join(runErr, shutdownErr, deregisterErr)
+ return runtimeHost.Run(context.Background())
}
+
+type ready bool
+
+func (value ready) Ready() bool { return bool(value) }
```
-B 使用同样的方式注册,在入站 A2A endpoint 校验 Router 签发的凭证,并使用 Router
-URL 和 Agent token 创建 handler。B 收到 Sample 的 `nested` 请求时,
-`NewConfiguredHandler` 使用公共 Agent SDK 按 Agent ID 与 capability 调用 A;B
-不会自行解析或直连 A。
+
+
+
+Runtime B:校验 Router 调用,并按 capability 调用 A
```go
package main
import (
"context"
- "errors"
"log"
"net/http"
"os"
- "os/signal"
"syscall"
"time"
"github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof"
"github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration"
runtimeb "github.com/NeKiro-project/NeKiro-Samples/runtime-b"
+ agenthost "github.com/NeKiro-project/nekiro-sdk-go/agent/host"
"github.com/NeKiro-project/nekiro-sdk-go/agent/routerauth"
)
@@ -299,145 +333,79 @@ func main() {
}
func run() error {
- address, err := runtimeb.ListenAddressFromEnvironment(os.LookupEnv)
- if err != nil {
- return err
- }
- config, err := runtimeb.LoadConfig(os.LookupEnv)
+ return runWithLookup(os.LookupEnv)
+}
+
+func runWithLookup(lookup func(string) (string, bool)) error {
+ address, err := runtimeb.ListenAddressFromEnvironment(lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime B listen address", err)
}
- authenticationConfig, err := routerauth.LoadConfig(os.LookupEnv)
+ authenticationConfig, err := routerauth.LoadConfig(lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime B authentication configuration", err)
}
- registrationConfig, err := runtimeb.LoadRegistrationConfig(
- os.LookupEnv, config.AgentID, config.InstanceID,
- )
+ config, err := runtimeb.LoadConfig(lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime B configuration", err)
}
- registrationClient, err := nacosregistration.NewHTTPClient(registrationConfig)
+ registrationConfig, err := runtimeb.LoadRegistrationConfig(lookup, config.AgentID, config.InstanceID)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageConfig, "load Runtime B registration configuration", err)
}
- registration, err := runtimeb.NewNacosRegistration(
- registrationConfig, registrationClient,
- )
- if err != nil {
- return err
+ var registration agenthost.Registration
+ var readiness runtimeb.Readiness = ready(true)
+ if registrationConfig.Mode == runtimeb.RegistrationModeNacos {
+ registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig)
+ if clientErr != nil {
+ return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B Nacos transport", clientErr)
+ }
+ runtimeRegistration, err := runtimeb.NewNacosRegistration(registrationConfig, registrationClient)
+ if err != nil {
+ return agenthost.Wrap(agenthost.StageRegistration, "create Runtime B registration", err)
+ }
+ registration = runtimeRegistration
+ readiness = runtimeRegistration
}
-
handler, err := runtimeb.NewConfiguredHandler(config, http.DefaultClient)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "create Runtime B handler", err)
}
- execution, err := runtimeb.NewHTTPHandlerWithAuthAndReadiness(
- handler, authenticationConfig, registration,
- )
+ execution, err := runtimeb.NewHTTPHandlerWithAuthAndReadiness(handler, authenticationConfig, readiness)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "configure Runtime B authentication", err)
}
- application, err := challengeproof.NewHandler(execution, os.LookupEnv)
+ application, err := challengeproof.NewHandler(execution, lookup)
if err != nil {
- return err
+ return agenthost.Wrap(agenthost.StageHandler, "configure Runtime B endpoint challenge", err)
}
- if err := registration.Register(context.Background()); err != nil {
- return err
- }
-
- ctx, stop := signal.NotifyContext(
- context.Background(), os.Interrupt, syscall.SIGTERM,
- )
- defer stop()
- server := &http.Server{Addr: address, Handler: application}
- serverErrors := make(chan error, 1)
- go func() {
- err := server.ListenAndServe()
- if errors.Is(err, http.ErrServerClosed) {
- err = nil
- }
- serverErrors <- err
- }()
- registrationErrors := make(chan error, 1)
- go func() { registrationErrors <- registration.Run(ctx) }()
-
- var runErr error
- registrationStopped := false
- select {
- case <-ctx.Done():
- case runErr = <-serverErrors:
- case runErr = <-registrationErrors:
- registrationStopped = true
+ shutdownTimeout := 5 * time.Second
+ if registrationConfig.RequestTimeout > 0 {
+ shutdownTimeout = registrationConfig.RequestTimeout
}
- stop()
-
- shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- shutdownErr := server.Shutdown(shutdownContext)
- if !registrationStopped {
- select {
- case registrationErr := <-registrationErrors:
- runErr = errors.Join(runErr, registrationErr)
- case <-shutdownContext.Done():
- runErr = errors.Join(runErr, shutdownContext.Err())
- }
+ runtimeHost, err := agenthost.New(agenthost.Config{
+ Address: address,
+ Handler: application,
+ Registration: registration,
+ ShutdownTimeout: shutdownTimeout,
+ Signals: []os.Signal{os.Interrupt, syscall.SIGTERM},
+ })
+ if err != nil {
+ return err
}
- deregisterErr := registration.Deregister(shutdownContext)
- return errors.Join(runErr, shutdownErr, deregisterErr)
+ return runtimeHost.Run(context.Background())
}
-```
-
-Handler 只会在 `routerauth` 验证 Router 凭证后提取 `PlatformContext`,再通过
-`agentsdk.Client.Invoke` 和 `TargetAgentID: "runtime-a"` 发起调用。完整实现见
-[B -> A 调用代码](https://github.com/NeKiro-project/NeKiro-Samples/blob/main/runtime-b/nested.go)。
-
-### 运行完整场景
-
-固定全部组件 revision 的
-[NeKiro-Stack](https://github.com/NeKiro-project/NeKiro-Stack) 会补齐上面刻意省略的
-可信 Card/Release 发布、Workspace 安装与权限、Router 凭证、PostgreSQL、安全
-Nacos、精确组件版本和 Ledger 断言。以下命令需要 Git、Go 1.26+、Docker、Bash
-和网络访问:
-
-```bash
-git clone https://github.com/NeKiro-project/NeKiro-Stack.git
-cd NeKiro-Stack
-
-work_root=$(mktemp -d)
-backend_env="$work_root/backend.env"
-prepared_env="$work_root/prepared.env"
-
-./scripts/write-ci-env.sh backend "$backend_env" "$(pwd)" nekiro-quickstart
-set -a
-source "$backend_env"
-set +a
-
-./scripts/prepare.sh "$(pwd)/components.json" "$work_root/checkouts" "$prepared_env"
-set -a
-source "$prepared_env"
-set +a
-go run ./cmd/nacos-secure-fixture generate "$NEKIRO_E2E_TLS_ROOT"
-docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \
- --file compose.yaml \
- --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \
- --profile router-nacos-secure \
- up --detach --wait --wait-timeout 120
-go test -tags=e2e -run '^TestInvokeToRecordAcceptance$' -count=1 ./tests/backend
+type ready bool
-docker compose --project-name "$NEKIRO_E2E_COMPOSE_PROJECT" \
- --file compose.yaml \
- --file "$NEKIRO_E2E_COMPOSE_OVERRIDE_FILE" \
- --profile router-nacos-secure \
- --profile runtime-registration \
- --profile watch-refresh \
- down --volumes --remove-orphans
+func (value ready) Ready() bool { return bool(value) }
```
-验收通过表示:A 已注册,Router 发现了 A 对应精确 Release 的实例,B 只通过 Router
-到达 A,并且 Ledger 中父子记录共享 `root_task_id` 和 `trace_id`。同一套验收还会
-覆盖 A -> B、实例下线、失败关闭路由和替换实例后的恢复。
+
+
+B 会先校验 Router 签发的凭证,再提取 `PlatformContext`。`nested` handler 随后使用
+公共 Agent SDK 和 `TargetAgentID: "runtime-a"` 发起调用;它不会读取 Nacos endpoint
+或直接连接 A。
### 开发 Core
diff --git a/docs/decisions/0020-layered-error-ownership.md b/docs/decisions/0020-layered-error-ownership.md
index fec9b40..23ec932 100644
--- a/docs/decisions/0020-layered-error-ownership.md
+++ b/docs/decisions/0020-layered-error-ownership.md
@@ -10,27 +10,86 @@
Core domain packages, cross-process contracts, and Agent Runtime lifecycle
code need different error semantics. Treating them as one global taxonomy
-would couple independent owners and would make dependency details easier to
-leak across trust boundaries.
+would couple independent owners and make dependency details easier to leak
+across trust boundaries.
+
+This is the same useful separation visible in Dubbo-Go: a protocol package owns
+wire error codes and translation, while individual subsystems classify their
+own failures. A caller can handle a stable boundary result without importing
+every implementation package or parsing provider text.
The Runtime lifecycle also repeats setup, serving, lease observation, and
-shutdown handling in each Sample. The SDK needs to provide that lifecycle
-mechanism without taking ownership of Runtime-specific configuration,
-handlers, transport security, or policy.
+shutdown handling in each Sample. The SDK needs to provide that mechanism
+without taking ownership of Runtime-specific configuration, handlers,
+transport security, or policy.
+
+## Decision
+
+Errors remain layered by ownership. There is no global Core `errors` package,
+catch mechanism, or flattened taxonomy.
+
+| Owner | Owns | Boundary rule |
+| --- | --- | --- |
+| `config_center` | Provider-neutral source outcomes such as missing, invalid, unauthorized, unavailable, cancellation, and revision/watch failures. | Providers map their dependency responses into these classifications without exposing provider payloads. |
+| `registry` | Agent Card, Release, registration, lease, and watch invariants. | Registry callers use `errors.Is/As` for local decisions; they do not infer meaning from error strings. |
+| Control Plane and Router domains | Catalog, Workspace, Gateway, dispatch, authentication, resolution, transport, and Ledger failures. | Each owning package maps its dependencies explicitly before returning to another Core boundary. |
+| `contracts` | Stable cross-process `PlatformErrorCode` values and versioned error shapes. | HTTP, A2A, SSE, and Ledger adapters emit only the contract-defined code, message, phase, and correlation fields. |
+| `nekiro-sdk-go/agent/host` | Runtime lifecycle stages: `config`, `registration`, `handler`, `serve`, and `shutdown`. | Host errors expose the stage and static safe text while preserving the cause for `errors.Is/As`; Runtime packages own configuration and policy errors. |
+
+### Translation and safety rules
+
+1. A package may introduce a sentinel or typed error only for a fact it owns.
+ Wrapping preserves the owner error for local `errors.Is/As` checks.
+2. A process or protocol boundary maps the complete error chain once. It must
+ select a versioned contract code and safe message; it must not pass through
+ dependency response bodies, URLs, credentials, PEM/key bytes, request or
+ Agent payloads, SQL details, or arbitrary cause text.
+3. `PlatformErrorCode` is not a replacement for local errors. A local error
+ may map to one code in one boundary and a different code in another phase
+ when the contract requires it; the mapping is documented and tested at that
+ boundary.
+4. Cancellation, deadline, missing, invalid, unauthorized, unavailable,
+ dependency, and internal outcomes remain distinct. No empty result, retry,
+ alternate endpoint, stale snapshot, or old revision may hide one outcome as
+ another without explicit policy evidence.
+5. Logs and metrics may classify an error more coarsely, but that projection is
+ not a new public error contract and must use redacted fields.
+
+### Runtime host boundary
+
+`agent/host` starts the supplied HTTP server, registers and observes the
+optional lease, and performs bounded shutdown and deregistration. It does not
+load deployment configuration, construct a registry provider, validate Router
+credentials, or implement Agent behavior. Runtime setup wraps failures with a
+host stage; the host owns only lifecycle failures after setup.
+
+## Compatibility
+
+- Existing owner-local errors and `contracts.PlatformErrorCode` values remain
+ unchanged.
+- The SDK host is additive. Runtime implementations may adopt it without
+ changing the Agent Card, Release, Router, or Ledger contracts.
+- Adding a new public code, changing a code/message, or moving an error owner
+ requires a contract/ADR review and an explicit compatibility window.
-## Proposed Direction
+## Consequences
-This ADR will define:
+- Core packages remain independently testable and do not depend on a shared
+ implementation-only error taxonomy.
+- Boundary handlers have a single, reviewable place for redaction and wire
+ translation.
+- Runtime Samples share lifecycle behavior while retaining their own setup,
+ security, and handler ownership.
+- Operators can distinguish lifecycle stage and stable boundary code without
+ receiving secrets or dependency internals.
-1. owner-local typed errors for Core domain packages;
-2. stable wire semantics through versioned contracts;
-3. explicit error translation at process and protocol boundaries;
-4. safe Runtime lifecycle stages owned by `nekiro-sdk-go/agent/host`; and
-5. the rejection of a global catch mechanism or flattened Core error
- taxonomy.
+## Rejected alternatives
-The final decision and consequences will be completed on the draft pull
-request before it is marked ready for review.
+- A root-level `errors` package containing every Core failure.
+- A global `recover`/catch layer that turns all failures into one response.
+- Parsing `error.Error()` strings to decide authorization, availability, or
+ retry behavior.
+- Returning raw Nacos, HTTP, SQL, Router, or Agent error text to callers.
## Fallback Delta
From 134f37cb2be038055bc0457e8dfe1d7e84ff7151 Mon Sep 17 00:00:00 2001
From: Nene7ko_ <1604009816@qq.com>
Date: Wed, 12 Aug 2026 01:50:29 +0800
Subject: [PATCH 3/3] docs: accept layered error ownership
---
docs/decisions/0020-layered-error-ownership.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/decisions/0020-layered-error-ownership.md b/docs/decisions/0020-layered-error-ownership.md
index 23ec932..6a5e644 100644
--- a/docs/decisions/0020-layered-error-ownership.md
+++ b/docs/decisions/0020-layered-error-ownership.md
@@ -1,7 +1,7 @@
# ADR 0020: Layered Error Ownership
-- Status: Proposed for issue #116
-- Date: 2026-08-11
+- Status: Accepted for issue #116
+- Date: 2026-08-12
- Decision owner: Platform Architecture
- Related: `nekiro-sdk-go#3`, `nekiro-sdk-go#4`, `NeKiro-Samples#11`, and
`NeKiro-Samples#12`