@christang/keel 5.62.0 → 5.64.0

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.
@@ -38,8 +38,8 @@ REQUIRED_SCRIPTS = [
38
38
  "scripts/validate_plugin.py",
39
39
  ]
40
40
 
41
- PACKAGE_VERSION = "5.62.0"
42
- PROTOCOL_VERSION = "5.62.0"
41
+ PACKAGE_VERSION = "5.64.0"
42
+ PROTOCOL_VERSION = "5.64.0"
43
43
  LEGACY_MANAGED_START = "<!-- keel:start version=2.1 -->"
44
44
  OPENSPEC_SCHEMA_NAME = "keel-spec-driven"
45
45
  # Mirrors KEEL_PACKAGE_NAME in scripts/install_to_repo.py, one of the two
@@ -90,6 +90,28 @@ MANAGED_END = "<!-- keel:end -->"
90
90
  TEMPLATE_CHECKSUM_PREFIX = "<!-- keel:content-sha256 "
91
91
  TEMPLATE_CHECKSUM_SUFFIX = " -->"
92
92
 
93
+ # The installed bootstrap block's byte budget, asserted by `thin-native-install`
94
+ # and `delegation-resident-text`. Raised from 1024 to 1400 in 5.63.0 to carry the
95
+ # Full/Lite routing rule (#131), deliberately and in a diff.
96
+ #
97
+ # The reason has to live here, because the cap's purpose is that the budget is
98
+ # **not quietly spent later** — the adjective is the whole specification. A cap
99
+ # phrased that way is not a claim that the number is right forever; it is a claim
100
+ # that nobody moves it without a reader noticing. So a documented raise satisfies
101
+ # it and a bare larger constant does not.
102
+ #
103
+ # Why routing earned it, when delegation did not: delegation is inert until a
104
+ # project declares it, so a repository declaring nothing was already served by the
105
+ # sentence in the block. Routing is never inert — every session routes, declared
106
+ # or not — and it was the one durable rule with no gate, no command, and no
107
+ # declaration carrying it to the agent. The rejected alternative was compressing
108
+ # the existing bullets to fit under 1024, which would have gone green while paying
109
+ # for routing out of gate-discipline prose that was already earning its place.
110
+ #
111
+ # 1400 rather than a round 1536: the headroom is "current content plus one short
112
+ # clause", so the next addition has to argue for itself the way this one did.
113
+ BOOTSTRAP_BLOCK_BYTE_BUDGET = 1400
114
+
93
115
  RESIDENT_BLOCKS = [
94
116
  {
95
117
  "name": "Bootstrap resident block",
@@ -110,6 +132,11 @@ RESIDENT_BLOCKS = [
110
132
  "read-only report/evidence",
111
133
  "native plugin",
112
134
  "keel --init",
135
+ # Routing is the one durable rule with no gate behind it, so the
136
+ # block is the only place it reaches a consuming repository (#131).
137
+ # Matched as a topic: both modes named in one statement.
138
+ re.compile(r"\bFull\b[^\n]*\bLite\b", re.IGNORECASE),
139
+ "full_mode_paths",
113
140
  ],
114
141
  },
115
142
  ]
@@ -18065,11 +18092,13 @@ def validate_delegation_resident_text_scenario() -> int:
18065
18092
  return 1
18066
18093
 
18067
18094
  # M1 — the consumer bootstrap deliberately does NOT carry the delegation
18068
- # clause. Its block has a sub-1KB budget with 11 bytes of headroom, and
18069
- # delegation is inert until declared, so an installing repository that
18070
- # declares nothing is fully served by the sentence already there. What the
18071
- # check enforces is that the sentence stays true by default, and that the
18072
- # budget is not quietly spent later.
18095
+ # clause. Its block has a byte budget (BOOTSTRAP_BLOCK_BYTE_BUDGET) with
18096
+ # little headroom, and delegation is inert until declared, so an installing
18097
+ # repository that declares nothing is fully served by the sentence already
18098
+ # there. What the check enforces is that the sentence stays true by default,
18099
+ # and that the budget is not quietly spent later — the budget moved once, in
18100
+ # 5.63.0 for the routing rule (#131), with the argument recorded at the
18101
+ # constant; a raise with no reason beside it is the drift this guards.
18073
18102
  bootstrap = ROOT / "assets/bootstrap/AGENTS.md"
18074
18103
  boot = flat(bootstrap)
18075
18104
  if re.sub(r"\s+", " ", "One current agent owns writes") not in boot:
@@ -18078,18 +18107,21 @@ def validate_delegation_resident_text_scenario() -> int:
18078
18107
  block = bootstrap.read_text(encoding="utf-8")
