Documentation
¶
Overview ¶
Package guardset 守卫装配契约:一组**相互独立**的判定被一并求值,拒绝被合成一条。
为什么需要它 ¶
本仓的守卫(tooldisclose 的披露门、netguard 的出站守卫、policy 的审计门、 pii 的脱敏面、toolfail 的失败契约)此前是并列的独立机制,接进执行链的活儿 全推给宿主。后果不是「少个功能」,而是**每个宿主各写一遍组合逻辑**,而组合 逻辑里恰好藏着三个必须全局一致的判定:
① 顺序:这些守卫谁先说话; ② 冲突:一次调用被多个守卫拒时,以谁的结论为准; ③ 合成:多条拒绝怎么变成模型看得懂的一句话。
这三件事与「用哪个守卫」无关,只与「守卫怎么被装配」有关——正是内核该定死的部分。 各自实现的结果不是风格不统一,是行为漂移:同一个调用在 A 宿主被报成「不可重试」, 在 B 宿主被报成「可重试」,两边都没写错,只是各自选了不同的合成规则。
与 invoke.Chain 的关系:这是组合,不是替代 ¶
oagentkit/guardx 的 invoke.Chain 已经提供了顺序执行、首个拒绝即短路、留痕与 panic 兜底。本包**不重复其中任何一条**,而是补它按设计不覆盖的那一面:
invoke.Chain 的语义是「排成一队逐个过,第一个拒就收口」——顺序即语义, 但这意味着**后面的守卫永远没机会说话**。模型一次只听得到第一个理由, 于是它改掉那个理由、重试、再撞上下一个,如此往复若干轮。 本包的语义是「一并求值,把全部拒绝合成一条」——只适用于那些 **纯判定、无副作用、彼此独立**的守卫(它们互不依赖对方的结论, 所以并行/一并求值不改变任何单独一条的判定结果)。
二者叠加而非取代:guardset.Set 把自己接成 invoke.Chain 上的**一环** (见 stage.go),链照旧短路,只是这一环内部已经把该说的都说了。 需要严格先后(前一环的结论是后一环的输入)时,仍应直接用 invoke.Chain 排多个 Stage。
关键取舍一:不可重试优先(fail-closed) ¶
合成多条拒绝时的重试结论是「**全部可重试**才算可重试」,不是「任一可重试」。
理由:只要有一条拒绝是不可恢复的,重试整个调用就绝无可能成功。 取「任一」的话,一个永久性拒绝会被一条无关的可重试拒绝「洗白」成可重试, 退避层于是把一次策略否定拖成无穷重试——这正是本仓反复拒绝的那类失效 (invoke.Chain 里认不出的否定一律不可重试,是同一条纪律的另一个落点)。
关键取舍二:空集合构造失败,不构造一个看起来能用的东西 ¶
没有守卫的集合等于没人看着这次调用。与其让它在运行期放行或静默拒绝, 不如在装配期直接失败——与 invoke.NewChain 对空执行体的处置同源: 一个「空转但看起来能用」的守卫集合,比构造失败危险得多。
撤销的归属:换整份,不改其中一条 ¶
构造成功后 Set 只读——那是它的**不变量**(并发共享下的可变集合就是竞态源, 且会让「当前有哪些守卫」出现**两个真相**)。但只读的代价是真实的: 运行期想临时停掉某条守卫(应急降级、灰度、误伤止损),宿主只能整个进程重启。
⇒ 解法不是「让 Set 可变」,而是**换整份**:装配面(见 assembly.go)持有 「当前这一份」,撤销 = 产出一份新的(少了某条守卫)+ 整份换上去。 换份是原子的,在途判定跑在旧份上,旧份不被修改,且**链不用重接**。
「全部撤销」不是降级,是失守——换上去的那份永远非空。
边界 ¶
本包只管**组合语义**:顺序、冲突裁决、合成。它不认识任何具体守卫, 也不认识任何具体工具——守卫从哪来、判什么,全部由宿主装配时注入。
Index ¶
- Constants
- Variables
- func Stage(s *Set) invoke.Stage
- func StageWithGuidance(s *Set) invoke.Stage
- type Assembly
- func (a *Assembly) Current() *Set
- func (a *Assembly) Evaluate(ctx context.Context, c invoke.Call) Outcome
- func (a *Assembly) Generation() uint64
- func (a *Assembly) Snapshot() Snapshot
- func (a *Assembly) Stage() invoke.Stage
- func (a *Assembly) StageWithGuidance() invoke.Stage
- func (a *Assembly) Subscribe(fn func(SwapRecord)) (Subscription, error)
- func (a *Assembly) Swap(ctx context.Context, next *Set) error
- func (a *Assembly) SwapFunc(ctx context.Context, mutate func(cur Snapshot) (*Set, error)) error
- func (a *Assembly) Unsubscribe(h Subscription)
- type AssemblyOption
- type Denial
- type Guard
- type Outcome
- type Set
- type Snapshot
- type Subscription
- type SwapGate
- type SwapRecord
Constants ¶
const StageName = "guard_set"
StageName 本环在链上的名字。固定值:留痕要能回答「是哪一环拒的」。
Variables ¶
var ( // ErrEmptySet 空守卫集合。构造期即失败:没人看着的调用不该看起来能用。 ErrEmptySet = errors.New("guardset: 守卫集合为空") // ErrUnnamedGuard 守卫缺名字。留痕要能回答「是哪一环拒的」,空名等于没有答案。 ErrUnnamedGuard = errors.New("guardset: 守卫未命名") // ErrDuplicateGuard 守卫重名。重名会让同一个答案有两个候选,留痕与指引同时失去指向。 ErrDuplicateGuard = errors.New("guardset: 守卫重名") // ErrUnassembled 未经构造的 Set 被拿来求值(运行期落点的 fail-closed)。 ErrUnassembled = errors.New("guardset: 守卫集合未装配") // ErrUnknownGuard 撤销一个不存在的守卫。**不静默忽略**:静默忽略会让宿主 // 以为「撤掉了某条守卫」,实际那份集合里根本没有它——撤销的是个不存在的东西。 ErrUnknownGuard = errors.New("guardset: 守卫名不在集合中") // ErrNilMutate 换份的改写函数为 nil。传 nil 进来只能说明调用方漏了判断, // 而漏判断的换份会「什么也不做」——静默无动作比报错更难查。 ErrNilMutate = errors.New("guardset: 换份的改写函数为空") // ErrNilSubscriber 订阅回调为空。登记一个必然 panic 的订阅者毫无意义, // 故直接报错并返回零句柄:调用方能**凭返回值**知道订阅没生效, // 而不是等到第一次变更时才发现「原来一直没跟上」。 ErrNilSubscriber = errors.New("guardset: 换份订阅回调为空") )
装配期与运行期的哨兵。用 oekit/pkg/errors(与全家族一致),调用方用 errors.Is 判定。
Functions ¶
func Stage ¶
Stage 把守卫集合接成 invoke 链上的一环。
约定与 invoke 链一致:拒绝走 (Verdict, nil),不返回 error——本环的「拒绝」是 正常判定结论,而 error 在链上被归口成「这一环自己坏了」,两者对调用方的处置不同, 混起来会让一次正常的策略否定看起来像故障。
set 为 nil 时按「未装配」拒绝(fail-closed):没装配守卫集合的链等于没人看着 调用,与 New 拒绝构造空集合是同一条纪律的运行期落点。
func StageWithGuidance ¶
StageWithGuidance 与 Stage 同义,但把**面向模型的指引**放进 Reason。
为什么提供两个而不是给 Stage 加选项:Reason 的用途由宿主决定(有人拿去留痕, 有人直接送进模型可见的失败消息),而这两种用途要的文本不同 (留痕要全且准,提示词要能照着做)。做成两个明确出口, 比留一个布尔开关让每个宿主自己记得切换要可靠。
Types ¶
type Assembly ¶
type Assembly struct {
// contains filtered or unexported fields
}
Assembly 守卫集合的装配面:持有「当前这一份」,并提供**整份换掉**的能力。
不变式 ¶
- 当前份永远非空、非 nil(构造期与换份期都保证);
- 换份是**整份**的,不存在「部分换」;
- 换份是原子的:在途判定跑在旧份上,下一次判定跑在新份上;
- 旧份**不被修改**——它可能正被在途判定读着;
- 代次单调递增、不回卷,且**只有一个真相**(住在被换的那份里)。
并发 ¶
判定路径**不加锁**(读一个 atomic.Pointer);换份走 CAS 循环。 订阅表是 internal/subhub 的 `Registry`(copy-on-write 整体替换):通知时取一次 快照、在锁外遍历它——读侧永远看不到改了一半的名单。
func NewAssembly ¶
func NewAssembly(s *Set, opts ...AssemblyOption) (*Assembly, error)
NewAssembly 构造装配面,初始装入 s。
s 为 nil 即拒绝:装配面一开始就没有守卫,那不是「还没装」,是「没人看着」, 而没人看着的调用不该看起来能用(与 `New` 拒绝空集合同源)。
func (*Assembly) Current ¶
Current 返回当前这一份(只读快照)。
返回的指针在**任何时候都仍然有效**——旧份不会被修改, 调用方可以放心地把它读到底(这是「撤销靠换整份、不靠改旧份」的直接后果)。
要同时拿到代次,请用 `Snapshot`(分开读会得到自相矛盾的一对)。
func (*Assembly) Evaluate ¶
Evaluate 按**当前那一份**求值。
装载当前份只做一次 ⇒ 一次判定全程对着同一份走, 不会因为中途换份而出现「一半旧、一半新」的合成结论。
结论里带**代次**(`Outcome.Generation`)——它回答「这次判定是按第几代做的」, 是审计要问的第一个问题之一。
func (*Assembly) Generation ¶
Generation 当前那一份的代次。调用方可在前后各取一次,变化即说明期间换过。
它是 `current` 的**投影**,不是另一个计数器——与 netguard 的 `PolicyGeneration()` 同构。
func (*Assembly) Stage ¶
Stage 把装配面接成 invoke 链上的一环。
与 `Stage(*Set)` 的关键差别:**每次判定都取当前那一份**。 于是换份之后**不需要重接链**——链上那一环始终指向装配面, 装配面指向当前份。这就是「撤销有机械路径」的落点: 换份即生效,不需要重启、不需要重建链。
约定与 `Stage(*Set)` 一致:拒绝走 (Verdict, nil),不返回 error。
func (*Assembly) StageWithGuidance ¶
StageWithGuidance 与 Stage 同义,但把**面向模型的指引**放进 Reason。
为什么要单独一个出口而不是给 Stage 加选项:Reason 的用途由宿主决定 (有人拿去留痕,有人直接送进模型可见的失败消息),而这两种用途要的文本不同。 与 `StageWithGuidance(*Set)` 同一条理由。
func (*Assembly) Subscribe ¶
func (a *Assembly) Subscribe(fn func(SwapRecord)) (Subscription, error)
Subscribe 订阅换份:换份真正落地的那一刻,把新快照推给回调。
为什么在代次之上还要这一层 ¶
代次只是**信号**:调用方得自己起一个轮询循环,前后各取一次代次比对。 轮询有三件它解决不了的事:
- 空转期仍在读全局状态,每个订阅者一份重复读取;
- 两次轮询之间若换了两次,只能看出「变了」,看不出「变成了什么」;
- 代次单调只保证「不漏掉发生过变更」,不保证「每一代都被谁记下过」。
订阅把这件事一次做完:变更发生的**那一刻**由写侧直接把新记录推给订阅者, 收到即所见,不靠事后比对推断。
fn 为 nil ⇒ 报错并返回零句柄:调用方能**凭返回值**知道订阅没生效, 而不是等到第一次变更时才发现「原来一直没跟上」。 a 为 nil ⇒ 同样报错(未被装配的装配面不该看起来能用,与 `NewAssembly` 同口径)。
与 netguard / pii 的订阅有一处**有意**不同:登记时不立即回调 ¶
那边推的是**策略快照**(「现在生效的是什么」),故登记后立刻补一次当前值—— 否则订阅者会带着一个从没填过的视图上线。这里推的是 **SwapRecord**(「刚刚换了 哪一份、从什么换成什么」):一条换份记录描述的是**已发生的事件**,把「登记那一刻」 伪造成一条换份记录,等于凭空宣布一次没发生过的换份,而 Seq / Generation 会因此 与实际代次对不上。
要「现在这一份」请直接取 `Snapshot()`——两个入口各自表达一件事, 而不是让一个入口同时兼职两种语义。
func (*Assembly) Swap ¶
Swap 整份替换为 next。
与 SwapFunc 的区别(必须分清) ¶
SwapFunc(ctx, mutate) 基于**当前那一份**算下一份 ⇒ 并发下**不丢更新** Swap(ctx, next) 直接换成 next ⇒ **不基于当前那一份**
`Swap` 的语义是「我要换成这一份」(例如从配置中心读到的全新名单), 不是「在当前那份上改一改」。**拿它做读改写就会丢更新**—— 两个并发撤销者会互相覆盖,被撤掉的守卫悄悄复活,且没有任何东西会提示。
要撤销某条守卫,请用 `SwapFunc`。
三条语义 ¶
- **整份换**:next 取代当前份的全部内容,不是「加一条」「删一条」;
- **原子**:在途判定跑在旧份上,下一次判定跑在新份上,没有中间态;
- **fail-closed**:next 为 nil ⇒ 拒绝换份,当前份**保持不变** (撤销失败不等于「换上一份没人管的东西」)。
换份被闸门拒绝时,返回闸门的错误,当前份同样保持不变。
func (*Assembly) SwapFunc ¶
SwapFunc 基于**当前那一份**的读改写:mutate 从观察到的当前份算出下一份, 整份换上去。
这是撤销的正确姿势 ¶
mutate 看到的永远是**最新**那一份:CAS 失败(有别的写者抢先)时重试, mutate 重新看到新的当前份。于是两个并发撤销者**不会互相覆盖**—— 管理员 A 撤 g1、管理员 B 撤 g2,两次撤销都生效。
旧实现若是「读当前份 → 算新的 → 直接换指针」,两个写者会拿到**同一份旧**, 后写者把先写者静默盖掉 ⇒ **撤销被撤销**(被撤掉的守卫悄悄复活)。
mutate 的契约 ¶
mutate 可能被调用**多次**(CAS 重试),故它必须是**纯函数**: 只读 `cur`、只依赖 `cur` 产出下一份,不做 I/O、不发通知、不改外部状态。
mutate 返回错误 ⇒ 换份放弃,当前份**保持不变**。 mutate 返回 nil 集合 ⇒ 拒绝(没人看着的调用不该看起来能用)。
闸门 ¶
闸(`WithSwapGate`)在**每次发布尝试前**跑,看到即将换上去的那一份。 CAS 重试会重复调用它 ⇒ 闸门必须是幂等的纯谓词。
func (*Assembly) Unsubscribe ¶
func (a *Assembly) Unsubscribe(h Subscription)
Unsubscribe 注销一个订阅者。句柄无效或已注销时静默返回(重复注销不是错误)。
type AssemblyOption ¶
type AssemblyOption func(*assemblyConfig)
AssemblyOption 装配面的可选装配件。
func WithSwapGate ¶
func WithSwapGate(g SwapGate) AssemblyOption
WithSwapGate 挂换份闸。g 为 nil 表示不挂(默认不拦)。
闸在换份**之前**跑,且闸看到的是**即将换上去的那一份**—— 授权判据要针对「新配置」,而不是「旧配置」。
func WithSwapRecorder ¶
func WithSwapRecorder(f func(SwapRecord)) AssemblyOption
WithSwapRecorder 挂换份留痕。f 为 nil 表示不留痕(不留痕是显式选择,不是默认)。
留痕回调在**锁外**调用,故它可以安全地读 `Current()`; 但它**不得**回调 `Swap`(那会与正在进行的换份互相等待)。
回调 panic 会被吞掉(不掀翻整次换份):换份**已经落地**,让回调抛异常就把 已经发生的事实改写成「没发生」——那正是本包要消灭的那类失效。
type Denial ¶
type Denial struct {
// Guard 拒的那一环的名字。
Guard string
// Reason 该环给出的依据。
Reason string
// Retryable 该环认为值不值得重试。
Retryable bool
}
Denial 一条拒绝。
三字段与 invoke.Verdict 的拒绝面一一对应,不引入新概念: 多存下来的只是**是哪一环拒的**,而那正是留痕要回答的第一个问题。
type Guard ¶
type Guard struct {
// Name 该环名字,进留痕与模型指引。不得为空、不得重名(见 New)。
Name string
// Rank 优先级,**小者先说话**。Rank 相同的按装配序(稳定)。
//
// 为什么要显式声明而不是靠装配顺序:装配顺序是「怎么写的」,Rank 是
// 「什么意思」。把语义藏在切片下标里,读代码的人得数到第几个才知道谁说了算,
// 而插入一个新守卫就会把所有人的次序悄悄改掉。
Rank int
// Check 判定函数。为 nil 视为该环坏了,按拒绝收口(fail-closed)。
Check func(ctx context.Context, c invoke.Call) invoke.Verdict
}
Guard 一项带优先级的独立判定。
之所以是「独立」:Check 不得依赖其他 Guard 的结论,否则一并求值就改变了 单条判定的语义,本包的整个前提(合成不改变任何一条的结论)随之失效。 需要有先后的守卫请直接排进 invoke.Chain,不要塞进一个 Set 里。
type Outcome ¶
type Outcome struct {
// Denials 全部拒绝,按优先级升序、同级按装配序(稳定)。
// 放行为空;有任何拒绝则非空。
Denials []Denial
// Primary 留痕用的主拒绝:优先级最高的那条(Denials[0])。放行为零值。
Primary Denial
// Allowed 是否全部放行。
Allowed bool
// Retryable 合成后的重试结论。语义见包注释「关键取舍一」:
// 只有**全部**拒绝都可重试时才为真。
Retryable bool
// Generation 判出这份结论时,守卫集合的**代次**。
//
// 它回答审计要问的第一个问题之一:「这次判定是按**第几代**守卫做的」。
// 撤销一条守卫后,早先已在途的判定仍按旧份走(那是设计,见 assembly.go)——
// 没有代次,这两件事在留痕里长得一模一样。
//
// 直接对 `Set` 求值时恒为 0(「不参与代次」);经 `Assembly` 求值时是那一份的代次。
Generation uint64
}
Outcome 一组守卫对一次调用的合并结论。
type Set ¶
type Set struct {
// contains filtered or unexported fields
}
Set 一组独立守卫,合成一次判定。
func New ¶
New 构造守卫集合。校验三条装配期的硬约束(都在这里失败,而不是留到运行期):
- 空集合拒绝构造:没人看着的调用不该看起来能用(包注释「关键取舍二」);
- 名字不得为空:留痕要能回答「是哪一环拒的」,空名等于这个问题没有答案;
- 名字不得重复:重名会让同一个答案有两个候选,审计与指引同时失去指向。
构造成功后内部顺序按 (Rank, 装配序) 固定下来,此后 Set 只读,可并发共享。
func (*Set) Evaluate ¶
Evaluate 一并求值全部守卫,合成一份结论。
三条求值纪律:
- **不短路**:每条拒绝都要拿到,否则模型只听得到第一个理由;
- **单项坏了按拒绝收口**(fail-closed):判定函数报错或 panic 一律计入拒绝, 而不是当成「这环管不着」放行——后者会让一次故障的门禁静默变成不设防;
- **合成取保守侧**:全部可重试才算可重试(包注释「关键取舍一」)。
func (*Set) Without ¶
Without 返回一份**去掉指定守卫**的新集合。原集合**不变**。
为什么是「返回新集合」而不是「删掉这一条」 ¶
因为 `Set` 不可变是它的不变量。删一条若能改到原集合,就有了「当前有哪些守卫」 两个真相,且并发读它就是竞态。故撤销一条守卫的机械路径是:
err = asm.SwapFunc(ctx, func(cur guardset.Snapshot) (*guardset.Set, error) {
return cur.Set.Without("rate_limiter")
})
两条拒绝 ¶
- 守卫名不在集合里 ⇒ 拒绝(**不静默忽略**)。静默忽略会让宿主以为 「撤掉了某条守卫」,实际那份集合里根本没有它——撤销的是个不存在的东西。
- 去掉之后集合为空 ⇒ 拒绝(`ErrEmptySet`)。**全部撤销不是降级,是失守**。
type Snapshot ¶
type Snapshot struct {
// Set 当前那一份守卫集合(只读,可长期持有)。
Set *Set
// Generation 当前那一份的代次。单调递增,不回卷;0 表示「从未换过」。
Generation uint64
}
Snapshot 某一时刻的快照:当前那一份守卫集合 + 它的代次。
两个字段**必须一起读**——分开读会得到「集合是第 N 代、代次是第 N-1 代」这种 自相矛盾的一对(那正是 netguard 修掉的「两套真相」的可观测形态)。 故本包只提供 `Assembly.Snapshot()` 这一个读入口。
type Subscription ¶
type Subscription uint64
Subscription 订阅句柄,传给 `Unsubscribe` 注销。0 表示无效。
它是**本包自己的命名类型**(底层 uint64),不是 subhub.Handle 的别名: 别名会让 guardset.Subscription 与 netguard.Subscription 变成同一个类型, 把别处的句柄传进本包 Unsubscribe 将编译通过然后静默什么都不注销。
type SwapGate ¶
SwapGate 换份闸:判断「这一次换整份是否被允许」。
返回非 nil 错误即**拒绝换份**,当前份保持不变。
为什么不把授权判据写进本包:授权是宿主的业务决定(谁能停掉哪条守卫、 要不要人审),本包只提供**接入点**——闸门钩子。宿主把自己的审批路径挂上来, 「撤销」就从「改代码 + 重启」变成一条机械路径。
调用次数 ¶
闸门在**每次发布尝试前**跑一次。CAS 重试(有别的写者抢先)会**重复调用**它, 故它必须是**幂等的纯谓词**:写审计、发通知这类副作用请放在别处 (或放在 mutate 里,但要自己保证幂等)。
type SwapRecord ¶
type SwapRecord struct {
// Seq 换份序号,从 1 起。单调整调。
//
// 与 `Generation` 是**同一个计数**(每次成功换份 +1)。保留 `Seq` 是为兼容
// v0.9.0 的既有调用方;新代码用 `Generation`。
Seq uint64
// From 换走的那一份的守卫名(按优先级序)。首次装配时为空。
From []string
// To 换上来的那一份的守卫名(按优先级序)。
To []string
// Generation 换完之后当前那一份的代次。
Generation uint64
// Set 换完之后当前那一份。持有它就一直有效(旧份不被修改)。
Set *Set
}
SwapRecord 一次换份的留痕。
留痕要能回答四个问题:**换了几次**(Seq)、**换走了什么**(From)、 **换上了什么**(To)、**换完之后是哪一代**(Generation)。