feat(steward): make a team plan name the Goal it staffs - #4532
Conversation
The team preview contract did not say which Goal a plan was for. The admission that validates its lanes is given host facts for one Goal's registered Agents, while the apply derived its Goal from the settlement it arrived in, so the same plan could be admitted against one Goal's Agents and materialized under another. The wiring slice that has to supply `team_plan_context` cannot be honest until that ambiguity is gone: there was no fact for the host to describe. A validated preview now names the exact Goal it staffs, next to the lanes, the quota envelope, the acceptance signal and the stop condition. The id is a registry-shaped id rather than free text, so a path, a sentence or an unbounded string cannot become the Goal a settlement materializes into. The apply refuses a plan whose named Goal differs from its settlement, before it loads anything or writes anything, so admission and settlement always describe one Goal. An unknown Goal is still refused by its own check, which the existing test now exercises through a plan that names that same unknown Goal instead of relying on the settlement's Goal. Verified: tests/test_steward_team_plan_preview.py, tests/test_steward_team_plan_apply.py, tests/extensions/test_governed_capability_execution.py and tests/test_manager_ssh_evidence.py 67 passed, including a preview that omits the Goal, four shapes of invalid Goal id, a plan retargeted to another Goal that creates nothing and still lets the named Goal's own plan apply, and the unknown-Goal refusal. examples/docs-governance-smoke.py ok. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
一、变更内容
- 团队预览契约新增必填
goal_id:校验通过的预览要点名它给哪个 Goal 配人,与 lanes、quota 包络、验收、终止条件并列;该 id 必须是指纹式的 registry 形状(非自由文本),路径、句子或超长字符串都不能成为落地时的 Goal。 - 落地在加载与写入之前就拒绝"点名 Goal ≠ 结算 Goal"的计划,因此准入与结算永远描述同一个 Goal。
- 既有"未知 Goal"拒绝保留自己的检查;该测试改为用"点名同一个未知 Goal"的计划来走这条路径,而不是依赖结算 Goal。
- RFC(英文 + 中文镜像)同步记录这两条规则。
二、依据与一致性
- 动机来自你自己这段结构里最尖锐的一处不一致:准入拿到的是某一个 Goal 的宿主事实(已注册 Agent + 本机支持的 advancement action kind),而落地此前从"提案所在的结算"推导 Goal;同一份计划因此可能按 A 的 Agent 通过准入、却在 B 下建出 lane。这也正是"让适配器传
team_plan_context"这条接线此前无法诚实实现的原因——没有可描述的事实。 - 与既有不变量一致:预览仍
applies: false、不建 Todo、不注册 Agent、不设 quota、不扣额度;落地仍经 canonical Todo owner、gap lane 不建任何东西、回执带 digest 且重放幂等。 - 与"默认行为变更必须披露"的规则一致:本契约尚无生产调用方,故未触及任何在跑的流程;这一点已在 PR 与 RFC 中写明,而不是默认读者知道。
三、验证
tests/test_steward_team_plan_preview.py、tests/test_steward_team_plan_apply.py、tests/extensions/test_governed_capability_execution.py、tests/test_manager_ssh_evidence.py:67 passed。新增覆盖:缺goal_id、四种非法 id 形状(空、空白、../escape、带空格、超长)、"改投另一个 Goal"的计划(不创建任何东西,且原 Goal 自己的计划仍能落地)、未知 Goal 拒绝。examples/docs-governance-smoke.py:ok。loopx canary premerge --from-git-diff:merge_gate_passed=true、self_merge_allowed=true、manual_holds=0、failures 0。
四、风险与残余缺口
- 兼容性:这是对
steward_team_plan_preview_v0的新增必填字段,旧计划载荷会因缺goal_id被拒。可接受的前提是"生产上尚无调用方产出预览"(已核实:没有任何调用方传team_plan_context),所以现在改最便宜;等接线完成后再改就会变成一次迁移。 - 仍需做:适配器提供准入事实(现在有了可描述的 Goal)、Chat 侧落地路径、lane Todo 的意图修订绑定、多 lane 前端确认面。
五、结论
批准以 admin squash 合并(self_merge_allowed=true)。单一目的、可回滚、无权限变化;它消掉的是"准入按一个 Goal、落地按另一个 Goal"这一会造成静默改投的具体缺陷,并让后续接线有明确的事实对象。
English verdict: Approved for an admin squash merge. A team plan now names the exact Goal it staffs, and the apply refuses a plan whose named Goal differs from its settlement, so admission and settlement always describe one Goal instead of two. 67 focused tests pass with new negative coverage (missing Goal, four invalid id shapes, retargeting, unknown Goal), docs governance smoke passes, and the canary premerge gate is green.
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
动机
缺口描述得很准确:团队预览契约从来没说这份计划是哪个 Goal 的。校验 lanes 的准入拿到的是"某一个 Goal"的注册 Agent 事实,而落地时的 Goal 取自提案所在的结算——于是同一份计划可以在 A 的 Agent 事实上通过准入、却在 B 下建成 lane,这条静默扩大语义的路径是真的。而且这个"契约还没有生产调用方"的窗口期,正是修它最便宜的时候:等适配器上线后再加必填字段就是一次破坏性迁移。PR 正文自己也把这点写明了。
改动思路
三处设计我认为都对:
- Goal id 必须是注册表形状,而不是自由文本。 用模块里已有的
_plan_text(非空 + public-safe + 长度)加上一个新的_GOAL_ID形状校验,路径(../escape)、带空格句子、超长串都被挡住。计划里点名的 Goal 之后会成为"结算要落到哪个 Goal"的依据,不能是自由文本。 - 拒绝位置在读取之前。
_apply_team_plan里那三行 guard 放在load_registry(...)之前,所以被改投的计划连注册表都不读,更不会写入任何 Todo——这与既有"未知 Goal 在任何写入前被拒绝"的形状一致。 - 保留原有未知 Goal 检查。 测试改成让计划自己点名那个不存在的 Goal,而不是靠结算的 Goal 去触发,所以两条规则各自有覆盖。
具体改动
governed_transition_proposal.py:新增 _GOAL_ID 形状、预览必填 goal_id、预览输出带上 goal_id、_apply_team_plan 的三行不一致拒绝;测试两处补上"缺 Goal / 四种非法 id 形状 / 改投到另一个 Goal 且不创建任何东西、同时被点名 Goal 自己的计划仍可落地 / 未知 Goal 拒绝";RFC 中英两版各补一条 bullet 并扩展落地段落。五文件 +72/-11。
我跑的验证:PR 正文点名的四个测试文件 67 passed,examples/docs-governance-smoke.py ok,git diff --check 干净;并核对了 guard 的代码位置确实在 load_registry 之前,_plan_text 对缺失字段抛的正是测试期望的 goal_id must be a non-empty string。
一个 P3(非阻塞,已记入 findings):_apply_team_plan 的 docstring 被顺手少缩进了一格(7 行从 4 空格变 3 空格)。运行期无影响(它是字符串字面量,字节码不变),但这是控制面模块里一处无意的改动,会让 diff 比它承载的改动更吵,也会进入后续所有人的 blame。恢复原缩进即可让本次 diff 只剩那两处有意改动。
另外记录但不作为 findings:新的 _GOAL_ID(^[A-Za-z0-9][A-Za-z0-9_.-]{0,159}$)与仓库既有的 loopx/feedback.py:GOAL_ID_RE(同字符集、无上界)以及 loopx/presentation/static_site.py:_SAFE_ID(同字符集、64 上界)是同一形状的第二/第三份声明。它比既有更严(多了 160 上界),不是错误,但值得在后续顺手收敛到一处。
对主干的风险
风险集中在"契约收紧"这一条,且已被作者披露:在同一 steward_team_plan_preview_v0 下新增必填字段,任何既有 payload 生产者都会失败——但正文说明目前没有任何生产调用方(没有任何东西传 team_plan_context),所以现在改是便宜的。持久化状态(结算回执)不需要迁移,其它 kind 的校验面未动。
还有一处残余值得写明(PR 正文也承认属"下一步"):准入侧的 team_plan_context 目前只带 registered_agent_ids 与 supported_action_kinds,不带 Goal,因此准入时还无法比较"payload 点名的 Goal"和"宿主事实所属的 Goal"。但落地侧的 guard 加上按结算 Goal 的重新校验,保证不会因此产生错误的写入——最坏情况是浮现出一个之后变成 gap 的预览(可信度问题),而不是建错 lane。这条残余由"提供准入事实的适配器"那片接手,本 PR 不作过度建设。
我的整体评价
这是一次方向正确、时机合理、边界克制的契约收紧:把"这份计划属于哪个 Goal"写进契约,用注册表形状挡住自由文本,把拒绝放在读取之前,并保留原有的未知 Goal 检查;两版 RFC 同步记录,测试正反例齐备(含"什么都没创建、被点名 Goal 自己的计划仍可落地"这种关键断言),我复跑得到 67 passed。
建议把 _apply_team_plan docstring 的缩进还原(P3),顺便考虑后续收敛 goal id 形状规则;两者都不构成合并阻塞。
English verdict: APPROVE (exact head d475cb6)
Problem
The team preview contract never said which Goal a plan was for. The admission that validates its
lanes receives host facts for one Goal's registered Agents, while the apply derived its Goal from
the settlement the proposal arrived in — so the same plan could be admitted against one Goal's Agents
and materialized under another, and the wiring slice that has to supply
team_plan_contexthad nofact to describe.
What changed
acceptance signal and stop condition.
become the Goal a settlement materializes into.
anything, so admission and settlement always describe one Goal.
that names the same unknown Goal instead of relying on the settlement's Goal.
Changed surfaces
loopx/control_plane/work_items/governed_transition_proposal.py(validator + apply guard).tests/test_steward_team_plan_preview.py,tests/test_steward_team_plan_apply.py.docs/architecture/rfcs/harness-selection-dsh-pi-v0.mdand.zh-CN.md.Validation
tests/test_steward_team_plan_preview.py,tests/test_steward_team_plan_apply.py,tests/extensions/test_governed_capability_execution.py,tests/test_manager_ssh_evidence.py:67 passed, including a preview that omits the Goal, four invalid Goal-id shapes, a plan retargeted
to another Goal that creates nothing and still lets the named Goal's own plan apply, and the
unknown-Goal refusal.
examples/docs-governance-smoke.py: ok.loopx canary premerge --from-git-diff:merge_gate_passed=true,self_merge_allowed=true,manual_holds=0, failures 0.Boundaries
This refines a contract that no production caller produces yet (nothing supplies
team_plan_context),which is why the change is cheap now and awkward later. It grants nothing: the plan still creates no
Todo, registers no Agent, sets no quota and spends nothing, and an owner's confirmation of the exact
preview is still the only thing that admits an apply. Still missing after this: the adapter that
supplies the admission facts, the Chat-side apply path, the intent-revision binding on materialized
lane Todos, and the frontend confirmation surface.