18079
18108
  body = block.split("<!-- keel:start", 1)[1].split("<!-- keel:end -->", 1)[0]
18080
18109
  size = len(("<!-- keel:start" + body + "<!-- keel:end -->").encode())
18081
- if size >= 1024:
18082
- report(f"delegation-resident-text: the bootstrap block is {size} bytes, over its 1KB budget.")
18110
+ if size >= BOOTSTRAP_BLOCK_BYTE_BUDGET:
18111
+ report(
18112
+ f"delegation-resident-text: the bootstrap block is {size} bytes, "
18113
+ f"over its {BOOTSTRAP_BLOCK_BYTE_BUDGET}-byte budget."
18114
+ )
18083
18115
  return 1
18084
18116
 
18085
18117
  # M1 — the config header counts its declarations correctly.
18086
18118
  config = ROOT / "keel/config.yaml"
18087
18119
  cfg = flat(config)
18088
- if re.sub(r"\s+", " ", "Four independent declarations") in cfg:
18089
- report("delegation-resident-text: the config header still says four declarations.")
18120
+ if re.sub(r"\s+", " ", "Five independent declarations") in cfg:
18121
+ report("delegation-resident-text: the config header still says five declarations.")
18090
18122
  return 1
18091
- if re.sub(r"\s+", " ", "Five independent declarations") not in cfg:
18092
- report("delegation-resident-text: the config header does not name five declarations.")
18123
+ if re.sub(r"\s+", " ", "Six independent declarations") not in cfg:
18124
+ report("delegation-resident-text: the config header does not name six declarations.")
18093
18125
  return 1
18094
18126
  if "delegation" not in cfg:
18095
18127
  report("delegation-resident-text: the config header does not document delegation.")
@@ -21097,10 +21129,10 @@ def validate_thin_native_install_scenario() -> int:
21097
21129
  return 1
21098
21130
  block = extract_managed_block(agents_text)
21099
21131
  block_bytes = len(block.encode("utf-8"))
21100
- if block_bytes >= 1024:
21132
+ if block_bytes >= BOOTSTRAP_BLOCK_BYTE_BUDGET:
21101
21133
  report(
21102
- "thin-native-install bootstrap block is not sub-1KB: "
21103
- f"{block_bytes} bytes"
21134
+ "thin-native-install bootstrap block is over its "
21135
+ f"{BOOTSTRAP_BLOCK_BYTE_BUDGET}-byte budget: {block_bytes} bytes"
21104
21136
  )
21105
21137
  return 1
21106
21138
 
@@ -27784,6 +27816,649 @@ def validate_an_authorization_names_its_repository_scenario() -> int:
27784
27816
  return 0
27785
27817
 
27786
27818
 
