okstra 0.179.1 → 0.179.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/plan-body-verification.md +5 -2
- package/runtime/python/okstra_ctl/dispatch_core.py +22 -9
- package/runtime/python/okstra_ctl/render_final_report.py +19 -14
- package/runtime/python/okstra_ctl/schema_excerpt.py +86 -11
- package/runtime/validators/validate-run.py +67 -11
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -230,6 +230,9 @@ round before any host or provider process starts.
|
|
|
230
230
|
**Verdict provenance (BLOCKING).** Every verdict recorded in `planItems[].verdicts[]` MUST trace back to a dispatch that actually returned a result file at the path above. The whole gate — classification, self-fix eligibility, promotion, `gateBlockedBy` — is computed from these votes, so an unbacked vote lets the round be skipped while the gate still reads `passed`. **Enforced:** `validators/validate-run.py` `_validate_plan_body_verdict_provenance` fails any `verdicts[].worker` with no matching `<worker>-plan-verify-r<N>-<task-type>-<seq>.md` result file. Recording a `verification-error` for a dispatch that produced no result is the correct way to represent a failed worker — inventing an `AGREE` is a contract violation.
|
|
231
231
|
|
|
232
232
|
4. After all dispatches return, lead aggregates verdicts per `P-*` item across workers and classifies each:
|
|
233
|
+
|
|
234
|
+
**Every item carries at least one verdict row (BLOCKING).** Aggregation covers the whole queue, not the subset that came back cleanly. An item left with an empty `verdicts[]` is not a weak signal the gate can discount — it classifies `all-non-result`, states as `needs-reverify`, and folds into `passed-with-dissent` next to items two verifiers actually agreed on, so a plan item nobody judged reads as a passing one. This is the shape a self-fix round produces when the planner adds an item and the targeted round-N queue never picks it up. A worker that returned nothing is a `verification-error` row (step 3), not a missing row; if an item was never dispatched, dispatch it before scoring the round. **Enforced:** `validators/validate-run.py` `_validate_round_recorded_verdicts` fails any run whose `roundCount` ≥ 1 leaves an extracted item with no verdict row.
|
|
235
|
+
|
|
233
236
|
- `full-consensus` — all participating analysers `AGREE` (SUPPLEMENT counts as agree on the item itself).
|
|
234
237
|
- `partial-consensus` — majority `AGREE`, dissenting `DISAGREE` recorded.
|
|
235
238
|
- `dissent-isolated` — only one worker `DISAGREE`s, others `AGREE` — treat as `partial-consensus` for gate purposes; record dissent. (Distinct from finding-convergence `worker-unique`, which means the *opposite*: only one worker AGREEs. Plan-body classifications use this dedicated label to avoid the collision.)
|
|
@@ -290,7 +293,7 @@ round before any host or provider process starts.
|
|
|
290
293
|
- `Blocks=approval`
|
|
291
294
|
- the item's `planItems[].clarificationId` set to that `C-<N>` (1:1 link). `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item's `clarificationId` is missing, dangling, or points at a non-`approval` row.
|
|
292
295
|
- set `approvalContext.classification` to `user-decision` for a majority `needs-user-input` item, `correctness-critical` for `DISAGREE(a)`, `DISAGREE(f)` on `P-Req-*`, or an independent Requirement Coverage blocker, and `noncritical-dissent` for another surviving majority disagreement.
|
|
293
|
-
- populate `approvalContext.planItemIds`, `activityIds`, `unblockCondition`, and `recommendedDisposition`. Every option carries a `disposition`: `select` only for `user-decision`, `accept-risk` only for `noncritical-dissent`, and `request-revision` / `reject` for any classification. `correctness-critical` never offers or records `accept-risk`. **Enforced:** `validators/validate-run.py` `_validate_approval_context`.
|
|
296
|
+
- populate `approvalContext.planItemIds`, `activityIds`, `unblockCondition`, and `recommendedDisposition`. `planItemIds` carries the **extracted item ids verbatim** — the ordinal form the extractor issues (`P-Opt-1`, `P-Step-1.1`), never the human label the plan prose uses for the same thing ("Option C", "Completion B"). The letter label is what that item's `subject` records (§"subject" above: `P-Opt-1` → "Option A: …"), so cite the id and let the subject carry the name; a label written into `planItemIds` reads as an unknown plan item and fails the run. Every option carries a `disposition`: `select` only for `user-decision`, `accept-risk` only for `noncritical-dissent`, and `request-revision` / `reject` for any classification. `correctness-critical` never offers or records `accept-risk`. **Enforced:** `validators/validate-run.py` `_validate_approval_context`.
|
|
294
297
|
- **Self-fix exhaustion is not risk acceptance.** A `noncritical-dissent` item remains blocking until the user explicitly selects `accept-risk`. Record the user's non-empty original text and the `user-decision-required` / `user-decision-evaluated` activity references in `approvalContext.resolution`; only then does `validators/validate-run.py` `_resolved_noncritical_dissent_ids` let `_is_dissent_downgraded` fold it into `passed-with-dissent`.
|
|
295
298
|
- **Correctness-critical defects cannot be waived.** After the user-directed correction, targeted re-verification of every linked item MUST record only `AGREE` or an acceptable `SUPPLEMENT`, and any independent Requirement Coverage blocker MUST be removed before the row becomes `resolved`. A `DISAGREE` or `verification-error` returns it to `open`. **Enforced:** `validators/validate-run.py` `_validate_correctness_resolution`.
|
|
296
299
|
- When a correctness-critical `planner-fixable` item is promoted, its `Statement` MUST state "planner self-fix attempted but unresolved" and name the stop reason. `validators/validate-run.py` `_validate_self_fix_before_clarification` fails when a planner-fixable majority item is promoted while the budget is not exhausted — it requires `selfFixRoundsApplied >= 1` **and** `selfFixStopReason` in `{no-progress, max-rounds-reached}`, so neither `all-resolved` nor `not-attempted` can excuse a promotion.
|
|
@@ -553,7 +556,7 @@ Changes from the round-1 template:
|
|
|
553
556
|
|
|
554
557
|
`**Prior dissent**: resolved | unresolved | none — <which prior objection, and what the current text does about it>`
|
|
555
558
|
|
|
556
|
-
**Enforced
|
|
559
|
+
**Enforced — on the answer, not on the question.** `validators/validate-run.py` `_validate_reverify_result_addresses_prior_dissent` fails a run whose round 2+ plan-verify **worker results** lack the `**Prior dissent**` line described directly above. Nothing reads the prompt file, so the `**Prior round dissent**` block is a guideline: it is how you make that line answerable, and a worker cannot write it without one. Note the two spellings are different anchors for different artifacts — `**Prior round dissent**` is the block you put in the prompt, `**Prior dissent**` is the line the worker puts in its result, and only the latter is checked (`scripts/okstra_ctl/verdict_blocks.py` parses it as a response field). A prompt that carries the block while the response format omits the line fails this check with the block present.
|
|
557
560
|
|
|
558
561
|
## Worker non-result handling in plan-body round (BLOCKING)
|
|
559
562
|
|
|
@@ -91,7 +91,11 @@ from .initial_prompt_materialization import (
|
|
|
91
91
|
)
|
|
92
92
|
from .agent_invocation import AgentInvocationError, materialize_retry_invocation
|
|
93
93
|
from .path_hints import hydrate_active_run_context
|
|
94
|
-
from .schema_excerpt import
|
|
94
|
+
from .schema_excerpt import (
|
|
95
|
+
bundle_excerpt_path,
|
|
96
|
+
describe_changed,
|
|
97
|
+
excerpt_contract_skew,
|
|
98
|
+
)
|
|
95
99
|
from .seeding import installed_version
|
|
96
100
|
from .report_finalize import (
|
|
97
101
|
STEP_VALIDATE_RUN,
|
|
@@ -2977,11 +2981,17 @@ def _reject_stale_schema_excerpt(
|
|
|
2977
2981
|
schema. A run long enough to straddle a runtime upgrade therefore has the
|
|
2978
2982
|
author writing to one contract and the validator reading another — and the
|
|
2979
2983
|
only thing that noticed was the renderer, in Phase 6, after the worker had
|
|
2980
|
-
authored the whole report. The
|
|
2984
|
+
authored the whole report. The same inputs are already on hand the moment the
|
|
2981
2985
|
dispatch is built, and the remedy is the same either way, so it belongs here.
|
|
2982
2986
|
|
|
2983
2987
|
Only the report writer is stopped: it is the only worker that authors against
|
|
2984
2988
|
the excerpt. Re-running bundle prep re-cuts it from the installed schema.
|
|
2989
|
+
|
|
2990
|
+
The stamp alone is not the test. Most releases change nothing this task-type
|
|
2991
|
+
authors against, and re-prep is not a cheap way to rewrite one stamp line: it
|
|
2992
|
+
opens a new run, so every artifact bound to the current one — convergence
|
|
2993
|
+
state, critic merges, the plan-item queue and its verdicts — is left behind.
|
|
2994
|
+
The guard therefore fires on the contract text and names what moved.
|
|
2985
2995
|
"""
|
|
2986
2996
|
writer = next(
|
|
2987
2997
|
(job for job in jobs if job.worker_id == REPORT_WRITER_WORKER_ID), None
|
|
@@ -2989,21 +2999,24 @@ def _reject_stale_schema_excerpt(
|
|
|
2989
2999
|
if writer is None:
|
|
2990
3000
|
return
|
|
2991
3001
|
expected = _string_value(manifest.get("expectedReportRecordPath"))
|
|
2992
|
-
|
|
3002
|
+
task_type = _string_value(manifest.get("taskType"))
|
|
3003
|
+
if not expected or not task_type:
|
|
2993
3004
|
return
|
|
2994
3005
|
excerpt_path = bundle_excerpt_path(_resolve_project_path(project_root, expected))
|
|
2995
3006
|
if excerpt_path is None:
|
|
2996
3007
|
return
|
|
2997
3008
|
installed = installed_version()
|
|
2998
|
-
|
|
2999
|
-
if
|
|
3009
|
+
skew = excerpt_contract_skew(excerpt_path, task_type, installed)
|
|
3010
|
+
if skew is None:
|
|
3000
3011
|
return
|
|
3001
3012
|
raise DispatchError(
|
|
3002
3013
|
f"the bundle's schema excerpt ({excerpt_path}) was cut from okstra "
|
|
3003
|
-
f"{cut_from}
|
|
3004
|
-
f"
|
|
3005
|
-
f"
|
|
3006
|
-
f"
|
|
3014
|
+
f"{skew.cut_from} and this runtime is {installed}, which states "
|
|
3015
|
+
f"{task_type}'s contract differently: {describe_changed(skew.changed)}. "
|
|
3016
|
+
f"The report writer authors against that excerpt and validation runs "
|
|
3017
|
+
f"against the installed schema, so dispatching now spends a full authoring "
|
|
3018
|
+
f"pass on the wrong contract. Re-prepare the task bundle to re-cut the "
|
|
3019
|
+
f"excerpt, then dispatch again."
|
|
3007
3020
|
)
|
|
3008
3021
|
|
|
3009
3022
|
|
|
@@ -59,7 +59,7 @@ from okstra_ctl.report_contract import (
|
|
|
59
59
|
markdown_template_for,
|
|
60
60
|
)
|
|
61
61
|
from okstra_ctl.report_markdown import ReportSections
|
|
62
|
-
from okstra_ctl.schema_excerpt import
|
|
62
|
+
from okstra_ctl.schema_excerpt import describe_changed, excerpt_contract_skew
|
|
63
63
|
from okstra_ctl.seeding import installed_version
|
|
64
64
|
from okstra_ctl.usage_cells import format_duration_ms, format_int, format_usd
|
|
65
65
|
|
|
@@ -753,34 +753,37 @@ def _bundle_excerpt_path(data_path: Path) -> Path | None:
|
|
|
753
753
|
|
|
754
754
|
|
|
755
755
|
def _with_excerpt_drift_hint(
|
|
756
|
-
exc: FinalReportRenderError, data_path: Path
|
|
756
|
+
exc: FinalReportRenderError, data_path: Path, task_type: str
|
|
757
757
|
) -> FinalReportRenderError:
|
|
758
|
-
"""Name the
|
|
758
|
+
"""Name the contract drift behind a schema failure, when that is the cause.
|
|
759
759
|
|
|
760
760
|
A long run straddles its own runtime upgrade: the report-writer authors
|
|
761
761
|
against the excerpt frozen into the bundle at prep time, while the renderer
|
|
762
762
|
validates against the installed schema. Without this the author sees only
|
|
763
763
|
`additional property ... not allowed` for a field the excerpt told it to
|
|
764
764
|
write, and has no way to tell a real mistake from a stale bundle.
|
|
765
|
+
|
|
766
|
+
An older stamp is not itself the cause. When the excerpt still states this
|
|
767
|
+
task-type's contract exactly, the failure came from somewhere else, and
|
|
768
|
+
pointing at the version would send the author to re-prepare a bundle that
|
|
769
|
+
was never wrong. The hint is attached only when a contract member actually
|
|
770
|
+
moved, and it names which.
|
|
765
771
|
"""
|
|
766
772
|
if "schema validation" not in str(exc):
|
|
767
773
|
return exc
|
|
768
774
|
excerpt_path = _bundle_excerpt_path(data_path)
|
|
769
|
-
if excerpt_path is None:
|
|
770
|
-
return exc
|
|
771
|
-
try:
|
|
772
|
-
cut_from = excerpt_cut_from_version(
|
|
773
|
-
json.loads(excerpt_path.read_text(encoding="utf-8"))
|
|
774
|
-
)
|
|
775
|
-
except (OSError, json.JSONDecodeError):
|
|
775
|
+
if excerpt_path is None or not task_type:
|
|
776
776
|
return exc
|
|
777
777
|
current = installed_version()
|
|
778
|
-
|
|
778
|
+
skew = excerpt_contract_skew(excerpt_path, task_type, current)
|
|
779
|
+
if skew is None:
|
|
779
780
|
return exc
|
|
780
781
|
return FinalReportRenderError(
|
|
781
782
|
f"{exc} — the bundle's schema excerpt ({excerpt_path}) was cut from okstra "
|
|
782
|
-
f"{cut_from} but validation ran on {current}
|
|
783
|
-
"
|
|
783
|
+
f"{skew.cut_from} but validation ran on {current}, which states "
|
|
784
|
+
f"{task_type}'s contract differently: {describe_changed(skew.changed)}; "
|
|
785
|
+
"author against the installed schema, not the excerpt, or re-prepare the "
|
|
786
|
+
"bundle."
|
|
784
787
|
)
|
|
785
788
|
|
|
786
789
|
|
|
@@ -815,7 +818,9 @@ def render_to_file(
|
|
|
815
818
|
template_path=resolved_template,
|
|
816
819
|
)
|
|
817
820
|
except FinalReportRenderError as exc:
|
|
818
|
-
|
|
821
|
+
header = data.get("header") if isinstance(data, dict) else None
|
|
822
|
+
task_type = header.get("taskType") if isinstance(header, dict) else ""
|
|
823
|
+
raise _with_excerpt_drift_hint(exc, data_path, str(task_type or "")) from exc
|
|
819
824
|
|
|
820
825
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
821
826
|
tmp = output_path.with_suffix(output_path.suffix + f".tmp.{os.getpid()}")
|
|
@@ -23,9 +23,12 @@ from __future__ import annotations
|
|
|
23
23
|
|
|
24
24
|
import json
|
|
25
25
|
import re
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
26
28
|
from pathlib import Path
|
|
27
29
|
|
|
28
|
-
from .
|
|
30
|
+
from .final_report_schema import load_schema_version
|
|
31
|
+
from .report_contract import CURRENT_REPORT_SCHEMA_VERSION, TASK_TYPE_DATA_PROPERTY
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
_ALL_PER_TYPE_PROPERTIES = frozenset(TASK_TYPE_DATA_PROPERTY.values())
|
|
@@ -79,28 +82,100 @@ def excerpt_cut_from_version(excerpt: dict) -> str:
|
|
|
79
82
|
return value if isinstance(value, str) else ""
|
|
80
83
|
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ExcerptSkew:
|
|
87
|
+
"""A bundle excerpt whose contract text differs from the installed one.
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
``cut_from`` is the older okstra version stamped into the bundle;
|
|
90
|
+
``changed`` names the excerpt members that differ, as ``properties.<name>``
|
|
91
|
+
/ ``$defs.<name>`` / a bare top-level key.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
cut_from: str
|
|
95
|
+
changed: tuple[str, ...]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _changed_members(section: str, old: object, fresh: object) -> list[str]:
|
|
99
|
+
old_map = old if isinstance(old, dict) else {}
|
|
100
|
+
fresh_map = fresh if isinstance(fresh, dict) else {}
|
|
101
|
+
return [
|
|
102
|
+
f"{section}.{name}"
|
|
103
|
+
for name in sorted(set(old_map) | set(fresh_map))
|
|
104
|
+
if old_map.get(name) != fresh_map.get(name)
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _changed_excerpt_members(old: dict, fresh: dict) -> tuple[str, ...]:
|
|
109
|
+
"""Every member of *old* that *fresh* states differently.
|
|
110
|
+
|
|
111
|
+
The stamp is excluded by definition — it is the thing that differs whenever
|
|
112
|
+
the two were cut by different runtimes, and it says nothing about what the
|
|
113
|
+
author is being told to write.
|
|
114
|
+
"""
|
|
115
|
+
changed = []
|
|
116
|
+
for section in ("properties", "$defs"):
|
|
117
|
+
changed.extend(_changed_members(section, old.get(section), fresh.get(section)))
|
|
118
|
+
changed.extend(
|
|
119
|
+
key
|
|
120
|
+
for key in sorted(set(old) | set(fresh))
|
|
121
|
+
if key not in ("properties", "$defs", EXCERPT_VERSION_KEY)
|
|
122
|
+
and old.get(key) != fresh.get(key)
|
|
123
|
+
)
|
|
124
|
+
return tuple(changed)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def describe_changed(changed: Sequence[str], limit: int = 5) -> str:
|
|
128
|
+
"""A one-clause rendering of :attr:`ExcerptSkew.changed` for an error message."""
|
|
129
|
+
if not changed:
|
|
130
|
+
return "no contract member differs"
|
|
131
|
+
head = ", ".join(f"`{name}`" for name in changed[:limit])
|
|
132
|
+
rest = len(changed) - limit
|
|
133
|
+
return f"{head} and {rest} more" if rest > 0 else head
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def excerpt_contract_skew(
|
|
137
|
+
excerpt_path: Path, task_type: str, installed: str
|
|
138
|
+
) -> ExcerptSkew | None:
|
|
139
|
+
"""What this task-type's contract states differently since the bundle was cut.
|
|
140
|
+
|
|
141
|
+
``None`` means the bundle excerpt still states the installed contract, and a
|
|
142
|
+
caller that blocks on skew must let the run through. Four ways to get it:
|
|
143
|
+
the file is absent or unreadable, it carries no stamp, the stamp matches, or
|
|
144
|
+
— the case a version comparison gets wrong — the stamp is older but every
|
|
145
|
+
contract member is identical because the release changed nothing this
|
|
146
|
+
task-type authors against. A patch release that touches only dispatch or
|
|
147
|
+
wizard code lands in that fourth case, and blocking an in-flight run on it
|
|
148
|
+
costs a full bundle re-prep to rewrite one stamp line.
|
|
149
|
+
|
|
150
|
+
The installed schema is loaded here rather than passed in, so a caller only
|
|
151
|
+
needs the excerpt path and the manifest's task-type. An install too old to
|
|
152
|
+
carry `schemas/` cannot be compared at all: that returns ``None`` too, since
|
|
153
|
+
an unprovable drift is not grounds to stop a dispatch.
|
|
88
154
|
|
|
89
155
|
The comparison used to live only in the renderer's error decorator, so it ran
|
|
90
156
|
in Phase 6 — after a worker had already authored a whole report against a
|
|
91
|
-
stale excerpt. The same
|
|
157
|
+
stale excerpt. The same inputs are available much earlier, and the fix
|
|
92
158
|
(re-prepare the bundle) is the same either way.
|
|
93
159
|
"""
|
|
94
160
|
if not installed:
|
|
95
|
-
return
|
|
161
|
+
return None
|
|
96
162
|
try:
|
|
97
163
|
excerpt = json.loads(excerpt_path.read_text(encoding="utf-8"))
|
|
98
164
|
except (OSError, json.JSONDecodeError):
|
|
99
|
-
return
|
|
165
|
+
return None
|
|
100
166
|
if not isinstance(excerpt, dict):
|
|
101
|
-
return
|
|
167
|
+
return None
|
|
102
168
|
cut_from = excerpt_cut_from_version(excerpt)
|
|
103
|
-
|
|
169
|
+
if not cut_from or cut_from == installed:
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
fresh = build_schema_excerpt(
|
|
173
|
+
load_schema_version(CURRENT_REPORT_SCHEMA_VERSION), task_type, installed
|
|
174
|
+
)
|
|
175
|
+
except Exception: # noqa: BLE001 — an unloadable schema proves no drift
|
|
176
|
+
return None
|
|
177
|
+
changed = _changed_excerpt_members(excerpt, fresh)
|
|
178
|
+
return ExcerptSkew(cut_from, changed) if changed else None
|
|
104
179
|
|
|
105
180
|
|
|
106
181
|
def bundle_excerpt_path(start: Path) -> Path | None:
|
|
@@ -4001,7 +4001,14 @@ def _recompute_plan_body_gate(
|
|
|
4001
4001
|
return "aborted-non-result"
|
|
4002
4002
|
if any(c == "majority-disagree" for c in classes):
|
|
4003
4003
|
return "blocked-by-disagreement"
|
|
4004
|
-
if any(c in ("has-dissent", "needs-reverify") for c in classes):
|
|
4004
|
+
if any(c in ("has-dissent", "needs-reverify", "all-non-result") for c in classes):
|
|
4005
|
+
# `all-non-result` belongs here for the same reason `needs-reverify`
|
|
4006
|
+
# does — it IS that shape with zero participating votes instead of one
|
|
4007
|
+
# (`_state_classification` maps it there, and the contract's step 5
|
|
4008
|
+
# lists `needs-reverify` under `passed-with-dissent`). Left out, an
|
|
4009
|
+
# item no verifier could judge scored `passed`: the all-error case
|
|
4010
|
+
# already reads `needs-reverify` in the state file while the gate it
|
|
4011
|
+
# feeds says every item reached consensus.
|
|
4005
4012
|
return "passed-with-dissent"
|
|
4006
4013
|
return "passed"
|
|
4007
4014
|
|
|
@@ -4448,10 +4455,18 @@ def _validate_approval_activity_refs(
|
|
|
4448
4455
|
}
|
|
4449
4456
|
unknown_activity_ids = sorted(activity_ids - set(activities))
|
|
4450
4457
|
if not activity_ids or unknown_activity_ids:
|
|
4458
|
+
# `recorded` names what the ACTIVITY LEDGER holds, not what this row
|
|
4459
|
+
# cited. Printing the row's own ids put the same value on both sides —
|
|
4460
|
+
# `unknown=['A-501'], recorded=['A-501']` — and hid the actual cause,
|
|
4461
|
+
# which is an empty `agentActivity[]` (the run never called
|
|
4462
|
+
# `okstra agent-activity append`). A reader cannot reach that from a
|
|
4463
|
+
# message that contradicts itself.
|
|
4464
|
+
recorded = sorted(activities)
|
|
4451
4465
|
failures.append(
|
|
4452
4466
|
f"final-report data.json: approval clarification `{row_id}` activityIds "
|
|
4453
4467
|
f"must reference agentActivity[].activityId values; unknown="
|
|
4454
|
-
f"{unknown_activity_ids or 'none'}, recorded=
|
|
4468
|
+
f"{unknown_activity_ids or 'none'}, recorded in agentActivity[]="
|
|
4469
|
+
f"{recorded or 'none — the activity ledger is empty'}."
|
|
4455
4470
|
)
|
|
4456
4471
|
elif not all(
|
|
4457
4472
|
_is_decision_required_activity(activities.get(ref)) for ref in activity_ids
|
|
@@ -4913,9 +4928,17 @@ def _validate_approval_context(
|
|
|
4913
4928
|
historical_coverage_ids,
|
|
4914
4929
|
activities,
|
|
4915
4930
|
):
|
|
4931
|
+
# 사람이 읽는 옵션 라벨(`P-Opt-C`)을 ID 로 착각하는 경우가 여기로
|
|
4932
|
+
# 온다. ID 는 추출기가 발행한 서수(`P-Opt-1`)이고, 문자 라벨은 그
|
|
4933
|
+
# 항목의 `subject` 가 나른다. 무엇을 써야 하는지 알려 주지 않으면
|
|
4934
|
+
# 작성자가 두 체계 사이에서 되돌아갈 곳이 없다.
|
|
4916
4935
|
failures.append(
|
|
4917
4936
|
f"final-report data.json: approval clarification `{row_id}` planItemIds "
|
|
4918
|
-
f"reference unknown plan items {sorted(unknown_ids)}."
|
|
4937
|
+
f"reference unknown plan items {sorted(unknown_ids)}. Use the "
|
|
4938
|
+
"extracted item id verbatim (ordinal, e.g. `P-Opt-1`) — the human "
|
|
4939
|
+
"label it carries (\"Option C\") lives in that item's `subject`, "
|
|
4940
|
+
f"not in its id. Extracted ids in this report: "
|
|
4941
|
+
f"{sorted(plan_items_by_id)[:12] or 'none'}."
|
|
4919
4942
|
)
|
|
4920
4943
|
linked_items = [
|
|
4921
4944
|
plan_items_by_id[item_id]
|
|
@@ -6606,7 +6629,7 @@ def _validate_stage_carry_sidecar_exists(
|
|
|
6606
6629
|
|
|
6607
6630
|
|
|
6608
6631
|
def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
6609
|
-
"""A round that ran must leave the votes it ran on.
|
|
6632
|
+
"""A round that ran must leave the votes it ran on — item by item.
|
|
6610
6633
|
|
|
6611
6634
|
The gate is re-derived from `planItems[].verdicts[]`, so an empty table
|
|
6612
6635
|
removes the very evidence the recompute judges. A *healthier* declared gate
|
|
@@ -6615,6 +6638,22 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
|
6615
6638
|
declaration: a lead writing `aborted-non-result` over an empty table
|
|
6616
6639
|
produces a gate nothing can audit, indistinguishable from a round that was
|
|
6617
6640
|
dispatched and whose results were never transcribed.
|
|
6641
|
+
|
|
6642
|
+
The per-item form is what survives a self-fix loop. Round 2+ queues are
|
|
6643
|
+
targeted, so an item the planner adds mid-loop and never puts in one keeps
|
|
6644
|
+
an empty `verdicts[]` while every neighbour carries votes — and nothing
|
|
6645
|
+
downstream reads that as a gap. An empty table classifies `all-non-result`
|
|
6646
|
+
(`_classify_plan_item_gate`), which states as `needs-reverify`, which
|
|
6647
|
+
`_recompute_plan_body_gate` folds into `passed-with-dissent`: a plan item
|
|
6648
|
+
no verifier ever judged leaves the gate in a passing value. The whole-table
|
|
6649
|
+
check could not see it, since it stands down the moment any one item has a
|
|
6650
|
+
vote.
|
|
6651
|
+
|
|
6652
|
+
An unjudged item is distinguishable from a legitimately unresolved one, and
|
|
6653
|
+
the difference is what is recorded rather than what is missing. A peer that
|
|
6654
|
+
returned nothing is a `verification-error` VOTE (§"Round protocol" step 3),
|
|
6655
|
+
so an all-error item still carries rows and still folds to `needs-reverify`
|
|
6656
|
+
on purpose. An empty table means no dispatch was accounted for at all.
|
|
6618
6657
|
"""
|
|
6619
6658
|
ip = data.get("implementationPlanning")
|
|
6620
6659
|
if not isinstance(ip, dict):
|
|
@@ -6628,16 +6667,33 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
|
6628
6667
|
items = [it for it in (pbv.get("planItems") or []) if isinstance(it, dict)]
|
|
6629
6668
|
if not items:
|
|
6630
6669
|
return
|
|
6631
|
-
|
|
6670
|
+
empty = [str(it.get("id") or "<unnamed>") for it in items if not it.get("verdicts")]
|
|
6671
|
+
if not empty:
|
|
6672
|
+
return
|
|
6673
|
+
if len(empty) == len(items):
|
|
6674
|
+
failures.append(
|
|
6675
|
+
"final-report data.json: planBodyVerification declares "
|
|
6676
|
+
f"`roundCount`={round_count} but every one of the {len(items)} "
|
|
6677
|
+
"`planItems[]` carries an empty `verdicts[]`. A round that ran MUST "
|
|
6678
|
+
"record the votes it produced — the gate is re-derived from this "
|
|
6679
|
+
"table, so an empty one leaves the declared `gateResult` unauditable. "
|
|
6680
|
+
"A dispatch that returned nothing is recorded as `verification-error`, "
|
|
6681
|
+
'not omitted (plan-body-verification.md §"Round protocol" step 4).'
|
|
6682
|
+
)
|
|
6632
6683
|
return
|
|
6684
|
+
shown = ", ".join(f"`{item_id}`" for item_id in empty[:5])
|
|
6685
|
+
more = f" and {len(empty) - 5} more" if len(empty) > 5 else ""
|
|
6633
6686
|
failures.append(
|
|
6634
6687
|
"final-report data.json: planBodyVerification declares "
|
|
6635
|
-
f"`roundCount`={round_count} but
|
|
6636
|
-
"`planItems[]`
|
|
6637
|
-
"
|
|
6638
|
-
"
|
|
6639
|
-
"
|
|
6640
|
-
|
|
6688
|
+
f"`roundCount`={round_count} but {len(empty)} of {len(items)} "
|
|
6689
|
+
f"`planItems[]` carry an empty `verdicts[]`: {shown}{more}. Every "
|
|
6690
|
+
"extracted plan item MUST be judged by the round — an item with no "
|
|
6691
|
+
"vote at all is not a dissent the gate can weigh, it is a plan item "
|
|
6692
|
+
"nobody verified, and it currently folds into `passed-with-dissent` "
|
|
6693
|
+
"alongside items that were properly cross-checked. Either dispatch it "
|
|
6694
|
+
"in this round's queue, or record the non-result as a "
|
|
6695
|
+
"`verification-error` verdict per plan-body-verification.md "
|
|
6696
|
+
'§"Round protocol" step 3 — an item is never left with no row.'
|
|
6641
6697
|
)
|
|
6642
6698
|
|
|
6643
6699
|
|