From 256bcc0e66ebe8a946e300b8d465731caea02bf4 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:45:43 +0800 Subject: [PATCH 1/3] feat(verification): classify and gate policy words in the mechanism surface Implement the explicitly approved mechanism-not-policy classification/check slice. Keep a maintained vocabulary and exact counts keyed by path, named scope and word; classify all 307 occurrences in 123 records across 28 files. Scan identifiers and runtime/SQL literals, ignore TypeScript comments, and refuse new, changed or stale sites without a reviewed table update. Wire check:policy-words into the shared blocking static contract and its agent plan, with a guard against duplicate/missing execution. Move the paired record to implemented with explicit approval, update inbound links, and retain the work-shape rewrite, field experiments and frame/storage migration as deferred/unapproved scope rather than claiming separation. Validation: targeted tests 16/16; keyless product tests 1594/1594; scoped agent:verify passed all 16 blocking checks under the existing 150s cap. The initial full run hit that cap and remains recorded as incomplete. Full mutation sweep caught 110/110 and restored 22/22 byte-identically; one repeated-dispatch mutant was caught by its 30s cap, not an assertion. --- agent-context.yaml | 1 + ...2026-09-20-the-program-answers-legality.md | 2 +- ...9-20-the-program-answers-legality.zh-CN.md | 2 +- .../2026-09-21-mechanism-not-policy.md | 113 +- .../2026-09-21-mechanism-not-policy.zh-CN.md | 95 ++ ...-09-24-mutants-are-derived-not-anchored.md | 4 +- ...-mutants-are-derived-not-anchored.zh-CN.md | 4 +- .../2026-09-21-mechanism-not-policy.zh-CN.md | 91 -- .../2026-09-21-the-frame-and-its-storage.md | 2 +- ...6-09-21-the-frame-and-its-storage.zh-CN.md | 2 +- docs/design/ci-cd-and-quality.md | 2 + docs/design/mechanism-in-the-middle.md | 9 +- docs/design/mechanism-in-the-middle.zh-CN.md | 6 +- package.json | 3 +- tests/tools/policy-word-check.test.ts | 110 ++ tests/tools/test-groups.test.ts | 11 + tools/policy-word-check.ts | 179 +++ tools/policy-word-list.ts | 1095 +++++++++++++++++ 18 files changed, 1580 insertions(+), 151 deletions(-) rename docs/decisions/{proposed => implemented}/2026-09-21-mechanism-not-policy.md (52%) create mode 100644 docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md delete mode 100644 docs/decisions/proposed/2026-09-21-mechanism-not-policy.zh-CN.md create mode 100644 tests/tools/policy-word-check.test.ts create mode 100644 tools/policy-word-check.ts create mode 100644 tools/policy-word-list.ts diff --git a/agent-context.yaml b/agent-context.yaml index b730e723..5637c2f3 100644 --- a/agent-context.yaml +++ b/agent-context.yaml @@ -153,6 +153,7 @@ routes: - complexity:gate - verify:packages - glossary:check + - check:policy-words - rtm:check - test:product advisory: [verify:research, verify:chaos] diff --git a/docs/decisions/implemented/2026-09-20-the-program-answers-legality.md b/docs/decisions/implemented/2026-09-20-the-program-answers-legality.md index 5fb72b92..65df4bca 100644 --- a/docs/decisions/implemented/2026-09-20-the-program-answers-legality.md +++ b/docs/decisions/implemented/2026-09-20-the-program-answers-legality.md @@ -4,7 +4,7 @@ **Status:** implemented **Approved:** explicit -**Relates to:** [Mechanism, not policy](../proposed/2026-09-21-mechanism-not-policy.md), [Board governance and capability addressing](2026-09-06-board-governance-addressing.md), [Name the collaboration protocol and its task-unit sub-protocol](2026-09-20-name-the-collaboration-protocol.md), [Task unit semantics](../../design/task-unit-semantics.md), [The contract's obligations](../../design/task-unit-semantics-obligations.md), [Protocol-governed collaboration: the parts, the gaps](../../design/protocol-governed-collaboration.md) +**Relates to:** [Mechanism, not policy](2026-09-21-mechanism-not-policy.md), [Board governance and capability addressing](2026-09-06-board-governance-addressing.md), [Name the collaboration protocol and its task-unit sub-protocol](2026-09-20-name-the-collaboration-protocol.md), [Task unit semantics](../../design/task-unit-semantics.md), [The contract's obligations](../../design/task-unit-semantics-obligations.md), [Protocol-governed collaboration: the parts, the gaps](../../design/protocol-governed-collaboration.md) ## Problem diff --git a/docs/decisions/implemented/2026-09-20-the-program-answers-legality.zh-CN.md b/docs/decisions/implemented/2026-09-20-the-program-answers-legality.zh-CN.md index f8812e0b..6475d5a9 100644 --- a/docs/decisions/implemented/2026-09-20-the-program-answers-legality.zh-CN.md +++ b/docs/decisions/implemented/2026-09-20-the-program-answers-legality.zh-CN.md @@ -4,7 +4,7 @@ **Status:** implemented **Approved:** explicit -**Relates to:** [机制,不是策略](../proposed/2026-09-21-mechanism-not-policy.zh-CN.md)、[黑板治理与能力寻址](2026-09-06-board-governance-addressing.zh-CN.md)、[给协作协议及其任务单元子协议命名](2026-09-20-name-the-collaboration-protocol.zh-CN.md)、[任务单元语义](../../design/task-unit-semantics.md)、[契约的义务](../../design/task-unit-semantics-obligations.md)、[协议化协作:组成部分与空缺](../../design/protocol-governed-collaboration.zh-CN.md) +**Relates to:** [机制,不是策略](2026-09-21-mechanism-not-policy.zh-CN.md)、[黑板治理与能力寻址](2026-09-06-board-governance-addressing.zh-CN.md)、[给协作协议及其任务单元子协议命名](2026-09-20-name-the-collaboration-protocol.zh-CN.md)、[任务单元语义](../../design/task-unit-semantics.md)、[契约的义务](../../design/task-unit-semantics-obligations.md)、[协议化协作:组成部分与空缺](../../design/protocol-governed-collaboration.zh-CN.md) ## 问题 diff --git a/docs/decisions/proposed/2026-09-21-mechanism-not-policy.md b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md similarity index 52% rename from docs/decisions/proposed/2026-09-21-mechanism-not-policy.md rename to docs/decisions/implemented/2026-09-21-mechanism-not-policy.md index dd9b0dc5..7cbedd26 100644 --- a/docs/decisions/proposed/2026-09-21-mechanism-not-policy.md +++ b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md @@ -2,12 +2,13 @@ [中文](2026-09-21-mechanism-not-policy.zh-CN.md) -**Status:** proposed -**Relates to:** [The program answers legality](../implemented/2026-09-20-the-program-answers-legality.md), [The frame, its data format, and its storage](2026-09-21-the-frame-and-its-storage.md), [Board governance and capability addressing](../implemented/2026-09-06-board-governance-addressing.md), [Protocol-governed collaboration: the parts, the gaps](../../design/protocol-governed-collaboration.md), [Task unit semantics](../../design/task-unit-semantics.md) +**Status:** implemented +**Approved:** explicit +**Relates to:** [The program answers legality](2026-09-20-the-program-answers-legality.md), [The frame, its data format, and its storage](../proposed/2026-09-21-the-frame-and-its-storage.md), [Board governance and capability addressing](../implemented/2026-09-06-board-governance-addressing.md), [Protocol-governed collaboration: the parts, the gaps](../../design/protocol-governed-collaboration.md), [Task unit semantics](../../design/task-unit-semantics.md) ## Problem -Two proposed records each need the same sentence, and neither states it. The legality record says what the +The legality and frame/storage records need the same ownership rule. The legality record says what the program does and what it refuses to decide. The frame and storage record says what the core knows and what it never parses. Those are the same division, seen from the side of the decision and from the side of the data, and because nothing names it, the division has to be re-argued every time a field or a rule appears. @@ -21,7 +22,7 @@ The split also needs to be sharp enough to settle an argument rather than to ser out of the core" does not decide whether `effect` or `operation` may be a column, or whether a legality rule may live in the program at all. -## Proposal +## Decision Take Hydra's principle - a kernel provides mechanisms and refuses policy - and state it as two contracts. @@ -44,11 +45,29 @@ Take Hydra's principle - a kernel provides mechanisms and refuses policy - and s **The decision procedure.** For each field, type and code path, ask whether it is mechanism or policy. A rule's _check_ is mechanism; the rule's _name, wording and enablement_ are policy. -**The mechanically checkable rule.** No policy word may appear in the mechanism layer's code, type -declarations or schema paths. The list is maintained - `patch`, `editable`, `files`, `instruction`, -`checks`, `repair-first` - and a hit is a leak rather than a style question. The list may grow, and adding -a word is recorded. A word alone is not enough to judge: `files` is a policy word in a ticket type and a -mechanism word at a filesystem boundary, so the check carries the path. +**The mechanically checkable rule.** Every vocabulary hit on the declared mechanism surface is +classified as `mechanism`, `policy`, or `undecided`, with a rationale and an exact occurrence count. +[The maintained list and classification table](../../../tools/policy-word-list.ts) own the vocabulary, +scan paths, and classifications. A word alone does not decide its role: `files` can name a ticket payload +or a filesystem boundary. Roles are not files. + +[`check:policy-words`](../../../tools/policy-word-check.ts) is blocking in the shared static contract. +It scans TypeScript identifiers and string/template literals, including SQL, but ignores TypeScript +comments. Camel, snake and kebab spellings count; `dispatch` does not count as `patch`. Each record is +keyed by repository path, enclosing named declaration/method chain (or ``), and word. Line +numbers are diagnostic only; a multiline literal is located at its starting line. Whitespace and +comment changes do not change a record's identity. `npm run check:policy-words -- --list` prints the +classified sites, observed locations and reasons. + +Existing policy hits are explicitly grandfathered, not declared clean. A new site, a count change in +either direction, a stale or duplicate record, invalid classification, invalid TypeScript or missing +scan path fails the check. Removing a leak requires retiring or reducing its record. New occurrences +require an explicit classification and rationale in the same reviewed change; the checker never +refreshes counts automatically. Vocabulary additions are recorded here with their rationale. + +The scope is bounded by the maintained path list, not all source, tests or drivers. Source imports and +schema literals on those paths count too. This implements the approved classification and static-check +slice (original Plan 2–3), not the work-shape conversion. The frame/storage proposal remains proposed. **Rows deliberately left undecided**, so that they are not settled by accident: @@ -58,7 +77,12 @@ mechanism word at a filesystem boundary, so the check carries the path. but whether an input carries content or only a digest is a separate question. - `operation`, which may be policy. -## What this makes of the work shape +## Deferred + +### Work-shape boundary + +The work-shape conversion (original Plan 5) is not part of the approved slice. The patch assumptions +below remain; their classification does not assert that an opaque adapter seam has been implemented. A work shape is not a core concept. It is the policy layer's name for the declaration-and-artifact pair that the core carries without understanding it. The six sites where the patch assumption lives classify as @@ -69,8 +93,8 @@ follows. | `BoardTicket.patch?: FrozenPatchTask & { digest }` | policy | the ticket carries an opaque `declaration` with a digest | | `patchFrozen()` calling `preparePatchWork`, and the `not a patch task` throw | policy inside the core | freezing and field validation move into the shape adapter; the core only hands back the opaque declaration | | `refuseWidening` reading `parent.patch.editable` | check is mechanism, wording and enablement are policy | permission closure becomes a declared constraint the program enforces and reports reasons for | -| the ten `FrozenPatchWork` signatures in the session mechanism | mixed | rendering a declaration into a prompt and submitting an artifact are policy; session lifecycle, metrics and cancellation are mechanism | -| `task_run_tasks.patch_files` / `patch_editable`, parsed in the store | policy in the schema | belongs in the payload document; the mechanism part is the run, task, revision, input, dependency and operation columns | +| `FrozenPatchWork` signatures in the session mechanism | mixed | rendering a declaration into a prompt and submitting an artifact are policy; session lifecycle, metrics and cancellation are mechanism | +| `task_run_tasks.patch_files` / `patch_editable`, parsed in the store | policy in the schema | belongs in the payload document; run, task and revision are mechanism, while input/dependency granularity and operation remain undecided | | drivers asserting `ticket.patch!.digest` | policy assertion | the mechanism assertion is that a declaration carries a digest, that a claim binds the attempt, and that a delivery binds the digest | The legality rule found today is the clearest case of the refinement this record adds: its check belongs to @@ -80,34 +104,35 @@ shape of fix as step 3 of the legality record, which landed on 2026-09-23: repai constraint rather than shared planning policy. Two independent fixes taking the same shape is evidence that the classification is the right one. +### Field experiments and separation evidence + +- `task_run_tasks.effect`, input/dependency granularity and `operation` remain undecided. Their owning + field must be exercised under an alternative declaration before deciding ownership; this gate does + not include those words or resolve them. +- The `files` groups in schema migration and `TaskUnit` are marked `undecided`: a group combines + patch-specific paths with another role. Resolve them by separating the DDL subjects or exercising + an alternative input declaration, not by relabelling the entire group mechanism. +- The remaining permission-closure name/enablement coupling needs a declared constraint while its + check continues to execute in the program and return reasons. +- Zero policy hits and a second work shape requiring no mechanism changes are unverified separation + goals, not consequences of passing this ratchet. The draft's [second-shape experiment](../../design/mechanism-in-the-middle.md#results) + remains evidence of coupling, not a completed adapter migration. + ## Alternatives considered - **Keep the slogan and decide case by case.** Rejected: that is what produced six sites, and it offers no test to apply. -- **Put the principle in the frame record.** Rejected: the principle is wider than the frame - it also - governs the program's share of decisions - so the frame record would become the owner of a rule about the - program. -- **Put it in the legality record.** Rejected for the same reason in reverse: that record is narrower, being - about the program's decisions, and what the core may know is not a legality question. -- **A plugin framework with a registry above the core.** Rejected: no second policy exists to justify the - machinery; one default adapter plus one other shape tests the seam. -- **Decide the undecided rows now.** Rejected: choosing them by preference is precisely what this record - replaces with evidence. - -## Acceptance criteria - -- The classification covers every site that mentions a policy word, each row marked mechanism, policy, or - undecided. -- No policy word appears in the mechanism layer's read and write paths, type declarations, or schema paths, - from a maintained list and checked mechanically. -- A legality rule's name and enablement come from a declaration, while its check runs in the program and - returns a reason per unit. -- Adding a second work shape changes no mechanism code, demonstrated by the value work the data-check - runner already performs. -- Every undecided row is marked as undecided and is resolved by an experiment rather than by preference. -- The legality record and the frame record both point here, so the division has one home. - -## Risks +- **Put the principle in either narrower record.** Rejected: it governs both what the core knows and the + program's share of decisions; neither the frame nor legality record owns the full rule. +- **A plugin framework above the core.** Rejected: one default adapter plus one other shape can test the + seam without building a registry framework. +- **Decide undecided rows by preference.** Rejected: the field experiments remain the deciding evidence. +- **Fail every existing policy hit immediately.** Rejected: the approved slice inventories the debt; + it does not authorize a big-bang work-shape rewrite. +- **A file-level word exemption or a maximum count.** Rejected: it silently admits replacement sites or + leaves dead exemptions. Named-scope exact counts expose moves and reductions for review. + +## Consequences - **Gutting the core.** Hydra's lesson cuts both ways: a kernel with no default policy is unusable. The practical form is that the core may carry one default policy, patch work, and must not require it. @@ -117,13 +142,9 @@ is the right one. policy nobody declared. - **A policy word may legitimately remain for a release.** This record does not require a big-bang rename; it requires that each remaining occurrence is listed. -- **A grep check can be gamed by synonyms.** The check is a floor, not a proof of separation. - -## Plan - -1. This record. No code. -2. The classification table, with the undecided rows marked. -3. The check: a maintained policy-word list plus a script, wired into the static set. -4. Both records point here. -5. Then the work-shape slice: the mechanism side carries an opaque declaration, with patch as the default - adapter rather than a type. +- **The ratchet can be gamed.** Synonyms and same-count replacements within one named scope can evade it. + Classifications and scope changes need review. The check is a floor, not proof of separation. +- **Exact counts cost maintenance.** Intentional code moves and removals require updating the table; + this prevents silent growth but does not prohibit a reviewed policy change. +- The legality and frame/storage records link here for this shared ownership rule. Neither link approves + the separate frame/storage migration. diff --git a/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md new file mode 100644 index 00000000..04c3e0e0 --- /dev/null +++ b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md @@ -0,0 +1,95 @@ +# 机制,不是策略 + +[English](2026-09-21-mechanism-not-policy.md) + +**Status:** implemented +**Approved:** explicit +**Relates to:** [程序只回答合法性](2026-09-20-the-program-answers-legality.zh-CN.md)、[帧、它的数据格式与它的存储](../proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md)、[黑板治理与能力寻址](../implemented/2026-09-06-board-governance-addressing.zh-CN.md)、[协议化协作:组成部分与空缺](../../design/protocol-governed-collaboration.zh-CN.md)、[任务单元语义](../../design/task-unit-semantics.md) + +## 问题 + +合法性与帧/存储记录需要同一条归属规则。合法性记录讲的是**程序做什么**、以及它拒绝决定什么;帧与存储记录讲的是**核心知道什么**、以及它从不解析什么。这是同一个划分,一面从决定看、一面从数据看;因为没有名字,每次出现一个新字段或一条新规则,这个划分都得重新论证一遍。 + +代价已经看得见:**"工作就是补丁"这个假设走到了六处**,其中一处是用核心自己的词汇写出来的合法性规则——`refuseWidening` 靠读 `parent.patch.editable` 来判定 permission closure,于是"关于写集"的规则被写成了"关于某个核心不该知道的工作形态"的规则。 + +而且这个划分必须锐到能**终结争论**,而不是当口号。"别把策略放进核心"这句话,并不能决定 `effect` 或 `operation` 能不能是一列,也不能决定一条合法性规则到底能不能住在程序里。 + +## 决策 + +取 Hydra 的原则——**内核提供机制、拒绝策略**——把它写成两条契约。 + +**机制契约**(核心:黑板、存储、程序): + +- 一次认领:比较交换、租约、attempt 围栏 +- 一份**不透明**声明 + 摘要 +- 一份**不透明**产物 + 摘要 +- 一个**由别人给出**的裁决 +- 生命周期:过期、回收、唤醒投递、紧凑读 +- 合法集:此刻哪些单元合法、有序、按声明的槽位预算裁剪、每个单元给理由 + +**策略契约**(核心之上:协议、计划、各 agent): + +- 单元的输入是什么、产物属于哪一类、产物怎么被判、工作怎么跑(同步还是分离式检查、可否打断、中途放弃是否安全)、以及它的并发前提 +- 合法性规则的**措辞与启用**:由协议具名、由计划声明启用 +- 选谁、按什么顺序、谁采纳、谁裁决 + +**判定程序**:对每个字段、每个类型、每条代码路径问一次——**这是机制还是策略?** 一条规则的**检查**是机制,规则的**名字、措辞与启用**是策略。 + +**可机械检查的规则**:声明的机制扫描面上,每次词汇命中都归入 `mechanism`、`policy` 或 `undecided`,附理由与精确次数。[维护的词表与分类表](../../../tools/policy-word-list.ts)拥有词汇、扫描路径和分类。词本身不决定角色:`files` 可以是票的载荷,也可以是文件系统边界。角色不等于文件。 + +[`check:policy-words`](../../../tools/policy-word-check.ts) 在共享静态契约中阻塞执行。它扫描 TypeScript 标识符、字符串和模板字面量(包括 SQL),忽略 TypeScript 注释;camel、snake、kebab 拼法都计入,但 `dispatch` 不算 `patch`。记录以仓库路径、外层具名声明/方法链(或 ``)和词为键。行号仅用于诊断,多行字面量定位到起始行;空白与注释变化不改变记录身份。`npm run check:policy-words -- --list` 输出分类站点、观察到的位置与理由。 + +既有策略命中有明确登记,不被宣称为清洁。新增站点、次数增减、失效或重复记录、无效分类、非法 TypeScript、缺失扫描路径都会使检查失败。消除泄漏需要删除或缩减相应记录;新增命中需要在同一份受审改动中明确分类并解释。检查器不自动刷新次数。加词及其理由记录在本文。 + +扫描面以维护的路径表为界,不涵盖全部源码、测试或驱动;范围内的 import 与 schema 字面量同样计入。这落地的是已批准的分类与静态检查切片(原 Plan 2–3),不是工作形态转换。帧/存储记录仍为 proposed。 + +**刻意留作待判的行**(免得被顺手定掉): + +- `task_run_tasks.effect`(声明的写集):只有当**机制必须强制写集不相交**时它才是机制;若强制是协议的义务,它就是策略数据。 +- `input` 与 `dependencies` 的粒度:依赖影响合法性与顺序,这是机制;但"输入"是携带内容还是只携带摘要,是另一个问题。 +- `operation`:可能是策略。 + +## 未完成项 + +### 工作形态边界 + +工作形态转换(原 Plan 5)不在获批切片内。以下补丁假设仍然存在;分类不等于已经实现不透明适配器边界。 + +**工作形态不是核心概念**,它是策略层对"核心不透明携带的那对声明与产物"的叫法。六处站点的分类如下。 + +| 站点 | 机制还是策略 | 归属 | +| ---------------------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------- | +| `BoardTicket.patch?: FrozenPatchTask & { digest }` | 策略 | 票上带一份不透明的 `declaration` 加摘要 | +| `patchFrozen()` 调 `preparePatchWork`、以及 `throw "not a patch task"` | 策略(在核心里) | 冻结与字段校验移进形态适配器;核心只交出不透明声明 | +| `refuseWidening` 读 `parent.patch.editable` | 检查是机制;措辞与启用是策略 | permission closure 变成程序**执行并给理由**的一条声明约束 | +| 会话机制的 `FrozenPatchWork` 签名 | 混合 | 把声明渲染成提示、提交产物是策略;会话生命周期、指标、取消是机制 | +| `task_run_tasks.patch_files` / `patch_editable`(store 里解析) | 策略进了 schema | 该进载荷文档;run、task、revision 是机制,input/dependencies 粒度与 operation 仍待判 | +| 驱动断言 `ticket.patch!.digest` | 策略断言 | 机制断言是:声明带摘要、认领绑 attempt、交付绑摘要 | + +今天那条合法性规则正是本文新增的那点修正的最清楚的例子:**它的检查属于程序,而 permission closure 的名字与启用属于声明。** 所以修法不是把检查搬出程序,而是**别再用核心的词汇硬编码这条规则**——这与合法性记录第 3 步(把 repair-first 从共享规划策略变成由计划声明的约束,2026-09-23 已落地)是**同一形状的修法**。两处各自独立的修法落在同一形状上,是"这个分类是对的"的证据。 + +### 字段实验与分离证据 + +- `task_run_tasks.effect`、input/dependencies 粒度与 `operation` 仍待判;其归属字段需要在另一种声明下被实际使用,再判断归属。门禁不包含这些词,也不替实验定案。 +- schema 迁移与 `TaskUnit` 中的 `files` 组标为 `undecided`:同组混有补丁专有路径与另一角色。通过拆开 DDL 主题或运行另一种输入声明来解决,不把整组改标为机制。 +- 余下 permission closure 的名字与启用耦合,需要声明约束;检查仍在程序中执行并按单元返回理由。 +- 零策略命中、第二种工作形态不改机制代码,仍是未验证的分离目标,不是门禁通过的结论。草稿的[第二形态实验](../../design/mechanism-in-the-middle.zh-CN.md#结果)仍是耦合证据,不是已完成的适配器迁移。 + +## 考虑过的替代方案 + +- **留着口号,逐案判断。** 拒绝:那正是产出六处站点的方式,没有可套用的检验。 +- **放进其中一份更窄的记录。** 拒绝:原则同时管核心可以知道什么和程序那半决定;帧或合法性记录都不拥有完整规则。 +- **核心之上建插件框架。** 拒绝:一个默认适配器加另一种形态就能检验边界,不需要注册表框架。 +- **凭偏好决定待判行。** 拒绝:字段实验仍是定案证据。 +- **立即拒绝所有既有策略命中。** 拒绝:获批切片登记债务,不授权一次性重写工作形态。 +- **按文件豁免某词,或只限制次数上限。** 拒绝:会静默接纳替换站点或留下失效豁免。具名作用域精确次数让移动和缩减进入审查。 + +## 后果 + +- **把核心做空。** Hydra 的教训是双向的:一个不带默认策略的内核不可用。务实形式是核心**可以带一个默认策略**(补丁工作),但**不得要求**它。 +- **策略词清单会僵化。** 同一个词在一处是机制、在另一处是策略,所以检查要带路径而不只是词,而清单必须接受加词。 +- **待判的行可能永久待判。** 一行标着待判却没有实验在背后,就是一个没人声明的策略。 +- **某个策略词可能合理地再留一个版本。** 本文不要求一次性重命名;它要求每一处残留都被**列出来**。 +- **门禁可以被绕过。** 同义词、同一具名作用域内次数不变的替换,都可能绕过检查。分类和扫描面变化需要审查;检查是地板,不是已经分离的证明。 +- **精确次数有维护成本。** 有意移动或消除代码需要更新表;它防止静默增长,不禁止经过审查的策略变动。 +- 合法性与帧/存储记录指向本文,共用这一归属规则;链接不批准独立的帧/存储迁移。 diff --git a/docs/decisions/implemented/2026-09-24-mutants-are-derived-not-anchored.md b/docs/decisions/implemented/2026-09-24-mutants-are-derived-not-anchored.md index d71c508b..166ba6bc 100644 --- a/docs/decisions/implemented/2026-09-24-mutants-are-derived-not-anchored.md +++ b/docs/decisions/implemented/2026-09-24-mutants-are-derived-not-anchored.md @@ -4,7 +4,7 @@ **Status:** implemented **Approved:** explicit -**Relates to:** [Tests do not need a filesystem](2026-09-20-tests-need-no-filesystem.md), [The checks read a live mutant](../../postmortem/0003-checks-read-a-live-mutant.md), [Bound agent verification as one run](2026-09-23-verification-whole-run-deadline.md), [The contract's obligations](../../design/task-unit-semantics-obligations.md), [Mechanism, not policy](../proposed/2026-09-21-mechanism-not-policy.md) +**Relates to:** [Tests do not need a filesystem](2026-09-20-tests-need-no-filesystem.md), [The checks read a live mutant](../../postmortem/0003-checks-read-a-live-mutant.md), [Bound agent verification as one run](2026-09-23-verification-whole-run-deadline.md), [The contract's obligations](../../design/task-unit-semantics-obligations.md), [Mechanism, not policy](2026-09-21-mechanism-not-policy.md) ## Problem @@ -212,7 +212,7 @@ that names the rule" is a defect this register reports and none of those tools w - **Insert the mutation into the code behind a runtime switch** (mutant schemata, `mutation_active("...")` guards). Rejected for this repository: the guard is real code in `src/`, and a switched-off mutation path is a policy word in the mechanism layer, which [mechanism, not - policy](../proposed/2026-09-21-mechanism-not-policy.md) forbids. + policy](2026-09-21-mechanism-not-policy.md) forbids. - **Auto-generate mutants from operators over the whole tree** (what pitest, StrykerJS and cargo-mutants do). Rejected as the form here: it would replace named evidence with a score, and the ledger needs a named tooth per row. The derived form keeps the operator idea and the naming. diff --git a/docs/decisions/implemented/2026-09-24-mutants-are-derived-not-anchored.zh-CN.md b/docs/decisions/implemented/2026-09-24-mutants-are-derived-not-anchored.zh-CN.md index 43761a32..a30a41fd 100644 --- a/docs/decisions/implemented/2026-09-24-mutants-are-derived-not-anchored.zh-CN.md +++ b/docs/decisions/implemented/2026-09-24-mutants-are-derived-not-anchored.zh-CN.md @@ -4,7 +4,7 @@ **Status:** implemented **Approved:** explicit -**Relates to:** [测试不需要文件系统](2026-09-20-tests-need-no-filesystem.md)、[检查读到的是一个还活着的 mutant](../../postmortem/0003-checks-read-a-live-mutant.md)、[把 agent 验证限制为一次运行](2026-09-23-verification-whole-run-deadline.md)、[契约的义务](../../design/task-unit-semantics-obligations.md)、[机制,而非策略](../proposed/2026-09-21-mechanism-not-policy.md) +**Relates to:** [测试不需要文件系统](2026-09-20-tests-need-no-filesystem.md)、[检查读到的是一个还活着的 mutant](../../postmortem/0003-checks-read-a-live-mutant.md)、[把 agent 验证限制为一次运行](2026-09-23-verification-whole-run-deadline.md)、[契约的义务](../../design/task-unit-semantics-obligations.md)、[机制,而非策略](2026-09-21-mechanism-not-policy.zh-CN.md) ## 问题 @@ -91,7 +91,7 @@ - **继续手写锚点、只是写得更小。**作为整体答案被否:那是纪律而不是机制,而纪律正是 52% 的牙当时违反的东西。它作为"替换字节"的规则保留下来,并且是刻意最小:不要消息文本、不要兄弟实参、能用一项时不要整条语句。 - **只按 AST 节点锚定,替换字节照旧**(早先的 `ast.within` 加一对文本)。被否,理由是实测:149 颗里已有 71 颗是这个形态,而本轮仍有两颗死在它上面——因为**替换内容**和**片段**仍是对行的拷贝。 - **整体改用输入侧检查替代 mutant。**按上面的测量被否:它没有区分力(每一颗被抓到的 mutant 从外部都可见),而且会丢掉唯一一种能谈"预料之外的实现疏忽"而非"已声明违规"的证据。 -- **把变异插进代码、放在运行时开关之后**(mutant schemata、`mutation_active("...")` 守卫)。本仓库否掉:那个守卫是 `src/` 里的真实代码,而"被关掉的变异路径"是机制层里的策略词,[机制,而非策略](../proposed/2026-09-21-mechanism-not-policy.md)禁止这样做。 +- **把变异插进代码、放在运行时开关之后**(mutant schemata、`mutation_active("...")` 守卫)。本仓库否掉:那个守卫是 `src/` 里的真实代码,而"被关掉的变异路径"是机制层里的策略词,[机制,而非策略](2026-09-21-mechanism-not-policy.zh-CN.md)禁止这样做。 - **对整个语法树按算子自动生成 mutant**(pitest、StrykerJS、cargo-mutants 的做法)。作为本仓库的形式被否:它会把具名证据换成一个分数,而账本每一行都需要一颗具名的牙。derived 形态保留了算子这个想法和"具名"。 - **干脆不进任何闸门、只靠常设规则。**被否:本记录的实测就是"没人注意到两颗牙已经停摆",而"依赖被记住"的规则正是本仓库在别处已经替换掉的形状。 diff --git a/docs/decisions/proposed/2026-09-21-mechanism-not-policy.zh-CN.md b/docs/decisions/proposed/2026-09-21-mechanism-not-policy.zh-CN.md deleted file mode 100644 index c2d70d13..00000000 --- a/docs/decisions/proposed/2026-09-21-mechanism-not-policy.zh-CN.md +++ /dev/null @@ -1,91 +0,0 @@ -# 机制,不是策略 - -[English](2026-09-21-mechanism-not-policy.md) - -**Status:** proposed -**Relates to:** [程序只回答合法性](../implemented/2026-09-20-the-program-answers-legality.zh-CN.md)、[帧、它的数据格式与它的存储](2026-09-21-the-frame-and-its-storage.zh-CN.md)、[黑板治理与能力寻址](../implemented/2026-09-06-board-governance-addressing.zh-CN.md)、[协议化协作:组成部分与空缺](../../design/protocol-governed-collaboration.zh-CN.md)、[任务单元语义](../../design/task-unit-semantics.md) - -## 问题 - -两份提案各需要同一句话,而谁都没把它说出来。合法性记录讲的是**程序做什么**、以及它拒绝决定什么;帧与存储记录讲的是**核心知道什么**、以及它从不解析什么。这是同一个划分,一面从决定看、一面从数据看;因为没有名字,每次出现一个新字段或一条新规则,这个划分都得重新论证一遍。 - -代价已经看得见:**"工作就是补丁"这个假设走到了六处**,其中一处是用核心自己的词汇写出来的合法性规则——`refuseWidening` 靠读 `parent.patch.editable` 来判定 permission closure,于是"关于写集"的规则被写成了"关于某个核心不该知道的工作形态"的规则。 - -而且这个划分必须锐到能**终结争论**,而不是当口号。"别把策略放进核心"这句话,并不能决定 `effect` 或 `operation` 能不能是一列,也不能决定一条合法性规则到底能不能住在程序里。 - -## 提案 - -取 Hydra 的原则——**内核提供机制、拒绝策略**——把它写成两条契约。 - -**机制契约**(核心:黑板、存储、程序): - -- 一次认领:比较交换、租约、attempt 围栏 -- 一份**不透明**声明 + 摘要 -- 一份**不透明**产物 + 摘要 -- 一个**由别人给出**的裁决 -- 生命周期:过期、回收、唤醒投递、紧凑读 -- 合法集:此刻哪些单元合法、有序、按声明的槽位预算裁剪、每个单元给理由 - -**策略契约**(核心之上:协议、计划、各 agent): - -- 单元的输入是什么、产物属于哪一类、产物怎么被判、工作怎么跑(同步还是分离式检查、可否打断、中途放弃是否安全)、以及它的并发前提 -- 合法性规则的**措辞与启用**:由协议具名、由计划声明启用 -- 选谁、按什么顺序、谁采纳、谁裁决 - -**判定程序**:对每个字段、每个类型、每条代码路径问一次——**这是机制还是策略?** 一条规则的**检查**是机制,规则的**名字、措辞与启用**是策略。 - -**可机械检查的规则**:**机制层(核心与程序)的代码、类型声明与 schema 路径里不得出现策略词汇。** 清单是维护的——`patch`、`editable`、`files`、`instruction`、`checks`、`repair-first`——命中即泄漏,不是风格问题。清单可以加词,加词要记录。光看词不足以判定:`files` 在票的类型里是策略词,在文件系统边界上是机制词,所以检查要带上**路径**。 - -**刻意留作待判的行**(免得被顺手定掉): - -- `task_run_tasks.effect`(声明的写集):只有当**机制必须强制写集不相交**时它才是机制;若强制是协议的义务,它就是策略数据。 -- `input` 与 `dependencies` 的粒度:依赖影响合法性与顺序,这是机制;但"输入"是携带内容还是只携带摘要,是另一个问题。 -- `operation`:可能是策略。 - -## 这给"工作形态"定了位 - -**工作形态不是核心概念**,它是策略层对"核心不透明携带的那对声明与产物"的叫法。六处站点的分类如下。 - -| 站点 | 机制还是策略 | 归属 | -| ---------------------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------- | -| `BoardTicket.patch?: FrozenPatchTask & { digest }` | 策略 | 票上带一份不透明的 `declaration` 加摘要 | -| `patchFrozen()` 调 `preparePatchWork`、以及 `throw "not a patch task"` | 策略(在核心里) | 冻结与字段校验移进形态适配器;核心只交出不透明声明 | -| `refuseWidening` 读 `parent.patch.editable` | 检查是机制;措辞与启用是策略 | permission closure 变成程序**执行并给理由**的一条声明约束 | -| 会话机制里十处 `FrozenPatchWork` | 混合 | 把声明渲染成提示、提交产物是策略;会话生命周期、指标、取消是机制 | -| `task_run_tasks.patch_files` / `patch_editable`(store 里解析) | 策略进了 schema | 该进载荷文档;机制部分是 run、task、revision、input、dependencies、operation 那几列 | -| 驱动断言 `ticket.patch!.digest` | 策略断言 | 机制断言是:声明带摘要、认领绑 attempt、交付绑摘要 | - -今天那条合法性规则正是本文新增的那点修正的最清楚的例子:**它的检查属于程序,而 permission closure 的名字与启用属于声明。** 所以修法不是把检查搬出程序,而是**别再用核心的词汇硬编码这条规则**——这与合法性记录第 3 步(把 repair-first 从共享规划策略变成由计划声明的约束,2026-09-23 已落地)是**同一形状的修法**。两处各自独立的修法落在同一形状上,是"这个分类是对的"的证据。 - -## 考虑过的替代方案 - -- **留着口号,逐案判断。** 拒绝:那就正是产出六处站点的方式,而且它不提供可以套用的检验。 -- **把这条原则放进帧记录。** 拒绝:原则比帧更宽——它也管程序那半决定——那样帧记录会变成"关于程序的规则"的拥有者。 -- **放进合法性记录。** 反向的同样理由拒绝:那份记录更窄(讲程序的决定),而"核心可以知道什么"不是合法性问题的。 -- **核心之上搞一个带注册表的插件框架。** 拒绝:还没有第二个策略来支撑这套机械;一个默认适配器加另一个形态就足以检验这条缝。 -- **现在就定掉待判的行。** 拒绝:凭偏好挑一边,正是本文要用证据替换掉的做法。 - -## 验收标准 - -- 分类覆盖所有出现策略词汇的站点,每行标为机制、策略或待判。 -- 机制层的读写路径、类型声明与 schema 路径里不出现策略词汇(清单维护,机械检查)。 -- 合法性规则的名字与启用来自声明,检查在程序里跑,并按单元返回理由。 -- 增加第二个工作形态不改变任何机制代码;用数据式检查运行器已经在做的"值工作"演示。 -- 每一行待判都标为待判,并且由**实验**而不是偏好来定。 -- 合法性记录与帧记录都指到这里,让这个划分只有一个出处。 - -## 风险 - -- **把核心做空。** Hydra 的教训是双向的:一个不带默认策略的内核不可用。务实形式是核心**可以带一个默认策略**(补丁工作),但**不得要求**它。 -- **策略词清单会僵化。** 同一个词在一处是机制、在另一处是策略,所以检查要带路径而不只是词,而清单必须接受加词。 -- **待判的行可能永久待判。** 一行标着待判却没有实验在背后,就是一个没人声明的策略。 -- **某个策略词可能合理地再留一个版本。** 本文不要求一次性重命名;它要求每一处残留都被**列出来**。 -- **grep 检查可以被同义词绕过。** 检查是地板,不是"已经分离"的证明。 - -## 计划 - -1. 本文。不动代码。 -2. 分类表,标出待判的行。 -3. 那条检查:维护的策略词清单加一个脚本,接进静态集合。 -4. 两份记录都指过来。 -5. 然后才是工作形态切片:机制侧携带不透明声明,`patch` 作为默认适配器而不再是类型。 diff --git a/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.md b/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.md index 2c426f3c..28b126e2 100644 --- a/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.md +++ b/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.md @@ -3,7 +3,7 @@ [中文](2026-09-21-the-frame-and-its-storage.zh-CN.md) **Status:** proposed -**Relates to:** [Mechanism, not policy](2026-09-21-mechanism-not-policy.md), [The program answers legality](../implemented/2026-09-20-the-program-answers-legality.md), [Name the collaboration protocol and its task-unit sub-protocol](../implemented/2026-09-20-name-the-collaboration-protocol.md), [Protocol-governed collaboration: the parts, the gaps](../../design/protocol-governed-collaboration.md), [Board governance and capability addressing](../implemented/2026-09-06-board-governance-addressing.md), [Task unit semantics](../../design/task-unit-semantics.md) +**Relates to:** [Mechanism, not policy](../implemented/2026-09-21-mechanism-not-policy.md), [The program answers legality](../implemented/2026-09-20-the-program-answers-legality.md), [Name the collaboration protocol and its task-unit sub-protocol](../implemented/2026-09-20-name-the-collaboration-protocol.md), [Protocol-governed collaboration: the parts, the gaps](../../design/protocol-governed-collaboration.md), [Board governance and capability addressing](../implemented/2026-09-06-board-governance-addressing.md), [Task unit semantics](../../design/task-unit-semantics.md) ## Problem diff --git a/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md b/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md index 2df24bf3..1b408d5f 100644 --- a/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md +++ b/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md @@ -3,7 +3,7 @@ [English](2026-09-21-the-frame-and-its-storage.md) **Status:** proposed -**Relates to:** [机制,不是策略](2026-09-21-mechanism-not-policy.zh-CN.md)、[程序只回答合法性](../implemented/2026-09-20-the-program-answers-legality.zh-CN.md)、[给协作协议及其任务单元子协议命名](../implemented/2026-09-20-name-the-collaboration-protocol.zh-CN.md)、[协议化协作:组成部分与空缺](../../design/protocol-governed-collaboration.zh-CN.md)、[黑板治理与能力寻址](../implemented/2026-09-06-board-governance-addressing.zh-CN.md)、[任务单元语义](../../design/task-unit-semantics.md) +**Relates to:** [机制,不是策略](../implemented/2026-09-21-mechanism-not-policy.zh-CN.md)、[程序只回答合法性](../implemented/2026-09-20-the-program-answers-legality.zh-CN.md)、[给协作协议及其任务单元子协议命名](../implemented/2026-09-20-name-the-collaboration-protocol.zh-CN.md)、[协议化协作:组成部分与空缺](../../design/protocol-governed-collaboration.zh-CN.md)、[黑板治理与能力寻址](../implemented/2026-09-06-board-governance-addressing.zh-CN.md)、[任务单元语义](../../design/task-unit-semantics.md) ## 问题 diff --git a/docs/design/ci-cd-and-quality.md b/docs/design/ci-cd-and-quality.md index 13e86ef2..11747117 100644 --- a/docs/design/ci-cd-and-quality.md +++ b/docs/design/ci-cd-and-quality.md @@ -61,6 +61,8 @@ exit_criteria: Replace with a stable contract test or remove after the redesign `npm run check:tests`(`tsc -p tsconfig.tests.json --noUnusedLocals --noUnusedParameters`)进入 `verify:static` 与 `ci-and-tests` 的阻塞集合。它检查 `tests/`、配置显式包含的源文件以及它们导入的依赖;不声明覆盖全部 `evals/`、`scripts/`、`tools/`。 +`check:policy-words` 在 `verify:static` 与 `ci-and-tests` 的阻塞集合中执行一次;维护词表、分类记录和扫描面由[机制,不是策略](../decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md#决策)拥有。它阻止未登记的命中与记录漂移,不证明机制层已无策略。 + `verify:static` 中的 `mutation:anchors`(`tools/mutation-teeth.ts --anchors-only`)只做一件事:把 110 颗具名 mutant 的位置全部解析一遍,不跑任何用例、不写任何字节,在 1 秒内回答“每一颗牙是否还瞄着东西”。它进入静态契约是因为**一颗锚点失效时没有别的检查会注意到**:全量 sweep 不跑(`mutation:teeth` 不在任何 CI 作业里),而一颗匹配不到位置的牙在 sweep 报告里只是“不可应用”并被排除出分母——本轮修掉的两颗牙就是这样悄无声息地停摆的。全量 sweep 仍然不进闸门:它是分钟级、要跑用例,属于推送前的常设规则([决策](../decisions/implemented/2026-09-24-mutants-are-derived-not-anchored.md))。 `verify:static` 中的 `complexity:gate` 默认以 `git merge-base HEAD origin/main` 为基线(可用 `--base ` 显式覆盖)。基线必须是 merge base 而不是 `HEAD`:后者只比较未提交的工作树,于是已提交到分支的改动完全不可见 —— 在 CI 的干净检出上它永远报“无改动”,等于每个 PR 都没有被这条 gate 检查过。因此每次运行都会**陈述自己用了哪个基线**,并**点名它未能测量的改动文件**(ESLint 拒绝某路径、或文件根本无法解析,都会产出“零发现”,与“量过且干净”无法区分)。 diff --git a/docs/design/mechanism-in-the-middle.md b/docs/design/mechanism-in-the-middle.md index 0c714ab5..41785f6e 100644 --- a/docs/design/mechanism-in-the-middle.md +++ b/docs/design/mechanism-in-the-middle.md @@ -7,7 +7,7 @@ A model of how a board entry travels, and the checks that decide whether the model earns a place in the records. This document owns the model, the rule that says when a boundary earns a seam, and the checks. It does not restate the parts inventory, which lives in [protocol-governed-collaboration.md](protocol-governed-collaboration.md), -nor the decisions, which live in three records: [mechanism, not policy](../decisions/proposed/2026-09-21-mechanism-not-policy.md), +nor the decisions, which live in three records: [mechanism, not policy](../decisions/implemented/2026-09-21-mechanism-not-policy.md), [the frame and its storage](../decisions/proposed/2026-09-21-the-frame-and-its-storage.md), and [the program answers legality](../decisions/implemented/2026-09-20-the-program-answers-legality.md). @@ -139,6 +139,9 @@ out in its favour. If they do not, this document is archived rather than promote ## The checks +The continuous classification check is owned by [mechanism, not policy](../decisions/implemented/2026-09-21-mechanism-not-policy.md#decision). +The measurements below are historical experiments, not the maintained inventory or proof of separation. + Predictions are recorded before the measurement, so a surprise is visible rather than rationalised. **(a) Policy words in the middle.** Grep the middle layer - `src/core/store/`, `src/integration/ooo-board.ts`, @@ -183,8 +186,8 @@ concentration is where it was predicted: `src/integration/ooo-board.ts` carries The measurement forced two corrections. First, the raw count overstates the case, which is the caveat this check was written with: `files` in `src/core/store/writes.ts` and `src/core/store/retrieval.ts` is a mechanism word - a path inside a store - and `checks` in `src/integration/ooo-candidate.ts` names the check _runner_, -which is mechanism too. The word list should therefore drop `files` and `checks` and keep `patch`, `editable` -and `instruction`. Second, what remains is still about 120 hits, so "the middle is policy-free" is false as a +which is mechanism too. That measurement suggested dropping `files` and `checks`; the maintained check +instead retains context-dependent words and classifies their sites under the decision linked above. Second, what remains is still about 120 hits, so "the middle is policy-free" is false as a description of today, at a scale an order of magnitude past the prediction. What the model has is directional support: the leak is real, large, and concentrated in three files, which is exactly what a seam would have to remove. diff --git a/docs/design/mechanism-in-the-middle.zh-CN.md b/docs/design/mechanism-in-the-middle.zh-CN.md index 536cd545..159ac068 100644 --- a/docs/design/mechanism-in-the-middle.zh-CN.md +++ b/docs/design/mechanism-in-the-middle.zh-CN.md @@ -4,7 +4,7 @@ **Created:** 2026-09-21 **Updated:** 2026-09-21 -一个黑板条目怎么走,以及决定这个模型有没有资格进记录的那几项检查。本文拥有模型、"一条边界什么时候够资格建缝"的那条规则、以及这些检查。它不复述大类清单(那在 [protocol-governed-collaboration.md](protocol-governed-collaboration.zh-CN.md)),也不复述决策(那在三份记录里:[机制,不是策略](../decisions/proposed/2026-09-21-mechanism-not-policy.zh-CN.md)、[帧、它的数据格式与它的存储](../decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md)、[程序只回答合法性](../decisions/implemented/2026-09-20-the-program-answers-legality.zh-CN.md))。 +一个黑板条目怎么走,以及决定这个模型有没有资格进记录的那几项检查。本文拥有模型、"一条边界什么时候够资格建缝"的那条规则、以及这些检查。它不复述大类清单(那在 [protocol-governed-collaboration.md](protocol-governed-collaboration.zh-CN.md)),也不复述决策(那在三份记录里:[机制,不是策略](../decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md)、[帧、它的数据格式与它的存储](../decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md)、[程序只回答合法性](../decisions/implemented/2026-09-20-the-program-answers-legality.zh-CN.md))。 ## 模型 @@ -70,6 +70,8 @@ ## 检查 +持续分类检查由[机制,不是策略](../decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md#决策)拥有。下方测量是历史实验,不是维护的清单,也不是已经分离的证明。 + 预测先写下来再测量,这样"意外"是可见的,而不是事后被圆过去的。 **(a)中间层里的策略词。** 对中间层——`src/core/store/`、`src/integration/ooo-board.ts`、`src/integration/ooo-dispatch.ts`、`src/integration/task-semantics.ts`、`src/integration/ooo-execution.ts`、`src/integration/ooo-candidate.ts`——grep 这份策略词清单:`patch`、`editable`、`instruction`、`checks`、`files`、`repair-first`。**每一处命中都要带路径列出,不能只给计数**,因为同一个词在一条路径上是机制、在另一条路径上是策略。预测:十五处以上,集中在板子与合法性模块。预期的发现:板子自己的票类型与那个冻结方法就是中心。 @@ -91,7 +93,7 @@ **(a)中间层里的策略词。** 预测十五处以上。实测六个文件里共 195 处:`patch` 106、`files` 39、`checks` 23、`editable` 14、`instruction` 12、`repair-first` 1。集中处与预测一致:`src/integration/ooo-board.ts` 带 53 处 `patch`,`src/integration/task-semantics.ts` 27 处 `patch` 与 9 处 `editable`,`src/core/store/base.ts` 13 处 `patch`。 -测量迫使两条修正。**第一,原始计数高估了情况**——而这正是写这条检查时要带上的那个前提:`src/core/store/writes.ts` 与 `src/core/store/retrieval.ts` 里的 `files` 是机制词(存储里的路径),`src/integration/ooo-candidate.ts` 里的 `checks` 指的是检查的**运行器**,也是机制。所以词表应**去掉 `files` 与 `checks`**,只留 `patch`、`editable`、`instruction`。**第二,剩下的仍有约 120 处**,所以「中间层是无策略的」作为**今天的描述**是假的,而且超出一个数量级。模型拿到的是**方向性支持**:泄漏真实、量大、集中在三个文件里——那正是接上一条缝要拿掉的东西。 +测量迫使两条修正。**第一,原始计数高估了情况**——而这正是写这条检查时要带上的那个前提:`src/core/store/writes.ts` 与 `src/core/store/retrieval.ts` 里的 `files` 是机制词(存储里的路径),`src/integration/ooo-candidate.ts` 里的 `checks` 指的是检查的**运行器**,也是机制。那次测量建议去掉 `files` 与 `checks`;维护的检查依据上方决策,保留依赖语境的词并按站点分类。**第二,剩下的仍有约 120 处**,所以「中间层是无策略的」作为**今天的描述**是假的,而且超出一个数量级。模型拿到的是**方向性支持**:泄漏真实、量大、集中在三个文件里——那正是接上一条缝要拿掉的东西。 **(b)两端。** 生产端预测「集中且可分」,实测三到六个函数散在三个文件:`src/integration/ooo-patch.ts` 里的 `preparePatchWork`、`patchPrompt`、`patchCandidate`、`patchSubmission`,`src/integration/ooo-session-mechanism.ts` 里的 `snapshotText`,`evals/ooo-execution/data-check-runner.ts` 里的 `runTestFile`。预测成立。呈现端预测「散落、没有缝」,实测六个站点:`src/core/store/base.ts` 的预览文本、`src/core/types.ts` 的条目与预览类型、`src/cli/protocol.ts` 的线上形状、`src/cli/service.ts` 的服务、`.pi/extensions/nmg/index.ts` 里面向 agent 的渲染、以及由 `src/prompts/nmg-prompts.yaml` 生成的工具描述。这条预测也成立,但有**一处对本文的修正**:黑板条目只有一种呈现,而旧表里的分层词汇是从记忆那一侧**借**来的,不是黑板这一侧**找到**的。对同一批站点后来的一遍检查看到计数看不到的东西:那六个站点是一条**链**,不是六个格式化器,而重复就坐在其中三处——store 的预览规则、适配器的通用助手、以及适配器广播路径里的那条裸切。 diff --git a/package.json b/package.json index 32686f7f..4e21ee42 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "mutation:anchors": "node --experimental-strip-types tools/mutation-teeth.ts --anchors-only", "verify:packages": "node --experimental-strip-types tools/verify-packages.ts", "check:lock": "node --experimental-strip-types tools/check-lock.ts", - "verify:static": "npm run build && npm run package:check && npm run check && npm run check:tests && npm run mutation:anchors && npm run check:lock && npm run lint && npm run format:check && npm run docs:check && npm run agent:context:check && npm run complexity:gate && npm run verify:packages && npm run glossary:check && npm run rtm:check", + "verify:static": "npm run build && npm run package:check && npm run check && npm run check:tests && npm run mutation:anchors && npm run check:lock && npm run lint && npm run format:check && npm run docs:check && npm run agent:context:check && npm run complexity:gate && npm run verify:packages && npm run glossary:check && npm run check:policy-words && npm run rtm:check", "verify:product-ci": "npm run build && npm run test:coverage", "verify:research": "npm run prompts:generate && npm run test:research", "verify:node-compat": "npm run build && npm run check && npm run package:check", @@ -142,6 +142,7 @@ "test:chaos": "npm run build && node --experimental-strip-types --test \"tests/chaos/*.test.ts\"", "test:cg": "node --experimental-strip-types --test tests/core/autodiff.test.ts tests/core/differentiable-controller.test.ts tests/core/hierarchical-activation.test.ts tests/core/memory-graph-reasoner.test.ts tests/core/fork-merge.test.ts tests/core/robustness.test.ts", "glossary:check": "node --experimental-strip-types tools/glossary-check.ts", + "check:policy-words": "node --experimental-strip-types tools/policy-word-check.ts", "rtm:check": "node --experimental-strip-types tools/rtm-check.ts" }, "devDependencies": { diff --git a/tests/tools/policy-word-check.test.ts b/tests/tools/policy-word-check.test.ts new file mode 100644 index 00000000..6a782b03 --- /dev/null +++ b/tests/tools/policy-word-check.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { + collectPolicyWords, + comparePolicyWords, + inspectMechanism, + policyWordsIn, +} from "../../tools/policy-word-check.ts"; +import { POLICY_WORDS, type ClassifiedSite } from "../../tools/policy-word-list.ts"; + +function site(overrides: Partial = {}): ClassifiedSite { + return { + path: "src/core/store/example.ts", + scope: "record", + word: "patch", + count: 1, + classification: "policy", + reason: "Existing shape assumption, not a mechanism.", + ...overrides, + }; +} + +test("camel, snake and kebab spellings are visible without counting dispatch as patch", () => { + assert.deepEqual( + policyWordsIn("patch_files preparePatchWork FrozenPatchWork PATCH_FILES", POLICY_WORDS), + ["patch", "patch", "patch", "patch", "files", "files"], + ); + assert.deepEqual(policyWordsIn("dispatch DispatchBoard dispatched", POLICY_WORDS), []); + assert.deepEqual(policyWordsIn("repair-first repair_first repairFirst", POLICY_WORDS), [ + "repair-first", + "repair-first", + "repair-first", + ]); +}); + +test("types, member accesses, SQL templates and runtime strings count; comments do not", () => { + const hits = collectPolicyWords( + "unit.ts", + `// patch files instruction + interface Ticket { patch?: FrozenPatchWork } + function record() { const sql = \`patch_files TEXT, patch_editable TEXT\`; + return parent.patch.editable + "not a patch task"; } + `, + ); + assert.equal(hits.filter((hit) => hit.word === "patch").length, 6); + assert.ok(hits.some((hit) => hit.scope === "Ticket" && hit.word === "patch")); + assert.ok(hits.some((hit) => hit.scope === "record" && hit.word === "editable")); + assert.ok(hits.every((hit) => hit.line > 1)); +}); + +test("a new policy word site is refused with its path and position", () => { + const hits = collectPolicyWords( + "src/core/store/example.ts", + "function record() { return ticket.patch; }", + ); + assert.match( + comparePolicyWords(hits, [])[0]!, + /example\.ts#record:patch: unclassified.*lines 1/u, + ); + assert.deepEqual(comparePolicyWords(hits, [site()]), []); + const doubled = collectPolicyWords( + "src/core/store/example.ts", + "function record() { return ticket.patch || parent.patch; }", + ); + assert.match(comparePolicyWords(doubled, [site()])[0]!, /classified 1, found 2/u); +}); + +test("removing a leak requires retiring the classification rather than leaving dead exemptions", () => { + assert.match(comparePolicyWords([], [site()])[0]!, /classified 1, found 0/u); +}); + +test("a same-count move to another member is not silently grandfathered", () => { + const hits = collectPolicyWords( + "src/core/store/example.ts", + "function another() { return ticket.patch; }", + ); + assert.equal(comparePolicyWords(hits, [site()]).length, 2); +}); + +test("class methods get distinct scope identities; whitespace and comment changes do not break them", () => { + const collect = (text: string) => + collectPolicyWords("unit.ts", text).map(({ path, scope, word }) => ({ path, scope, word })); + assert.deepEqual( + collect("class Board { freeze() { return task.patch; } }"), + collect("class Board {\n // patch\n freeze() { return task.patch; }\n }"), + ); + assert.equal( + collect("class Board { freeze() { return task.patch; } }")[0]!.scope, + "Board.freeze", + ); +}); + +test("malformed TypeScript, invalid classifications and duplicate entries fail closed", () => { + assert.throws(() => collectPolicyWords("broken.ts", "function broken( {"), /cannot inspect/u); + assert.match(comparePolicyWords([], [site({ reason: "" })])[0]!, /invalid classification/u); + assert.ok( + comparePolicyWords([], [site(), site()]).some((error) => + /duplicate classification/u.test(error), + ), + ); +}); + +test("the repository's maintained table classifies every occurrence on the declared surface", () => { + const report = inspectMechanism(resolve(import.meta.dirname, "../..")); + assert.ok(report.files > 0); + assert.ok(report.hits.length > 0); + assert.deepEqual(report.errors, []); +}); diff --git a/tests/tools/test-groups.test.ts b/tests/tools/test-groups.test.ts index 3aef99f8..bc78e413 100644 --- a/tests/tools/test-groups.test.ts +++ b/tests/tools/test-groups.test.ts @@ -90,6 +90,17 @@ test("tests-surface type checking is blocking in the shared static contract", () ); }); +test("the maintained policy-word check blocks the shared static contract exactly once", () => { + const checks = [...packageJson.scripts["verify:static"]!.matchAll(/npm run ([\w:-]+)/gu)].map( + (match) => match[1]!, + ); + assert.equal(checks.filter((name) => name === "check:policy-words").length, 1); + assert.equal( + packageJson.scripts["check:policy-words"], + "node --experimental-strip-types tools/policy-word-check.ts", + ); +}); + test("agent static checks match the CI contract without nested duplicate execution", () => { const context = parseYaml( readFileSync(new URL("../../agent-context.yaml", import.meta.url), "utf8"), diff --git a/tools/policy-word-check.ts b/tools/policy-word-check.ts new file mode 100644 index 00000000..c1ae188d --- /dev/null +++ b/tools/policy-word-check.ts @@ -0,0 +1,179 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +import { CLASSIFICATIONS, MECHANISM_PATHS, POLICY_WORDS } from "./policy-word-list.ts"; +import type { ClassifiedSite } from "./policy-word-list.ts"; + +export interface PolicyWordHit { + path: string; + scope: string; + word: string; + line: number; +} + +/** Match snake/kebab/camel names as words, not `patch` inside `dispatch`. */ +export function policyWordsIn(text: string, words: readonly string[]): string[] { + const separated = text + .replace(/([a-z0-9])([A-Z])/gu, "$1 $2") + .replace(/([A-Z])([A-Z][a-z])/gu, "$1 $2") + .toLowerCase(); + return words.flatMap((word) => { + const escaped = word.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const spelling = escaped.split("-").join("[\\s_-]+"); + const pattern = new RegExp(`(? word); + }); +} + +function scopeName(node: ts.Node): string | undefined { + if (ts.isConstructorDeclaration(node)) return "constructor"; + if ( + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) + ) + return node.name?.getText(); + return undefined; +} + +function literalText(node: ts.Node): string | undefined { + if ( + ts.isIdentifier(node) || + ts.isPrivateIdentifier(node) || + ts.isStringLiteral(node) || + ts.isNoSubstitutionTemplateLiteral(node) || + ts.isTemplateHead(node) || + ts.isTemplateMiddle(node) || + ts.isTemplateTail(node) + ) + return node.text; + return undefined; +} + +/** Comments are not code; SQL and runtime text inside literals are inspected. */ +export function collectPolicyWords( + path: string, + text: string, + words = POLICY_WORDS, +): PolicyWordHit[] { + const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true); + // Parse failures must not become an empty (passing) inventory. + const diagnostics: readonly ts.Diagnostic[] = ( + source as ts.SourceFile & { + parseDiagnostics: readonly ts.Diagnostic[]; + } + ).parseDiagnostics; + if (diagnostics.length) throw new Error(`${path}: cannot inspect invalid TypeScript`); + const hits: PolicyWordHit[] = []; + function visit(node: ts.Node, parents: readonly string[]): void { + const name = scopeName(node); + const scopes = name ? [...parents, name] : parents; + const value = literalText(node); + if (value !== undefined) { + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + for (const word of policyWordsIn(value, words)) { + hits.push({ path, scope: scopes.join(".") || "", word, line }); + } + } + ts.forEachChild(node, (child) => visit(child, scopes)); + } + visit(source, []); + return hits; +} + +function siteKey(site: Pick): string { + return `${site.path}#${site.scope}:${site.word}`; +} + +function validClassification(site: ClassifiedSite): boolean { + return ( + !!site.reason.trim() && + ["mechanism", "policy", "undecided"].includes(site.classification) && + Number.isInteger(site.count) && + site.count > 0 + ); +} + +export function comparePolicyWords( + hits: readonly PolicyWordHit[], + sites: readonly ClassifiedSite[], +): string[] { + const actual = new Map(); + for (const hit of hits) { + const key = siteKey(hit); + const group = actual.get(key) ?? []; + group.push(hit); + actual.set(key, group); + } + const errors: string[] = []; + const seen = new Set(); + for (const site of sites) { + const key = siteKey(site); + if (seen.has(key)) errors.push(`${key}: duplicate classification`); + seen.add(key); + if (!validClassification(site)) errors.push(`${key}: invalid classification`); + const group = actual.get(key) ?? []; + if (group.length !== site.count) { + errors.push( + `${key}: classified ${site.count}, found ${group.length} (lines ${group.map((hit) => hit.line).join(", ") || "none"})`, + ); + } + actual.delete(key); + } + for (const [key, group] of actual) + errors.push( + `${key}: unclassified (${group.length} at lines ${group.map((hit) => hit.line).join(", ")})`, + ); + return errors; +} + +function sourceFiles(path: string): string[] { + if (statSync(path).isFile()) return [path]; + return readdirSync(path) + .sort() + .flatMap((name) => { + const child = resolve(path, name); + return statSync(child).isDirectory() || child.endsWith(".ts") ? sourceFiles(child) : []; + }); +} + +export function inspectMechanism(directory: string): { + hits: PolicyWordHit[]; + files: number; + errors: string[]; +} { + const files = MECHANISM_PATHS.flatMap((path) => sourceFiles(resolve(directory, path))); + const hits = files.flatMap((path) => + collectPolicyWords(relative(directory, path).replaceAll("\\", "/"), readFileSync(path, "utf8")), + ); + const errors = CLASSIFICATIONS.filter((site) => !POLICY_WORDS.includes(site.word)).map( + (site) => `${siteKey(site)}: word outside the maintained list`, + ); + errors.push(...comparePolicyWords(hits, CLASSIFICATIONS)); + return { hits, files: files.length, errors }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const report = inspectMechanism(process.cwd()); + console.log( + `policy words: ${report.hits.length} occurrences in ${report.files} files, ${report.errors.length} errors`, + ); + for (const error of report.errors) console.error(error); + if (process.argv.includes("--list")) { + for (const site of CLASSIFICATIONS) { + const lines = report.hits + .filter((hit) => siteKey(hit) === siteKey(site)) + .map((hit) => hit.line); + console.log( + `${siteKey(site)} ${site.classification} count=${site.count} lines=${lines.join(",")}: ${site.reason}`, + ); + } + } + if (report.errors.length) process.exitCode = 1; +} diff --git a/tools/policy-word-list.ts b/tools/policy-word-list.ts new file mode 100644 index 00000000..76505e30 --- /dev/null +++ b/tools/policy-word-list.ts @@ -0,0 +1,1095 @@ +export const POLICY_WORDS: readonly string[] = [ + "patch", + "editable", + "files", + "instruction", + "checks", + "repair-first", +]; + +/** The measured middle, plus the compiler model and the shared session mechanism. */ +export const MECHANISM_PATHS: readonly string[] = [ + "src/core/store", + "src/integration/ooo-board.ts", + "src/integration/ooo-dispatch.ts", + "src/integration/ooo-execution.ts", + "src/integration/ooo-candidate.ts", + "src/integration/ooo-fusion-plan.ts", + "src/integration/ooo-session-mechanism.ts", + "src/integration/task-semantics.ts", + "src/integration/task-semantics-model.ts", + "src/integration/task-semantics-interleavings.ts", +]; + +export interface ClassifiedSite { + path: string; + scope: string; + word: string; + count: number; + classification: "mechanism" | "policy" | "undecided"; + reason: string; +} + +// Counts are maintained by review, never refreshed automatically by the checker. +export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ + { + path: "src/core/store/base.ts", + scope: "NmgStoreBase.freezeTaskRunTask", + word: "patch", + count: 2, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/base.ts", + scope: "NmgStoreBase.freezeTaskRunTask", + word: "files", + count: 1, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/base.ts", + scope: "NmgStoreBase.freezeTaskRunTask", + word: "editable", + count: 1, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/base.ts", + scope: "NmgStoreBase.insertTaskRunTask", + word: "patch", + count: 8, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/base.ts", + scope: "NmgStoreBase.insertTaskRunTask", + word: "files", + count: 4, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/base.ts", + scope: "NmgStoreBase.insertTaskRunTask", + word: "editable", + count: 4, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/base.ts", + scope: "NmgStoreBase.taskRunTasks", + word: "patch", + count: 8, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/base.ts", + scope: "NmgStoreBase.taskRunTasks", + word: "files", + count: 4, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/base.ts", + scope: "NmgStoreBase.taskRunTasks", + word: "editable", + count: 4, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/maintenance.ts", + scope: "withMaintenance.recordActiveGraphAttribution", + word: "patch", + count: 1, + classification: "mechanism", + reason: "SQLite json_patch merges diagnostic timing fields; not a work shape.", + }, + { + path: "src/core/store/schema.ts", + scope: "migrate", + word: "patch", + count: 2, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/schema.ts", + scope: "migrate", + word: "editable", + count: 1, + classification: "policy", + reason: + "Persists or parses the patch-specific frozen declaration rather than an opaque work shape.", + }, + { + path: "src/core/store/schema.ts", + scope: "migrate", + word: "files", + count: 2, + classification: "undecided", + reason: + "One DDL literal contains both patch_files storage and filesystem fingerprint prose; ownership is mixed.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "", + word: "patch", + count: 8, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "PatchTaskSpec", + word: "patch", + count: 4, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "PatchTaskSpec", + word: "instruction", + count: 1, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "PatchTaskSpec", + word: "files", + count: 1, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "PatchTaskSpec", + word: "editable", + count: 1, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "Row", + word: "patch", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "Row", + word: "files", + count: 1, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "Row", + word: "editable", + count: 1, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardTicket", + word: "patch", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission", + word: "patch", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.constructor", + word: "patch", + count: 10, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.constructor", + word: "editable", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.constructor", + word: "files", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.constructor", + word: "instruction", + count: 1, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.createManifestTables", + word: "patch", + count: 4, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.createManifestTables", + word: "editable", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.createManifestTables", + word: "files", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.createCheckTable", + word: "checks", + count: 1, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.migrateToTaskTables", + word: "checks", + count: 14, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.migrateToTaskTables", + word: "patch", + count: 4, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.migrateToTaskTables", + word: "editable", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.migrateToTaskTables", + word: "files", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.patchSpec", + word: "patch", + count: 3, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.patchFrozen", + word: "patch", + count: 4, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.patchFrozen", + word: "instruction", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.patchFrozen", + word: "files", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.patchFrozen", + word: "editable", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.inputDigest", + word: "patch", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.externalReady", + word: "checks", + count: 1, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.issueCheck", + word: "checks", + count: 2, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.cancelCheck", + word: "checks", + count: 1, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.checkRecord", + word: "checks", + count: 1, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.submitCheck", + word: "checks", + count: 1, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.projection", + word: "patch", + count: 1, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.claim", + word: "patch", + count: 4, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.proposalCommit", + word: "patch", + count: 6, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.proposalCommit", + word: "files", + count: 1, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.installPatchTask", + word: "patch", + count: 4, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.submit", + word: "patch", + count: 1, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.verifyCandidate", + word: "patch", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.cancel", + word: "checks", + count: 1, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.refreshDerived", + word: "patch", + count: 2, + classification: "policy", + reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", + }, + { + path: "src/integration/ooo-board.ts", + scope: "BoardAdmission.abandonCheck", + word: "checks", + count: 1, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-dispatch.ts", + scope: "", + word: "patch", + count: 5, + classification: "policy", + reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", + }, + { + path: "src/integration/ooo-dispatch.ts", + scope: "WorkerMetrics", + word: "checks", + count: 1, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-dispatch.ts", + scope: "PlanWorker", + word: "patch", + count: 1, + classification: "policy", + reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", + }, + { + path: "src/integration/ooo-dispatch.ts", + scope: "DispatchTicket", + word: "patch", + count: 2, + classification: "policy", + reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", + }, + { + path: "src/integration/ooo-dispatch.ts", + scope: "dispatchUnit", + word: "patch", + count: 4, + classification: "policy", + reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", + }, + { + path: "src/integration/ooo-dispatch.ts", + scope: "dispatchUnit", + word: "instruction", + count: 2, + classification: "policy", + reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", + }, + { + path: "src/integration/ooo-dispatch.ts", + scope: "dispatchUnit", + word: "files", + count: 2, + classification: "policy", + reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", + }, + { + path: "src/integration/ooo-dispatch.ts", + scope: "dispatchUnit", + word: "editable", + count: 2, + classification: "policy", + reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", + }, + { + path: "src/integration/ooo-candidate.ts", + scope: "DataCheck", + word: "files", + count: 1, + classification: "policy", + reason: "The data-check contract binds candidate values to a file-shaped mapping.", + }, + { + path: "src/integration/ooo-candidate.ts", + scope: "verifyCandidate", + word: "files", + count: 3, + classification: "mechanism", + reason: "Writes caller-supplied relative paths at the filesystem runner boundary.", + }, + { + path: "src/integration/ooo-candidate.ts", + scope: "verifyCandidate", + word: "checks", + count: 4, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-candidate.ts", + scope: "verifyDataChecks", + word: "checks", + count: 5, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-candidate.ts", + scope: "verifyDataChecks", + word: "files", + count: 3, + classification: "policy", + reason: "The data-check contract binds candidate values to a file-shaped mapping.", + }, + { + path: "src/integration/ooo-fusion-plan.ts", + scope: "", + word: "repair-first", + count: 1, + classification: "policy", + reason: "The protocol constraint vocabulary is still declared inside the planner module.", + }, + { + path: "src/integration/ooo-fusion-plan.ts", + scope: "nextSessionMove", + word: "repair-first", + count: 2, + classification: "mechanism", + reason: "Checks the plan-declared constraint and reports why this session cannot continue.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "", + word: "patch", + count: 6, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "CheckTool", + word: "files", + count: 1, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "checkToolCandidate", + word: "patch", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "checkToolCandidate", + word: "files", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "ArtifactParams", + word: "files", + count: 1, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "artifactEnvelope", + word: "patch", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "artifactEnvelope", + word: "files", + count: 4, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "patchEnvelope", + word: "patch", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "patchEnvelope", + word: "files", + count: 3, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "conclusionEnvelope", + word: "patch", + count: 1, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "conclusionEnvelope", + word: "files", + count: 1, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "PatchExecOptions", + word: "patch", + count: 1, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "patchSessionInput", + word: "patch", + count: 4, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "patchSessionInput", + word: "files", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "piCompletionAllowed", + word: "patch", + count: 1, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "PiRun", + word: "checks", + count: 1, + classification: "mechanism", + reason: + "Executes or records caller-declared checks; does not choose their wording or enablement.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "UnitState", + word: "patch", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "snapshotText", + word: "patch", + count: 1, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "snapshotText", + word: "files", + count: 4, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "snapshotText", + word: "instruction", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "snapshotText", + word: "editable", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "artifactFromText", + word: "patch", + count: 1, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "artifactFromText", + word: "files", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-session-mechanism.ts", + scope: "SessionRunInput", + word: "patch", + count: 2, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "", + word: "patch", + count: 7, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "TaskUnit", + word: "instruction", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "TaskUnit", + word: "patch", + count: 3, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "TaskUnit", + word: "files", + count: 2, + classification: "undecided", + reason: + "Combines patch envelope paths with input-file granularity; the latter remains an experiment question.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "TaskUnit", + word: "editable", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "CompileInput", + word: "patch", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "", + word: "instruction", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "", + word: "files", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "", + word: "editable", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "within", + word: "patch", + count: 2, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "refuseForeignSpecs", + word: "patch", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "refuseOutOfRange", + word: "patch", + count: 3, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "refusePermissionExpansion", + word: "patch", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "refusePermissionExpansion", + word: "editable", + count: 2, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "refusePermissionExpansion", + word: "files", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "refuseSpecFields", + word: "patch", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "freezeEnvelope", + word: "patch", + count: 3, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "freezeEnvelope", + word: "instruction", + count: 2, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "freezeEnvelope", + word: "files", + count: 4, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "freezeEnvelope", + word: "editable", + count: 4, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "frozenPatch", + word: "patch", + count: 3, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "frozenPatch", + word: "files", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "unitFor", + word: "patch", + count: 7, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "unitFor", + word: "instruction", + count: 2, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "unitFor", + word: "files", + count: 2, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "unitFor", + word: "editable", + count: 1, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "refuseWidening", + word: "patch", + count: 3, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, + { + path: "src/integration/task-semantics.ts", + scope: "refuseWidening", + word: "editable", + count: 3, + classification: "policy", + reason: + "The compiler or refinement rule names patch-specific fields, validation and permissions.", + }, +]; From ab8e0f76fd2c00c30a62dcd109d22049b8d9a88b Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:23:37 +0800 Subject: [PATCH 2/3] feat(execution): dispatch opaque declarations through the shared loop Require a digest-bearing declaration carrier, preserve adopter specialization, and pass the frozen declaration unchanged instead of reconstructing patch work. Move preparation to the default adopters and distinguish named claim refusals from failed units. Keep the patch driver's worker specialized at its boundary. Add non-patch numeric fixtures, including real store lease/delivery/independent verdict coverage, and a compile-time guard against the optional-carrier hole. Retire eight policy-word groups whose twenty occurrences were removed; keep all remaining classifications and the blocking ratchet intact. Record reproducible field probes and correct effect-class/write-set confusion. Keep permission/session separation, storage ownership and the independent frame proposal explicitly open; no protocol framework or schema migration. Validation: 16 blocking Agent checks at the unchanged 150s deadline; 1598 product tests; 47 targeted cases; 110/110 mutation teeth caught and 22/22 byte-identical restorations. One catch remains the existing 30s execution cap. Scoped LSP: zero diagnostics across 16 changed TypeScript files. --- .../2026-09-19-dispatch-loop-is-shared.md | 8 +- ...026-09-19-dispatch-loop-is-shared.zh-CN.md | 6 +- .../2026-09-21-mechanism-not-policy.md | 61 +++++--- .../2026-09-21-mechanism-not-policy.zh-CN.md | 31 +++-- .../2026-09-21-the-frame-and-its-storage.md | 15 +- ...6-09-21-the-frame-and-its-storage.zh-CN.md | 4 +- docs/design/task-unit-semantics.md | 2 +- .../execution/field-ownership-2026-10-01.json | 91 ++++++++++++ .../execution/field-ownership-2026-10-01.md | 77 ++++++++++ evals/ooo-execution/board-slots.test.ts | 4 +- evals/ooo-execution/field-ownership.ts | 131 ++++++++++++++++++ evals/ooo-execution/patch-cycle.test.ts | 28 ++-- evals/ooo-execution/plan-driver.ts | 9 +- src/integration/ooo-board.ts | 14 +- src/integration/ooo-dispatch.ts | 67 ++++----- src/integration/ooo-runner.ts | 10 +- tests/integration/ooo-dispatch.test.ts | 17 ++- tests/integration/ooo-managed-fence.test.ts | 2 +- .../ooo-post-commit-notification.test.ts | 15 +- .../integration/ooo-read-paths-agree.test.ts | 2 +- tests/integration/ooo-store-run-board.test.ts | 16 +-- tests/integration/ooo-task-tables.test.ts | 2 +- .../integration/ooo-value-store-board.test.ts | 122 ++++++++++++++++ tests/integration/ooo-work-shape.test.ts | 120 ++++++++++++++++ tools/policy-word-list.ts | 64 --------- 25 files changed, 704 insertions(+), 214 deletions(-) create mode 100644 docs/experiments/execution/field-ownership-2026-10-01.json create mode 100644 docs/experiments/execution/field-ownership-2026-10-01.md create mode 100644 evals/ooo-execution/field-ownership.ts create mode 100644 tests/integration/ooo-value-store-board.test.ts create mode 100644 tests/integration/ooo-work-shape.test.ts diff --git a/docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.md b/docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.md index fab55e14..18cef03a 100644 --- a/docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.md +++ b/docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.md @@ -33,12 +33,14 @@ loop would create the second implementation the same decision warned against, an - **One programmatic loop.** `dispatchPlan` lives with the other shared execution decisions, takes a board and worker port, and consumes the board's candidate set. It owns the ordering of - the claim, the freeze, the worker call, the result entry, the verdict, the failure and refusal + the claim, the worker call, the result entry, the verdict, the failure and refusal accounting, and the session decision at each boundary (`decideSessionMove`), recorded as a run fact when a run-fact port is supplied. - **The worker is a port.** What the loop calls to produce a candidate is supplied by its caller - the arms supply a live model worker or a recorded one, the host supplies the runner it already has. The - port's shape is the one the arms already use; the adapter implements it, and no policy moves into it. + port is parameterized by a declaration with a digest; freezing, rendering and interpretation belong + to its adopter. The [opaque declaration decision](2026-09-21-mechanism-not-policy.md#opaque-dispatch-declarations) + governs this boundary. - **The arms keep what makes them research.** Their spec files, their cells' declarations (bounds, slots, per-unit session declarations), their report format and their archive stay where they are. The arms get their own _declarations and measurements_; they no longer get their own _loop_. @@ -64,7 +66,7 @@ loop would create the second implementation the same decision warned against, an - **The measurements stay comparable.** The driver's report format and its CLI do not change; the loop it calls is the same code, so a cell recorded before and after this move is the same cell. The archive's `matrix.json` and the plan's grade rule keep working unchanged. -- **The board is a port.** The loop consumes `DispatchBoard`, the freeze, a +- **The board is a port.** The loop consumes `DispatchBoard`, an admitted opaque declaration, a worker port and the shared session decision; nothing in the move adds a product policy or a new store column. - **Extraction is not product activation.** The fast dispatch tests exercise a fake board, with a real diff --git a/docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.zh-CN.md b/docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.zh-CN.md index 6a70310f..154960e4 100644 --- a/docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.zh-CN.md +++ b/docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.zh-CN.md @@ -17,8 +17,8 @@ **派发一份计划的那个循环搬进共享层,臂的驱动成为它的调用方。** -- **一份程序化循环。** `dispatchPlan` 接收黑板与 worker 端口,消费黑板给出的合法集,负责认领、冻结、调用 worker、放结果条目、判定、失败与拒绝记账的操作顺序;边界调用已有 `decideSessionMove`,在提供运行事实端口时记录决定。 -- **worker 是端口。** 产出候选的那一步由调用方提供——臂提供实时模型 worker 或录制 worker,宿主提供它已有的 runner。端口形状就是臂已在用的那个;适配层实现它,**策略不进端口**。 +- **一份程序化循环。** `dispatchPlan` 接收黑板与 worker 端口,消费黑板给出的合法集,负责认领、调用 worker、放结果条目、判定、失败与拒绝记账的操作顺序;边界调用已有 `decideSessionMove`,在提供运行事实端口时记录决定。 +- **worker 是端口。** 产出候选的那一步由调用方提供——臂提供实时模型 worker 或录制 worker,宿主提供它已有的 runner。端口以带摘要的声明参数化;冻结、渲染和解释属于采用方。[不透明声明决策](2026-09-21-mechanism-not-policy.zh-CN.md#不透明派发声明)拥有这条边界。 - **臂保留使其成为研究的东西。** spec 文件、每个格的声明(上限、slots、每单元会话声明)、报告格式与归档都留在原处。臂保留自己的**声明与测量**;不再保留自己的**循环**。 - **2026-09-17 那条决策除这一条外继续有效。** 它"推迟规划平台"的规则不变——通用调度器(自己决定/排序计划、持有队列)不是这次搬的东西。变的只是**执行一份给定计划的循环住在哪**。 @@ -32,7 +32,7 @@ ## 后果 - **测量保持可比。** 驱动的报告格式与 CLI 不变;它调用的循环是同一份代码,所以在此之前与之后记录的格子是同一个格子。归档的 `matrix.json` 与计划的读数分级规则照旧可用。 -- **黑板是端口。** 循环消费 `DispatchBoard`、冻结、worker 端口与共享会话决策;不新增产品策略或存储列。 +- **黑板是端口。** 循环消费 `DispatchBoard`、已准入的不透明声明、worker 端口与共享会话决策;不新增产品策略或存储列。 - **抽取不等于产品启用。** 快速测试使用假 board,并以真 Store 验证会话事实落库;它们不证明现有 `nmg_board` 宿主流程调用了该循环。产品接入沿用现有黑板与宿主流程,不新增工具、命令或独立 OoO 入口。[调用链审查](../../design/task-unit-semantics-obligations.md#product-call-path-audit-2026-09-20)分别记录已有规则、已有生命周期操作与实际观察到的调用边界。 - **臂的身份保留。** 它的驱动保留 spec 格式、实时/桩 worker 的选择、报告与归档;评审者仍然可以只读臂的驱动就知道"臂跑了什么"。 diff --git a/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md index 7cbedd26..95f87a75 100644 --- a/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md +++ b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md @@ -66,13 +66,28 @@ require an explicit classification and rationale in the same reviewed change; th refreshes counts automatically. Vocabulary additions are recorded here with their rationale. The scope is bounded by the maintained path list, not all source, tests or drivers. Source imports and -schema literals on those paths count too. This implements the approved classification and static-check -slice (original Plan 2–3), not the work-shape conversion. The frame/storage proposal remains proposed. +schema literals on those paths count too. The gate implements the classification and static-check +slice (original Plan 2–3); it does not prove work-shape separation. The frame/storage proposal remains proposed. + +### Opaque dispatch declarations + +The dispatch port carries a required, nullable `declaration`, parameterized by its shape, with only a +`digest` required by the shared mechanism. The base `BoardTicket` has the same opaque carrier. Default +adopters specialize it to their own declaration type and freeze and validate it before returning a +ticket. The loop passes that declaration unchanged; it neither reconstructs patch work nor renders it. +A missing declaration is an adopter contract failure, not an implicit patch default. + +An adopter can return a named refusal without issuing a ticket. The loop records that as a slot refusal, +separately from a failed worker or an invalid ticket. The [numeric-adopter evidence](../../experiments/execution/field-ownership-2026-10-01.md#numeric-adopter) +exercises both the shared port and the real store's lease, delivery and independent verdict. It is a +controlled seam test, not a production peer-protocol installation or a frame/storage migration. **Rows deliberately left undecided**, so that they are not settled by accident: -- `task_run_tasks.effect`, the declared write set, is mechanism only if the mechanism must enforce that - write sets do not overlap. If enforcement is a protocol obligation, it is policy data. +- `task_run_tasks.effect` is an effect-class label, not the declared proposal-write set. Whether + resource-set exclusion is a common mechanism obligation or a protocol obligation is a separate + question; the [field experiment](../../experiments/execution/field-ownership-2026-10-01.md) does not + turn the current class-label gate into an exclusion check. - the granularity of `input` and `dependencies`: dependencies affect legality and order, which is mechanism, but whether an input carries content or only a digest is a separate question. - `operation`, which may be policy. @@ -81,21 +96,18 @@ slice (original Plan 2–3), not the work-shape conversion. The frame/storage pr ### Work-shape boundary -The work-shape conversion (original Plan 5) is not part of the approved slice. The patch assumptions -below remain; their classification does not assert that an opaque adapter seam has been implemented. +The opaque dispatch seam is one part of the work-shape conversion (original Plan 5), not completion +of the conversion. A work shape is the policy layer's name for the declaration-and-artifact pair that +the core carries without understanding it. Patch freezing remains in the default adopters; requiring +that shape in the shared port does not. -A work shape is not a core concept. It is the policy layer's name for the declaration-and-artifact pair -that the core carries without understanding it. The six sites where the patch assumption lives classify as -follows. +The remaining coupling is: -| Site | Mechanism or policy | Where it belongs | -| ---------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `BoardTicket.patch?: FrozenPatchTask & { digest }` | policy | the ticket carries an opaque `declaration` with a digest | -| `patchFrozen()` calling `preparePatchWork`, and the `not a patch task` throw | policy inside the core | freezing and field validation move into the shape adapter; the core only hands back the opaque declaration | -| `refuseWidening` reading `parent.patch.editable` | check is mechanism, wording and enablement are policy | permission closure becomes a declared constraint the program enforces and reports reasons for | -| `FrozenPatchWork` signatures in the session mechanism | mixed | rendering a declaration into a prompt and submitting an artifact are policy; session lifecycle, metrics and cancellation are mechanism | -| `task_run_tasks.patch_files` / `patch_editable`, parsed in the store | policy in the schema | belongs in the payload document; run, task and revision are mechanism, while input/dependency granularity and operation remain undecided | -| drivers asserting `ticket.patch!.digest` | policy assertion | the mechanism assertion is that a declaration carries a digest, that a claim binds the attempt, and that a delivery binds the digest | +| Site | Mechanism or policy | Remaining boundary | +| --- | --- | --- | +| `refuseWidening` reading `parent.patch.editable` | check is mechanism, wording and enablement are policy | permission closure becomes a declared constraint the program enforces and reports reasons for | +| `FrozenPatchWork` signatures in the session mechanism | mixed | prompt rendering and artifact validation are policy; session lifecycle, metrics and cancellation are mechanism | +| `task_run_tasks.patch_files` / `patch_editable`, parsed in the store | policy in the schema | belongs in the declaration's payload; reconcile the storage target with the frame proposal's typed Task-Unit tables and NULL board payload before migrating | The legality rule found today is the clearest case of the refinement this record adds: its check belongs to the program, while permission closure's name and enablement belong to a declaration. The fix is therefore not @@ -106,17 +118,20 @@ is the right one. ### Field experiments and separation evidence -- `task_run_tasks.effect`, input/dependency granularity and `operation` remain undecided. Their owning - field must be exercised under an alternative declaration before deciding ownership; this gate does - not include those words or resolve them. +- The [controlled field probe](../../experiments/execution/field-ownership-2026-10-01.md) supplies + reproducible evidence for effect labels, content-bound digests, dependency release and operation + interpretation. It narrows the questions; it does not decide the universal input granularity or a + storage migration. This gate does not include those words or resolve them. - The `files` groups in schema migration and `TaskUnit` are marked `undecided`: a group combines patch-specific paths with another role. Resolve them by separating the DDL subjects or exercising an alternative input declaration, not by relabelling the entire group mechanism. - The remaining permission-closure name/enablement coupling needs a declared constraint while its check continues to execute in the program and return reasons. -- Zero policy hits and a second work shape requiring no mechanism changes are unverified separation - goals, not consequences of passing this ratchet. The draft's [second-shape experiment](../../design/mechanism-in-the-middle.md#results) - remains evidence of coupling, not a completed adapter migration. +- A numeric declaration runs through the widened dispatch seam and the real board lifecycle without + further mechanism changes. Zero policy hits, generic permission/session boundaries and a four-role + peer-protocol interface remain unverified goals. Passing the ratchet proves none of them. The draft's + [second-shape experiment](../../design/mechanism-in-the-middle.md#results) remains historical coupling + evidence, not the current seam test. ## Alternatives considered diff --git a/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md index 04c3e0e0..2add166a 100644 --- a/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md +++ b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md @@ -41,11 +41,17 @@ 既有策略命中有明确登记,不被宣称为清洁。新增站点、次数增减、失效或重复记录、无效分类、非法 TypeScript、缺失扫描路径都会使检查失败。消除泄漏需要删除或缩减相应记录;新增命中需要在同一份受审改动中明确分类并解释。检查器不自动刷新次数。加词及其理由记录在本文。 -扫描面以维护的路径表为界,不涵盖全部源码、测试或驱动;范围内的 import 与 schema 字面量同样计入。这落地的是已批准的分类与静态检查切片(原 Plan 2–3),不是工作形态转换。帧/存储记录仍为 proposed。 +扫描面以维护的路径表为界,不涵盖全部源码、测试或驱动;范围内的 import 与 schema 字面量同样计入。门禁落地分类与静态检查切片(原 Plan 2–3),不证明工作形态已经分离。帧/存储记录仍为 proposed。 + +### 不透明派发声明 + +派发端口携带必需、可为空的 `declaration`,以形态参数化;共享机制只要求其有 `digest`。基础 `BoardTicket` 使用同一不透明载体。默认采用方将其特化为自己的声明类型,在返回票据前完成冻结与校验。循环原样传递声明,不重建补丁工作,也不渲染它。缺失声明是采用方违反契约,不会隐式回退成补丁。 + +采用方可以在发出票据之前返回具名拒绝;循环将其记为槽位拒绝,与 worker 失败、无效票据分别记账。[数值采用方证据](../../experiments/execution/field-ownership-2026-10-01.md#numeric-adopter)覆盖共享端口,以及真实 store 的租约、交付和独立裁决。这是受控边界测试,不是产品安装了 peer 协议,也不是帧/存储迁移。 **刻意留作待判的行**(免得被顺手定掉): -- `task_run_tasks.effect`(声明的写集):只有当**机制必须强制写集不相交**时它才是机制;若强制是协议的义务,它就是策略数据。 +- `task_run_tasks.effect` 是 effect 类别标签,不是声明的提议写集。资源集互斥是共同机制义务还是协议义务,需要另判;[字段实验](../../experiments/execution/field-ownership-2026-10-01.md)不会把现有类别门槛变成互斥检查。 - `input` 与 `dependencies` 的粒度:依赖影响合法性与顺序,这是机制;但"输入"是携带内容还是只携带摘要,是另一个问题。 - `operation`:可能是策略。 @@ -53,27 +59,24 @@ ### 工作形态边界 -工作形态转换(原 Plan 5)不在获批切片内。以下补丁假设仍然存在;分类不等于已经实现不透明适配器边界。 +不透明派发边界是工作形态转换(原 Plan 5)的一部分,不等于转换已完成。工作形态是策略层对“核心不透明携带的那对声明与产物”的叫法。补丁冻结属于默认采用方,共享端口不要求这种形态。 -**工作形态不是核心概念**,它是策略层对"核心不透明携带的那对声明与产物"的叫法。六处站点的分类如下。 +余下耦合如下: -| 站点 | 机制还是策略 | 归属 | -| ---------------------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------- | -| `BoardTicket.patch?: FrozenPatchTask & { digest }` | 策略 | 票上带一份不透明的 `declaration` 加摘要 | -| `patchFrozen()` 调 `preparePatchWork`、以及 `throw "not a patch task"` | 策略(在核心里) | 冻结与字段校验移进形态适配器;核心只交出不透明声明 | -| `refuseWidening` 读 `parent.patch.editable` | 检查是机制;措辞与启用是策略 | permission closure 变成程序**执行并给理由**的一条声明约束 | -| 会话机制的 `FrozenPatchWork` 签名 | 混合 | 把声明渲染成提示、提交产物是策略;会话生命周期、指标、取消是机制 | -| `task_run_tasks.patch_files` / `patch_editable`(store 里解析) | 策略进了 schema | 该进载荷文档;run、task、revision 是机制,input/dependencies 粒度与 operation 仍待判 | -| 驱动断言 `ticket.patch!.digest` | 策略断言 | 机制断言是:声明带摘要、认领绑 attempt、交付绑摘要 | +| 站点 | 机制还是策略 | 待分离边界 | +| --- | --- | --- | +| `refuseWidening` 读 `parent.patch.editable` | 检查是机制;措辞与启用是策略 | permission closure 成为程序执行并给理由的一条声明约束 | +| 会话机制的 `FrozenPatchWork` 签名 | 混合 | 提示渲染和产物校验是策略;会话生命周期、指标、取消是机制 | +| `task_run_tasks.patch_files` / `patch_editable`(store 里解析) | 策略进了 schema | 属于声明的载荷;迁移前须与帧提案中 Task-Unit 类型表、黑板 payload 为 NULL 的安排对齐 | 今天那条合法性规则正是本文新增的那点修正的最清楚的例子:**它的检查属于程序,而 permission closure 的名字与启用属于声明。** 所以修法不是把检查搬出程序,而是**别再用核心的词汇硬编码这条规则**——这与合法性记录第 3 步(把 repair-first 从共享规划策略变成由计划声明的约束,2026-09-23 已落地)是**同一形状的修法**。两处各自独立的修法落在同一形状上,是"这个分类是对的"的证据。 ### 字段实验与分离证据 -- `task_run_tasks.effect`、input/dependencies 粒度与 `operation` 仍待判;其归属字段需要在另一种声明下被实际使用,再判断归属。门禁不包含这些词,也不替实验定案。 +- [受控字段探针](../../experiments/execution/field-ownership-2026-10-01.md)提供 effect 标签、内容绑定摘要、依赖释放和 operation 解释的可复现证据。它缩小问题,不决定通用输入粒度或存储迁移。门禁不包含这些词,也不替实验定案。 - schema 迁移与 `TaskUnit` 中的 `files` 组标为 `undecided`:同组混有补丁专有路径与另一角色。通过拆开 DDL 主题或运行另一种输入声明来解决,不把整组改标为机制。 - 余下 permission closure 的名字与启用耦合,需要声明约束;检查仍在程序中执行并按单元返回理由。 -- 零策略命中、第二种工作形态不改机制代码,仍是未验证的分离目标,不是门禁通过的结论。草稿的[第二形态实验](../../design/mechanism-in-the-middle.zh-CN.md#结果)仍是耦合证据,不是已完成的适配器迁移。 +- 数值声明能经过加宽的派发边界与真实黑板生命周期,无须再改机制。零策略命中、通用 permission/session 边界、四角色 peer 协议接口仍是未验证目标;门禁通过不证明这些目标。草稿的[第二形态实验](../../design/mechanism-in-the-middle.zh-CN.md#结果)仍是历史耦合证据,不是当前的边界测试。 ## 考虑过的替代方案 diff --git a/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.md b/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.md index 28b126e2..867164b5 100644 --- a/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.md +++ b/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.md @@ -123,11 +123,12 @@ A peer protocol supplies four narrow things: does not change. Reusable material exists; an interface with all four roles does not. `RoundQueryPort` carries two typed -reads - `cancelled()` and `accepted()` - and the dispatch loop fails a claim whose ticket has no `patch`, -so patch work is the only work shape today and a peer protocol has to bring its own. Widening that seam -(or adding a port beside `DispatchBoard`) is a step of this record, not a fact that already holds. What is -already in place is the legality computation the board calls, the read-only query port pattern, and the -dispatch loop's own separation of a refusal to use a slot from a failed unit. +reads - `cancelled()` and `accepted()`. The [dispatch declaration seam](../implemented/2026-09-21-mechanism-not-policy.md#opaque-dispatch-declarations) +is parameterized by an opaque declaration with a digest, and a controlled numeric adopter uses the +shared loop and real board lifecycle without patch fields. That does not implement protocol selectors, +payload storage, projection or version rules. The four-role interface remains a step of this record. +The existing legality computation, read-only query port pattern and separation of slot refusal from +unit failure are reusable mechanisms. Once that seam exists, replacing a protocol means another implementation plus another `protocol` value; the payloads of entries written under the old value stay exactly as they are - readable, not actionable, @@ -167,8 +168,8 @@ test. Anything that needs a new table, a new tool or a new channel is outside th 3. Domain refusals become data at the RPC and tool boundary while request-level errors stay errors; the store keeps throwing. 4. Tool schemas shaped for strict mode; flat T0/T1 rendering. -5. The four-role protocol seam: widen the query port or add one beside `DispatchBoard`, and let a peer - protocol bring its own work shape instead of requiring `patch`. +5. The four-role protocol seam: extend the opaque dispatch declaration port with declaration validation, + legality, projection and acceptance under a named, versioned protocol. 6. A threat list, with the two rules carried as text until a trust boundary exists. ## Alternatives considered diff --git a/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md b/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md index 1b408d5f..72b27db2 100644 --- a/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md +++ b/docs/decisions/proposed/2026-09-21-the-frame-and-its-storage.zh-CN.md @@ -78,7 +78,7 @@ 3. **投影**——T1 的一行摘要与 T2 的结构。必须有,因为载荷对板子不透明;MCP 与 A2A 出于同样理由也是这个形状。 4. **验收**——接受一次交付的判据。形式(digest 加独立裁决)不变。 -可复用的材料存在,但四项职责齐全的接口不存在。`RoundQueryPort` 只有两个 typed 读——`cancelled()` 与 `accepted()`;派发循环对没有 `patch` 的票直接判失败,所以今天唯一的“工作形态”就是 patch,平级协议必须自带它自己的。把这条缝拓宽(或在 `DispatchBoard` 旁边加一个 port)是本文的**计划步骤**,不是既成事实。已经到位的是板子调用的合法性计算、只读查询端口的模式,以及派发循环里已有的“拒绝使用槽位”与“单元失败”之分。 +可复用的材料存在,但四项职责齐全的接口不存在。`RoundQueryPort` 只有两个 typed 读——`cancelled()` 与 `accepted()`。[派发声明边界](../implemented/2026-09-21-mechanism-not-policy.zh-CN.md#不透明派发声明)以带摘要的不透明声明参数化;受控数值采用方无须补丁字段,能使用共享循环和真实黑板生命周期。这不等于已经实现协议选择器、payload 存储、投影或版本规则;四角色接口仍是本文的计划步骤。现有合法性计算、只读查询端口模式、槽位拒绝与单元失败的区分,是可复用机制。 那条缝一旦存在,替换一个协议就是换一个实现加换一个 `protocol` 取值;旧取值下写入的载荷原样留着——可读、不可行动,且不需要迁移。 @@ -105,7 +105,7 @@ ECMA-434 对 NLIP 是符合性要求,配套安全指南给十五类威胁打 2. 一次迁移:`protocol`、`protocol_version`、`payload_format`、`payload`,加那条不变量测试。 3. 域内拒绝在 RPC 与工具边界变成数据,而请求级错误仍是错误;store 继续抛异常。 4. 工具 schema 按 strict 模式写;T0/T1 扁平渲染。 -5. 四项职责的协议缝:拓宽查询端口,或在 `DispatchBoard` 旁边加一个;让平级协议自带工作形态,而不是要求 `patch`。 +5. 四项职责的协议缝:在不透明派发声明端口上,为具名、版本化协议接入声明校验、合法性、投影与验收。 6. 一份威胁清单,两条规则先以文字承载,等出现信任边界再谈强制。 ## 考虑过的替代方案 diff --git a/docs/design/task-unit-semantics.md b/docs/design/task-unit-semantics.md index 2aa49f69..f19a4b93 100644 --- a/docs/design/task-unit-semantics.md +++ b/docs/design/task-unit-semantics.md @@ -106,7 +106,7 @@ Task IR 是从现有声明与记录计算出的只读视图;不接收另一份 board `kind` 的唯一 owner 是 [TaskBoardKind](../../src/core/types.ts)。它描述协作消息用途:`handoff` 交接、`result` 返回、`decision` 判定;不是 patch/conclusion 类型,也不是 effect。OoO 行内的 patch/snapshot `kind` 又是内部任务类别。三者不得互相转换或新增 `task-ir` kind、工具、频道来承载重复定义。 -数据流为:现有 RoundSpec 研究入口 → 共享规范化/校验 → 现有任务契约与计划 → 派生分析视图 → `BoardTicket`。需要产品接入的解析规则必须移到共享 owner,由研究入口和各 harness 调用;不能把研究 A/B/C 输入格式提升为通用语言。`FrozenPatchTask` 仍由 `preparePatchWork` 产生,票据继续携带完整冻结 work,禁止重抄字段子集。 +数据流为:现有 RoundSpec 研究入口 → 共享规范化/校验 → 现有任务契约与计划 → 派生分析视图 → `BoardTicket`。需要产品接入的解析规则必须移到共享 owner,由研究入口和各 harness 调用;不能把研究 A/B/C 输入格式提升为通用语言。默认补丁采用方用 `preparePatchWork` 生成完整冻结声明,不重抄字段子集。票据和共享循环通过带摘要的不透明 `declaration` 携带它,不要求所有采用方的声明都有补丁字段;冻结、解释和验收由采用方负责。[声明边界决策](../decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md#不透明派发声明)说明缺失声明与具名拒绝的区别。 编译必须对无映射能力明确拒绝,不静默丢字段或降为自由文本。第一版只分析现有 patch/snapshot 契约,验证器、预算和访问规则不重实现。新增语义先扩展 owner、身份摘要及对应检查,再进入派生视图。 diff --git a/docs/experiments/execution/field-ownership-2026-10-01.json b/docs/experiments/execution/field-ownership-2026-10-01.json new file mode 100644 index 00000000..7ae84377 --- /dev/null +++ b/docs/experiments/execution/field-ownership-2026-10-01.json @@ -0,0 +1,91 @@ +{ + "measuredAt": "2026-10-01T14:10:00.180Z", + "baseRevision": "256bcc0e66ebe8a946e300b8d465731caea02bf4", + "sources": [ + { + "path": "src/integration/task-semantics.ts", + "sha256": "0c87147812ec4ee3948fa229f279666972e1e30f9bc1107e6eff4c766fec1c87" + }, + { + "path": "src/integration/ooo-execution.ts", + "sha256": "ddfa301504da7a1458d3fc8b9a9d61269cbeb6cee78f0061b1b60a1986482da5" + }, + { + "path": "src/integration/ooo-patch.ts", + "sha256": "9f0acb44899c55f567bf3f353a3f3c54d984815f2626cdd6de3be956a490e8b0" + } + ], + "controlledModels": 5, + "scope": "pure Task-Unit model/eligibility and snapshot evaluator; no live providers or protocol migration", + "overlappingProposalPaths": { + "writes": [ + [ + "data/shared.txt" + ], + [ + "data/shared.txt" + ] + ], + "effects": [ + "isolated-artifact", + "isolated-artifact" + ], + "legal": [ + "A", + "B" + ], + "changedEffectLegal": [ + "A" + ], + "changedEffectReasons": [ + { + "id": "A", + "legal": true, + "reasons": [] + }, + { + "id": "B", + "legal": false, + "reasons": [ + "effect-not-startable" + ] + } + ] + }, + "changedInputBytes": { + "beforeDigest": "0a3f02cabe63b7285b47f5734edbaf83ecbbaf5a22eb6c54e6405d6a0caf3484", + "afterDigest": "6b019519e9f178a5d6cbda1e864bd13c71989b445427198b5c96ff7bbf0ec7ab", + "beforeLegal": [ + "A", + "B" + ], + "afterLegal": [ + "A", + "B" + ] + }, + "declaredDependency": { + "beforeAccepted": [ + "A" + ], + "afterAccepted": [ + "B" + ] + }, + "operations": [ + { + "operation": "double", + "legal": [ + "P" + ], + "answer": "8" + }, + { + "operation": "sum", + "legal": [ + "P" + ], + "answer": "0" + } + ] +} diff --git a/docs/experiments/execution/field-ownership-2026-10-01.md b/docs/experiments/execution/field-ownership-2026-10-01.md new file mode 100644 index 00000000..64cd8761 --- /dev/null +++ b/docs/experiments/execution/field-ownership-2026-10-01.md @@ -0,0 +1,77 @@ +# Field ownership and the opaque dispatch seam + +Measured on 2026-10-01, Windows / Node v24.19.0. This is controlled evidence, not a +live workload or an approval of the frame/storage proposal. + +## Method + +[The machine-readable record](field-ownership-2026-10-01.json) binds five compiled +models to their base revision and the SHA-256 of each semantic source. The +[probe](../../../evals/ooo-execution/field-ownership.ts) supplies the complete inputs +and assertions. From the repository root: + +```sh +node --experimental-strip-types evals/ooo-execution/field-ownership.ts --out .temp/field-ownership-replay.json +``` + +The stored observation is preserved; replay writes a separate output. There are +no model calls, embeddings, database writes, or official benchmark data. + +## Field observations + +- **Effect class is not a write set.** Two `isolated-artifact` units propose the + same `data/shared.txt` path and are both legal at two slots. Changing B's class + to `local-write` leaves only A legal and names `effect-not-startable` for B. + This observes a class-label gate, not resource-set exclusion. These are proposed + artifacts, not concurrent writes to a shared file; it does not establish that + arbitrary side effects are safe. +- **Input bytes and eligibility are distinct.** Changing A's frozen file content + changes its declaration digest but leaves the legal set unchanged at the same + declared revision and dependency facts. The freezer binds content; this + eligibility calculation does not inspect the bytes. It does not authorize + changing a live declaration or establish a universal input representation. +- **Declared edges bind release to acceptance.** With B depending on A, A is + offered before acceptance and B after A's digest-bound acceptance fact. The + fact is controlled input to the pure model, not a worker's self-report. +- **Operation interprets a result.** `double` and `sum` have the same legal set + under a read-only unit with input `4`, but their answers are `8` and `0`. + This shows the default snapshot interpreter's choice; it does not choose a + storage location or prove that every future protocol has an operation field. + +## Numeric adopter + +[The port fixture](../../../tests/integration/ooo-work-shape.test.ts) declares +`{ digest, values: [2, 3] }`, not patch work. Its execution and named-refusal cases +failed against the patch-only loop, then passed through the opaque declaration +port. The carrier is required: a ticket without a declaration field cannot +silently satisfy the TypeScript port. An unsupported shape returns its name +before taking a lease or invoking a worker, and is recorded as a slot refusal. + +[The real-store fixture](../../../tests/integration/ooo-value-store-board.test.ts) +adds a numeric adopter without modifying the store's coordination implementation. +It takes the real lease, delivers an artifact, records an outside verdict from +`numeric-judge` rather than `numeric-worker`, and releases the accepted result. +The declaration body is unchanged. Declaration validation and numeric acceptance +are in the adopter; the store does not interpret the numbers. Both fixtures ran +without a provider. Their four cases passed. + +This is a small programmatic adopter, not an installed product peer protocol. +The real-store fixture uses existing `content` to transport its controlled +body. It neither adds the proposed `payload` columns nor validates the proposal's +content/payload separation, projection interface, protocol selectors, unknown +protocol quarantine, or threat model. Those are different tests and work. + +## What remains undecided + +The measurements support the [ownership decision](../../decisions/implemented/2026-09-21-mechanism-not-policy.md) +without making a vocabulary ratchet proof of a policy-free mechanism. Permission +closure still reads patch metadata, the session module still mixes patch policy +with lifecycle, and the store still parses patch-specific task columns. + +Storage needs a distinct decision: the mechanism record assigns shape fields to +the declaration payload, while the [frame proposal](../../decisions/proposed/2026-09-21-the-frame-and-its-storage.md) +retains typed Task-Unit tables and requires the board payload to stay NULL for +Task-Unit entries. These observations do not choose whether to retain a +protocol-owned typed declaration or migrate it to an opaque task document. Do +not put a second copy of the task in the board payload to make the seam appear +complete. diff --git a/evals/ooo-execution/board-slots.test.ts b/evals/ooo-execution/board-slots.test.ts index 486b25bf..a3da939e 100644 --- a/evals/ooo-execution/board-slots.test.ts +++ b/evals/ooo-execution/board-slots.test.ts @@ -55,7 +55,7 @@ function fixture( * decides. Accepting a claimed task is what frees its dependents. */ async function acceptUnit( gate: BoardAdmission, - ticket: { owner: string; patch?: { digest: string } }, + ticket: { owner: string; declaration: { digest: string } | null }, ) { const entry = gate.putTaskBoardEntry({ taskId: gate.channel, @@ -64,7 +64,7 @@ async function acceptUnit( content: JSON.stringify({ ticket, artifact: JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: [{ path: TARGET, content: expected }], }), }), diff --git a/evals/ooo-execution/field-ownership.ts b/evals/ooo-execution/field-ownership.ts new file mode 100644 index 00000000..e5cdea25 --- /dev/null +++ b/evals/ooo-execution/field-ownership.ts @@ -0,0 +1,131 @@ +/** Controlled field-ownership probe. Run from the repository root with --out . + * No providers, store mutation, or benchmark data. Observations do not select a storage policy. */ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { parseArgs } from "node:util"; +import { + compileTaskUnits, + dispatchTasks, + type CompiledTasks, +} from "../../src/integration/task-semantics.ts"; +import { snapshotAnswer, unitLegality } from "../../src/integration/ooo-execution.ts"; +import type { PatchTaskSpec, ProbePlan } from "../../src/integration/ooo-board.ts"; + +const { values } = parseArgs({ options: { out: { type: "string" } } }); +if (!values.out) throw new Error("usage: --out "); +const FILE = "data/shared.txt"; +const spec = (content = "1"): PatchTaskSpec => ({ + instruction: "produce a value", + files: { [FILE]: content }, + editable: [FILE], + verify: async () => "accept", +}); +const plan: ProbePlan = [ + ["A", "v1", [], "isolated-artifact", null, null], + ["B", "v1", [], "isolated-artifact", null, null], +]; +let controlledModels = 0; +function compiled(plan: ProbePlan, specs?: Record): CompiledTasks { + const result = compileTaskUnits({ plan, specs }); + if (!result.legal) throw new Error(JSON.stringify(result.refusals)); + controlledModels++; + return result; +} +const units = compiled(plan, { A: spec(), B: spec() }); +const tasks = dispatchTasks(units.units, {}); +const overlapping = unitLegality(tasks, 2); +assert.deepEqual( + units.units.map((unit) => unit.effects.proposeWrite), + [[FILE], [FILE]], +); +assert.deepEqual(overlapping.legal, ["A", "B"]); +const changedEffect = unitLegality( + tasks.map((task) => (task.id === "B" ? { ...task, effect: "local-write" } : task)), + 2, +); +assert.deepEqual(changedEffect.legal, ["A"]); +assert.ok( + changedEffect.units.find((unit) => unit.id === "B")?.reasons.includes("effect-not-startable"), +); + +const changedBytes = compiled(plan, { A: spec("2"), B: spec() }); +const changedByteLegality = unitLegality(dispatchTasks(changedBytes.units, {}), 2); +assert.notEqual(units.units[0]!.patch!.digest, changedBytes.units[0]!.patch!.digest); +assert.deepEqual(changedByteLegality.legal, overlapping.legal); + +const dependentPlan: ProbePlan = [plan[0]!, ["B", "v1", ["A"], "isolated-artifact", null, null]]; +const dependent = compiled(dependentPlan, { A: spec(), B: spec() }); +const beforeDependency = unitLegality(dispatchTasks(dependent.units, {}), 2); +const afterDependency = unitLegality( + dispatchTasks(dependent.units, { + artifacts: { A: "a-value" }, + verdicts: { A: { digest: "a-value", verdict: "accepted" } }, + }), + 2, +); +assert.deepEqual(beforeDependency.legal, ["A"]); +assert.deepEqual(afterDependency.legal, ["B"]); + +const operations = (["double", "sum"] as const).map((operation) => { + const model = compiled([["P", "4", [], "read-only", null, operation]]); + return { + operation, + legal: unitLegality(dispatchTasks(model.units, {}), 1).legal, + answer: snapshotAnswer({ operation, input: "4", dependencies: {} }), + }; +}); +assert.deepEqual( + operations.map((row) => row.legal), + [["P"], ["P"]], +); +assert.deepEqual( + operations.map((row) => row.answer), + ["8", "0"], +); + +const sources = [ + "src/integration/task-semantics.ts", + "src/integration/ooo-execution.ts", + "src/integration/ooo-patch.ts", +]; +const report = { + measuredAt: new Date().toISOString(), + baseRevision: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(), + sources: sources.map((path) => ({ + path, + sha256: createHash("sha256").update(readFileSync(path)).digest("hex"), + })), + controlledModels, + scope: + "pure Task-Unit model/eligibility and snapshot evaluator; no live providers or protocol migration", + overlappingProposalPaths: { + writes: units.units.map((unit) => unit.effects.proposeWrite), + effects: tasks.map((task) => task.effect), + legal: overlapping.legal, + changedEffectLegal: changedEffect.legal, + changedEffectReasons: changedEffect.units, + }, + changedInputBytes: { + beforeDigest: units.units[0]!.patch!.digest, + afterDigest: changedBytes.units[0]!.patch!.digest, + beforeLegal: overlapping.legal, + afterLegal: changedByteLegality.legal, + }, + declaredDependency: { + beforeAccepted: beforeDependency.legal, + afterAccepted: afterDependency.legal, + }, + operations, +}; +assert.equal(report.sources.length, 3); +writeFileSync(values.out, JSON.stringify(report, null, 2) + "\n"); +console.log( + JSON.stringify({ + output: values.out, + measuredAt: report.measuredAt, + models: report.controlledModels, + sources: report.sources.length, + }), +); diff --git a/evals/ooo-execution/patch-cycle.test.ts b/evals/ooo-execution/patch-cycle.test.ts index 841bfe00..81f1b89b 100644 --- a/evals/ooo-execution/patch-cycle.test.ts +++ b/evals/ooo-execution/patch-cycle.test.ts @@ -56,7 +56,7 @@ function fixture(t: TestContext, verify: PatchTaskSpec["verify"] = verifyRename) function submitPatch( gate: BoardAdmission, - ticket: { owner: string; patch?: { digest: string } }, + ticket: { owner: string; declaration: { digest: string } | null }, artifact: string, ) { const entry = gate.putTaskBoardEntry({ @@ -73,7 +73,7 @@ test("contract: a verified patch candidate is what dependents bind to, and only const gate = fixture(t); const ticket = gate.claim("P", "worker-p"); const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: [{ path: TARGET, content: expected }], }); assert.deepEqual(gate.accepted(), {}); @@ -97,7 +97,7 @@ test("acceptance lands on the board as a deliverable and an outside verdict, not .readTaskBoard({ taskId: gate.channel }) .entries.find((entry) => entry.claimedBy === "worker-p")!.id; const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: [{ path: TARGET, content: expected }], }); assert.equal(await submitPatch(gate, ticket, artifact), "accepted"); @@ -123,7 +123,7 @@ test("the board verdict is what accepts an artifact, not the round's own column" .readTaskBoard({ taskId: gate.channel }) .entries.find((entry) => entry.claimedBy === "worker-p")!.id; const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: [{ path: TARGET, content: expected }], }); assert.equal(await submitPatch(gate, ticket, artifact), "accepted"); @@ -166,7 +166,7 @@ test("safety: worker-supplied approval is ignored and a reissued attempt fences const first = gate.claim("P", "worker-p"); // Extra fields are not a verdict gate.channel: the artifact shape must be exact. const selfApproved = JSON.stringify({ - digest: first.patch!.digest, + digest: first.declaration!.digest, files: files(expected), passed: true, verdict: "accept", @@ -179,10 +179,10 @@ test("safety: worker-supplied approval is ignored and a reissued attempt fences gate.now += 61_000; const second = gate.claim("P", "worker-q"); assert.equal(second.attempt, 2); - assert.notEqual(second.patch!.digest, first.patch!.digest); - const stale = JSON.stringify({ digest: first.patch!.digest, files: files(expected) }); + assert.notEqual(second.declaration!.digest, first.declaration!.digest); + const stale = JSON.stringify({ digest: first.declaration!.digest, files: files(expected) }); assert.equal(await submitPatch(gate, first, stale), "stale"); - const fresh = JSON.stringify({ digest: second.patch!.digest, files: files(expected) }); + const fresh = JSON.stringify({ digest: second.declaration!.digest, files: files(expected) }); assert.equal(await submitPatch(gate, second, fresh), "accepted"); assert.equal(await submitPatch(gate, second, fresh), "duplicate"); }); @@ -193,7 +193,7 @@ test("safety: a rejected proposal never accepts worker text and the same attempt assert.equal(await submitPatch(gate, first, proposal()), "rejected"); assert.equal(gate.next(), null); const corrected = JSON.stringify({ - digest: first.patch!.digest, + digest: first.declaration!.digest, files: [{ path: TARGET, content: expected }], }); assert.equal(await submitPatch(gate, first, corrected), "accepted"); @@ -207,7 +207,7 @@ test("safety: a rejected proposal never accepts worker text and the same attempt test("contract: a conclusion is admissible without files but cannot carry a change", async (t) => { const gate = fixture(t); const first = gate.claim("P", "worker-1"); - const digest = first.patch!.digest; + const digest = first.declaration!.digest; const conclusion = (extra: Record = {}) => JSON.stringify({ digest, @@ -238,7 +238,7 @@ test("safety: a host check that throws or is undecidable cannot accept a candida }); const ticket = gate.claim("P", "worker-p"); const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: [{ path: TARGET, content: expected }], }); assert.equal(await submitPatch(gate, ticket, artifact), "rejected"); @@ -252,7 +252,7 @@ test("an outside rejection withdraws the release of a dependent, and the round f .readTaskBoard({ taskId: gate.channel }) .entries.find((entry) => entry.claimedBy === "worker-p")!.id; const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: [{ path: TARGET, content: expected }], }); assert.equal(await submitPatch(gate, ticket, artifact), "accepted"); @@ -288,7 +288,7 @@ test("acceptance survives the entry's own TTL, because the round retains what it const gate = fixture(t); const ticket = gate.claim("P", "worker-p"); const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: [{ path: TARGET, content: expected }], }); assert.equal(await submitPatch(gate, ticket, artifact), "accepted"); @@ -335,7 +335,7 @@ test("cancelling a round releases the pins it held, so nothing it referenced lea const gate = fixture(t); const ticket = gate.claim("P", "worker-p"); const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: [{ path: TARGET, content: expected }], }); assert.equal(await submitPatch(gate, ticket, artifact), "accepted"); diff --git a/evals/ooo-execution/plan-driver.ts b/evals/ooo-execution/plan-driver.ts index 1cb95fda..2e981bde 100644 --- a/evals/ooo-execution/plan-driver.ts +++ b/evals/ooo-execution/plan-driver.ts @@ -35,7 +35,7 @@ import { type ProbePlan, } from "../../src/integration/ooo-board.ts"; import { verifyDataChecks } from "../../src/integration/ooo-candidate.ts"; -import type { PatchSubmission } from "../../src/integration/ooo-patch.ts"; +import type { PatchSubmission, FrozenPatchWork } from "../../src/integration/ooo-patch.ts"; import type { DataCheck } from "../../src/integration/ooo-candidate.ts"; import { testFileCheck } from "./data-check-runner.ts"; import type { SessionPlan } from "../../src/integration/ooo-execution.ts"; @@ -48,10 +48,11 @@ import { dispatchPlan, type DispatchedUnit } from "../../src/integration/ooo-dis import type { WorkerMetrics, PlanWorkerResult, - PlanWorker, + PlanWorker as DispatchWorker, PlanSession, } from "../../src/integration/ooo-dispatch.ts"; -export type { WorkerMetrics, PlanWorkerResult, PlanWorker, PlanSession }; +export type PlanWorker = DispatchWorker; +export type { WorkerMetrics, PlanWorkerResult, PlanSession }; /** One unit of the plan: what it is asked for, what it may edit, and what its own candidate must * pass. The last one is the unit's acceptance; the parent check is separate and fixed. Both are @@ -264,7 +265,7 @@ export async function runPlan(spec: PlanDriverSpec): Promise { // The loop is the shared one: this driver supplies what only it knows - the plan, each task's spec, // the declared bound, the legality view, the worker, and the session identity its measurements are - // keyed by - and the shared layer owns the order of operations (claim, freeze, worker, result, + // keyed by - and the shared layer owns the order of operations (claim, worker, result, // verdict, and the session decision at each boundary). const outcome = await dispatchPlan({ board: gate, diff --git a/src/integration/ooo-board.ts b/src/integration/ooo-board.ts index 8ab8fbd0..c4d29e89 100644 --- a/src/integration/ooo-board.ts +++ b/src/integration/ooo-board.ts @@ -10,12 +10,13 @@ import { patchSubmission, preparePatchWork, type ConclusionKind, - type FrozenPatchTask, + type FrozenPatchWork, type PatchBudget, type PatchLimits, type PatchSubmission, } from "./ooo-patch.ts"; import { workDigest, workDigestOf } from "./work-identity.ts"; +import type { WorkDeclaration } from "./ooo-dispatch.ts"; export type ProbeOperation = SnapshotWork["operation"]; @@ -122,7 +123,7 @@ function parsePayload(content: string): { ticket: BoardTicket; artifact: string return { ticket: ticket as BoardTicket, artifact }; } -export interface BoardTicket { +export interface BoardTicket { runId: string; taskId: string; revision: string; @@ -133,9 +134,8 @@ export interface BoardTicket { input: string; dependencies: Record; operation: ProbeOperation | null; - // The frozen work itself, plus the digest that names it: typing it as a hand-listed - // subset is what let the ticket silently omit every field added later. - patch?: FrozenPatchTask & { digest: string }; + // The shape owner freezes before issuing the ticket. Mechanism readers know only its digest. + readonly declaration: Declaration | null; } /** Which run of a store to open. Omitted, a store with exactly one run continues it and a @@ -1068,7 +1068,7 @@ export class BoardAdmission extends NmgStore { return facts; } - claim(id: string, agentId: string): BoardTicket { + claim(id: string, agentId: string): BoardTicket { if (!id || !agentId) throw new Error("task and agent required"); if (this.cancelled() !== null) throw new Error("round cancelled"); return this.transaction(() => { @@ -1114,7 +1114,7 @@ export class BoardAdmission extends NmgStore { input: row.input, operation: spec ? null : (row.operation as ProbeOperation), dependencies, - patch: frozen ? { ...frozen.work, digest: frozen.digest } : undefined, + declaration: frozen, }; }); } diff --git a/src/integration/ooo-dispatch.ts b/src/integration/ooo-dispatch.ts index 158c797b..1b2501e4 100644 --- a/src/integration/ooo-dispatch.ts +++ b/src/integration/ooo-dispatch.ts @@ -6,7 +6,7 @@ * record facts in, `decideSessionMove`'s - the same rule plus the cancellation read). What this module * owns is the order of operations no caller should have to re-invent: * - * candidates -> claim a ticket -> freeze the task -> call the worker -> put the result on the + * candidates -> claim a frozen declaration -> call the worker -> put the result on the * board -> let the store decide -> account for it -> ask whether the session may continue. * * Two things are deliberately *not* decided here. @@ -26,8 +26,6 @@ * worker, their session identity and their report. See * `docs/decisions/implemented/2026-09-19-dispatch-loop-is-shared.md`. */ -import type { PatchWork, FrozenPatchWork } from "./ooo-patch.ts"; -import { preparePatchWork } from "./ooo-patch.ts"; import type { LegalityAnswer, SessionPlan } from "./ooo-execution.ts"; import { nextSessionMove } from "./ooo-fusion-plan.ts"; import { decideSessionMove } from "./ooo-session-facts.ts"; @@ -59,9 +57,14 @@ export type WorkerMetrics = { export type PlanWorkerResult = string | { artifact?: string; metrics?: WorkerMetrics; failure?: string }; -export type PlanWorker = ( +/** Only this identity is shared. The adopter owns the declaration's payload and its validation. */ +export interface WorkDeclaration { + readonly digest: string; +} + +export type PlanWorker = ( taskId: string, - frozen: FrozenPatchWork, + frozen: Declaration, dependencies: Readonly>, session?: PlanSession, ) => Promise; @@ -75,15 +78,19 @@ export interface PlanSession { } /** The open handoff one unit is admitted through, as the loop reads it. */ -export interface DispatchTicket { +export interface DispatchTicket { /** The claim's own attempt number: it names this try, so it is read from the claim and not from the * frozen work, which the same task can be re-issued with. */ readonly attempt: number; readonly dependencies: Readonly>; - /** The frozen work this claim admits, absent when the task is not a patch task. */ - readonly patch?: PatchWork; + /** Host-frozen and digest-bound before a claim is offered to the worker. No shape is required. */ + readonly declaration: Declaration | null; } +/** A named domain refusal is not a claimed attempt or a failed unit. */ +export type DispatchClaim = + DispatchTicket | { refused: string }; + /** A board entry this run put on the channel. Only its identity is read here. */ export interface DispatchEntry { readonly id: string; @@ -97,7 +104,7 @@ export interface DispatchEntry { * board satisfies this by being one, or by a thin adapter over it - which is where a board-specific * decision belongs. */ -export interface DispatchBoard { +export interface DispatchBoard { /** The channel this run's entries go on. */ readonly channel: string; /** The board's clock, as the loop stamps its entries with it. */ @@ -112,7 +119,7 @@ export interface DispatchBoard { /** The accepted artifact per task id: the rule every dependency and every parent check reads. */ accepted(): Readonly>; /** Take one unit. The board re-checks legality here, so a stale answer becomes a refusal. */ - claim(taskId: string, owner: string): DispatchTicket; + claim(taskId: string, owner: string): DispatchClaim; /** Put this run's entry on the channel, and say where it landed. */ putTaskBoardEntry(input: { taskId: string; @@ -198,9 +205,9 @@ export interface SessionCapability { } /** What the loop needs to run one plan. The declarations stay the caller's; the ordering does not. */ -export interface DispatchPlanInput { +export interface DispatchPlanInput { /** The board the plan runs through: the product's, or an instrument's over the same loop. */ - board: DispatchBoard; + board: DispatchBoard; /** Plan order. The legal set comes from the board, not from here; this is what must finish. */ plan: readonly string[]; /** How many legal units may be in flight at once. */ @@ -211,7 +218,7 @@ export interface DispatchPlanInput { /** The legality view the shared move reads. Built by the caller: the arms from their spec, a product * caller from the frozen run and the board's own facts. */ legality: (pendingBranches: readonly string[]) => SessionPlan; - worker: PlanWorker; + worker: PlanWorker; /** Who a unit's handoff is offered to and who therefore claims it: one name, one home. */ ownerOf: (taskId: string) => string; /** Where a unit's session decision is recorded. Given, the decision is `decideSessionMove`'s - the @@ -245,16 +252,16 @@ type UnitAttempt = * One unit through the board: claim, run the worker, put the result on the channel, submit. The store * decides the verdict; this loop never reads a worker's claim about itself. */ -async function dispatchUnit( - input: DispatchPlanInput, +async function dispatchUnit( + input: DispatchPlanInput, taskId: string, session?: PlanSession, ): Promise { const board = input.board; const claimedAt = Date.now(); - let ticket: DispatchTicket; + let claim: DispatchClaim; try { - ticket = board.claim(taskId, input.ownerOf(taskId)); + claim = board.claim(taskId, input.ownerOf(taskId)); } catch (error) { const reason = error instanceof Error ? error.message : String(error); // The board publishes a handoff only for the task it has selected, so while one unit is claimed no @@ -263,23 +270,15 @@ async function dispatchUnit( return { refused: reason }; return { failure: `${taskId}: ${reason}` }; } - if (!ticket.patch) - return { failure: `${taskId}: the claim admits no patch work, so nothing can be produced` }; - const work = ticket.patch; - const frozen = preparePatchWork({ - taskId: work.taskId, - attempt: ticket.attempt, - instruction: work.instruction, - files: work.files, - editable: work.editable, - visible: work.visible, - admittedConclusions: work.admittedConclusions, - budget: work.budget, - limits: work.limits, - }); + if ("refused" in claim) return { refused: claim.refused }; + const ticket = claim; + if (!ticket.declaration) + return { + failure: `${taskId}: the claim admits no frozen declaration, so nothing can be produced`, + }; let produced: PlanWorkerResult; try { - produced = await input.worker(taskId, frozen, ticket.dependencies, session); + produced = await input.worker(taskId, ticket.declaration, ticket.dependencies, session); } catch (error) { return { failure: `${taskId}: ${error instanceof Error ? error.message : String(error)}` }; } @@ -318,7 +317,9 @@ async function dispatchUnit( * from the board and the store at the moment it is needed, so a caller that stops and resumes, or two * callers of one run, see the same state instead of a caller's memory of it. */ -export async function dispatchPlan(input: DispatchPlanInput): Promise { +export async function dispatchPlan( + input: DispatchPlanInput, +): Promise { const board = input.board; const units: DispatchedUnit[] = []; const order: string[] = []; diff --git a/src/integration/ooo-runner.ts b/src/integration/ooo-runner.ts index 58091744..f95cdc5d 100644 --- a/src/integration/ooo-runner.ts +++ b/src/integration/ooo-runner.ts @@ -26,6 +26,7 @@ import { preparePatchWork, type PatchSubmission, type PatchWork, + type FrozenPatchWork, } from "./ooo-patch.ts"; import { unitLegality, type LegalityAnswer } from "./ooo-execution.ts"; import { compileTaskUnits, dispatchTasks, type RecordedFacts } from "./task-semantics.ts"; @@ -70,7 +71,7 @@ type RunTask = ReturnType[number]; type RunBinding = ReturnType["bindings"][number]; /** The store's board for one run, satisfying the loop's port and nothing else. */ -export class StoreRunBoard implements DispatchBoard { +export class StoreRunBoard implements DispatchBoard { readonly channel: string; readonly #store: NmgStore; readonly #options: RunBoardOptions; @@ -107,7 +108,7 @@ export class StoreRunBoard implements DispatchBoard { } /** Take one unit. The claim is the store's compare-and-set on the entry its binding names. */ - claim(taskId: string, owner: string): DispatchTicket { + claim(taskId: string, owner: string): DispatchTicket { const binding = this.#bindingFor(taskId); const entry = coordinatedBoardWrite(this.#store, { runId: this.#options.runId, @@ -124,6 +125,7 @@ export class StoreRunBoard implements DispatchBoard { const attempt = entry.attempt ?? binding.attempt; const task = this.#task(taskId); const accepted = this.accepted(); + const work = this.#work(task, attempt); return { attempt, dependencies: Object.fromEntries( @@ -131,7 +133,7 @@ export class StoreRunBoard implements DispatchBoard { .filter((dependency) => dependency in accepted) .map((dependency) => [dependency, accepted[dependency]!]), ), - patch: this.#work(task, attempt) ?? undefined, + declaration: work === null ? null : frozenOf(work), }; } @@ -173,7 +175,7 @@ export class StoreRunBoard implements DispatchBoard { /** * Let the acceptance decide the verdict of what was delivered, under the judge's own name. The - * frozen envelope is rebuilt here for the same reason the loop rebuilds it: the digest is the + * frozen envelope is rebuilt here from the adopter's declaration: the digest is the * artifact's identity, and recomputing it is how a caller holding only an entry id gets it back. */ async submit(entryId: string): Promise { diff --git a/tests/integration/ooo-dispatch.test.ts b/tests/integration/ooo-dispatch.test.ts index b9b1cd50..f5164117 100644 --- a/tests/integration/ooo-dispatch.test.ts +++ b/tests/integration/ooo-dispatch.test.ts @@ -33,7 +33,12 @@ import { type LegalityAnswer, type SessionPlan, } from "../../src/integration/ooo-execution.ts"; -import type { PlanSession, PlanWorker } from "../../src/integration/ooo-dispatch.ts"; +import type { + PlanSession, + PlanWorker as DispatchWorker, +} from "../../src/integration/ooo-dispatch.ts"; +import { preparePatchWork, type FrozenPatchWork } from "../../src/integration/ooo-patch.ts"; +type PlanWorker = DispatchWorker; const REMOVE_TEMP_TREE = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }; @@ -51,7 +56,7 @@ type BoardOptions = { }; /** A board in memory: legal set, claims, entries and verdicts, with no store behind any of it. */ -class StubBoard implements DispatchBoard { +class StubBoard implements DispatchBoard { readonly channel = "run-1"; now = 1_700_000_000_000; /** What a delivered-but-unaccepted unit reports, so a case can drive a rejection. */ @@ -105,7 +110,7 @@ class StubBoard implements DispatchBoard { return this.#accepted; } - claim(taskId: string, _owner: string): DispatchTicket { + claim(taskId: string, _owner: string): DispatchTicket { if (this.#options.refuse?.(taskId)) throw new Error(`no published handoff for ${taskId}`); const attempt = (this.#claims.get(taskId) ?? 0) + 1; this.#claims.set(taskId, attempt); @@ -113,19 +118,19 @@ class StubBoard implements DispatchBoard { return { attempt, dependencies: {}, - patch: { + declaration: preparePatchWork({ taskId, attempt, instruction: `work on ${taskId}`, files: { "src/unit.ts": "export const value = 1;\n" }, editable: ["src/unit.ts"], - }, + }), }; } putTaskBoardEntry(input: { taskId: string; kind: "result"; content: string }): { id: string } { const id = `entry-${++this.#entries}`; - this.#taskOf.set(id, JSON.parse(input.content).ticket.patch.taskId); + this.#taskOf.set(id, JSON.parse(input.content).ticket.declaration.work.taskId); return { id }; } diff --git a/tests/integration/ooo-managed-fence.test.ts b/tests/integration/ooo-managed-fence.test.ts index 11464ce8..573e0ada 100644 --- a/tests/integration/ooo-managed-fence.test.ts +++ b/tests/integration/ooo-managed-fence.test.ts @@ -136,7 +136,7 @@ test("a claim the board retires inside the verification window cannot be committ // committing. What happens in between is another writer retiring the entry the round holds - // verification is await-capable, which is exactly the window the design fences. const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: [{ path: "a.ts", content: "export const a = 2;\n" }], }); const result = gate.putTaskBoardEntry({ diff --git a/tests/integration/ooo-post-commit-notification.test.ts b/tests/integration/ooo-post-commit-notification.test.ts index 2632504c..c5b155c8 100644 --- a/tests/integration/ooo-post-commit-notification.test.ts +++ b/tests/integration/ooo-post-commit-notification.test.ts @@ -16,7 +16,6 @@ import { type PatchTaskSpec, type ProbePlan, } from "../../src/integration/ooo-board.ts"; -import { preparePatchWork } from "../../src/integration/ooo-patch.ts"; const TASK = "A"; const FILE = "src/check.ts"; @@ -34,18 +33,8 @@ async function submitOne(board: BoardAdmission): Promise { }; board.installPatchTask(TASK, spec); const ticket = board.claim(TASK, "post-commit-test"); - assert.ok(ticket.patch, "the plan declares a patch task"); - const frozen = preparePatchWork({ - taskId: ticket.patch.taskId, - attempt: ticket.attempt, - instruction: ticket.patch.instruction, - files: ticket.patch.files, - editable: ticket.patch.editable, - visible: ticket.patch.visible, - admittedConclusions: ticket.patch.admittedConclusions, - budget: ticket.patch.budget, - limits: ticket.patch.limits, - }); + assert.ok(ticket.declaration, "the plan declares executable work"); + const frozen = ticket.declaration; // The worker's artifact is the wire shape the host validates (`{ digest, files: [{ path, content }] }`), // not the store's `PatchSubmission`: the host re-derives the submission from its own frozen work. const artifact = JSON.stringify({ diff --git a/tests/integration/ooo-read-paths-agree.test.ts b/tests/integration/ooo-read-paths-agree.test.ts index e04090c5..c6c17f72 100644 --- a/tests/integration/ooo-read-paths-agree.test.ts +++ b/tests/integration/ooo-read-paths-agree.test.ts @@ -64,7 +64,7 @@ async function accept( agent: string, ): Promise { const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: Object.entries(specs[task]!.files).map(([path, content]) => ({ path, content: `${content}// candidate for ${task}\n`, diff --git a/tests/integration/ooo-store-run-board.test.ts b/tests/integration/ooo-store-run-board.test.ts index 5116abb3..042c9e36 100644 --- a/tests/integration/ooo-store-run-board.test.ts +++ b/tests/integration/ooo-store-run-board.test.ts @@ -18,11 +18,7 @@ import { NmgStore } from "../../src/core/store.ts"; import { dispatchPlan, type PlanWorker } from "../../src/integration/ooo-dispatch.ts"; import type { SessionPlan } from "../../src/integration/ooo-execution.ts"; import { StoreRunBoard, type AcceptanceAnswer } from "../../src/integration/ooo-runner.ts"; -import { - preparePatchWork, - type FrozenPatchWork, - type PatchSubmission, -} from "../../src/integration/ooo-patch.ts"; +import { type FrozenPatchWork, type PatchSubmission } from "../../src/integration/ooo-patch.ts"; import { coordinatedBoardWrite, createBoundEntry, @@ -103,7 +99,7 @@ function artifactFor(taskId: string, frozen: FrozenPatchWork): string { }); } -const worker: PlanWorker = async (taskId, frozen) => artifactFor(taskId, frozen); +const worker: PlanWorker = async (taskId, frozen) => artifactFor(taskId, frozen); /** The acceptance reads the submission and answers; its name is not the deliverer's. */ const acceptance = { @@ -195,10 +191,8 @@ test("the deliverer cannot judge its own delivery, so a run cannot self-accept", }); const entryId = bindingEntry(store, "P"); const ticket = board.claim("P", "worker:P"); - // The artifact the loop would have delivered, frozen the way the loop freezes it. Reading a digest - // off the ticket wrote an artifact with no digest at all, and the store took it: a delivery is a - // digest and a reference, and the wire shape is the host's check rather than the board's. - const frozen = preparePatchWork({ ...ticket.patch!, attempt: ticket.attempt }); + // The adopter freezes before claiming; the wire artifact is checked by the host, not the store. + const frozen = ticket.declaration!; const artifact = artifactFor("P", frozen); coordinatedBoardWrite(store, { runId: RUN, @@ -299,7 +293,7 @@ test("the board answers what is legal and why, and asking changes nothing", asyn // other), which is why the gate named here is the claim. In-doubt work across runs is the // umbrella's own open gap, not something this read may paper over. const ticket = board.claim("P", "worker:P"); - const frozen = preparePatchWork({ ...ticket.patch!, attempt: ticket.attempt }); + const frozen = ticket.declaration!; coordinatedBoardWrite(store, { runId: RUN, entryId, diff --git a/tests/integration/ooo-task-tables.test.ts b/tests/integration/ooo-task-tables.test.ts index 91698798..6824f77a 100644 --- a/tests/integration/ooo-task-tables.test.ts +++ b/tests/integration/ooo-task-tables.test.ts @@ -81,7 +81,7 @@ function fixture(t: TestContext) { // that one task. const spec = specs[task]!; const artifact = JSON.stringify({ - digest: ticket.patch!.digest, + digest: ticket.declaration!.digest, files: Object.entries(spec.files).map(([path, content]) => ({ path, content: `${content}// candidate for ${task} diff --git a/tests/integration/ooo-value-store-board.test.ts b/tests/integration/ooo-value-store-board.test.ts new file mode 100644 index 00000000..bd6ffe3a --- /dev/null +++ b/tests/integration/ooo-value-store-board.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { NmgStore } from "../../src/core/store.ts"; +import { dispatchPlan, type DispatchBoard } from "../../src/integration/ooo-dispatch.ts"; +import { workDigest } from "../../src/integration/work-identity.ts"; + +/** The adopter owns numeric declaration/acceptance. The store sees only the existing board header. */ +test("a numeric adopter uses the real board's lease, delivery and outside verdict without a patch declaration", async () => { + const store = new NmgStore(":memory:"); + try { + const channel = "numeric-adopter"; + const declaration = Object.freeze({ + digest: workDigest(JSON.stringify([2, 3])), + values: Object.freeze([2, 3]), + }); + const initial = store.putTaskBoardEntry({ + taskId: channel, + agentId: "numeric-host", + to: "numeric-worker", + kind: "handoff", + content: JSON.stringify(declaration), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + const current = () => { + const entry = store.getTaskBoardEntryById(channel, initial.id); + assert.ok(entry); + return entry; + }; + const board: DispatchBoard = { + channel, + get now() { + return Date.now(); + }, + candidates() { + const entry = current(); + return entry.status === "open" && entry.claimedBy === null ? ["sum"] : []; + }, + legality() { + const legal = [...this.candidates()]; + return { + legal, + room: legal.length, + units: [{ id: "sum", legal: legal.length > 0, reasons: [] }], + }; + }, + accepted(): Readonly> { + const entry = current(); + return entry.verdict === "accepted" && + entry.judgedDigest === entry.deliverableDigest && + entry.deliverableRef !== null + ? { sum: entry.deliverableRef } + : {}; + }, + claim(_taskId, owner) { + assert.equal( + current().content, + JSON.stringify(declaration), + "the adopter validates its declaration", + ); + const entry = store.claimTaskBoardEntry({ + taskId: channel, + entryId: initial.id, + agentId: owner, + }); + assert.ok(entry.attempt); + return { attempt: entry.attempt, dependencies: {}, declaration }; + }, + putTaskBoardEntry(input) { + const wire = JSON.parse(input.content) as { artifact: unknown }; + assert.equal(typeof wire.artifact, "string"); + const artifact = wire.artifact as string; + const entry = store.deliverTaskBoardEntry({ + taskId: channel, + entryId: initial.id, + agentId: input.agentId, + digest: workDigest(artifact), + ref: artifact, + }); + return { id: entry.id }; + }, + async submit(entryId) { + const entry = current(); + assert.ok(entry.deliverableRef); + const value = JSON.parse(entry.deliverableRef) as { digest: string; value: number }; + const verdict = + value.digest === declaration.digest && value.value === 5 ? "accepted" : "rejected"; + store.judgeTaskBoardEntry({ taskId: channel, entryId, agentId: "numeric-judge", verdict }); + return verdict; + }, + }; + const outcome = await dispatchPlan({ + board, + plan: ["sum"], + slots: 1, + ownerOf: () => "numeric-worker", + legality: () => ({ tasks: [], declarations: {} }), + worker: async (_taskId, admitted) => { + assert.equal(admitted, declaration); + return JSON.stringify({ + digest: admitted.digest, + value: admitted.values.reduce((a, b) => a + b, 0), + }); + }, + }); + assert.deepEqual(outcome.failures, []); + assert.deepEqual(outcome.slotRefusals, []); + assert.equal(outcome.units[0]?.verdict, "accepted"); + assert.equal(current().deliveredBy, "numeric-worker"); + assert.equal(current().judgedBy, "numeric-judge"); + assert.notEqual(current().deliveredBy, current().judgedBy); + assert.equal(current().judgedDigest, current().deliverableDigest); + assert.deepEqual(Object.keys(outcome.accepted), ["sum"]); + assert.equal( + current().content, + JSON.stringify(declaration), + "the store did not turn the body into another work shape", + ); + } finally { + store.close(); + } +}); diff --git a/tests/integration/ooo-work-shape.test.ts b/tests/integration/ooo-work-shape.test.ts new file mode 100644 index 00000000..559f4a7f --- /dev/null +++ b/tests/integration/ooo-work-shape.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { dispatchPlan, type DispatchTicket } from "../../src/integration/ooo-dispatch.ts"; +import { workDigest } from "../../src/integration/work-identity.ts"; +import type { LegalityAnswer } from "../../src/integration/ooo-execution.ts"; + +/** A declaration the shared loop has never seen: numeric values, not source files. */ +const VALUES = Object.freeze({ + digest: workDigest(JSON.stringify({ values: [2, 3] })), + values: Object.freeze([2, 3]), +}); + +class ValueBoard { + readonly channel = "value-run"; + readonly now = 1_700_000_000_000; + claims = 0; + #accepted: Record = {}; + #artifact = ""; + #held = false; + readonly shape: string; + readonly declaration: typeof VALUES | null; + constructor(shape = "numeric-values", declaration: typeof VALUES | null = VALUES) { + this.shape = shape; + this.declaration = declaration; + } + + candidates(): readonly string[] { + return this.#held || this.#accepted.sum !== undefined ? [] : ["sum"]; + } + + legality(): LegalityAnswer { + const legal = [...this.candidates()]; + return { + legal, + room: this.#held ? 0 : 1, + units: [{ id: "sum", legal: legal.includes("sum"), reasons: [] }], + }; + } + + accepted(): Readonly> { + return this.#accepted; + } + + claim(_taskId: string, _owner: string) { + // Shape interpretation and refusal belong to the adopter, before taking a lease. + if (this.shape !== "numeric-values") + return { refused: `unsupported work shape: ${this.shape}` }; + this.#held = true; + this.claims++; + return { attempt: 1, dependencies: {}, declaration: this.declaration }; + } + + putTaskBoardEntry(input: { content: string }): { id: string } { + const entry = JSON.parse(input.content) as { + artifact: string; + ticket: { declaration: typeof VALUES }; + }; + assert.deepEqual(entry.ticket.declaration, VALUES, "the declaration is carried, not rewritten"); + this.#artifact = entry.artifact; + return { id: "sum-result" }; + } + + async submit(_entryId: string): Promise { + const artifact = JSON.parse(this.#artifact) as { digest: string; value: number }; + if (artifact.digest !== VALUES.digest || artifact.value !== 5) return "rejected"; + this.#accepted.sum = this.#artifact; + this.#held = false; + return "accepted"; + } +} + +test("the carrier is required, so a legacy optional-shape ticket cannot silently satisfy the port", () => { + type LegacyTicket = { attempt: number; dependencies: Record }; + const required: LegacyTicket extends DispatchTicket ? false : true = true; + assert.equal(required, true); +}); + +test("an unseen numeric declaration runs through the shared loop without a patch envelope", async () => { + const board = new ValueBoard(); + let called = 0; + const outcome = await dispatchPlan({ + board, + plan: ["sum"], + slots: 1, + legality: () => ({ tasks: [], declarations: {} }), + ownerOf: () => "value-worker", + worker: async (_taskId, declaration) => { + called++; + assert.equal(declaration, VALUES, "the loop does not reinterpret or freeze the payload"); + return JSON.stringify({ + digest: VALUES.digest, + value: VALUES.values.reduce((a, b) => a + b, 0), + }); + }, + }); + assert.deepEqual(outcome.failures, []); + assert.deepEqual(outcome.order, ["sum"]); + assert.equal(outcome.units[0]?.verdict, "accepted"); + assert.equal(called, 1); + assert.equal(board.claims, 1); +}); + +test("an unknown shape is refused by name before claiming, not reported as a failed unit", async () => { + const board = new ValueBoard("not-supported-v9"); + const outcome = await dispatchPlan({ + board, + plan: ["sum"], + slots: 1, + legality: () => ({ tasks: [], declarations: {} }), + ownerOf: () => "value-worker", + worker: async () => { + throw new Error("a refused declaration must never run a worker"); + }, + }); + assert.equal(board.claims, 0); + assert.deepEqual(outcome.units, []); + assert.deepEqual(outcome.failures, []); + assert.deepEqual(outcome.slotRefusals, ["unsupported work shape: not-supported-v9"]); +}); diff --git a/tools/policy-word-list.ts b/tools/policy-word-list.ts index 76505e30..a32311c7 100644 --- a/tools/policy-word-list.ts +++ b/tools/policy-word-list.ts @@ -212,14 +212,6 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ classification: "policy", reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", }, - { - path: "src/integration/ooo-board.ts", - scope: "BoardTicket", - word: "patch", - count: 2, - classification: "policy", - reason: "The board ticket, schema, freeze or submission path knows the patch work shape.", - }, { path: "src/integration/ooo-board.ts", scope: "BoardAdmission", @@ -501,14 +493,6 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ reason: "Executes or records caller-declared checks; does not choose their wording or enablement.", }, - { - path: "src/integration/ooo-dispatch.ts", - scope: "", - word: "patch", - count: 5, - classification: "policy", - reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", - }, { path: "src/integration/ooo-dispatch.ts", scope: "WorkerMetrics", @@ -518,54 +502,6 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ reason: "Executes or records caller-declared checks; does not choose their wording or enablement.", }, - { - path: "src/integration/ooo-dispatch.ts", - scope: "PlanWorker", - word: "patch", - count: 1, - classification: "policy", - reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", - }, - { - path: "src/integration/ooo-dispatch.ts", - scope: "DispatchTicket", - word: "patch", - count: 2, - classification: "policy", - reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", - }, - { - path: "src/integration/ooo-dispatch.ts", - scope: "dispatchUnit", - word: "patch", - count: 4, - classification: "policy", - reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", - }, - { - path: "src/integration/ooo-dispatch.ts", - scope: "dispatchUnit", - word: "instruction", - count: 2, - classification: "policy", - reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", - }, - { - path: "src/integration/ooo-dispatch.ts", - scope: "dispatchUnit", - word: "files", - count: 2, - classification: "policy", - reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", - }, - { - path: "src/integration/ooo-dispatch.ts", - scope: "dispatchUnit", - word: "editable", - count: 2, - classification: "policy", - reason: "Dispatch tickets and workers require and reconstruct the frozen patch work shape.", - }, { path: "src/integration/ooo-candidate.ts", scope: "DataCheck", From 803d7d98ac7651609dd9937498c015e1ba5697ac Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:11:29 +0800 Subject: [PATCH 3/3] feat(execution): declare refinement bounds and separate session policy Enforce explicitly named refinement constraints against normalized resource identities instead of consulting a parent patch envelope. Preserve parent permission obligations and refuse malformed or unsupported declarations. Separate generic session input/state/runner contracts and caller-declared completion bounds from default patch rendering and artifact interpretation. Keep compatibility exports as references to the same implementation. Register the adopter's route and retain its policy-word classifications in the scan. Update bilingual ownership decisions and add controlled numeric, malformed constraint, generic-session and forwarded-export coverage. Storage ownership and the independent frame proposal remain open; no schema or live-provider changes are included. Verification: 89 targeted cases, 1610 product cases, 16 scoped blocking checks, zero diagnostics on 11 touched TypeScript files; 110 mutation teeth caught and 22 targets restored byte-identically. One tooth was caught by its existing 30-second cap. Preserve earlier failed verification receipts and two observed Windows cleanup EPERMs; successful focused/full retries do not explain them. --- agent-context.yaml | 3 +- .../2026-09-19-fusion-session-mechanism.md | 2 +- ...26-09-19-fusion-session-mechanism.zh-CN.md | 2 +- .../2026-09-21-mechanism-not-policy.md | 54 +-- .../2026-09-21-mechanism-not-policy.zh-CN.md | 21 +- docs/design/task-unit-semantics.md | 2 +- .../execution/field-ownership-2026-10-01.md | 6 +- src/integration/ooo-patch-session.ts | 235 +++++++++++ src/integration/ooo-patch.ts | 7 +- src/integration/ooo-session-mechanism.ts | 376 +++--------------- src/integration/task-semantics.ts | 105 ++++- tests/integration/ooo-generic-session.test.ts | 96 +++++ .../integration/ooo-ordinary-failure.test.ts | 1 + .../integration/ooo-session-layering.test.ts | 26 +- .../task-refinement-declaration.test.ts | 135 +++++++ .../integration/task-semantics-cases.test.ts | 6 +- tests/integration/task-semantics.test.ts | 1 + tools/policy-word-list.ts | 80 ++-- 18 files changed, 741 insertions(+), 417 deletions(-) create mode 100644 src/integration/ooo-patch-session.ts create mode 100644 tests/integration/ooo-generic-session.test.ts create mode 100644 tests/integration/task-refinement-declaration.test.ts diff --git a/agent-context.yaml b/agent-context.yaml index 5637c2f3..f56ed19e 100644 --- a/agent-context.yaml +++ b/agent-context.yaml @@ -240,6 +240,7 @@ routes: - src/integration/check-runner.ts - src/integration/ooo-session-facts.ts - src/integration/ooo-session-mechanism.ts + - src/integration/ooo-patch-session.ts - src/integration/task-advisers.ts - src/integration/task-coordinator.ts - src/integration/task-semantics-interleavings.ts @@ -250,7 +251,7 @@ routes: - docs/design/ooo-execution-bootstrap.md - docs/design/task-unit-semantics.md - docs/design/ooo-fusion-planning.md - tests: [tests/integration/ooo-*.test.ts, tests/integration/task-semantics*.test.ts] + tests: [tests/integration/ooo-*.test.ts, tests/integration/task-semantics*.test.ts, tests/integration/task-refinement*.test.ts] verify: blocking: [check, test:product, build] advisory: [] diff --git a/docs/decisions/implemented/2026-09-19-fusion-session-mechanism.md b/docs/decisions/implemented/2026-09-19-fusion-session-mechanism.md index 2fc21b45..155d4244 100644 --- a/docs/decisions/implemented/2026-09-19-fusion-session-mechanism.md +++ b/docs/decisions/implemented/2026-09-19-fusion-session-mechanism.md @@ -6,7 +6,7 @@ **Approved:** explicit **Relates to:** [the pilot and its ceiling](2026-09-18-fusion-and-speculation-pilot.md), [fusion legality and accounting](../implemented/2026-09-18-fusion-legality-and-accounting.md) -Implementation evidence: `createPiSessionRunner` and `patchSessionInput` in `.pi/extensions/nmg/ooo-execution.ts` hold one session per run behind the `UnitState` box, `executePiInputWith` delegates to it so the extension keeps exactly one tool surface, and `piSessionWorker` in `evals/ooo-execution/plan-driver.ts` holds one runner per session id. The smoke reported both units in one session (`sessions: [["alpha","summary"]]`, 21k tokens). What remains is not mechanism: no product-side caller yet decides to reuse a session. +Implementation evidence: `createPiSessionRunner` in `.pi/extensions/nmg/ooo-execution.ts` consumes `patchSessionInput` from the [default session adopter](../../../src/integration/ooo-patch-session.ts) and holds one session per run behind its `UnitState` box, `executePiInputWith` delegates to it so the extension keeps exactly one tool surface, and `piSessionWorker` in `evals/ooo-execution/plan-driver.ts` holds one runner per session id. The smoke reported both units in one session (`sessions: [["alpha","summary"]]`, 21k tokens). What remains is not mechanism: no product-side caller yet decides to reuse a session. ## Problem diff --git a/docs/decisions/implemented/2026-09-19-fusion-session-mechanism.zh-CN.md b/docs/decisions/implemented/2026-09-19-fusion-session-mechanism.zh-CN.md index e59a45ce..34696a03 100644 --- a/docs/decisions/implemented/2026-09-19-fusion-session-mechanism.zh-CN.md +++ b/docs/decisions/implemented/2026-09-19-fusion-session-mechanism.zh-CN.md @@ -6,7 +6,7 @@ **Approved:** explicit **Relates to:** [pilot 与其上限](2026-09-18-fusion-and-speculation-pilot.zh-CN.md)、[融合合法性与其记账](../implemented/2026-09-18-fusion-legality-and-accounting.zh-CN.md) -实现证据:`.pi/extensions/nmg/ooo-execution.ts` 的 `createPiSessionRunner` 与 `patchSessionInput` 通过 `UnitState` 盒子让每次运行持有一个会话,`executePiInputWith` 委托给它,扩展因此只保留一套 tool surface,`evals/ooo-execution/plan-driver.ts` 的 `piSessionWorker` 每个 session id 持有一个 runner。冒烟结果显示两个单元同处一个会话(`sessions: [["alpha","summary"]]`,21k tokens)。仍未完成的不再是机制:产品侧还没有调用方去决定复用会话。 +实现证据:`.pi/extensions/nmg/ooo-execution.ts` 的 `createPiSessionRunner` 消费[默认会话采用方](../../../src/integration/ooo-patch-session.ts)的 `patchSessionInput`,通过其 `UnitState` 盒子让每次运行持有一个会话,`executePiInputWith` 委托给它,扩展因此只保留一套 tool surface,`evals/ooo-execution/plan-driver.ts` 的 `piSessionWorker` 每个 session id 持有一个 runner。冒烟结果显示两个单元同处一个会话(`sessions: [["alpha","summary"]]`,21k tokens)。仍未完成的不再是机制:产品侧还没有调用方去决定复用会话。 ## Problem diff --git a/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md index 95f87a75..1bdc0adc 100644 --- a/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md +++ b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md @@ -92,29 +92,38 @@ controlled seam test, not a production peer-protocol installation or a frame/sto but whether an input carries content or only a digest is a separate question. - `operation`, which may be policy. -## Deferred - -### Work-shape boundary +### Declared refinement constraints and session policy + +A refinement declares `constraints`, including an explicit empty list. A supported primitive +`within-parent-writes` checks the parent's normalized proposal-write resource set, not +`parent.patch.editable`. The protocol supplies its name; presence enables it. Unsupported primitives, +unknown fields and duplicate names are refused at their declaration. A parent's permission obligation +cannot be dropped by omitting its constraint. The [controlled refinement cases](../../../tests/integration/task-refinement-declaration.test.ts) +exercise opaque numeric resource identities, not file paths, and retain obligation coverage. + +Generic session input, state and runner contracts carry rendered text, counters and caller-declared +bounds without patch interpretation. [The default session adopter](../../../src/integration/ooo-patch-session.ts) +owns patch rendering, artifact validation, tool names and its snapshot-read minimum. Compatibility +exports in the shared module refer to these same functions, not copies. The adapter remains in the +scan surface, classified as policy; relocation is not a file exemption or a claim of zero policy. +The [generic-session cases](../../../tests/integration/ooo-generic-session.test.ts) exercise an input and +state with no patch fields and completion with a declared zero-read minimum, while the default policy +retains its read requirement. They do not run a live harness. -The opaque dispatch seam is one part of the work-shape conversion (original Plan 5), not completion -of the conversion. A work shape is the policy layer's name for the declaration-and-artifact pair that -the core carries without understanding it. Patch freezing remains in the default adopters; requiring -that shape in the shared port does not. +## Deferred -The remaining coupling is: +### Declaration storage -| Site | Mechanism or policy | Remaining boundary | -| --- | --- | --- | -| `refuseWidening` reading `parent.patch.editable` | check is mechanism, wording and enablement are policy | permission closure becomes a declared constraint the program enforces and reports reasons for | -| `FrozenPatchWork` signatures in the session mechanism | mixed | prompt rendering and artifact validation are policy; session lifecycle, metrics and cancellation are mechanism | -| `task_run_tasks.patch_files` / `patch_editable`, parsed in the store | policy in the schema | belongs in the declaration's payload; reconcile the storage target with the frame proposal's typed Task-Unit tables and NULL board payload before migrating | +Dispatch, refinement checking and generic session contracts do not require a patch envelope. The +store still parses `task_run_tasks.patch_files` / `patch_editable`. Those shape fields belong to the +declaration owner, not the board's generic header. Reconcile their storage target with the frame +proposal's typed Task-Unit tables and NULL board payload before migrating. This separation does not +approve that independent storage change. -The legality rule found today is the clearest case of the refinement this record adds: its check belongs to -the program, while permission closure's name and enablement belong to a declaration. The fix is therefore not -to move the check out of the program but to stop hard-wiring the rule in the kernel's vocabulary - the same -shape of fix as step 3 of the legality record, which landed on 2026-09-23: repair-first became a declared -constraint rather than shared planning policy. Two independent fixes taking the same shape is evidence that the classification -is the right one. +Permission closure retains a program check while its name and enablement come from the declaration. +This has the same boundary as the legality record's declared repair-first constraint: enforcing a +selected rule is not choosing it. The write-subset primitive does not claim to enforce arbitrary +resource exclusion or all effects. ### Field experiments and separation evidence @@ -125,11 +134,10 @@ is the right one. - The `files` groups in schema migration and `TaskUnit` are marked `undecided`: a group combines patch-specific paths with another role. Resolve them by separating the DDL subjects or exercising an alternative input declaration, not by relabelling the entire group mechanism. -- The remaining permission-closure name/enablement coupling needs a declared constraint while its - check continues to execute in the program and return reasons. - A numeric declaration runs through the widened dispatch seam and the real board lifecycle without - further mechanism changes. Zero policy hits, generic permission/session boundaries and a four-role - peer-protocol interface remain unverified goals. Passing the ratchet proves none of them. The draft's + further mechanism changes. The controlled permission and session cases establish their narrower + contracts, not a policy-free store or a four-role peer-protocol interface. Zero policy hits and that + broader interface remain unverified goals. Passing the ratchet proves none of them. The draft's [second-shape experiment](../../design/mechanism-in-the-middle.md#results) remains historical coupling evidence, not the current seam test. diff --git a/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md index 2add166a..96a98e94 100644 --- a/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md +++ b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.zh-CN.md @@ -55,28 +55,25 @@ - `input` 与 `dependencies` 的粒度:依赖影响合法性与顺序,这是机制;但"输入"是携带内容还是只携带摘要,是另一个问题。 - `operation`:可能是策略。 -## 未完成项 +### 声明的 refinement 约束与会话策略 + +refinement 显式声明 `constraints`,没有启用的规则也声明空列表。`within-parent-writes` 原语检查父单元规范化后的提议写资源集,不读取 `parent.patch.editable`;名字由协议给出,出现即启用。不支持的原语、未知字段、重复名字在声明位置拒绝;不能通过漏声明约束丢掉父权限义务。[受控 refinement 用例](../../../tests/integration/task-refinement-declaration.test.ts)使用不透明数值资源身份而非文件路径,并保留义务覆盖检查。 -### 工作形态边界 +通用会话输入、状态、runner 契约携带已渲染文本、计数器和调用方声明的边界,不解释补丁。[默认会话采用方](../../../src/integration/ooo-patch-session.ts)拥有补丁渲染、产物校验、工具名字和快照最少读取次数。共享模块的兼容导出指向同一批函数,不复制实现。采用方仍在扫描面内并分类为策略;移动不是文件豁免,也不证明零策略。[通用会话用例](../../../tests/integration/ooo-generic-session.test.ts)验证无补丁字段的输入与状态,以及显式零读取下限;默认策略仍要求读取。它们不运行真实 harness。 -不透明派发边界是工作形态转换(原 Plan 5)的一部分,不等于转换已完成。工作形态是策略层对“核心不透明携带的那对声明与产物”的叫法。补丁冻结属于默认采用方,共享端口不要求这种形态。 +## 未完成项 -余下耦合如下: +### 声明存储 -| 站点 | 机制还是策略 | 待分离边界 | -| --- | --- | --- | -| `refuseWidening` 读 `parent.patch.editable` | 检查是机制;措辞与启用是策略 | permission closure 成为程序执行并给理由的一条声明约束 | -| 会话机制的 `FrozenPatchWork` 签名 | 混合 | 提示渲染和产物校验是策略;会话生命周期、指标、取消是机制 | -| `task_run_tasks.patch_files` / `patch_editable`(store 里解析) | 策略进了 schema | 属于声明的载荷;迁移前须与帧提案中 Task-Unit 类型表、黑板 payload 为 NULL 的安排对齐 | +派发、refinement 检查和通用会话契约不要求补丁信封。store 仍解析 `task_run_tasks.patch_files` / `patch_editable`;这些形态字段属于声明 owner,不属于黑板的通用 header。迁移前须与帧提案中 Task-Unit 类型表、黑板 payload 为 NULL 的安排对齐。这次分离不批准独立存储改动。 -今天那条合法性规则正是本文新增的那点修正的最清楚的例子:**它的检查属于程序,而 permission closure 的名字与启用属于声明。** 所以修法不是把检查搬出程序,而是**别再用核心的词汇硬编码这条规则**——这与合法性记录第 3 步(把 repair-first 从共享规划策略变成由计划声明的约束,2026-09-23 已落地)是**同一形状的修法**。两处各自独立的修法落在同一形状上,是"这个分类是对的"的证据。 +permission closure 保留程序中的检查,名字与启用来自声明,与合法性记录里的声明 repair-first 约束具有同一边界:执行已选规则不等于选择规则。写集子集原语不假称已强制任意资源互斥或所有 effect。 ### 字段实验与分离证据 - [受控字段探针](../../experiments/execution/field-ownership-2026-10-01.md)提供 effect 标签、内容绑定摘要、依赖释放和 operation 解释的可复现证据。它缩小问题,不决定通用输入粒度或存储迁移。门禁不包含这些词,也不替实验定案。 - schema 迁移与 `TaskUnit` 中的 `files` 组标为 `undecided`:同组混有补丁专有路径与另一角色。通过拆开 DDL 主题或运行另一种输入声明来解决,不把整组改标为机制。 -- 余下 permission closure 的名字与启用耦合,需要声明约束;检查仍在程序中执行并按单元返回理由。 -- 数值声明能经过加宽的派发边界与真实黑板生命周期,无须再改机制。零策略命中、通用 permission/session 边界、四角色 peer 协议接口仍是未验证目标;门禁通过不证明这些目标。草稿的[第二形态实验](../../design/mechanism-in-the-middle.zh-CN.md#结果)仍是历史耦合证据,不是当前的边界测试。 +- 数值声明能经过加宽的派发边界与真实黑板生命周期,无须再改机制。受控 permission/session 用例证明各自的窄契约,不证明 store 已无策略或已有四角色 peer 协议接口。零策略命中与完整协议接口仍是未验证目标;门禁通过不证明这些目标。草稿的[第二形态实验](../../design/mechanism-in-the-middle.zh-CN.md#结果)仍是历史耦合证据,不是当前的边界测试。 ## 考虑过的替代方案 diff --git a/docs/design/task-unit-semantics.md b/docs/design/task-unit-semantics.md index f19a4b93..ea8c5b0e 100644 --- a/docs/design/task-unit-semantics.md +++ b/docs/design/task-unit-semantics.md @@ -102,7 +102,7 @@ Task IR 是从现有声明与记录计算出的只读视图;不接收另一份 | placement | 无现有等价字段 | 仅由共享调度策略产生临时融合/分配建议,不进入当前票据,不影响验收;`visible/editable` 不承担 placement 语义。持久化执行关联用记录中的任务/attempt/执行者引用,不复制任务声明 | | deps / requires | `ProbePlan` 依赖列表;`CycleOptions.requires: Requirement[]` | deps 是派发与产物依赖;requires 是 cycle 对已记录产物的 `verified/mutant-killed/test-title` 条件。两者不等同,当前 requires 不是通用授权/分支谓词注册表 | | external fact / assumptions | `ProbePlan` wait event、[外部检查票据](../../src/integration/check-ticket.ts);无通用 assumptions 类型 | 检查事件保留原身份;本文的有限事实推测仍需在共享协议显式扩展,不能把缺失功能伪装成 `requires` 或票据已有字段 | -| refinement | 无现有父义务映射类型 | 离线模型可验证预先给定的拆分关系;生产表示须扩展共享任务契约,不能成为 board 新 kind 或第二份任务正文 | +| refinement | [RefinementSpec](../../src/integration/task-semantics.ts):父义务到输出的映射,以及显式具名 `constraints` | 只读模型声明,不是持久化任务正文;`within-parent-writes` 比较规范化的提议写资源集,不读取补丁信封。协议给出名字,出现即启用,未启用也声明空列表;父权限义务不能通过漏约束丢失。 | board `kind` 的唯一 owner 是 [TaskBoardKind](../../src/core/types.ts)。它描述协作消息用途:`handoff` 交接、`result` 返回、`decision` 判定;不是 patch/conclusion 类型,也不是 effect。OoO 行内的 patch/snapshot `kind` 又是内部任务类别。三者不得互相转换或新增 `task-ir` kind、工具、频道来承载重复定义。 diff --git a/docs/experiments/execution/field-ownership-2026-10-01.md b/docs/experiments/execution/field-ownership-2026-10-01.md index 64cd8761..47ac2c5e 100644 --- a/docs/experiments/execution/field-ownership-2026-10-01.md +++ b/docs/experiments/execution/field-ownership-2026-10-01.md @@ -65,8 +65,10 @@ protocol quarantine, or threat model. Those are different tests and work. The measurements support the [ownership decision](../../decisions/implemented/2026-09-21-mechanism-not-policy.md) without making a vocabulary ratchet proof of a policy-free mechanism. Permission -closure still reads patch metadata, the session module still mixes patch policy -with lifecycle, and the store still parses patch-specific task columns. +closure read patch metadata and the session module mixed patch policy with +lifecycle at the measured revision. The ownership decision records the current +boundaries; the historical source hashes above are not claims about later code. +The store's patch-specific declaration storage remains a separate question. Storage needs a distinct decision: the mechanism record assigns shape fields to the declaration payload, while the [frame proposal](../../decisions/proposed/2026-09-21-the-frame-and-its-storage.md) diff --git a/src/integration/ooo-patch-session.ts b/src/integration/ooo-patch-session.ts new file mode 100644 index 00000000..4c18a8a1 --- /dev/null +++ b/src/integration/ooo-patch-session.ts @@ -0,0 +1,235 @@ +/** Default patch-session policy: rendering, tool vocabulary and artifact interpretation. + * The session mechanism carries its rendered input and accounting without requiring this shape. */ +import { + patchCandidate, + patchPrompt, + DEFAULT_PATCH_LIMITS, + type FrozenPatchWork, + type PatchLimits, +} from "./ooo-patch.ts"; +import { completionAllowed } from "./ooo-session-mechanism.ts"; +import type { + SessionInput, + SessionState, + SessionRunner, + PushbackSpec, +} from "./ooo-session-mechanism.ts"; + +export const SNAPSHOT_LIMITS: PatchLimits = DEFAULT_PATCH_LIMITS; + +/** Host-owned check exposure. The worker cannot select a command or a path outside its envelope. */ +export interface CheckTool { + label: string; + maxRuns: number; + run: ( + files: { path: string; content: string }[], + ) => Promise<{ verdict: "accept" | "reject" | "undecidable"; log: string }>; +} + +export function checkToolCandidate( + frozen: FrozenPatchWork, + files: { path: string; content: string }[], +): Readonly> { + return patchCandidate(frozen, JSON.stringify({ digest: frozen.digest, files })); +} + +export type ArtifactParams = { + digest: string; + files?: { path: string; content: string }[]; + conclusion?: string; + summary?: string; + evidence?: string; + citations?: { case: string; test: string }[]; +}; + +/** Sampling constraints do not replace host validation; values obey the same artifact contract. */ +export function artifactEnvelope( + frozen: FrozenPatchWork, + params: ArtifactParams, +): { ok: true; json: string } | { ok: false; error: string } { + if (params.digest !== frozen.digest) + return { ok: false, error: `digest must be exactly ${frozen.digest}` }; + const files = params.files ?? []; + return files.length ? patchEnvelope(params, files) : conclusionEnvelope(frozen, params); +} + +function patchEnvelope( + params: ArtifactParams, + files: { path: string; content: string }[], +): { ok: true; json: string } | { ok: false; error: string } { + if (params.conclusion || params.summary || params.evidence) + return { ok: false, error: "a patch carries files only; it cannot also carry a conclusion" }; + return { ok: true, json: JSON.stringify({ digest: params.digest, files }) }; +} + +function conclusionEnvelope( + frozen: FrozenPatchWork, + params: ArtifactParams, +): { ok: true; json: string } | { ok: false; error: string } { + const missing = (["conclusion", "summary", "evidence"] as const).filter( + (key) => !params[key]?.trim(), + ); + if (missing.length) + return { + ok: false, + error: + "provide files, or a conclusion with conclusion, summary and evidence; " + + `missing or empty ${missing.join(", ")}`, + }; + const admitted = frozen.work.admittedConclusions; + if (!(admitted as readonly string[]).includes(params.conclusion!)) + return { + ok: false, + error: `conclusion must be one of ${admitted.join(", ")}; got ${params.conclusion}`, + }; + const citations = params.citations ?? []; + if (citations.length > 16) return { ok: false, error: "at most 16 citations" }; + for (const entry of citations) + if (!entry?.case?.trim() || !entry?.test?.trim()) + return { ok: false, error: "every citation needs a non-empty case and test" }; + return { + ok: true, + json: JSON.stringify({ + digest: params.digest, + kind: "conclusion", + conclusion: params.conclusion, + summary: params.summary, + evidence: params.evidence, + citations, + }), + }; +} + +export interface PatchExecOptions { + check?: CheckTool; + pushback?: PushbackSpec; + /** A fixed chain surface cannot sample a different literal union for each unit. */ + looseConclusion?: boolean; +} + +export const ARTIFACT_TOOL = "submit_artifact"; + +/** Render one declaration once, shared by the single-unit and chain paths. */ +export function patchSessionInput( + frozen: FrozenPatchWork, + options: PatchExecOptions = {}, +): SessionRunInput { + const { check, pushback } = options; + const note = check + ? `\nYou may call ${check.label} with your proposed files to run the round's fixed check before answering; at most ${check.maxRuns} calls are allowed. It runs only that check and never writes to the repository.` + : ""; + const pushbackNote = pushback?.requirements.length + ? `\nIf what you received cannot satisfy one of these declared requirements, call report_dependency_failure with the exact task and requirement instead of finishing the work: ` + + JSON.stringify(pushback.requirements) + : ""; + // A chain's fixed tools and loosened conclusion schema make these per-unit restrictions prompt-owned. + const looseNote = options.looseConclusion + ? `\nThis session exposes the tools of every unit it will run, and this unit has ${ + check ? `the check ${check.label}` : "no check" + } and ${ + pushback?.requirements.length ? "a declared requirement" : "no declared requirement" + }, so call only read_snapshot${check ? ", run_check" : ""}${ + pushback?.requirements.length ? ", report_dependency_failure" : "" + } and ${ARTIFACT_TOOL}.\n` + + `Answer with files, or with a conclusion whose kind is one of ${JSON.stringify( + frozen.work.admittedConclusions, + )}, exactly as written - never both, and a kind outside that list is refused.` + : ""; + return { + prompt: patchPrompt(frozen, ARTIFACT_TOOL) + note + pushbackNote + looseNote, + snapshot: snapshotText(frozen), + maxArtifact: frozen.work.budget.output, + limits: frozen.work.limits, + looseConclusion: options.looseConclusion === true, + ...(check !== undefined ? { check } : {}), + frozen, + ...(pushback !== undefined ? { pushback } : {}), + }; +} + +/** The default policy requires one snapshot read; other adopters declare their own bounds. */ +export function piCompletionAllowed( + stopReason: string | undefined, + timedOut: boolean, + turns: number, + reads: number, + limits: PatchLimits = SNAPSHOT_LIMITS, +): boolean { + return completionAllowed(stopReason === "stop", timedOut, turns, reads, { + minTurns: 1, + maxTurns: limits.turns, + minReads: 1, + maxReads: limits.reads, + }); +} + +/** Send only the readable subset; hidden baseline content must not enter the prompt. */ +export function snapshotText(frozen: FrozenPatchWork): string { + const files = Object.fromEntries( + frozen.work.visible.map((path) => [path, frozen.work.files[path]]), + ); + const hidden = Object.keys(frozen.work.files).filter( + (path) => !frozen.work.visible.includes(path), + ); + return JSON.stringify({ + digest: frozen.digest, + taskId: frozen.work.taskId, + attempt: frozen.work.attempt, + instruction: frozen.work.instruction, + editable: frozen.work.editable, + budget: frozen.work.budget, + limits: frozen.work.limits, + files, + ...(hidden.length ? { hidden } : {}), + }); +} + +/** A text answer follows the same contract as the artifact tool; it is not a weaker fallback. */ +export function artifactFromText( + frozen: FrozenPatchWork, + text: string, +): { ok: true; json: string } | { ok: false; error: string } { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false, error: "the answer is not JSON" }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) + return { ok: false, error: "the answer is not a JSON object" }; + const candidate = parsed as ArtifactParams; + return artifactEnvelope(frozen, { + digest: candidate.digest, + files: candidate.files, + conclusion: candidate.conclusion, + summary: candidate.summary, + evidence: candidate.evidence, + citations: candidate.citations, + }); +} + +/** Default tool vocabulary, derived from the features enabled by the host. */ +export function toolNames(hasCheck: boolean, hasPushback: boolean, hasArtifact: boolean) { + return [ + "read_snapshot", + ...(hasCheck ? ["run_check"] : []), + ...(hasPushback ? ["report_dependency_failure"] : []), + ...(hasArtifact ? [ARTIFACT_TOOL] : []), + ]; +} + +/** Patch state is an adopter extension, not a required property of the session mechanism. */ +export interface UnitState extends SessionState { + frozen?: FrozenPatchWork; + check?: CheckTool; + pushback?: PushbackSpec; +} + +export interface SessionRunInput extends SessionInput { + looseConclusion?: boolean; + check?: CheckTool; + frozen?: FrozenPatchWork; + pushback?: PushbackSpec; +} + +export type PiSessionRunner = SessionRunner; diff --git a/src/integration/ooo-patch.ts b/src/integration/ooo-patch.ts index e89e4ba5..63ad91ea 100644 --- a/src/integration/ooo-patch.ts +++ b/src/integration/ooo-patch.ts @@ -1,4 +1,5 @@ import { workDigest } from "./work-identity.ts"; +import type { SessionLimits } from "./ooo-session-mechanism.ts"; export interface PatchBudget { perFile: number; @@ -8,11 +9,7 @@ export interface PatchBudget { /** Execution limits belong to the host envelope too: a real development task needs * more model turns and time than a one-line arithmetic probe, and the raise is * bound into the digest instead of being a worker-controlled parameter. */ -export interface PatchLimits { - turns: number; - reads: number; - timeoutMs: number; -} +export type PatchLimits = SessionLimits; /** Budgets belong to the frozen host envelope, not to a hard-coded constant: * a bigger task raises them explicitly and the raise is part of the digest. */ diff --git a/src/integration/ooo-session-mechanism.ts b/src/integration/ooo-session-mechanism.ts index 0e9a090e..337bb7e0 100644 --- a/src/integration/ooo-session-mechanism.ts +++ b/src/integration/ooo-session-mechanism.ts @@ -1,122 +1,38 @@ -/** - * The session mechanism, shared. - * - * Which unit runs under which session, what a unit's state is, and when its work is complete: - * none of it talks to a harness, so all of it is decided the same way whoever is running - pi - * today, DSH next. The adapter holds the runner objects and calls the model; everything here is - * the part that must not differ between them. Moved out of .pi/extensions/nmg/ooo-execution.ts, - * whose consumers used to import a harness to reach a shared mechanism. - */ -import { - patchCandidate, - patchPrompt, - type FrozenPatchWork, - type PatchLimits, -} from "./ooo-patch.ts"; +/** Session lifecycle, counters and completion checks independent of work-shape interpretation. + * The compatibility exports carry the default policy; the generic contracts do not require it. */ -export const SNAPSHOT_LIMITS: PatchLimits = Object.freeze({ - turns: 3, - reads: 2, - timeoutMs: 45_000, -}); - -/** Host-owned check exposure for patch tasks. The worker may run the round's own - * fixed check on its proposed files, bounded by `maxRuns`; it cannot choose a - * command, reach a path outside the frozen editable list, or see anything else. - * Every live round so far failed because a worker could not verify its own patch. */ -export interface CheckTool { - label: string; - maxRuns: number; - run: ( - files: { path: string; content: string }[], - ) => Promise<{ verdict: "accept" | "reject" | "undecidable"; log: string }>; -} - -/** Validates proposed files through the same shared contract as a submission, so a - * check call cannot smuggle a path, exceed a budget, or assert an unchanged file. */ -export function checkToolCandidate( - frozen: FrozenPatchWork, - files: { path: string; content: string }[], -): Readonly> { - return patchCandidate(frozen, JSON.stringify({ digest: frozen.digest, files })); -} - -/** The artifact contract as one flat parameter set, so the shape can be enforced at - * sampling time instead of described in prose. */ -export type ArtifactParams = { - digest: string; - files?: { path: string; content: string }[]; - conclusion?: string; - summary?: string; - evidence?: string; - citations?: { case: string; test: string }[]; -}; - -/** Builds the exact JSON envelope the shared contract expects, or explains what is - * wrong so the model can correct it inside the same attempt. Presence is not enough: - * a live round answered with `kind: "no-change"` and a prose `conclusion`, which the - * envelope passed through to a host rejection. Values are checked here too, and the - * host still validates the result: constrained decoding removes syntax failures only. */ -export function artifactEnvelope( - frozen: FrozenPatchWork, - params: ArtifactParams, -): { ok: true; json: string } | { ok: false; error: string } { - if (params.digest !== frozen.digest) - return { ok: false, error: `digest must be exactly ${frozen.digest}` }; - const files = params.files ?? []; - return files.length ? patchEnvelope(params, files) : conclusionEnvelope(frozen, params); +export interface SessionLimits { + turns: number; + reads: number; + timeoutMs: number; } -function patchEnvelope( - params: ArtifactParams, - files: { path: string; content: string }[], -): { ok: true; json: string } | { ok: false; error: string } { - if (params.conclusion || params.summary || params.evidence) - return { ok: false, error: "a patch carries files only; it cannot also carry a conclusion" }; - return { ok: true, json: JSON.stringify({ digest: params.digest, files }) }; +/** Bounds are declared by the caller. The mechanism does not choose a read minimum. */ +export interface CompletionBounds { + minTurns: number; + maxTurns: number; + minReads: number; + maxReads: number; } -function conclusionEnvelope( - frozen: FrozenPatchWork, - params: ArtifactParams, -): { ok: true; json: string } | { ok: false; error: string } { - const missing = (["conclusion", "summary", "evidence"] as const).filter( - (key) => !params[key]?.trim(), +export function completionAllowed( + completed: boolean, + timedOut: boolean, + turns: number, + reads: number, + bounds: CompletionBounds, +): boolean { + return ( + completed && + !timedOut && + turns >= bounds.minTurns && + turns <= bounds.maxTurns && + reads >= bounds.minReads && + reads <= bounds.maxReads ); - if (missing.length) - return { - ok: false, - error: - "provide files, or a conclusion with conclusion, summary and evidence; " + - `missing or empty ${missing.join(", ")}`, - }; - const admitted = frozen.work.admittedConclusions; - if (!(admitted as readonly string[]).includes(params.conclusion!)) - return { - ok: false, - error: `conclusion must be one of ${admitted.join(", ")}; got ${params.conclusion}`, - }; - const citations = params.citations ?? []; - if (citations.length > 16) return { ok: false, error: "at most 16 citations" }; - for (const entry of citations) - if (!entry?.case?.trim() || !entry?.test?.trim()) - return { ok: false, error: "every citation needs a non-empty case and test" }; - return { - ok: true, - json: JSON.stringify({ - digest: params.digest, - kind: "conclusion", - conclusion: params.conclusion, - summary: params.summary, - evidence: params.evidence, - citations, - }), - }; } -/** What the current task may push back on. A worker may report that a declared - * requirement on a dependency does not hold in what it actually received, which ends - * the attempt instead of letting it finish work on an input it cannot use. */ +/** A host-declared requirement on an upstream artifact, not a worker-selected predicate. */ export interface PushbackSpec { requirements: readonly { task: string; requirement: string }[]; } @@ -127,87 +43,7 @@ export interface PushbackReport { evidence: string; } -export interface PatchExecOptions { - check?: CheckTool; - pushback?: PushbackSpec; - /** - * Set by a caller whose session loosened the artifact schema's conclusion to a plain string. A chain - * fixes its tool surface when the session is created, so a per-unit literal union cannot be sampled - * there; the admitted kinds then have to be named in the prompt, because the schema no longer can. - */ - looseConclusion?: boolean; -} - -/** Tool the artifact is delivered through. Structural prevention of prose: the schema - * is the contract and the parameters are validated by our own code, so a text answer - * cannot be mistaken for a submission. */ -export const ARTIFACT_TOOL = "submit_artifact"; - -/** Produces an untrusted proposal, never applies files or marks a task accepted. */ -/** The single-unit input `executePiPatch` runs, exposed so a chain can drive the same work through one - * session: the prompt, the snapshot and the bounds are built here once, and both paths read them from - * here rather than each describing the task again. */ -export function patchSessionInput( - frozen: FrozenPatchWork, - options: PatchExecOptions = {}, -): SessionRunInput { - const { check, pushback } = options; - const note = check - ? `\nYou may call ${check.label} with your proposed files to run the round's fixed check before answering; at most ${check.maxRuns} calls are allowed. It runs only that check and never writes to the repository.` - : ""; - const pushbackNote = pushback?.requirements.length - ? `\nIf what you received cannot satisfy one of these declared requirements, call report_dependency_failure with the exact task and requirement instead of finishing the work: ` + - JSON.stringify(pushback.requirements) - : ""; - // What a fixed surface costs, said in the one place that can say it. A chain registers the tools of - // every unit it will run, so this unit is shown tools it cannot use, and its artifact schema had to - // loosen the conclusion to a string. Both rules are the prompt's now, because the surface cannot - // shrink per unit and the schema can no longer name the kinds. Measured, not assumed: without this, - // a live fused unit spent its turn budget on a run_check it has no check for and on a submission - // that carried files and a conclusion at once, which the envelope refuses. - const looseNote = options.looseConclusion - ? `\nThis session exposes the tools of every unit it will run, and this unit has ${ - check ? `the check ${check.label}` : "no check" - } and ${ - pushback?.requirements.length ? "a declared requirement" : "no declared requirement" - }, so call only read_snapshot${check ? ", run_check" : ""}${ - pushback?.requirements.length ? ", report_dependency_failure" : "" - } and ${ARTIFACT_TOOL}.\n` + - `Answer with files, or with a conclusion whose kind is one of ${JSON.stringify( - frozen.work.admittedConclusions, - )}, exactly as written - never both, and a kind outside that list is refused.` - : ""; - return { - prompt: patchPrompt(frozen, ARTIFACT_TOOL) + note + pushbackNote + looseNote, - snapshot: snapshotText(frozen), - maxArtifact: frozen.work.budget.output, - limits: frozen.work.limits, - looseConclusion: options.looseConclusion === true, - ...(check !== undefined ? { check } : {}), - frozen, - ...(pushback !== undefined ? { pushback } : {}), - }; -} - -export function piCompletionAllowed( - stopReason: string | undefined, - timedOut: boolean, - turns: number, - reads: number, - limits: PatchLimits = SNAPSHOT_LIMITS, -): boolean { - return ( - stopReason === "stop" && - !timedOut && - turns >= 1 && - turns <= limits.turns && - reads >= 1 && - reads <= limits.reads - ); -} - -/** One bounded Pi execution. `pushback` is present only when the worker ended the - * attempt by reporting that a dependency cannot satisfy a declared requirement. */ +/** One bounded execution. Optional cumulative fields distinguish unit spend from session spend. */ export interface PiRun { artifact: string; pushback?: PushbackReport; @@ -218,23 +54,13 @@ export interface PiRun { turns: number; checks: number; tokens: number; - /** Provider-reported cache accounting: without it a re-sent snapshot and a cached one - * look identical in the token total, and the cost question cannot be answered. */ cacheRead: number; cacheWrite: number; - /** The rest of what the provider reports for this unit's own turns: what it did not serve from cache, - * what the model wrote, and the price it put on the turns. Recorded apart from `tokens` because they - * are priced differently and a total cannot be taken apart again. */ inputTokens: number; outputTokens: number; cost: number; - /** A digest of the input this unit's session was given. The prompt is built from it, so two runs that - * agree on the spec can still differ here - which is what makes an instrument version checkable rather - * than argued about. */ + /** Identity of the rendered input actually presented, not just the task specification. */ promptDigest: string; - /** The session's cumulative totals. In a chain `tokens`/`cacheRead`/`cacheWrite` are this unit's - * own spend and these are the session's, which is what fusion's delta claim is read from; for a - * single-unit runner the two are equal. */ sessionTokens?: number; sessionCacheRead?: number; sessionCacheWrite?: number; @@ -243,36 +69,22 @@ export interface PiRun { sessionCost?: number; } -/** One unit's mutable state, held by the tool set. The tools read this object at call time rather - * than closing over its values, which is what lets a fused chain keep one session and one tool surface - * while each unit gets its own snapshot, check, budget and counters. The single-unit path builds one - * box and never re-points it, so both paths are the same code. */ -export interface UnitState { +/** Mutable per-unit state. An adopter extends this with its own tool and declaration state. */ +export interface SessionState { snapshot: string; - limits: PatchLimits; + limits: SessionLimits; maxArtifact: number; - frozen?: FrozenPatchWork; - check?: CheckTool; - pushback?: PushbackSpec; reads: { value: number }; runs: { value: number }; turns: number; - /** The tools this unit actually called, in order. What fills a turn budget is a fact worth reading: - * a chain registers a tool its current unit cannot use, and only the calls say whether that cost a - * turn - the counters for reads and checks do not move when a tool refuses the call. */ calls: string[]; artifact: string | null; - /** Why the last submission was refused, when it was: the envelope's own words. A unit that ran out of - * turns after submitting has its reason here, and without it the only visible symptom is "no artifact". */ artifactError: string | null; report: PushbackReport | null; - /** Ends the current unit's attempt; re-pointed per unit by a chain. */ abort: () => void; } -/** One assistant turn's provider-reported usage, as much of it as the provider fills in. Every field is - * optional because providers differ: what they agree on is the total, and a missing split has to read as - * "not reported" rather than as zero spent. */ +/** Providers need not report the same split; absent accounting is not zero spend. */ interface TurnUsage { totalTokens?: number; input?: number; @@ -282,10 +94,7 @@ interface TurnUsage { cost?: { total?: number }; } -/** The assistant turns of one session, summed. `input` is what the provider did not serve from cache, - * `output` is what the model wrote, `cacheRead`/`cacheWrite` are the cached halves and `cost` is the - * provider's own price. These are the numbers a cost claim needs: a token total adds them together, and - * a cached input token is not priced like a fresh one, so no arithmetic on the total recovers them. */ +/** Sum the accounting reported for assistant turns, without inventing unreported splits. */ export function usageTotals(messages: readonly { role: string; usage?: TurnUsage }[]) { let total = 0; let input = 0; @@ -307,7 +116,6 @@ export function usageTotals(messages: readonly { role: string; usage?: TurnUsage return { total, input, output, cacheRead, cacheWrite, cost }; } -/** Tokens the assistant actually spent in this fresh session. */ export function totalTokens(messages: readonly { role: string; usage?: TurnUsage }[]) { return usageTotals(messages).total; } @@ -317,29 +125,7 @@ export function cacheTotals(messages: readonly { role: string; usage?: TurnUsage return { cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite }; } -/** The snapshot text: only the readable subset travels, because the whole baseline is - * re-sent on every turn and the visible set is digest-bound. */ -export function snapshotText(frozen: FrozenPatchWork): string { - const files = Object.fromEntries( - frozen.work.visible.map((path) => [path, frozen.work.files[path]]), - ); - const hidden = Object.keys(frozen.work.files).filter( - (path) => !frozen.work.visible.includes(path), - ); - return JSON.stringify({ - digest: frozen.digest, - taskId: frozen.work.taskId, - attempt: frozen.work.attempt, - instruction: frozen.work.instruction, - editable: frozen.work.editable, - budget: frozen.work.budget, - limits: frozen.work.limits, - files, - ...(hidden.length ? { hidden } : {}), - }); -} - -/** The bounded text artifact of a finished attempt, or a reason it cannot be used. */ +/** Bounded text extraction does not interpret an artifact's domain schema. */ export function boundedArtifact( message: { content: readonly { type: string; text?: string }[] } | undefined, maxArtifact: number, @@ -353,84 +139,46 @@ export function boundedArtifact( return artifact && artifact.length <= maxArtifact ? artifact : null; } -/** A patch attempt's answer written as text instead of through the artifact tool. - * - * The text path must not be a second, weaker contract: a live round answered this way - * with a conclusion-shaped object and no files, and the host could only refuse the whole - * attempt as `invalid patch structure` after the model had been paid for. Validating - * through the same envelope the tool uses makes the text channel obey exactly the tool - * channel's rules, and turns an unshaped answer into a recorded failed attempt with the - * precise reason. */ -export function artifactFromText( - frozen: FrozenPatchWork, - text: string, -): { ok: true; json: string } | { ok: false; error: string } { - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return { ok: false, error: "the answer is not JSON" }; - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) - return { ok: false, error: "the answer is not a JSON object" }; - const candidate = parsed as ArtifactParams; - return artifactEnvelope(frozen, { - digest: candidate.digest, - files: candidate.files, - conclusion: candidate.conclusion, - summary: candidate.summary, - evidence: candidate.evidence, - citations: candidate.citations, - }); -} - -/** The name list the session must expose, derived from what the host enabled. */ -export function toolNames(hasCheck: boolean, hasPushback: boolean, hasArtifact: boolean) { - return [ - "read_snapshot", - ...(hasCheck ? ["run_check"] : []), - ...(hasPushback ? ["report_dependency_failure"] : []), - ...(hasArtifact ? [ARTIFACT_TOOL] : []), - ]; -} - -/** Tool surface for one patch attempt. Each tool is bounded, parameter-free where it - * must be, and reads only host-owned state: the worker cannot choose a command, a path - * outside the frozen editable list, or a requirement that was not declared to it. */ -/** A turn-level error worth reporting, or null. Only assistant messages carry a - * stop reason, so the role check belongs here rather than in the session callback. */ export function turnError(event: { type: string; message: { role: string; stopReason?: string; errorMessage?: string }; }): string | null { if (event.type !== "turn_end" || event.message.role !== "assistant") return null; if (event.message.stopReason !== "error") return null; - return `pi turn error: ${event.message.errorMessage} -`; + return `pi turn error: ${event.message.errorMessage}\n`; } -/** One unit's input into a session: everything its tools and its completion contract read. */ -export interface SessionRunInput { +/** Rendered input, with execution bounds but no required declaration or tool vocabulary. */ +export interface SessionInput { prompt: string; snapshot: string; maxArtifact: number; - limits: PatchLimits; - /** Whether this input was built for a session whose artifact schema is loosened, so the prompt had to - * name the admitted conclusion kinds. The runner checks it against its own mode. */ - looseConclusion?: boolean; - check?: CheckTool; - frozen?: FrozenPatchWork; - pushback?: PushbackSpec; + limits: SessionLimits; } -/** A session that can run more than one unit: the mechanism fusion's policy half needs. - * - * `PiRun.tokens` is the **unit's own** spend (the session's total minus what it was when the unit - * started) and `sessionTokens` the session's cumulative total, because fusion's claim is about the - * delta: a later unit in a warm context should spend less than a fresh session on the same work. A - * single-unit runner reports the same numbers both ways, so nothing that reads `tokens` changes. */ -export interface PiSessionRunner { +export interface SessionRunner { sessionId: string; - runUnit(input: SessionRunInput): Promise; + runUnit(input: Input): Promise; dispose(): void; } + +/** Compatibility facade, not another implementation. Default-policy bodies live in the adopter. */ +export { + SNAPSHOT_LIMITS, + ARTIFACT_TOOL, + checkToolCandidate, + artifactEnvelope, + patchSessionInput, + piCompletionAllowed, + snapshotText, + artifactFromText, + toolNames, +} from "./ooo-patch-session.ts"; +export type { + CheckTool, + ArtifactParams, + PatchExecOptions, + UnitState, + SessionRunInput, + PiSessionRunner, +} from "./ooo-patch-session.ts"; diff --git a/src/integration/task-semantics.ts b/src/integration/task-semantics.ts index 2c08bbe0..31dee09a 100644 --- a/src/integration/task-semantics.ts +++ b/src/integration/task-semantics.ts @@ -570,6 +570,12 @@ export function deriveStatus( export const MODELLED_ACTIONS = ["next", "publish"] as const; export const UNMODELLED_ACTIONS = ["fuse", "prepare"] as const; +/** A supported primitive, named and enabled by the declaring protocol rather than by the checker. */ +export interface RefinementConstraint { + readonly kind: "within-parent-writes"; + readonly name: string; +} + /** A refinement is a host-declared split, not something a language model asserts. */ export interface RefinementSpec { parent: string; @@ -577,6 +583,74 @@ export interface RefinementSpec { join: string; /** Parent obligation → the part outputs that carry it. */ obligations: Readonly>>; + /** Presence enables a named primitive; an empty list never creates an implicit rule. */ + constraints: readonly RefinementConstraint[]; +} + +const REFINEMENT_CONSTRAINT_FIELDS = new Set(["kind", "name"]); + +function constraintRefusals(value: unknown, task: string, index: number): Refusal[] { + const field = `constraints.${index}`; + if (!value || typeof value !== "object" || Array.isArray(value)) + return [{ task, field, reason: "a refinement constraint must be a named declaration" }]; + const constraint = value as Partial; + const refusals = unknownKeys(value, REFINEMENT_CONSTRAINT_FIELDS).map((key) => ({ + task, + field: `${field}.${key}`, + reason: "unsupported refinement constraint field", + })); + if (typeof constraint.name !== "string" || !constraint.name.trim()) + refusals.push({ + task, + field: `${field}.name`, + reason: "the protocol must name its constraint", + }); + if (constraint.kind !== "within-parent-writes") + refusals.push({ + task, + field: `${field}.kind`, + reason: `unsupported primitive ${String(constraint.kind)} for ${constraint.name ?? "unnamed constraint"}`, + }); + return refusals; +} + +function declaredRefinementConstraints(spec: RefinementSpec): { + rules: readonly RefinementConstraint[]; + refusals: Refusal[]; +} { + if (!Array.isArray(spec.constraints)) + return { + rules: [], + refusals: [ + { + task: spec.parent, + field: "constraints", + reason: + "the protocol must declare its refinement constraints, even when none are enabled", + }, + ], + }; + const rules: RefinementConstraint[] = []; + const refusals: Refusal[] = []; + const names = new Set(); + for (const [index, constraint] of spec.constraints.entries()) { + const invalid = constraintRefusals(constraint, spec.parent, index); + if (invalid.length) { + refusals.push(...invalid); + continue; + } + if (names.has(constraint.name)) { + refusals.push({ + task: spec.parent, + field: `constraints.${index}.name`, + reason: `duplicate constraint name ${constraint.name}`, + }); + continue; + } + names.add(constraint.name); + rules.push(constraint); + } + return { rules, refusals }; } function refuseUnmappedObligations(parent: TaskUnit, spec: RefinementSpec): Refusal[] { @@ -590,16 +664,15 @@ function refuseUnmappedObligations(parent: TaskUnit, spec: RefinementSpec): Refu })); } -function refuseWidening(part: TaskUnit, parent: TaskUnit): Refusal[] { - if (!parent.patch) return []; - const allowed = new Set(parent.patch.editable); - return (part.patch?.editable ?? []) - .filter((path) => !allowed.has(path)) - .map((path) => ({ +function refuseWidening(part: TaskUnit, parent: TaskUnit, rule: RefinementConstraint): Refusal[] { + const allowed = new Set(parent.effects.proposeWrite); + return part.effects.proposeWrite + .filter((resource) => !allowed.has(resource)) + .map((resource) => ({ task: part.id, - field: "editable", + field: "effects.proposeWrite", obligation: "permission-closure" as const, - reason: `a split may not widen the write set: ${path} is outside the parent's`, + reason: `${rule.name}: ${resource} is outside the parent's declared write set`, })); } @@ -610,9 +683,11 @@ export function checkRefinement(compiled: CompiledTasks, spec: RefinementSpec): if (!parent) { return [{ task: spec.parent, field: "parent", reason: "refinement parent is not in the plan" }]; } + const declared = declaredRefinementConstraints(spec); const refusals: Refusal[] = spec.parts.includes(spec.join) - ? [] + ? [...declared.refusals] : [ + ...declared.refusals, { task: spec.parent, field: "join", @@ -620,13 +695,23 @@ export function checkRefinement(compiled: CompiledTasks, spec: RefinementSpec): reason: "the join must be one of the parts", }, ]; + if ( + parent.obligations.includes("permission-closure") && + !declared.rules.some((rule) => rule.kind === "within-parent-writes") + ) + refusals.push({ + task: parent.id, + field: "constraints", + obligation: "permission-closure", + reason: "the parent's permission obligation needs a declared within-parent-writes constraint", + }); for (const part of spec.parts) { const unit = byId.get(part); if (!unit) { refusals.push({ task: part, field: "parts", reason: "refinement part is not in the plan" }); continue; } - refusals.push(...refuseWidening(unit, parent)); + for (const rule of declared.rules) refusals.push(...refuseWidening(unit, parent, rule)); } return [...refusals, ...refuseUnmappedObligations(parent, spec)]; } diff --git a/tests/integration/ooo-generic-session.test.ts b/tests/integration/ooo-generic-session.test.ts new file mode 100644 index 00000000..d96475c9 --- /dev/null +++ b/tests/integration/ooo-generic-session.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as mechanism from "../../src/integration/ooo-session-mechanism.ts"; +import type { + SessionInput, + SessionState, + SessionRunner, + PiRun, +} from "../../src/integration/ooo-session-mechanism.ts"; +import { workDigest } from "../../src/integration/work-identity.ts"; + +test("generic session input and state need no patch interpretation or tool policy", async () => { + const input: SessionInput = { + prompt: "sum these values", + snapshot: "2,3", + maxArtifact: 256, + limits: { turns: 2, reads: 0, timeoutMs: 100 }, + }; + const state: SessionState = { + snapshot: input.snapshot, + limits: input.limits, + maxArtifact: input.maxArtifact, + reads: { value: 0 }, + runs: { value: 0 }, + turns: 0, + calls: [], + artifact: null, + artifactError: null, + report: null, + abort() {}, + }; + const opaque: Extract extends never + ? true + : false = true; + assert.equal(opaque, true); + const runner: SessionRunner = { + sessionId: "numeric-session", + async runUnit(admitted): Promise { + state.turns++; + state.artifact = String( + admitted.snapshot + .split(",") + .map(Number) + .reduce((a, b) => a + b, 0), + ); + return { + artifact: state.artifact, + sessionId: this.sessionId, + provider: "local-control", + model: "numeric-fixture", + reads: 0, + turns: 1, + checks: 0, + tokens: 0, + cacheRead: 0, + cacheWrite: 0, + inputTokens: 0, + outputTokens: 0, + cost: 0, + promptDigest: workDigest(admitted.prompt), + }; + }, + dispose() { + state.abort(); + }, + }; + const result = await runner.runUnit(input); + assert.equal(result.artifact, "5"); + assert.equal(state.turns, 1); + assert.equal(state.reads.value, 0); + runner.dispose(); +}); + +test("generic completion enforces the caller's bounds, not an implicit snapshot-read minimum", () => { + const bounds = { minTurns: 1, maxTurns: 2, minReads: 0, maxReads: 2 }; + assert.equal(typeof mechanism.completionAllowed, "function"); + assert.equal(mechanism.completionAllowed(true, false, 1, 0, bounds), true); + assert.equal(mechanism.completionAllowed(true, false, 1, 0, { ...bounds, minReads: 1 }), false); + assert.equal(mechanism.completionAllowed(true, true, 1, 0, bounds), false); + assert.equal(mechanism.completionAllowed(false, false, 1, 0, bounds), false); + assert.equal(mechanism.completionAllowed(true, false, 3, 0, bounds), false); + assert.equal(mechanism.completionAllowed(true, false, 1, 3, bounds), false); + assert.equal( + mechanism.piCompletionAllowed("stop", false, 1, 0), + false, + "default policy keeps its read requirement", + ); +}); + +test("the compatibility facade exposes the default adapter's actual functions, not a second implementation", async () => { + const patch = await import("../../src/integration/ooo-patch-session.ts"); + assert.equal(mechanism.patchSessionInput, patch.patchSessionInput); + assert.equal(mechanism.artifactEnvelope, patch.artifactEnvelope); + assert.equal(mechanism.artifactFromText, patch.artifactFromText); + assert.equal(mechanism.piCompletionAllowed, patch.piCompletionAllowed); +}); diff --git a/tests/integration/ooo-ordinary-failure.test.ts b/tests/integration/ooo-ordinary-failure.test.ts index e9c50558..76ac63f7 100644 --- a/tests/integration/ooo-ordinary-failure.test.ts +++ b/tests/integration/ooo-ordinary-failure.test.ts @@ -163,6 +163,7 @@ test("a split that drops a parent obligation is refused by name, with its locati parent: "B", parts: ["A"], join: "A", + constraints: [{ kind: "within-parent-writes", name: "permission-closure" }], obligations: {}, }); assert.ok(dropped.length > 0, "a split that maps no parent obligation is refused, not accepted"); diff --git a/tests/integration/ooo-session-layering.test.ts b/tests/integration/ooo-session-layering.test.ts index fb19bf8e..f7b709fc 100644 --- a/tests/integration/ooo-session-layering.test.ts +++ b/tests/integration/ooo-session-layering.test.ts @@ -11,6 +11,7 @@ import assert from "node:assert/strict"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import test from "node:test"; +import ts from "typescript"; const SHARED = "src/integration/ooo-session-mechanism.ts"; const ADAPTER = "nmg/ooo-execution.ts"; @@ -29,7 +30,16 @@ function typescriptFiles(directory: string): string[] { /** The names the shared module exports, which are the names the adapter may not hand out. */ function sharedNames(): string[] { - return readFileSync(SHARED, "utf8") + const text = readFileSync(SHARED, "utf8"); + const source = ts.createSourceFile(SHARED, text, ts.ScriptTarget.Latest, true); + const forwarded = source.statements + .filter(ts.isExportDeclaration) + .flatMap((statement) => + statement.exportClause && ts.isNamedExports(statement.exportClause) + ? statement.exportClause.elements.map((element) => element.name.text) + : [], + ); + const direct = text .split(/\r?\n/) .map( (line) => @@ -38,6 +48,7 @@ function sharedNames(): string[] { )?.[1], ) .filter((name): name is string => Boolean(name)); + return [...new Set([...direct, ...forwarded])]; } /** @@ -74,6 +85,19 @@ function namesTakenFromAdapter(text: string): string[] { return names; } +test("the public surface includes explicit compatibility forwards as well as direct declarations", () => { + const names = sharedNames(); + for (const name of [ + "artifactEnvelope", + "patchSessionInput", + "UnitState", + "SessionInput", + "SessionState", + "SessionRunner", + ]) + assert.ok(names.includes(name), `${name} must not disappear behind a forwarding export`); +}); + test("no file asks the adapter for a name the shared mechanism owns", () => { const shared = sharedNames(); // A guard that reads an empty list would pass for the wrong reason. diff --git a/tests/integration/task-refinement-declaration.test.ts b/tests/integration/task-refinement-declaration.test.ts new file mode 100644 index 00000000..676b79d7 --- /dev/null +++ b/tests/integration/task-refinement-declaration.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + checkRefinement, + type TaskUnit, + type CompiledTasks, + type RefinementSpec, + type RefinementConstraint, +} from "../../src/integration/task-semantics.ts"; + +/** A shape adapter can supply normalized resource identities without a patch envelope. */ +function unit(id: string, writes: readonly string[], permission = true): TaskUnit { + return { + id, + revision: "v1", + index: id === "parent" ? 0 : 1, + instruction: "produce a numeric artifact", + patch: null, + inputs: { files: [], dependencies: [] }, + effects: { effect: "isolated-artifact", operation: null, read: [], proposeWrite: writes }, + requires: [], + waitEvent: null, + obligations: permission ? ["permission-closure"] : [], + }; +} +function model( + parentWrites: readonly string[], + childWrites: readonly string[], + permission = true, +): CompiledTasks { + return { + units: [unit("parent", parentWrites, permission), unit("child", childWrites, permission)], + refusals: [], + legal: true, + digest: "normalized-control-model", + }; +} +const scope = [{ kind: "within-parent-writes", name: "numeric-resource-closure" }] as const; +function split(constraints: readonly RefinementConstraint[] = scope): RefinementSpec { + return { + parent: "parent", + parts: ["child"], + join: "child", + obligations: { "permission-closure": ["child"] }, + constraints, + }; +} +function malformed(constraints: readonly unknown[]): RefinementSpec { + return { ...split(), constraints } as unknown as RefinementSpec; +} + +// Negative mappings use an explicit malformed-boundary cast, not a diagnostic suppression. +test("a declared write constraint rejects wider numeric resources without reading patch metadata", () => { + const spec = split(); + const refusals = checkRefinement(model(["value:sum"], ["value:admin"]), spec); + assert.equal(refusals.length, 1); + assert.equal(refusals[0]?.task, "child"); + assert.equal(refusals[0]?.field, "effects.proposeWrite"); + assert.equal(refusals[0]?.obligation, "permission-closure"); + assert.match(refusals[0]?.reason ?? "", /numeric-resource-closure/); + assert.match(refusals[0]?.reason ?? "", /value:admin/); +}); + +test("a declared write constraint permits narrower opaque resources", () => { + assert.deepEqual( + checkRefinement(model(["value:sum", "value:label"], ["value:sum"]), split()), + [], + ); +}); + +test("a parent's permission obligation cannot be silently lost by omitting its declared constraint", () => { + const refusals = checkRefinement(model(["value:sum"], ["value:sum"]), split([])); + assert.ok( + refusals.some( + (refusal) => refusal.field === "constraints" && refusal.obligation === "permission-closure", + ), + ); +}); + +test("the mechanism does not enable a write rule a protocol did not declare or require", () => { + assert.deepEqual(checkRefinement(model(["value:sum"], ["value:admin"], false), split([])), []); +}); + +test("a protocol chooses the rule's name, while its declared primitive remains the same", () => { + const spec = split([{ kind: "within-parent-writes", name: "artifact-authority" }]); + const refusals = checkRefinement(model([], ["value:sum"]), spec); + assert.match(refusals[0]?.reason ?? "", /artifact-authority/); +}); + +test("an unsupported named primitive is refused at its declaration instead of ignored", () => { + const spec = malformed([{ kind: "guess-permissions", name: "guessed-authority" }]); + const refusals = checkRefinement(model([], []), spec); + assert.ok( + refusals.some( + (refusal) => + refusal.field === "constraints.0.kind" && refusal.reason.includes("guessed-authority"), + ), + ); +}); + +test("a missing constraint array, unnamed rule and non-object declaration are refused", () => { + const missing = { ...split(), constraints: undefined } as unknown as RefinementSpec; + assert.ok( + checkRefinement(model([], []), missing).some((refusal) => refusal.field === "constraints"), + ); + assert.ok( + checkRefinement(model([], []), malformed([{ kind: "within-parent-writes", name: " " }])).some( + (refusal) => refusal.field === "constraints.0.name", + ), + ); + assert.ok( + checkRefinement(model([], []), malformed([null])).some( + (refusal) => refusal.field === "constraints.0", + ), + ); +}); + +test("aliases and duplicate names are refused at the declaration", () => { + const aliased = malformed([{ kind: "within-parent-writes", name: "scope", enabled: true }]); + assert.ok( + checkRefinement(model([], []), aliased).some( + (refusal) => refusal.field === "constraints.0.enabled", + ), + ); + const duplicate = split([ + { kind: "within-parent-writes", name: "scope" }, + { kind: "within-parent-writes", name: "scope" }, + ]); + assert.ok( + checkRefinement(model([], []), duplicate).some( + (refusal) => refusal.field === "constraints.1.name", + ), + ); +}); diff --git a/tests/integration/task-semantics-cases.test.ts b/tests/integration/task-semantics-cases.test.ts index 1d477977..d853c0ab 100644 --- a/tests/integration/task-semantics-cases.test.ts +++ b/tests/integration/task-semantics-cases.test.ts @@ -96,6 +96,7 @@ test("case 2: a lost obligation or a widened permission is refused, and an assum parent: "P", parts: ["J"], join: "J", + constraints: [{ kind: "within-parent-writes", name: "permission-closure" }], obligations: { "input-closure": ["J"] }, // deliberately drops artifact-handoff and the rest }); assert.ok( @@ -103,8 +104,8 @@ test("case 2: a lost obligation or a widened permission is refused, and an assum "every parent obligation must map to a part output", ); - // Widening is checked against a parent that actually has a frozen envelope: two units in - // the same plan, the second writing a file the first does not own. + // The default adapter projects its frozen envelope into declared proposal-write resources. + // The second unit proposes a resource the parent's declaration does not authorize. const siblings: ProbePlan = [ ["P", "1", [], "isolated-artifact", null, null], ["C", "1", [], "isolated-artifact", null, null], @@ -115,6 +116,7 @@ test("case 2: a lost obligation or a widened permission is refused, and an assum parent: "P", parts: ["C"], join: "C", + constraints: [{ kind: "within-parent-writes", name: "permission-closure" }], obligations: Object.fromEntries(sibParent.obligations.map((obligation) => [obligation, ["C"]])), }); assert.ok( diff --git a/tests/integration/task-semantics.test.ts b/tests/integration/task-semantics.test.ts index 8034b677..20f32327 100644 --- a/tests/integration/task-semantics.test.ts +++ b/tests/integration/task-semantics.test.ts @@ -295,6 +295,7 @@ test("a refinement must carry every parent obligation and may not widen the writ parent: "P", parts: ["P1", "P2"], join: "P1", + constraints: [{ kind: "within-parent-writes", name: "permission-closure" }], obligations: Object.fromEntries(partial.map((obligation) => [obligation, ["P1"]])), }); const missing = refusals.filter((refusal) => refusal.field.startsWith("obligations.")); diff --git a/tools/policy-word-list.ts b/tools/policy-word-list.ts index a32311c7..63c65ece 100644 --- a/tools/policy-word-list.ts +++ b/tools/policy-word-list.ts @@ -16,6 +16,7 @@ export const MECHANISM_PATHS: readonly string[] = [ "src/integration/ooo-candidate.ts", "src/integration/ooo-fusion-plan.ts", "src/integration/ooo-session-mechanism.ts", + "src/integration/ooo-patch-session.ts", "src/integration/task-semantics.ts", "src/integration/task-semantics-model.ts", "src/integration/task-semantics-interleavings.ts", @@ -564,13 +565,22 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ path: "src/integration/ooo-session-mechanism.ts", scope: "", word: "patch", - count: 6, + count: 4, classification: "policy", reason: - "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + "Compatibility exports carry the default adapter; generic session input/state/runner contracts do not require its work shape.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", + scope: "", + word: "patch", + count: 8, + classification: "policy", + reason: + "The default adopter owns patch rendering and validation, above the generic session lifecycle.", + }, + { + path: "src/integration/ooo-patch-session.ts", scope: "CheckTool", word: "files", count: 1, @@ -579,7 +589,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "checkToolCandidate", word: "patch", count: 2, @@ -588,7 +598,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "checkToolCandidate", word: "files", count: 2, @@ -597,7 +607,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "ArtifactParams", word: "files", count: 1, @@ -606,7 +616,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "artifactEnvelope", word: "patch", count: 2, @@ -615,7 +625,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "artifactEnvelope", word: "files", count: 4, @@ -624,7 +634,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "patchEnvelope", word: "patch", count: 2, @@ -633,7 +643,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "patchEnvelope", word: "files", count: 3, @@ -642,7 +652,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "conclusionEnvelope", word: "patch", count: 1, @@ -651,7 +661,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "conclusionEnvelope", word: "files", count: 1, @@ -660,7 +670,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "PatchExecOptions", word: "patch", count: 1, @@ -669,7 +679,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "patchSessionInput", word: "patch", count: 4, @@ -678,7 +688,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "patchSessionInput", word: "files", count: 2, @@ -687,7 +697,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "piCompletionAllowed", word: "patch", count: 1, @@ -705,16 +715,16 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Executes or records caller-declared checks; does not choose their wording or enablement.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "UnitState", word: "patch", - count: 2, + count: 1, classification: "policy", reason: "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "snapshotText", word: "patch", count: 1, @@ -723,7 +733,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "snapshotText", word: "files", count: 4, @@ -732,7 +742,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "snapshotText", word: "instruction", count: 2, @@ -741,7 +751,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "snapshotText", word: "editable", count: 2, @@ -750,7 +760,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "artifactFromText", word: "patch", count: 1, @@ -759,7 +769,7 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "artifactFromText", word: "files", count: 2, @@ -768,10 +778,10 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", }, { - path: "src/integration/ooo-session-mechanism.ts", + path: "src/integration/ooo-patch-session.ts", scope: "SessionRunInput", word: "patch", - count: 2, + count: 1, classification: "policy", reason: "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", @@ -1010,22 +1020,4 @@ export const CLASSIFICATIONS: readonly ClassifiedSite[] = [ reason: "The compiler or refinement rule names patch-specific fields, validation and permissions.", }, - { - path: "src/integration/task-semantics.ts", - scope: "refuseWidening", - word: "patch", - count: 3, - classification: "policy", - reason: - "The compiler or refinement rule names patch-specific fields, validation and permissions.", - }, - { - path: "src/integration/task-semantics.ts", - scope: "refuseWidening", - word: "editable", - count: 3, - classification: "policy", - reason: - "The compiler or refinement rule names patch-specific fields, validation and permissions.", - }, ];