27819
+ def validate_the_routing_rule_reaches_the_decision_scenario() -> int:
27820
+ """Issue #131: routing is the one durable rule with nowhere to reach the agent.
27821
+
27822
+ The block `keel --init` installs is five bullets and says nothing about Full
27823
+ or Lite; routing has no implementation at all, only four lines of prose in
27824
+ Keel's own README. So the first decision of every session is taken without
27825
+ the rule, and a project whose risk does not track diff size has no way to say
27826
+ so. The declaration is one-directional on purpose: it can raise the process
27827
+ floor and there is no key that lowers it.
27828
+ """
27829
+ label = "the-routing-rule-reaches-the-decision"
27830
+ declared = "results/experiments.jsonl"
27831
+ reason = "append-only; a one-field diff is not revertible"
27832
+
27833
+ with tempfile.TemporaryDirectory(prefix="keel-routing-") as raw:
27834
+ root = Path(raw)
27835
+
27836
+ def fixture(name: str, body: str | None) -> Path:
27837
+ repo = root / name
27838
+ repo.mkdir()
27839
+ if body is not None:
27840
+ (repo / "keel").mkdir()
27841
+ (repo / "keel" / "config.yaml").write_text(body, encoding="utf-8")
27842
+ return repo
27843
+
27844
+ def context(repo: Path) -> str:
27845
+ return run_keel(repo, "context").stdout
27846
+
27847
+ # M1 — a declared path is reported with the reason it carries, and the
27848
+ # projection is otherwise the one it would have printed anyway.
27849
+ plain = fixture("plain", "fast_check: echo plain\n")
27850
+ declaring = fixture(
27851
+ "declaring",
27852
+ f"fast_check: echo plain\nfull_mode_paths:\n - {declared}: {reason}\n",
27853
+ )
27854
+ out = context(declaring)
27855
+ if declared not in out:
27856
+ report(
27857
+ f"{label}: no routing line — a repository that declared "
27858
+ f"{declared!r} is told nothing about it at the one moment the "
27859
+ "routing decision is made."
27860
+ )
27861
+ report(out)
27862
+ return 1
27863
+ if reason not in out:
27864
+ report(
27865
+ f"{label}: the declared path is reported without its reason, so "
27866
+ "the agent learns the file is special and not what makes it so."
27867
+ )
27868
+ report(out)
27869
+ return 1
27870
+ # D5 — the fixture creates no file at that path, on purpose.
27871
+ if (declaring / declared).exists():
27872
+ report(f"{label}: the fixture created the declared path; D5 is untested.")
27873
+ return 1
27874
+ baseline = [
27875
+ line
27876
+ for line in context(plain).splitlines()
27877
+ if line.startswith(("Keel context:", "Next action:"))
27878
+ ]
27879
+ moved = [
27880
+ line
27881
+ for line in out.splitlines()
27882
+ if line.startswith(("Keel context:", "Next action:"))
27883
+ ]
27884
+ if baseline != moved:
27885
+ report(
27886
+ f"{label}: the declaration moved the projection's status or next "
27887
+ f"action; {baseline!r} became {moved!r}."
27888
+ )
27889
+ return 1
27890
+
27891
+ # M2 — an entry with no reason is not a declaration.
27892
+ bare = fixture(
27893
+ "bare", f"full_mode_paths:\n - {declared}\n"
27894
+ )
27895
+ out = context(bare)
27896
+ # Behavior before message: what D2 forbids is the entry counting as a
27897
+ # declaration, and whether it is also reported well comes after that.
27898
+ routing_lines = [
27899
+ line for line in out.splitlines() if line.startswith("Routing:")
27900
+ ]
27901
+ if any(declared in line for line in routing_lines):
27902
+ report(
27903
+ f"{label}: the reason-less entry was read as a declaration; "
27904
+ f"{routing_lines!r}"
27905
+ )
27906
+ return 1
27907
+ if "full_mode_paths" not in out or declared not in out:
27908
+ report(
27909
+ f"{label}: an entry with no reason was dropped silently; the "
27910
+ "author is left believing they declared what they typed."
27911
+ )
27912
+ report(out)
27913
+ return 1
27914
+ if "<path>: <reason>" not in out:
27915
+ report(
27916
+ f"{label}: the refusal does not name the form the entry needs."
27917
+ )
27918
+ report(out)
27919
+ return 1
27920
+
27921
+ # 1.2 — a declaration Keel cannot fully read raises the floor rather
27922
+ # than dropping it. `authorize:` and `triage:` fail closed and closed
27923
+ # means *less proceeds without a human*; for a declaration whose purpose
27924
+ # is to add process, the same principle is more Full mode, not less.
27925
+ mixed = fixture(
27926
+ "mixed",
27927
+ f"full_mode_paths:\n - {declared}: {reason}\n - src/lib.js\n",
27928
+ )
27929
+ out = context(mixed)
27930
+ routing_lines = [
27931
+ line for line in out.splitlines() if line.startswith("Routing:")
27932
+ ]
27933
+ if not any("every change" in line.lower() for line in routing_lines):
27934
+ report(
27935
+ f"{label}: routes only the entries it could read — a "
27936
+ "declaration Keel half-read was treated as the policy, so a "
27937
+ "typo silently lowers the process floor. {0!r}".format(
27938
+ routing_lines
27939
+ )
27940
+ )
27941
+ report(out)
27942
+ return 1
27943
+ if any(declared in line for line in routing_lines):
27944
+ report(
27945
+ f"{label}: the readable entry was reported as the declared set "
27946
+ "beside an unreadable one, so half a declaration acted as a "
27947
+ f"whole one; {routing_lines!r}"
27948
+ )
27949
+ return 1
27950
+ if "src/lib.js" not in out:
27951
+ report(f"{label}: the unreadable entry is not named, so the state is not attributable.")
27952
+ report(out)
27953
+ return 1
27954
+ doctor = run_keel(mixed, "--doctor").stdout
27955
+ if "full_mode_paths: failed" not in doctor or "src/lib.js" not in doctor:
27956
+ report(
27957
+ f"{label}: the doctor does not report the declaration as failed "
27958
+ "and name the entry, so it reports health the projection "
27959
+ "contradicts."
27960
+ )
27961
+ report(doctor)
27962
+ return 1
27963
+ # The conservative branch is reached by an unreadable declaration, not
27964
+ # by any declaration at all.
27965
+ if "every change" in context(declaring).lower():
27966
+ report(
27967
+ f"{label}: a readable declaration reported the "
27968
+ "everything-routes-Full state."
27969
+ )
27970
+ return 1
27971
+
27972
+ # M3 — the common case pays nothing.
27973
+ for name, body in (
27974
+ ("absent", None),
27975
+ ("blockless", "fast_check: echo blockless\n"),
27976
+ ("empty", "fast_check: echo empty\nfull_mode_paths:\n"),
27977
+ ):
27978
+ repo = fixture(name, body)
27979
+ out = context(repo)
27980
+ if "Routing:" in out or "full_mode_paths" in out:
27981
+ report(
27982
+ f"{label}: the {name} repository was told about a routing "
27983
+ "declaration it does not have."
27984
+ )
27985
+ report(out)
27986
+ return 1
27987
+
27988
+ # 1.3 — the rule itself, in the artifact `keel --init` installs. Asserted on
27989
+ # the shipped block rather than on a fixture: this is the file a consuming
27990
+ # repository receives, and its two budgets are what keep it resident.
27991
+ bootstrap_path = ROOT / "assets/bootstrap/AGENTS.md"
27992
+ bootstrap = bootstrap_path.read_text(encoding="utf-8")
27993
+ for needle, why in (
27994
+ ("Full", "the block must name the complete flow"),
27995
+ ("Lite", "the block must name the local flow"),
27996
+ ("100 lines", "the block must carry the size heuristic it is correcting"),
27997
+ ("full_mode_paths", "the block must say a project can declare exceptions"),
27998
+ ):
27999
+ if needle not in bootstrap:
28000
+ report(
28001
+ f"{label}: bootstrap states no routing rule — {why}; "
28002
+ f"{needle!r} is absent. Routing is the first decision of a "
28003
+ "session and the block a consuming repository receives is "
28004
+ "where it has to arrive."
28005
+ )
28006
+ return 1
28007
+ # Both budgets. The line one runs the check the suite enforces rather than
28008
+ # counting here; the byte one is asserted at its raised value.
28009
+ budget_errors: list[str] = []
28010
+ validate_resident_blocks(budget_errors)
28011
+ if budget_errors:
28012
+ report(f"{label}: the routing line broke the resident block line budget.")
28013
+ for error in budget_errors:
28014
+ report(f"- {error}")
28015
+ return 1
28016
+ body = bootstrap.split("<!-- keel:start", 1)[1].split("<!-- keel:end -->", 1)[0]
28017
+ block_bytes = len(("<!-- keel:start" + body + "<!-- keel:end -->").encode())
28018
+ if block_bytes >= BOOTSTRAP_BLOCK_BYTE_BUDGET:
28019
+ report(
28020
+ f"{label}: the block is {block_bytes} bytes, over its "
28021
+ f"{BOOTSTRAP_BLOCK_BYTE_BUDGET}-byte budget."
28022
+ )
28023
+ return 1
28024
+
28025
+ # The raise is only authorized because it is loud: a larger constant with no
28026
+ # argument beside it is exactly the quiet drift the cap was built to stop,
28027
+ # and it would pass every check above. So both assertions of the cap must
28028
+ # carry the reason it moved, not just the number.
28029
+ suite = (ROOT / "scripts/validate_plugin.py").read_text(encoding="utf-8")
28030
+ for marker in ("thin-native-install bootstrap block", "over its"):
28031
+ start = suite.find(marker)
28032
+ if start < 0:
28033
+ report(f"{label}: the byte-cap assertion {marker!r} is gone.")
28034
+ return 1
28035
+ # The rationale for a constant in this file lives in the comment block above
28036
+ # it, so that is where it is read from — walking back over the contiguous
28037
+ # comment lines rather than guessing a window.
28038
+ lines = suite.splitlines()
28039
+ at = next(
28040
+ i for i, line in enumerate(lines)
28041
+ if line.startswith("BOOTSTRAP_BLOCK_BYTE_BUDGET =")
28042
+ )
28043
+ top = at
28044
+ while top > 0 and lines[top - 1].startswith("#"):
28045
+ top -= 1
28046
+ rationale = "\n".join(lines[top:at + 1])
28047
+ for needle in ("routing", "not quietly"):
28048
+ if needle not in rationale:
28049
+ report(
28050
+ f"{label}: names the new cap without the reason it moved; "
28051
+ f"{needle!r} is absent from the constant's own rationale. A "
28052
+ "reader inheriting a larger number and no argument is the drift "
28053
+ "the cap exists to prevent."
28054
+ )
28055
+ return 1
28056
+
28057
+ if label not in {name for name, _ in SCENARIOS}:
28058
+ report(f"{label}: the scenario registry does not include it.")
28059
+ return 1
28060
+ report(f"{label} scenario passed.")
28061
+ return 0
28062
+
28063
+
28064
+ def validate_a_claim_names_what_would_falsify_it_scenario() -> int:
28065
+ """Issue #132: an honest red does not mean the check can catch the defect.
28066
+
28067
+ A consistency check whose red was real, whose signature predicted it, and
28068
+ whose green was real stayed green through a 1000x unit error — the one thing
28069
+ it existed to catch — because `pytest.approx` carries a default absolute
28070
+ tolerance. `.red` proves the check failed before the implementation existed
28071
+ and `Fails with:` predicts the red of an *absent* feature; neither says
28072
+ anything about the red of a *broken* one. `Detects:` declares the injection
28073
+ that answers the third question, in the same shape: inside the check text, so
28074
+ it enters the fingerprint, and enforced by requiring its literal in Evidence.
28075
+ """
28076
+ label = "a-claim-names-what-would-falsify-it"
28077
+ mutation = "sed -i s/0.0005/0.5/ run_sta.py"
28078
+ failure = "assert 5e-16 == 5e-13"
28079
+
28080
+ with tempfile.TemporaryDirectory(prefix="keel-detects-") as raw:
28081
+ root = Path(raw)
28082
+
28083
+ def start(name: str, **kwargs) -> dict:
28084
+ return strategy_probe_start(root, name, strategy_probe_task(**kwargs))
28085
+
28086
+ def commands_of(payload: dict) -> list[dict]:
28087
+ contract = payload.get("contract") or {}
28088
+ capsule = contract.get("capsule") or {}
28089
+ return ((capsule.get("verification") or {}).get("commands") or [])
28090
+
28091
+ # M1 — two clauses on one check, both parsed. The failure signature comes
28092
+ # first, so this is also the assertion that a signature no longer has to
28093
+ # be the final clause.
28094
+ chained = (
28095
+ "M1: node test.js asserts the public behavior. "
28096
+ f"Fails with: `boom` Detects: `{mutation}` -> `{failure}`",
28097
+ )
28098
+ payload = start("chained", strategy="vertical-tdd", commands=chained)
28099
+ if payload.get("status") != "pass":
28100
+ report(
28101
+ f"{label}: Detects: is not parsed — a check carrying a failure "
28102
+ "signature followed by an injection clause was refused; got "
28103
+ f"{payload.get('status')!r} {problem_text(payload)!r}."
28104
+ )
28105
+ return 1
28106
+ compiled = commands_of(payload)
28107
+ if len(compiled) != 1:
28108
+ report(f"{label}: the chained check did not compile to one check; {compiled!r}")
28109
+ return 1
28110
+ entry = compiled[0]
28111
+ if entry.get("failsWith") != "boom":
28112
+ report(
28113
+ f"{label}: Detects: is not parsed — the failure signature was "
28114
+ f"lost when a clause followed it; got {entry!r}."
28115
+ )
28116
+ return 1
28117
+ detects = entry.get("detects") or {}
28118
+ if detects.get("mutation") != mutation or detects.get("failure") != failure:
28119
+ report(
28120
+ f"{label}: Detects: is not parsed — the compiled check does not "
28121
+ f"carry the declared injection; got {entry!r}."
28122
+ )
28123
+ return 1
28124
+ # The clause is inside the check text, so it is inside the fingerprint.
28125
+ # Without this, an injection edited after the run would be invisible.
28126
+ other = start(
28127
+ "chained-edited",
28128
+ strategy="vertical-tdd",
28129
+ commands=(
28130
+ "M1: node test.js asserts the public behavior. "
28131
+ f"Fails with: `boom` Detects: `{mutation}` -> `assert 1 == 2`",
28132
+ ),
28133
+ )
28134
+ first = str((payload.get("contract") or {}).get("fingerprint") or "")
28135
+ second = str((other.get("contract") or {}).get("fingerprint") or "")
28136
+ if not first or first == second:
28137
+ report(
28138
+ f"{label}: editing the declared injection did not move the "
28139
+ f"fingerprint; {first!r} vs {second!r}."
28140
+ )
28141
+ return 1
28142
+
28143
+ # M1, second half — the declaration is enforced at completion, against a
28144
+ # `.detects` Evidence entry for the same check. The clause is a claim Keel
28145
+ # records: it never runs the mutation, so what completion holds is that
28146
+ # the declared failure appears in what the author recorded.
28147
+ def complete(name: str, detects_evidence: str | None) -> dict:
28148
+ repo = root / name
28149
+ evidence = [
28150
+ " - Contract: pending",
28151
+ " - M1: pass. node test.js reported the behavior.",
28152
+ " - M1.red: fail. `boom` as predicted.",
28153
+ " - M1.green: pass.",
28154
+ ]
28155
+ if detects_evidence is not None:
28156
+ evidence.append(f" - M1.detects: {detects_evidence}")
28157
+ task = "\n".join(
28158
+ [
28159
+ "- [x] 1.1 Injection probe",
28160
+ " - Owner: claude",
28161
+ " - Mode: implementation",
28162
+ " - Covers:",
28163
+ " - E1: the task proves its own behavior",
28164
+ " - Read:",
28165
+ " - README.md",
28166
+ " - Touch:",
28167
+ " - src/example.js",
28168
+ " - Verify:",
28169
+ " - Strategy: vertical-tdd",
28170
+ " - M1: node test.js asserts the public behavior. "
28171
+ f"Fails with: `boom` Detects: `{mutation}` -> `{failure}`",
28172
+ " - Evidence:",
28173
+ *evidence,
28174
+ " - Review:",
28175
+ " - Status: pass",
28176
+ " - Acceptance check: the behavior is proven.",
28177
+ " - Scope check: only Touch changed.",
28178
+ " - Findings: none.",
28179
+ " - Blocker: none",
28180
+ " - Reauthorizations: none",
28181
+ ]
28182
+ )
28183
+ write_gate_fixture(repo, tasks=task)
28184
+ started = run_keel(
28185
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
28186
+ "--record", "--json",
28187
+ )
28188
+ try:
28189
+ anchor_value = str(
28190
+ (json.loads(started.stdout).get("contract") or {}).get(
28191
+ "fingerprint"
28192
+ )
28193
+ or ""
28194
+ )
28195
+ except json.JSONDecodeError:
28196
+ anchor_value = ""
28197
+ if not anchor_value:
28198
+ return {"status": "unstarted", "problems": [{"message": started.stdout[:300]}]}
28199
+ done = run_keel(
28200
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
28201
+ "--json",
28202
+ )
28203
+ try:
28204
+ return json.loads(done.stdout)
28205
+ except json.JSONDecodeError:
28206
+ return {"status": "unparsed", "problems": [{"message": done.stdout[:400]}]}
28207
+
28208
+ absent = complete("no-detects", None)
28209
+ if absent.get("status") == "pass":
28210
+ report(
28211
+ f"{label}: Detects: is not enforced — a declared injection with "
28212
+ "no `.detects` Evidence completed cleanly."
28213
+ )
28214
+ return 1
28215
+ if "missing-injection-evidence" not in problem_codes(absent):
28216
+ report(
28217
+ f"{label}: Detects: is not enforced — an absent `.detects` was "
28218
+ f"refused under another diagnostic; {problem_codes(absent)!r} "
28219
+ f"{problem_text(absent)!r}."
28220
+ )
28221
+ return 1
28222
+ wrong = complete("wrong-detects", "ran the mutation; it still passed.")
28223
+ if wrong.get("status") == "pass":
28224
+ report(
28225
+ f"{label}: Detects: is not enforced — a `.detects` recording "
28226
+ "something other than the declared failure completed cleanly."
28227
+ )
28228
+ return 1
28229
+ if "injection-missing-declared-failure" not in problem_codes(wrong):
28230
+ report(
28231
+ f"{label}: a `.detects` lacking the declared failure was "
28232
+ f"refused under another diagnostic; {problem_codes(wrong)!r} "
28233
+ f"{problem_text(wrong)!r}."
28234
+ )
28235
+ return 1
28236
+ right = complete("good-detects", f"fail, as declared: `{failure}`.")
28237
+ if right.get("status") != "pass":
28238
+ report(
28239
+ f"{label}: a `.detects` carrying the declared failure was still "
28240
+ f"refused; {problem_codes(right)!r} {problem_text(right)!r}."
28241
+ )
28242
+ return 1
28243
+
28244
+ # M2 — a (regression) check may declare one. It has no honest red by
28245
+ # construction and is exempt from .red/.green, so an injection is the
28246
+ # only mechanism that can show it is not vacuous: this is where the
28247
+ # clause is worth most, which is why the report's suggested refusal here
28248
+ # was narrowed rather than adopted.
28249
+ regression = start(
28250
+ "regression",
28251
+ strategy="vertical-tdd",
28252
+ commands=(
28253
+ "M1: node test.js asserts the new behavior. Fails with: `boom`",
28254
+ f"M2 (regression): node all.js stays green. Detects: `{mutation}` -> `{failure}`",
28255
+ ),
28256
+ )
28257
+ if regression.get("status") != "pass":
28258
+ report(
28259
+ f"{label}: regression check may not declare an injection; got "
28260
+ f"{regression.get('status')!r} {problem_text(regression)!r}."
28261
+ )
28262
+ return 1
28263
+ second_entry = next(
28264
+ (e for e in commands_of(regression) if e.get("label") == "M2"), {}
28265
+ )
28266
+ if not (second_entry.get("detects") or {}).get("failure"):
28267
+ report(
28268
+ f"{label}: regression check may not declare an injection — the "
28269
+ f"clause was dropped; got {second_entry!r}."
28270
+ )
28271
+ return 1
28272
+
28273
+ # M3 — a malformed clause is named, not ignored. Both shapes: one literal
28274
+ # with no arrow, and an arrow with nothing after it.
28275
+ for name, clause in (
28276
+ ("one-literal", f"Detects: `{mutation}`"),
28277
+ ("no-target", f"Detects: `{mutation}` ->"),
28278
+ ):
28279
+ bad = start(
28280
+ name,
28281
+ strategy="vertical-tdd",
28282
+ commands=(f"M1: node test.js asserts the behavior. {clause}",),
28283
+ )
28284
+ if bad.get("status") == "pass":
28285
+ report(
28286
+ f"{label}: a malformed injection clause ({name}) was "
28287
+ "silently ignored, which leaves the author believing an "
28288
+ "injection is enforced when none was parsed."
28289
+ )
28290
+ return 1
28291
+ if "malformed-injection" not in problem_codes(bad):
28292
+ report(
28293
+ f"{label}: a malformed injection clause ({name}) was "
28294
+ "silently ignored under another diagnostic; got "
28295
+ f"{problem_codes(bad)!r} {problem_text(bad)!r}."
28296
+ )
28297
+ return 1
28298
+ # A marker inside inline code is quoted material, not a declaration —
28299
+ # which is what lets this repository's own tasks write about the clause.
28300
+ quoted = start(
28301
+ "quoted",
28302
+ strategy="vertical-tdd",
28303
+ commands=(
28304
+ "M1: node test.js asserts that a check may close with "
28305
+ "`Detects:` and two literals. Fails with: `boom`",
28306
+ ),
28307
+ )
28308
+ if quoted.get("status") != "pass":
28309
+ report(
28310
+ f"{label}: a clause named inside inline code was silently "
28311
+ f"ignored as a declaration; got {problem_text(quoted)!r}."
28312
+ )
28313
+ return 1
28314
+
28315
+ # 1.2 — `Measured:` binds a literal to the check's own recorded output.
28316
+ # The failure class it answers is a number that reads like a measurement
28317
+ # and is an estimate or a recollection; the one instance of it that was
28318
+ # caught in the reporting session was caught exactly this way, by sitting
28319
+ # in a clause the gate held against recorded output.
28320
+ measured_literal = "1799.9"
28321
+
28322
+ def complete_measured(name: str, m1_evidence: str, clause: str) -> dict:
28323
+ repo = root / name
28324
+ task = "\n".join(
28325
+ [
28326
+ "- [x] 1.1 Measurement probe",
28327
+ " - Owner: claude",
28328
+ " - Mode: implementation",
28329
+ " - Covers:",
28330
+ " - E1: the task proves its own behavior",
28331
+ " - Read:",
28332
+ " - README.md",
28333
+ " - Touch:",
28334
+ " - src/example.js",
28335
+ " - Verify:",
28336
+ " - Strategy: vertical-tdd",
28337
+ f" - M1: node test.js reports the fanout load.{clause}",
28338
+ " - Evidence:",
28339
+ " - Contract: pending",
28340
+ f" - M1: {m1_evidence}",
28341
+ " - M1.red: fail. the reader did not exist.",
28342
+ " - M1.green: pass.",
28343
+ " - Review:",
28344
+ " - Status: pass",
28345
+ " - Acceptance check: the behavior is proven.",
28346
+ " - Scope check: only Touch changed.",
28347
+ " - Findings: none.",
28348
+ " - Blocker: none",
28349
+ " - Reauthorizations: none",
28350
+ ]
28351
+ )
28352
+ write_gate_fixture(repo, tasks=task)
28353
+ run_keel(
28354
+ repo, "gate", "task-start", "--change", "demo", "--task", "1.1",
28355
+ "--record", "--json",
28356
+ )
28357
+ done = run_keel(
28358
+ repo, "gate", "task-complete", "--change", "demo", "--task", "1.1",
28359
+ "--json",
28360
+ )
28361
+ try:
28362
+ return json.loads(done.stdout)
28363
+ except json.JSONDecodeError:
28364
+ return {"status": "unparsed", "problems": [{"message": done.stdout[:400]}]}
28365
+
28366
+ estimated = complete_measured(
28367
+ "measured-absent",
28368
+ "pass. node test.js reported a fanout load of 1200 fF.",
28369
+ f" Measured: `{measured_literal}`",
28370
+ )
28371
+ if estimated.get("status") == "pass":
28372
+ report(
28373
+ f"{label}: Measured: is not enforced — a declared literal absent "
28374
+ "from the check's own recorded output completed cleanly, which is "
28375
+ "an estimate presented as a measurement."
28376
+ )
28377
+ return 1
28378
+ if "measurement-missing-from-evidence" not in problem_codes(estimated):
28379
+ report(
28380
+ f"{label}: a declared measurement absent from the output was "
28381
+ f"refused under another diagnostic; {problem_codes(estimated)!r} "
28382
+ f"{problem_text(estimated)!r}."
28383
+ )
28384
+ return 1
28385
+ real = complete_measured(
28386
+ "measured-present",
28387
+ f"pass. node test.js reported a fanout load of {measured_literal} fF.",
28388
+ f" Measured: `{measured_literal}`",
28389
+ )
28390
+ if real.get("status") != "pass":
28391
+ report(
28392
+ f"{label}: a declared measurement present in the output was "
28393
+ f"refused; {problem_codes(real)!r} {problem_text(real)!r}."
28394
+ )
28395
+ return 1
28396
+ # D4 — opt-in. A number in Evidence that no check declared is required
28397
+ # nowhere: the universal rule was declined on measurement, because in this
28398
+ # repository's own archive it would reach 847 inline-code spans, most of
28399
+ # them version strings, computed counts, and quoted references.
28400
+ undeclared = complete_measured(
28401
+ "measured-undeclared",
28402
+ "pass. node test.js reported `1200` fF across `2433` fanout pins.",
28403
+ "",
28404
+ )
28405
+ if undeclared.get("status") != "pass":
28406
+ report(
28407
+ f"{label}: numbers in Evidence with no `Measured:` clause were "
28408
+ f"refused, so the opt-in boundary did not hold; "
28409
+ f"{problem_codes(undeclared)!r} {problem_text(undeclared)!r}."
28410
+ )
28411
+ return 1
28412
+
28413
+ # 1.3 — the clauses are documented where the first one is documented. A
28414
+ # vocabulary an author cannot discover is a vocabulary nobody declares.
28415
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
28416
+ agents = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
28417
+ for needle, why in (
28418
+ ("Detects:", "the injection clause must be named"),
28419
+ ("Measured:", "the measurement clause must be named"),
28420
+ ):
28421
+ if needle not in readme:
28422
+ report(
28423
+ f"{label}: README does not name {needle} — {why}, and a clause "
28424
+ "an author cannot find is a clause nobody declares."
28425
+ )
28426
+ return 1
28427
+ if needle not in agents:
28428
+ report(
28429
+ f"{label}: the protocol's verification discipline does not name "
28430
+ f"{needle} — {why}."
28431
+ )
28432
+ return 1
28433
+ flat_readme = re.sub(r"\s+", " ", readme)
28434
+ for needle, why in (
28435
+ ("records the claim", "the README must say Keel does not judge the injection"),
28436
+ ("does not run", "the README must say Keel does not run the mutation"),
28437
+ ):
28438
+ if needle not in flat_readme:
28439
+ report(f"{label}: README does not name Detects: honestly — {why}.")
28440
+ return 1
28441
+ # The chaining is taught by the example rather than by prose about it: a
28442
+ # reader copies the example, and an example showing one clause teaches that
28443
+ # one clause is all there is.
28444
+ chained_example = re.search(
28445
+ r"Fails with: `[^`]+` Detects: `[^`]+` -> `[^`]+`", readme
28446
+ )
28447
+ if not chained_example:
28448
+ report(
28449
+ f"{label}: README does not name Detects: in a worked example beside "
28450
+ "a failure signature, so a reader learns the clauses are mutually "
28451
+ "exclusive from the only example they have."
28452
+ )
28453
+ return 1
28454
+
28455
+ if label not in {name for name, _ in SCENARIOS}:
28456
+ report(f"{label}: the scenario registry does not include it.")
28457
+ return 1
28458
+ report(f"{label} scenario passed.")
28459
+ return 0
28460
+
28461
+
27787
28462
  SCENARIOS: tuple = (
27788
28463
  ("stateless-continuity", validate_stateless_continuity_scenario),
27789
28464
  ("core-gates", validate_core_gates_scenario),
@@ -28134,6 +28809,14 @@ SCENARIOS: tuple = (
28134
28809
  "an-authorization-names-its-repository",
28135
28810
  validate_an_authorization_names_its_repository_scenario,
28136
28811
  ),
28812
+ (
28813
+ "the-routing-rule-reaches-the-decision",
28814
+ validate_the_routing_rule_reaches_the_decision_scenario,
28815
+ ),
28816
+ (
28817
+ "a-claim-names-what-would-falsify-it",
28818
+ validate_a_claim_names_what_would_falsify_it_scenario,
28819
+ ),
28137
28820
  )
28138
28821
 
28139
28822