guardset

package
v0.31.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 10 Imported by: 0

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

View Source
const StageName = "guard_set"

StageName 本环在链上的名字。固定值:留痕要能回答「是哪一环拒的」。

Variables

View Source
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

func Stage(s *Set) invoke.Stage

Stage 把守卫集合接成 invoke 链上的一环。

约定与 invoke 链一致:拒绝走 (Verdict, nil),不返回 error——本环的「拒绝」是 正常判定结论,而 error 在链上被归口成「这一环自己坏了」,两者对调用方的处置不同, 混起来会让一次正常的策略否定看起来像故障。

set 为 nil 时按「未装配」拒绝(fail-closed):没装配守卫集合的链等于没人看着 调用,与 New 拒绝构造空集合是同一条纪律的运行期落点。

func StageWithGuidance

func StageWithGuidance(s *Set) invoke.Stage

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

func (a *Assembly) Current() *Set

Current 返回当前这一份(只读快照)。

返回的指针在**任何时候都仍然有效**——旧份不会被修改, 调用方可以放心地把它读到底(这是「撤销靠换整份、不靠改旧份」的直接后果)。

要同时拿到代次,请用 `Snapshot`(分开读会得到自相矛盾的一对)。

func (*Assembly) Evaluate

func (a *Assembly) Evaluate(ctx context.Context, c invoke.Call) Outcome

Evaluate 按**当前那一份**求值。

装载当前份只做一次 ⇒ 一次判定全程对着同一份走, 不会因为中途换份而出现「一半旧、一半新」的合成结论。

结论里带**代次**(`Outcome.Generation`)——它回答「这次判定是按第几代做的」, 是审计要问的第一个问题之一。

func (*Assembly) Generation

func (a *Assembly) Generation() uint64

Generation 当前那一份的代次。调用方可在前后各取一次,变化即说明期间换过。

它是 `current` 的**投影**,不是另一个计数器——与 netguard 的 `PolicyGeneration()` 同构。

func (*Assembly) Snapshot

func (a *Assembly) Snapshot() Snapshot

Snapshot 一次读出当前那一份与它的代次。

两个字段来自**同一次装载**,故不可能自相矛盾。

func (*Assembly) Stage

func (a *Assembly) Stage() invoke.Stage

Stage 把装配面接成 invoke 链上的一环。

与 `Stage(*Set)` 的关键差别:**每次判定都取当前那一份**。 于是换份之后**不需要重接链**——链上那一环始终指向装配面, 装配面指向当前份。这就是「撤销有机械路径」的落点: 换份即生效,不需要重启、不需要重建链。

约定与 `Stage(*Set)` 一致:拒绝走 (Verdict, nil),不返回 error。

func (*Assembly) StageWithGuidance

func (a *Assembly) StageWithGuidance() invoke.Stage

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

func (a *Assembly) Swap(ctx context.Context, next *Set) error

Swap 整份替换为 next。

与 SwapFunc 的区别(必须分清)

SwapFunc(ctx, mutate)  基于**当前那一份**算下一份 ⇒ 并发下**不丢更新**
Swap(ctx, next)        直接换成 next ⇒ **不基于当前那一份**

`Swap` 的语义是「我要换成这一份」(例如从配置中心读到的全新名单), 不是「在当前那份上改一改」。**拿它做读改写就会丢更新**—— 两个并发撤销者会互相覆盖,被撤掉的守卫悄悄复活,且没有任何东西会提示。

要撤销某条守卫,请用 `SwapFunc`。

三条语义

  1. **整份换**:next 取代当前份的全部内容,不是「加一条」「删一条」;
  2. **原子**:在途判定跑在旧份上,下一次判定跑在新份上,没有中间态;
  3. **fail-closed**:next 为 nil ⇒ 拒绝换份,当前份**保持不变** (撤销失败不等于「换上一份没人管的东西」)。

换份被闸门拒绝时,返回闸门的错误,当前份同样保持不变。

func (*Assembly) SwapFunc

func (a *Assembly) SwapFunc(ctx context.Context, mutate func(cur Snapshot) (*Set, error)) error

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 一组守卫对一次调用的合并结论。

func (Outcome) Guidance

func (o Outcome) Guidance() string

Guidance 合并后面向模型的指引:逐条列出未通过的检查,并点明该先处理哪一条。

为什么要单独一个面:留痕是给人看的(要全、要准),提示词是给模型看的 (要能照着做)。用同一段文字服务两头,结果是留痕太啰嗦而提示词抓不住重点。

指向「先处理哪一条」用的是不可重试优先——那一条才是真正卡住的地方, 让模型先去修一条修得动、但修完仍然过不去的检查,是白跑一轮。

func (Outcome) Reason

func (o Outcome) Reason() string

Reason 合并后的留痕依据:把全部拒绝按序拼成一行。

为什么不用 Primary 的理由单独留痕:只记一条会让审计误以为「就这一处有问题」, 而模型拿到完整清单后可能只改掉了优先级最高的那一条——下一轮又拒, 排查的人却在留痕里看不到第二处曾经也拒过。

type Set

type Set struct {
	// contains filtered or unexported fields
}

Set 一组独立守卫,合成一次判定。

func New

func New(guards ...Guard) (*Set, error)

New 构造守卫集合。校验三条装配期的硬约束(都在这里失败,而不是留到运行期):

  • 空集合拒绝构造:没人看着的调用不该看起来能用(包注释「关键取舍二」);
  • 名字不得为空:留痕要能回答「是哪一环拒的」,空名等于这个问题没有答案;
  • 名字不得重复:重名会让同一个答案有两个候选,审计与指引同时失去指向。

构造成功后内部顺序按 (Rank, 装配序) 固定下来,此后 Set 只读,可并发共享。

func (*Set) Evaluate

func (s *Set) Evaluate(ctx context.Context, c invoke.Call) Outcome

Evaluate 一并求值全部守卫,合成一份结论。

三条求值纪律:

  • **不短路**:每条拒绝都要拿到,否则模型只听得到第一个理由;
  • **单项坏了按拒绝收口**(fail-closed):判定函数报错或 panic 一律计入拒绝, 而不是当成「这环管不着」放行——后者会让一次故障的门禁静默变成不设防;
  • **合成取保守侧**:全部可重试才算可重试(包注释「关键取舍一」)。

func (*Set) Guards

func (s *Set) Guards() []string

Guards 返回已装配的守卫名(按优先级序,只读快照,供自检与调试)。

func (*Set) Without

func (s *Set) Without(names ...string) (*Set, error)

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

type SwapGate func(ctx context.Context, next *Set) error

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)。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL