test(coordination): carry a recorded decision rejection in the production-scale fixture - #4549
Conversation
…tion-scale fixture The shared production-scale coordination fixture only generated approvals, so no provider conformance arm ever had to prove that an explicitly refused decision stays recorded without becoming active authority. Add a bounded rejection band to the checked-in envelope and the shared generator, expose the derived expectations, assert the dimension on every provider arm, and add one independent mutation/negative case. - envelope: rejected_standing_decision_count. The refusal is scoped under a second decision kind so it keeps its own decision identity and cannot merge into the approved scope. - generator: expected_inactive_standing_decision_count, plus a retained-standing count that now includes refusals; expected_user_archive_count is derived from the retained count instead of a fixed offset. - conformance: per-arm inactive_count and per-entry active === (outcome === "approve"), so no arm can present a refusal as an approval. - fixture test: a rejection is a receipt, only an explicit approval activates a scope, and dropping the typed decision_scope leaves no entry at all. Refs GH-C102 (loopx-project#4541) Signed-off-by: superwesleyhys-ux <251160695+superwesleyhys-ux@users.noreply.github.com>
44e89d8 to
819d167
Compare
huangruiteng
left a comment
There was a problem hiding this comment.
动机
这个 PR 补的是共享 production-scale coordination fixture 里缺的那一半:它以前只生成 approve,于是没有任何 provider arm 需要证明"明确拒绝"这一半。判据本身不是"没有生效的批准",而是"有被记录的拒绝"——如果 provider 把"没有 active 批准"读成"没有任何决定",所有 arm 都照样绿。判据 active = (decision_outcome === "approve") 之前在 standing_decision.test.ts(纯投影单测)里覆盖过,但生成器、envelope 和 conformance harness 这条真实链路上没有拒绝样本。
改动思路
作者没有新造一个 fixture,也没有把断言堆成一个新 smoke,而是扩展现有那条链路:envelope 加一个 rejected_standing_decision_count: 2 band,生成器在同一个 granularity: "goal" 上换一个 kind: "write_scope" 造两条 reject(保持 decision identity 分到独立的组,不与 approve 抢同一个 identity),conformance harness 从"只看 active_count"升级为"看 inactive_count + 逐条 active 与 outcome 一致",再加一个独立模块放 mutation 与负例。三个设计选择我都核对过:
- 拒绝与批准是两个 dimension,不是两个 key:
kind不同 → authority identity([kind, granularity, scope_key, global])不同 → 拒绝不会把已有批准挤成 inactive。这正是我担心的方向,代码里candidate()用scope.kind/granularity/scope_key组成 identity,两条拒绝共享同一 identity,所以投影只出一条 inactive 条目。 - archive 期望是推导出来的,不是放宽:
expected_user_archive_count变成min(done - standing - rejected, done - 5),只因为"被留下的 standing receipt 多了两条"而下降两条;断言它的 arm 没有改断言本身,仍然通过。 - 负例是真的负例:mutation case 把最新一条 reject 改成 approve,要求 activation 恰好 +1 且没有任何 reject 仍为 active;另一个 case 去掉 typed
decision_scope,要求它不再算 standing receipt——这条正好防住"从 prose 推断身份"。
具体改动
tests/fixtures/control_plane/coordination_production_scale_v0.json(+1):新增rejected_standing_decision_count: 2。tests/control_plane_ts/production_scale_coordination_fixture.ts(+40/-2):生成拒绝 band、导出PRODUCTION_SCALE_REJECTED_DECISION_COUNT、导出expected_inactive_standing_decision_count(按 identity 去重计),并把expected_user_archive_count改成推导式。tests/control_plane_ts/authority_store_conformance.ts(+11/-1):每个 arm 断言inactive_count,并对所有条目断言entry.active === (entry.outcome === "approve")。tests/control_plane_ts/production_scale_rejected_decision.test.ts(新增 79 行):正向(拒绝是 receipt、status done、global_gate)、mutation(reject→approve)、负例(缺 typed scope 不构成 receipt)三类。
关键代码讲解
production_scale_coordination_fixture.ts的 rejected band:复用partialEnd + linked_decision_count之后的 user 行,只加task_class/decision_scope/decision_outcome/global_gate/goal_bound,与 approve band 的唯一语义差别是kind和 outcome——这是"同一判据、第二种结果"的最小构造。expected_inactive_standing_decision_count:用new Set(JSON.stringify([kind, granularity, scope_key, "global"]))按 identity 计数,而不是按行数——所以两条拒绝只贡献 1,和投影的"一个 identity 一条 entry"对齐。expected_user_archive_count = Math.min(expectedUserDone - expectedStanding - rejectedStanding.length, expectedUserDone - 5):说明保留数变成max(standing + rejected, 5),即拒绝和批准一样进入"不可搬动"集合;min保证期望只会变小,不会凭空放宽。authority_store_conformance.ts的逐条断言entry.active === (entry.outcome === "approve"):把"只有显式批准才是权威"变成每个 provider arm 都必须过的判据,而不是只看总数。production_scale_rejected_decision.test.ts的 mutation case:把最新 reject 改成 approve 后要求active恰好 +1、且没有任何 reject 仍为 active——如果投影是按"最近一条 receipt 决定整组"实现的,这条会精确抓住。
对主干的风险
运行时风险为零:4 个文件全在 tests/,没有触碰 loopx/control_plane/todos/standing_decision.ts 或任何 provider 代码,所以不存在产品行为变化。真正需要审的是"这条测试会不会把旧期望悄悄放宽/写空",我的核对结论是没有:
expected_user_archive_count的下降幅度正好等于两条被保留的拒绝行,且所有断言它的 arm 断言本身未改;fixture 自带的 drift 自检(requireSafeCount与构造期断言)仍然 fail-closed。- 新增断言不是"复述实现":
inactive_count与逐条active是判据,mutation/负例给出独立反例。
证据缺口(环境,不是本 PR):SQLite 与 PostgreSQL arm 在本机跑不了——本机是 Node 25.5.0 / SQLite 3.51.2,所有 SQLite arm 在任何 fixture 断言之前就报 SQLite authority runtime is not qualified (… require the WAL-reset fix …, Use the qualified Node 22.22.3 runtime)。也就是说这一半我只在 file / NoKV / JSONL 三个 arm 上验证过;如果你希望 sqlite/postgres 也覆盖到拒绝维度,需要在资格运行时上补跑,这属于环境问题。
我的整体评价
APPROVE。 这是一个"小而准"的测试增强:不新增 fixture、不改产品代码,把一条已经存在的判据(拒绝是决定、且永不激活)第一次推进到所有 provider 共享的 conformance 链路里,并配了 mutation 与"缺 typed scope 不成 receipt"两个独立反例;archive 期望的变化是推导出来的、只降两条,属于"跟着事实走"而不是放宽。证据:git merge-tree 对 main 干净;新模块 + fixture 自检 6 passed;file/NoKV/JSONL arm 301 passed;同一 envelope 的两个 Python 消费者 21 passed;SQLite/PostgreSQL arm 因本机运行时未达资格未执行(原因与判据无关)。
English verdict: APPROVE — at head 819d167 this is a test-only change (4 files under tests/, +128/-3, no production code) that finally makes every shared provider arm prove the refusal half of the standing-decision invariant: a recorded reject stays a standing receipt with active: false, counts under inactive_count, survives archive, and can never raise active_count. I verified the design choices that could have weakened coverage: the rejection band uses a second decision kind so identity grouping cannot deactivate an existing approval, expected_user_archive_count is now a derivation that moves by exactly the two retained rows rather than a relaxed constant, and the new module adds an independent mutation case (newest reject -> approve must raise activation by exactly one) plus a negative case (a rejection without its typed decision scope is not a standing receipt). Evidence: merge-tree against origin/main is clean; the new module and fixture self-test pass (6), the file/NoKV/JSONL authority-store arms pass (301), and the two Python consumers of the same envelope pass (21). The SQLite and PostgreSQL arms were not executed here because this host's Node 25.5.0 / SQLite 3.51.2 is rejected by the runtime qualification check before any fixture assertion.
Refs GH-C102 — contributor task board row: "Extend the shared production-scale coordination fixture with one accepted RFC invariant or reproduced public regression that its current envelope does not cover. Update the checked-in envelope, shared generator, and an independent negative or mutation-style assertion; run the same dimension through every affected provider conformance arm."
Claimed in #4541.
Invariant
projectStandingDecisionskeeps an explicitreject/cancelas astanding_decision_receipt_v0entry withactive: false, counts it underinactive_count, retains it during archive exactly like an approval, and neverlets it raise
active_count: activation isdecision_outcome === "approve"andnothing else.
tests/control_plane_ts/standing_decision.test.tscovers the pureprojection, but the shared fixture every provider arm reduces only ever
generated approvals, so no arm had to prove the refusal half.
What changed
tests/fixtures/control_plane/coordination_production_scale_v0.jsonrejected_standing_decision_count: 2bandtests/control_plane_ts/production_scale_coordination_fixture.tsPRODUCTION_SCALE_REJECTED_DECISION_COUNT, exposeexpected_inactive_standing_decision_count, deriveexpected_user_archive_countfrom the retained counttests/control_plane_ts/authority_store_conformance.tsinactive_countand per-entryactive === (outcome === "approve")tests/control_plane_ts/production_scale_rejected_decision.test.tsDesign choices worth reviewing:
scope_key. The rejection band useskind: "write_scope"on the samegranularity: "goal"andscope_key, soauthority identity (
[kind, granularity, scope_key, global]) keeps the refusalin its own group. Reusing the approved kind with a different key would have
been ambiguous about which dimension separates them.
expected_user_archive_countis derived, not relaxed. Archive retains everystanding receipt, so two refusals leave two fewer movable rows: the value is
now
min(done - retained, done - 5)instead ofdone - 5. The dimension stillmoves 186 rows; it is not loosened to make an arm pass.
collapses them to a single inactive entry via the existing chronology rule.
expected_inactive_standing_decision_countcounts identities, and the focusedtest pins that collapse.
Non-goals: no standing-decision predicate change, no projection semantics change,
no production code, no provider configuration change.
Validation
node --test tests/control_plane_ts/production_scale_rejected_decision.test.ts(3 tests: receipt shape, approval-only activation mutation, typed-scope removal)
node --test tests/control_plane_ts/authority_store.test.ts→ 99/99 (file arm,includes the new
inactive_countassertions)node --test tests/control_plane_ts/sqlite_authority_store.test.ts→ 112/112(SQLite arm)
npm run typecheck:control-plane→ cleanpython3 examples/control_plane/todo-archive-completed-smoke.py,python3 examples/control_plane/todo-standing-decision-authority-smoke.py→ ok (archive retention contract unchanged in behavior)
python3 -m pytest -q --basetemp=<fresh tmp> tests/control_plane/test_todo_semantic_kernel.py tests/control_plane/test_coordination_runtime_shadow_adapter.py→ 21 passedTwo environment notes, both pre-existing and unrelated to this change:
3.51.3+ WAL-reset fix). The default runtime here was 22.22.2, which fails
those arms at baseline; they pass on 22.22.3.
concurrency, bootstrap and rollback families only — none of them touch
standing decisions, archiving, or the production-scale fixture, and all pass
when the affected files run on their own.
pytestalso needs--basetemphere because the default
tmp_pathroot cannot be created in this sandbox.CI remains authoritative for the NoKV and PostgreSQL arms.