Backend Profile 控制面与运行时边界¶
本设计承接 Tenant 与 Agent App/Revision 闭环,定义租户级数据后端选择、生命周期、 并发控制和一次执行快照。实现跟踪见 Issue #20。
目标与范围¶
Backend Profile 是租户级控制面根实体。它把一次 Agent 执行所需的数据能力绑定到明确的 provider 配置,使 Gateway/Worker 可以固定 Session、Memory、Knowledge、Artifact 和 Audit 后端,而不把 Secret 值或进程内客户端写入配置。
本阶段交付以下边界:
- 稳定 Profile 身份、租户内唯一 key、生命周期和乐观锁版本。
- 五种能力绑定以及严格的 provider/config 规范化契约。
- 显式以
tenant_id + profile_id为作用域的 InMemory Repository。 - 创建、完整配置替换、暂停、恢复和停用的结构化变更事件。
- 同时固定 Tenant/Profile 版本和配置摘要的不可变执行快照。
- 只含 Secret 引用的 Storage Factory 输入。
- PostgreSQL 目标 DDL、同租户默认引用和 tRPC-Agent-Go 装配映射。
本阶段不创建 Redis、SQL、向量库、对象存储或审计后端客户端,不引入 tRPC-Agent-Go 依赖, 不实现数据库 migration、Secret Manager、数据迁移、Gateway、Worker 或跨节点配置缓存。
核心模型¶
Profile 根实体¶
Profile 至少包含:
| 字段 | 约束 |
|---|---|
tenant_id |
可信租户隔离键;所有 Repository 调用都显式传入 |
profile_id |
服务端生成,格式为 bp_ 加 26 位 Crockford ULID;创建后不可变 |
profile_key |
trim、小写化后的 [a-z][a-z0-9-]{1,63};租户内唯一且不可变 |
display_name |
trim 后 1–200 字符 |
description |
最多 2000 字符 |
status |
active、suspended 或 disabled |
schema_version |
当前只接受 1;未知版本必须拒绝 |
bindings |
按 capability 排序的规范化完整配置;每种 capability 最多一个 |
content_digest |
规范化配置的 SHA-256 小写十六进制摘要 |
version |
从 1 开始,每次成功配置更新或状态变更递增一次 |
created_at / updated_at |
UTC;更新时保持创建时间不变 |
profile_id 和 profile_key 都不是全局授权边界。即使 ID 冲突概率可忽略,所有读取和修改仍
必须同时携带 Tenant ID;按 key 查询若后续增加,也必须显式限定 Tenant。
Capability Binding¶
第一版能力集合是封闭枚举:
| Capability | 语义 |
|---|---|
session |
对话事件、状态、摘要和会话生命周期 |
memory |
跨会话长期记忆 |
knowledge |
Knowledge/RAG 的索引与检索存储 |
artifact |
会话产物及其版本 |
audit |
强制审计事件的持久化出口;不等同于采样 trace |
每个绑定只保存以下控制面字段:
type CapabilityBinding struct {
Capability Capability
Provider string
Endpoint string
Options map[string]string
SecretRef string
}
切片而不是 map[Capability]Binding 是对外序列化边界;规范化后按 capability 固定排序。
重复 capability、未知 capability 或空 provider 均直接失败,不能使用“最后一个覆盖前一个”的
隐式语义。
Provider Schema Catalog¶
租户提交的 provider 名称不能自动变成可执行插件。本阶段定义由进程启动代码构造的、只读的 Provider Schema Catalog;Catalog 属于受信任平台代码,不属于租户配置。每条 schema 明确:
- provider 规范名以及允许绑定的 capability。
- endpoint 是禁止、可选还是必需。
secret_ref是禁止、可选还是必需。- 允许的 option key、类型/取值规则、默认值和规范化函数。
Repository 在创建和完整配置更新时必须通过同一个 Catalog 规范化绑定。未注册的 capability/provider 组合、未知 option、非法值或缺少必需字段都返回可识别的 invalid error。 Factory 只消费已经规范化的快照,不能再次接受任意 JSON 或忽略未知字段。
Catalog 允许后续适配器逐步注册 tRPC-Agent-Go 已支持的 provider,而不在本 Issue 假装已经 实现真实客户端。单元测试使用受控 schema 覆盖 endpoint、option 和 Secret 规则;真实 adapter 落地时必须同时增加对应 schema、Factory 映射与集成测试。
无密钥配置规则¶
endpoint 只表达网络位置,不是 DSN:
- trim 后的原始 UTF-8 序列最长 2048 字节;URI 解析、主机规范化和百分号转义后的最终序列化结果也最长 2048 字节。百分号转义可能扩展非 ASCII path,因此两个边界都会独立校验。原始文本和解析后的 path 都不允许控制字符。
- URI 不允许 userinfo、query 或 fragment,因而不能携带用户名、密码或签名参数。
- 第一版只接受单一 hostname authority;多节点/故障转移列表必须由后续 provider schema 以 结构化字段建模,不能塞入逗号分隔的 endpoint 字符串。
- provider schema 决定 endpoint 是否必需以及允许的 scheme。
options 使用 map[string]string,避免任意 JSON 类型穿过运行时边界。Catalog 只接受显式
allowlist;password、passwd、pwd、passphrase、token、api_key、secret、credential、dsn、
connection_string 等敏感 key 即使被 schema 错误声明也必须由公共校验拒绝。错误消息不能
回显 endpoint、option value 或 Secret 值。
secret_ref 是不透明引用,trim 后为 1–256 个安全字符。它可以参与摘要,以便 Secret 引用
切换产生新缓存键,但引用解析结果永远不能回写 Profile、事件、快照或 Factory 输入。真正的
Secret Resolver 在后续 Issue 中以可信 Tenant 作用域解析引用,并直接把结果交给具体 adapter。
规范化与内容摘要¶
完整配置写入前执行以下确定性步骤:
- 校验
schema_version = 1、字段长度和 capability 唯一性。 - 规范化 provider、endpoint、
secret_ref和所有 option。 - 由 Catalog 物化参与语义的默认 option。
- 按 capability 固定排序绑定,按 key 排序每个 options map。
- 使用带长度前缀的确定性编码计算 SHA-256。
摘要包含 schema version、所有 capability/provider、endpoint、规范化 options 和 Secret 引用; 不包含 Profile ID/key、展示字段、状态、version、时间、actor 或 reason。展示字段更新仍递增 Profile version,但只有后端语义改变才改变 digest。状态转换不改变 digest。
生命周期¶
创建完整 Session 配置
│
▼
active
│ ▲
暂停 │ │ 恢复(重新校验 Session)
▼ │
suspended
│
停用 │
▼
disabled
终态
- 创建时状态默认为
active;也可以显式创建suspended,但不能直接创建disabled。 - 任意非 disabled Profile 至少有一个有效 binding;active 必须包含
session。 - suspended 拒绝新快照,但允许完整配置替换;因此可以先补齐或切换 Session 后再恢复。
- active 的完整配置替换不能移除 Session。
- resume 必须重新校验完整配置以及 Session binding。
- disabled 是终态,拒绝配置更新、恢复和新执行;读取仍可用于管理和审计。
- 暂停或停用只影响后续快照,不篡改已经创建的执行快照。
Tenant 是第一道运行门禁,Backend Profile 是第二道门禁;只有二者都 active 才能创建新快照。
本阶段唯一合法的 Profile 选择源是 tenant.default_backend_profile_id。它为 NULL、指向非
active Profile 或与传入 Profile 不一致时,快照构造都必须失败,不得回退到平台默认、其他
Profile 或其他租户。未来若 Agent Revision 或 Channel Binding 支持显式覆盖,必须先定义新的
可信选择来源、同租户校验和优先级,再扩展快照构造器;不能把任意 Profile 参数当作授权。
Repository 与审计事件¶
目录沿用当前 trpcservice 责任域,不改变现有 Tenant/Agent 结构:
trpcservice/backend/
├── backend.go # 包说明、Profile、binding、Catalog 和领域校验
├── repository.go # 租户作用域 Repository、写入输入和事件
├── runtime.go # BackendExecutionSnapshot 与 Factory 输入
└── inmemory/
├── inmemory.go # 单进程 Repository
└── rwmutex.go # 可响应 Context 取消的锁边界
目标接口为:
type Repository interface {
Create(context.Context, CreateInput) (*Profile, ChangeEvent, error)
Get(context.Context, string, string) (*Profile, error)
UpdateConfiguration(
context.Context,
UpdateConfigurationInput,
) (*Profile, ChangeEvent, error)
TransitionStatus(
context.Context,
TransitionStatusInput,
) (*Profile, ChangeEvent, error)
}
Get 的两个字符串按顺序是 Tenant ID 和 Profile ID;写入输入结构体必须同时包含这两个字段
(Create 只由调用方提供 Tenant ID,Profile ID 由服务端生成)。Create、完整配置替换和状态
迁移都要求可信 ChangeMetadata,并与新状态一起返回事件:
| 字段 | 说明 |
|---|---|
event_type |
created、configuration_updated、suspended、resumed、disabled |
tenant_id / profile_id |
明确的租户和 Profile 作用域 |
previous_status / current_status |
创建事件的 previous status 为空 |
previous_digest / current_digest |
配置变更前后摘要;状态事件两者相同 |
actor_type / actor_id |
非空可信主体 |
reason |
trim 后 1–1000 字符 |
correlation_id |
非空管理请求关联 ID |
previous_version / next_version |
创建为 0 -> 1,其余必须为 n -> n+1 |
occurred_at |
UTC 发生时间 |
事件和 Profile 一起构造后才能提交内存状态。生产 Repository 必须在同一事务内写 Profile 和 Outbox;不能先提交配置再尽力记录事件。事件同样返回防御性副本。
乐观锁、隔离和 Context¶
- 创建唯一索引按
(tenant_id, profile_key)分区;跨租户同 key 合法。 - Get、Update、Transition 都以
(tenant_id, profile_id)定位,Tenant 不匹配表现为 not found, 不能泄露对象是否存在于其他租户。 - Update 和 Transition 必须匹配
expected_version;并发操作只有一个成功。 - 每次调用在入口、获得锁后、提交修改前检查
ctx.Err()。 - 可取消锁保证等待写锁时 Context 取消会及时返回;不能在锁内执行外部 I/O。
- Repository 保存和返回时深拷贝 binding slice、options map 以及所有可选指针。
InMemory 只用于单进程开发和测试,不提供持久化、跨进程一致性或 Secret 缓存。
不可变执行快照¶
BackendExecutionSnapshot 封闭保存 Tenant 和 Profile 的防御性副本。构造器接收既有的
tenant.ConfigurationSnapshot、Profile,以及接受该 Profile 时使用的同一个不可变 Catalog,
并校验:
- Tenant snapshot 有效且 Tenant active。
- Profile 自身全部不变量和 digest 有效。
- Tenant 的
default_backend_profile_id非空且等于 Profile ID。 - Profile 与 Tenant ID 相同且 Profile active。
- Profile 包含有效 Session binding。
快照访问器每次返回副本;零值、手工拼装或 Context 中损坏的快照不能通过校验。Context 注入 与读取都再次克隆,避免调用方在一次执行中观察到控制面热更新。
缓存键至少包含:
tenant_id + tenant_version + profile_id + profile_version + content_digest
StorageFactoryInput 是快照到后续运行时 adapter 的唯一输出,包含固定身份/version/digest 和
规范化 bindings。它不包含 Secret 值、数据库连接、HTTP client、tRPC-Agent-Go Service、
Factory 函数或任意 any 字段。其 SecretRef 仍只是引用;后续 Resolver 和 adapter 必须在
可信 Tenant 作用域内完成解析与客户端构造。
PostgreSQL 目标模型¶
以下 DDL 描述完整性和事务边界,本 Issue 不将其落成 migration。
-- Match Go strings.TrimSpace/unicode.IsSpace for every boundary field that
-- crosses between the SQL adapter and the Go Repository/runtime.
CREATE OR REPLACE FUNCTION public.trim_backend_profile_text(value TEXT)
RETURNS TEXT
LANGUAGE sql
IMMUTABLE
PARALLEL SAFE
AS $$
SELECT pg_catalog.btrim(
value,
U&'\0009\000A\000B\000C\000D\0020\0085\00A0\1680\2000\2001\2002\2003\2004\2005\2006\2007\2008\2009\200A\2028\2029\202F\205F\3000'
)
$$;
CREATE TABLE backend_profile (
tenant_id TEXT NOT NULL REFERENCES tenant(tenant_id),
profile_id TEXT NOT NULL
CHECK (profile_id ~ '^bp_[0-7][0-9A-HJKMNP-TV-Z]{25}$'),
profile_key TEXT NOT NULL
CHECK (profile_key ~ '^[a-z][a-z0-9-]{1,63}$'),
display_name TEXT NOT NULL
CHECK (display_name = public.trim_backend_profile_text(display_name)
AND length(display_name) BETWEEN 1 AND 200),
description TEXT NOT NULL DEFAULT ''
CHECK (description = public.trim_backend_profile_text(description)
AND length(description) <= 2000),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'disabled')),
schema_version INT NOT NULL DEFAULT 1 CHECK (schema_version = 1),
content_digest TEXT NOT NULL CHECK (content_digest ~ '^[0-9a-f]{64}$'),
version BIGINT NOT NULL DEFAULT 1 CHECK (version >= 1),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, profile_id),
UNIQUE (tenant_id, profile_key)
);
CREATE TABLE backend_profile_binding (
tenant_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
capability TEXT NOT NULL
CHECK (capability IN ('session', 'memory', 'knowledge', 'artifact', 'audit')),
provider TEXT NOT NULL CHECK (provider ~ '^[a-z][a-z0-9_-]{0,63}$'),
endpoint TEXT NOT NULL DEFAULT '' CHECK (length(endpoint) <= 2048),
options JSONB NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(options) = 'object'),
secret_ref TEXT NOT NULL DEFAULT '' CHECK (length(secret_ref) <= 256),
PRIMARY KEY (tenant_id, profile_id, capability),
FOREIGN KEY (tenant_id, profile_id)
REFERENCES backend_profile(tenant_id, profile_id)
ON DELETE RESTRICT
);
ALTER TABLE tenant
ADD CONSTRAINT fk_tenant_default_backend
FOREIGN KEY (tenant_id, default_backend_profile_id)
REFERENCES backend_profile(tenant_id, profile_id);
JSONB 只用于持久化已经由 Go Catalog 校验并规范化的字符串 map,不是开放扩展口。SQL Repository 必须严格解码,拒绝非字符串 value、未知 option 和未知 schema version。数据库 约束不能独自表达 provider-specific schema,因此写入权限只授予受控函数。
active 必须存在 Session binding 是跨表约束,应由延迟 constraint trigger 或同一事务中的受控 写函数在事务结束时验证;不能分别提交根表和 bindings。Profile identity 使用 trigger 禁止修改。 配置替换应锁定根行、验证 expected version、替换完整 binding 集合、重新计算 digest、递增一次 version,并在同一事务写 Outbox。
CREATE TABLE backend_profile_change_outbox (
event_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_type TEXT NOT NULL CHECK (event_type IN (
'created', 'configuration_updated',
'suspended', 'resumed', 'disabled'
)),
tenant_id TEXT NOT NULL,
profile_id TEXT NOT NULL,
previous_status TEXT CHECK (
previous_status IS NULL
OR previous_status IN ('active', 'suspended')
),
current_status TEXT NOT NULL
CHECK (current_status IN ('active', 'suspended', 'disabled')),
previous_digest TEXT,
current_digest TEXT NOT NULL CHECK (current_digest ~ '^[0-9a-f]{64}$'),
actor_type TEXT NOT NULL
CHECK (actor_type = public.trim_backend_profile_text(actor_type)
AND length(actor_type) > 0),
actor_id TEXT NOT NULL
CHECK (actor_id = public.trim_backend_profile_text(actor_id)
AND length(actor_id) > 0),
reason TEXT NOT NULL
CHECK (reason = public.trim_backend_profile_text(reason)
AND length(reason) BETWEEN 1 AND 1000),
correlation_id TEXT NOT NULL
CHECK (correlation_id = public.trim_backend_profile_text(correlation_id)
AND length(correlation_id) > 0),
previous_version BIGINT NOT NULL CHECK (previous_version >= 0),
next_version BIGINT NOT NULL CHECK (next_version = previous_version + 1),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (tenant_id, profile_id)
REFERENCES backend_profile(tenant_id, profile_id),
CHECK (
(event_type = 'created'
AND previous_status IS NULL
AND current_status IN ('active', 'suspended')
AND previous_digest IS NULL
AND previous_version = 0
AND next_version = 1)
OR
(event_type <> 'created'
AND previous_status IS NOT NULL
AND previous_digest IS NOT NULL
AND previous_digest ~ '^[0-9a-f]{64}$'
AND previous_version >= 1)
)
);
以下触发器封住不能只靠根表 CHECK 表达的跨表不变量。两条 constraint trigger 都是
deferred,使受控事务可以先替换 bindings 再提交根状态,但事务结束时所有非 disabled Profile
必须至少有一个 binding,且 active Profile 必须有 Session binding。
CREATE OR REPLACE FUNCTION backend_profile_reject_disabled_insert()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.status = 'disabled' THEN
RAISE EXCEPTION 'backend profile cannot be created disabled';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER backend_profile_no_disabled_insert
BEFORE INSERT ON backend_profile
FOR EACH ROW EXECUTE FUNCTION backend_profile_reject_disabled_insert();
CREATE OR REPLACE FUNCTION backend_profile_reject_identity_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
OR NEW.profile_id IS DISTINCT FROM OLD.profile_id
OR NEW.profile_key IS DISTINCT FROM OLD.profile_key THEN
RAISE EXCEPTION 'backend profile identity is immutable';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER backend_profile_identity_immutable
BEFORE UPDATE ON backend_profile
FOR EACH ROW EXECUTE FUNCTION backend_profile_reject_identity_change();
CREATE OR REPLACE FUNCTION backend_profile_binding_reject_identity_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
OR NEW.profile_id IS DISTINCT FROM OLD.profile_id
OR NEW.capability IS DISTINCT FROM OLD.capability THEN
RAISE EXCEPTION 'backend profile binding identity is immutable';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER backend_profile_binding_identity_immutable
BEFORE UPDATE ON backend_profile_binding
FOR EACH ROW EXECUTE FUNCTION backend_profile_binding_reject_identity_change();
CREATE OR REPLACE FUNCTION backend_profile_require_valid_bindings()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
v_tenant_id TEXT;
v_profile_id TEXT;
v_status TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
v_tenant_id := OLD.tenant_id;
v_profile_id := OLD.profile_id;
ELSE
v_tenant_id := NEW.tenant_id;
v_profile_id := NEW.profile_id;
END IF;
SELECT status INTO v_status
FROM backend_profile
WHERE tenant_id = v_tenant_id AND profile_id = v_profile_id;
-- A deleted/absent root needs no capability check. Profile deletion is not
-- exposed by this lifecycle, but this keeps the trigger total.
IF NOT FOUND THEN
RETURN NULL;
END IF;
IF v_status <> 'disabled' AND NOT EXISTS (
SELECT 1
FROM backend_profile_binding
WHERE tenant_id = v_tenant_id
AND profile_id = v_profile_id
) THEN
RAISE EXCEPTION 'non-disabled backend profile requires at least one binding';
END IF;
IF v_status = 'active' AND NOT EXISTS (
SELECT 1
FROM backend_profile_binding
WHERE tenant_id = v_tenant_id
AND profile_id = v_profile_id
AND capability = 'session'
) THEN
RAISE EXCEPTION 'active backend profile requires a session binding';
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER backend_profile_root_bindings_guard
AFTER INSERT OR UPDATE OF status ON backend_profile
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION backend_profile_require_valid_bindings();
CREATE CONSTRAINT TRIGGER backend_profile_binding_rows_guard
AFTER INSERT OR UPDATE OR DELETE ON backend_profile_binding
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION backend_profile_require_valid_bindings();
生命周期入口必须锁定 Profile、校验 expected version 和默认引用,并原子写 Outbox。以下函数 展示 suspend/resume/disable 的完整数据库语义;Catalog 校验过的完整配置替换采用相同锁定和 Outbox 模式,但 binding payload 仍由严格解码的 SQL Repository 传入,不能在 SQL 中猜测 provider schema。disabled 是终态门禁:锁定并确认对象存在后,所有 mutation 都先返回 disabled, 再判断 expected version;这样当前或陈旧调用在 InMemory 与 SQL adapter 中具有相同错误分类。
CREATE OR REPLACE FUNCTION transition_backend_profile_status(
p_tenant_id TEXT,
p_profile_id TEXT,
p_expected_version BIGINT,
p_next_status TEXT,
p_actor_type TEXT,
p_actor_id TEXT,
p_reason TEXT,
p_correlation_id TEXT
) RETURNS BIGINT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
v_default_profile_id TEXT;
v_previous_status TEXT;
v_previous_version BIGINT;
v_previous_updated_at TIMESTAMPTZ;
v_digest TEXT;
v_next_version BIGINT;
v_occurred_at TIMESTAMPTZ;
v_actor_type TEXT;
v_actor_id TEXT;
v_reason TEXT;
v_correlation_id TEXT;
BEGIN
v_actor_type := public.trim_backend_profile_text(p_actor_type);
v_actor_id := public.trim_backend_profile_text(p_actor_id);
v_reason := public.trim_backend_profile_text(p_reason);
v_correlation_id := public.trim_backend_profile_text(p_correlation_id);
-- All operations that change Tenant defaults or Profile status use the
-- same Tenant -> Profile lock order.
SELECT default_backend_profile_id INTO v_default_profile_id
FROM public.tenant
WHERE tenant_id = p_tenant_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'tenant does not exist';
END IF;
SELECT status, version, updated_at, content_digest
INTO v_previous_status, v_previous_version, v_previous_updated_at, v_digest
FROM public.backend_profile
WHERE tenant_id = p_tenant_id AND profile_id = p_profile_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'backend profile does not exist';
END IF;
IF v_previous_status = 'disabled' THEN
RAISE EXCEPTION 'backend profile is disabled';
END IF;
IF v_previous_version <> p_expected_version THEN
RAISE EXCEPTION 'backend profile version conflict';
END IF;
IF v_actor_type IS NULL OR length(v_actor_type) = 0
OR v_actor_id IS NULL OR length(v_actor_id) = 0
OR v_correlation_id IS NULL OR length(v_correlation_id) = 0
OR v_reason IS NULL OR length(v_reason) NOT BETWEEN 1 AND 1000 THEN
RAISE EXCEPTION 'backend profile transition requires valid audit metadata';
END IF;
IF (v_previous_status, p_next_status) NOT IN (
('active', 'suspended'),
('active', 'disabled'),
('suspended', 'active'),
('suspended', 'disabled')
) THEN
RAISE EXCEPTION 'invalid backend profile status transition';
END IF;
IF p_next_status = 'active' AND NOT EXISTS (
SELECT 1 FROM public.backend_profile_binding
WHERE tenant_id = p_tenant_id
AND profile_id = p_profile_id
AND capability = 'session'
) THEN
RAISE EXCEPTION 'active backend profile requires a session binding';
END IF;
IF p_next_status = 'disabled'
AND v_default_profile_id = p_profile_id THEN
RAISE EXCEPTION 'tenant default backend profile must be switched first';
END IF;
-- now() is fixed at transaction start and may predate a lock wait. Capture
-- wall-clock time once after validation and never move updated_at backwards.
v_occurred_at := GREATEST(clock_timestamp(), v_previous_updated_at);
UPDATE public.backend_profile
SET status = p_next_status,
version = version + 1,
updated_at = v_occurred_at
WHERE tenant_id = p_tenant_id
AND profile_id = p_profile_id
AND version = p_expected_version
RETURNING version INTO v_next_version;
IF NOT FOUND THEN
RAISE EXCEPTION 'backend profile version conflict';
END IF;
INSERT INTO public.backend_profile_change_outbox (
event_type, tenant_id, profile_id,
previous_status, current_status,
previous_digest, current_digest,
actor_type, actor_id, reason, correlation_id,
previous_version, next_version, occurred_at
) VALUES (
CASE p_next_status
WHEN 'suspended' THEN 'suspended'
WHEN 'active' THEN 'resumed'
ELSE 'disabled'
END,
p_tenant_id, p_profile_id,
v_previous_status, p_next_status,
v_digest, v_digest,
v_actor_type, v_actor_id, v_reason, v_correlation_id,
v_previous_version, v_next_version, v_occurred_at
);
RETURN v_next_version;
END;
$$;
REVOKE ALL ON FUNCTION transition_backend_profile_status(
TEXT, TEXT, BIGINT, TEXT, TEXT, TEXT, TEXT, TEXT
) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION transition_backend_profile_status(
TEXT, TEXT, BIGINT, TEXT, TEXT, TEXT, TEXT, TEXT
) TO tenant_admin_writer;
SQL adapter 集成测试必须让一个早启动事务等待 Tenant/Profile 锁,证明提交后的 updated_at 不早于
锁内读取的前一版本时间,且根记录与 Outbox occurred_at 使用同一个时间值。
停用 Tenant 默认 Profile 时,Admin 编排事务必须先切换或清空
tenant.default_backend_profile_id,否则拒绝停用。所有相关 SQL 路径统一使用“先锁 Tenant,
再锁目标 Profile”的顺序;设置默认引用时也必须在持有 Tenant 行锁后锁定并验证目标 Profile
属于同一 Tenant 且 active。复合外键只证明对象存在,不能代替状态校验。Backend Repository
本身不能凭过期的调用方布尔值推断 Tenant 默认引用;SQL 实现在同一事务校验,InMemory 阶段
由上层 Tenant 编排负责。无论哪种实现,都不得自动选择另一个 Profile。
运行时角色不能直接修改 Profile、binding 或 Outbox 表。受控 SQL 函数使用固定
search_path、显式撤销 PUBLIC EXECUTE,并由不属于运行时连接池的 migration owner 持有。
Worker 只接收快照,不枚举控制面表。
与 tRPC-Agent-Go 的映射¶
映射以 tRPC-Agent-Go main 在设计时的提交
0e352fd
为依据;未来 adapter 应固定兼容版本并用编译/集成测试检测上游变化。
tRPC-Agent-Go 的 Session、Memory、Knowledge VectorStore 和 Artifact 接口没有独立
tenant_id 参数。平台 adapter 必须在构造时捕获 StorageFactoryInput.TenantID,并在每次
底层表查询、keyspace、collection、bucket prefix 或等价 provider 分区中强制使用该可信
Tenant 边界;框架的 AppName/UserID/SessionID 命名空间只是第二层防碰撞,不是授权条件。
无法提供可验证租户分区的 provider 不得共享给多个租户。每个真实 adapter 落地时必须增加
双租户 conformance test,证明相同框架 ID 不会跨租户读写。
| Capability | tRPC-Agent-Go 边界 | 平台 adapter 责任 |
|---|---|---|
| Session | session.Service;通过 runner.WithSessionService 注入 |
adapter 固定 Tenant 分区并构造受支持实现;Runner identity 继续使用 Tenant 命名空间作为第二层隔离 |
| Memory | memory.Service;通过 runner.WithMemoryService 注入 |
adapter 固定 Tenant 分区,再映射 AppName/UserID |
| Knowledge | knowledge.Knowledge 和 vectorstore.VectorStore |
adapter 先固定 Tenant 分区并构造 VectorStore;Agent Factory 再以 knowledge.New 和 knowledge.WithVectorStore 构造 Knowledge,最后通过 llmagent.WithKnowledge 注入 LLMAgent |
| Artifact | artifact.Service;通过 runner.WithArtifactService 注入 |
adapter 固定 Tenant/bucket prefix,再映射 App/User/Session 作用域 |
| Audit | tRPC-Agent-Go 复用 OpenTelemetry,没有与上述 Service 同构的强制审计存储接口 | 平台 Audit adapter 独立持久化强制事件;OTel exporter 只负责 telemetry,采样不能代替审计 |
tRPC-Agent-Go 仓库当前包含多种 Session、Memory、VectorStore 和 Artifact 实现,但“上游存在 package”不等于平台已经允许该 provider。只有对应 adapter、Catalog schema 和测试一起落地后, provider 才能在租户配置中启用。
Knowledge 的 embedder/model 引用不属于 Backend Profile。它必须来自后续发布且不可变的
Agent/Knowledge 配置与同租户 Model Profile,并与 Backend snapshot 一起固定后再调用
knowledge.New。在该可信依赖来源落地前,配置了 Knowledge binding 的运行时 Factory 必须
fail closed,不能借用聊天模型、进程环境变量或全局默认 embedder。
Storage Factory 的构造顺序是:校验快照 → 读取已注册 adapter → 按 Tenant 授权解析
secret_ref → 构造客户端/Service → 注入 Agent/Runner。Secret Resolver 失败必须令本次构造
失败,不能使用空密码、环境变量全局默认值或另一个租户的缓存项降级。
验证与分阶段交付¶
实现测试至少覆盖:
- ID/key/status/version/time/schema 和 capability/provider/config 边界。
- 同 key 跨租户允许、同租户重复拒绝以及跨租户读取表现为 not found。
- 创建、读取、完整配置更新、暂停、恢复、终态停用。
- active Session 必需规则和恢复时重新校验。
- slice/map 的入参、存储、返回值、事件、快照和 Factory 输入深拷贝。
- expected version 并发单一获胜,以及等待锁时 Context 取消。
- 所有写入事件的 actor、reason、correlation、状态、digest 和前后版本。
- Tenant/Profile 非 active、作用域不一致、损坏摘要和 Context 零值快照拒绝。
- Tenant 默认 Profile 为空、不匹配或非 active 时拒绝快照,且不回退到其他 Profile。
- 快照固定 Tenant/Profile version/digest 且不可能携带 Secret 值或 live client。
- 每个真实 adapter 以相同框架 ID 做双租户 conformance test,证明底层查询/分区显式隔离。
交付顺序固定为:
- 本设计文档、导航和数据模型交叉引用,独立审查通过。
trpcservice/backend领域模型、Catalog、规范化、摘要和边界测试。trpcservice/backend/inmemoryRepository、事件、并发/Context/深拷贝测试。- 执行快照、Storage Factory 输入和运行时边界测试。
- 全量
go test、race、format、lint、build、MkDocs strict、diff check 和最终审查。
每个阶段都在同一功能分支和 PR 中提交,并在进入下一阶段前完成独立审查。代码阶段不能在 文档审查通过前开始。
后续依赖顺序¶
Backend Profile
└── Model Profile / Secret Resolver
└── Channel Binding
└── Gateway / Worker 最小执行链路
真实 provider adapter、Secret 解析、数据库 migration 和数据迁移分别由后续 Issue 落地; 本控制面契约只为它们提供可审计、可冻结且不会泄露凭据的配置输入。