diff --git a/agent-context.yaml b/agent-context.yaml index b730e723..f56ed19e 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] @@ -239,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 @@ -249,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-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-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-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/implemented/2026-09-21-mechanism-not-policy.md b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md new file mode 100644 index 00000000..1bdc0adc --- /dev/null +++ b/docs/decisions/implemented/2026-09-21-mechanism-not-policy.md @@ -0,0 +1,173 @@ +# Mechanism, not policy + +[中文](2026-09-21-mechanism-not-policy.zh-CN.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 + +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. + +The cost is already visible. The assumption that work means a patch reached six places, and one of them is a +legality rule written in the kernel's own vocabulary: `refuseWidening` decides permission closure by reading +`parent.patch.editable`, so a rule about write sets is expressed in terms of a work shape the kernel is not +supposed to know. + +The split also needs to be sharp enough to settle an argument rather than to serve as a slogan. "Keep policy +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. + +## Decision + +Take Hydra's principle - a kernel provides mechanisms and refuses policy - and state it as two contracts. + +**Mechanism contract** (the core: the board, the store, the program): + +- one claim: compare-and-set, lease, attempt fence +- an opaque declaration with a digest +- an opaque artifact with a digest +- a verdict given by someone else +- the lifecycle: expiry, reaping, wake delivery, compact read +- the legal set: which units are legal now, in order, cut to the declared slot budget, with a reason per unit + +**Policy contract** (above the core: the protocol, the plan, the agents): + +- what a unit's inputs are, the artifact class, how the artifact is judged, how the work runs (synchronous + or detached, interruptibility, whether abandoning it midway is safe), and its concurrency preconditions +- the wording and the enablement of legality rules: named by the protocol, enabled by the plan +- who is chosen, in what order, who adopts, who judges + +**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.** 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. 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` 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. + +### 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. + +## Deferred + +### Declaration storage + +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. + +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 + +- 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. +- A numeric declaration runs through the widened dispatch seam and the real board lifecycle without + 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. + +## 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 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. +- **The policy-word list can ossify.** A word may be mechanism in one place and policy in another, so the + check needs the path rather than the word alone, and the list has to accept additions. +- **An undecided row can become permanent.** A row marked undecided without an experiment behind it is a + 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. +- **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..96a98e94 --- /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。 + +### 不透明派发声明 + +派发端口携带必需、可为空的 `declaration`,以形态参数化;共享机制只要求其有 `digest`。基础 `BoardTicket` 使用同一不透明载体。默认采用方将其特化为自己的声明类型,在返回票据前完成冻结与校验。循环原样传递声明,不重建补丁工作,也不渲染它。缺失声明是采用方违反契约,不会隐式回退成补丁。 + +采用方可以在发出票据之前返回具名拒绝;循环将其记为槽位拒绝,与 worker 失败、无效票据分别记账。[数值采用方证据](../../experiments/execution/field-ownership-2026-10-01.md#numeric-adopter)覆盖共享端口,以及真实 store 的租约、交付和独立裁决。这是受控边界测试,不是产品安装了 peer 协议,也不是帧/存储迁移。 + +**刻意留作待判的行**(免得被顺手定掉): + +- `task_run_tasks.effect` 是 effect 类别标签,不是声明的提议写集。资源集互斥是共同机制义务还是协议义务,需要另判;[字段实验](../../experiments/execution/field-ownership-2026-10-01.md)不会把现有类别门槛变成互斥检查。 +- `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。 + +## 未完成项 + +### 声明存储 + +派发、refinement 检查和通用会话契约不要求补丁信封。store 仍解析 `task_run_tasks.patch_files` / `patch_editable`;这些形态字段属于声明 owner,不属于黑板的通用 header。迁移前须与帧提案中 Task-Unit 类型表、黑板 payload 为 NULL 的安排对齐。这次分离不批准独立存储改动。 + +permission closure 保留程序中的检查,名字与启用来自声明,与合法性记录里的声明 repair-first 约束具有同一边界:执行已选规则不等于选择规则。写集子集原语不假称已强制任意资源互斥或所有 effect。 + +### 字段实验与分离证据 + +- [受控字段探针](../../experiments/execution/field-ownership-2026-10-01.md)提供 effect 标签、内容绑定摘要、依赖释放和 operation 解释的可复现证据。它缩小问题,不决定通用输入粒度或存储迁移。门禁不包含这些词,也不替实验定案。 +- schema 迁移与 `TaskUnit` 中的 `files` 组标为 `undecided`:同组混有补丁专有路径与另一角色。通过拆开 DDL 主题或运行另一种输入声明来解决,不把整组改标为机制。 +- 数值声明能经过加宽的派发边界与真实黑板生命周期,无须再改机制。受控 permission/session 用例证明各自的窄契约,不证明 store 已无策略或已有四角色 peer 协议接口。零策略命中与完整协议接口仍是未验证目标;门禁通过不证明这些目标。草稿的[第二形态实验](../../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.md b/docs/decisions/proposed/2026-09-21-mechanism-not-policy.md deleted file mode 100644 index dd9b0dc5..00000000 --- a/docs/decisions/proposed/2026-09-21-mechanism-not-policy.md +++ /dev/null @@ -1,129 +0,0 @@ -# Mechanism, not policy - -[中文](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) - -## Problem - -Two proposed records each need the same sentence, and neither states it. 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. - -The cost is already visible. The assumption that work means a patch reached six places, and one of them is a -legality rule written in the kernel's own vocabulary: `refuseWidening` decides permission closure by reading -`parent.patch.editable`, so a rule about write sets is expressed in terms of a work shape the kernel is not -supposed to know. - -The split also needs to be sharp enough to settle an argument rather than to serve as a slogan. "Keep policy -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 - -Take Hydra's principle - a kernel provides mechanisms and refuses policy - and state it as two contracts. - -**Mechanism contract** (the core: the board, the store, the program): - -- one claim: compare-and-set, lease, attempt fence -- an opaque declaration with a digest -- an opaque artifact with a digest -- a verdict given by someone else -- the lifecycle: expiry, reaping, wake delivery, compact read -- the legal set: which units are legal now, in order, cut to the declared slot budget, with a reason per unit - -**Policy contract** (above the core: the protocol, the plan, the agents): - -- what a unit's inputs are, the artifact class, how the artifact is judged, how the work runs (synchronous - or detached, interruptibility, whether abandoning it midway is safe), and its concurrency preconditions -- the wording and the enablement of legality rules: named by the protocol, enabled by the plan -- who is chosen, in what order, who adopts, who judges - -**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. - -**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. -- 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. - -## What this makes of the work shape - -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. - -| 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 | -| 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 | -| 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 -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. - -## 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 - -- **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. -- **The policy-word list can ossify.** A word may be mechanism in one place and policy in another, so the - check needs the path rather than the word alone, and the list has to accept additions. -- **An undecided row can become permanent.** A row marked undecided without an experiment behind it is a - 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. 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..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 @@ -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 @@ -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 2df24bf3..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 @@ -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) ## 问题 @@ -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/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/docs/design/task-unit-semantics.md b/docs/design/task-unit-semantics.md index 2aa49f69..ea8c5b0e 100644 --- a/docs/design/task-unit-semantics.md +++ b/docs/design/task-unit-semantics.md @@ -102,11 +102,11 @@ 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、工具、频道来承载重复定义。 -数据流为:现有 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..47ac2c5e --- /dev/null +++ b/docs/experiments/execution/field-ownership-2026-10-01.md @@ -0,0 +1,79 @@ +# 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 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) +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/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/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-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-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/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-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-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-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-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-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-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/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/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/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..63c65ece --- /dev/null +++ b/tools/policy-word-list.ts @@ -0,0 +1,1023 @@ +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/ooo-patch-session.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: "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: "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-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: 4, + classification: "policy", + reason: + "Compatibility exports carry the default adapter; generic session input/state/runner contracts do not require its work shape.", + }, + { + 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, + classification: "policy", + reason: + "Patch-specific worker inputs, artifact envelopes or prompt rendering remain in the session module.", + }, + { + path: "src/integration/ooo-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.ts", + scope: "UnitState", + 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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.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-patch-session.ts", + scope: "SessionRunInput", + word: "patch", + count: 1, + 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.", + }, +];