Every ticket as the root of its provenance graph — goal, patch, approval, runs and (as the ticket-class build lands) mandate, manifestations, guarantees, journey, artifacts and children. Rendered through the <<<TicketGraphList>>> tag.
goal
THIS UNIT SUPERSEDES #295 — it is the SAME module and the same work, re-filed to be
SELF-CONTAINED. The superseded unit named its source only as a `git cat-file` command
against the forge's bare repos, which the sandboxed pair_loop worker that executes these
tickets cannot run, so it could not be done as filed (#346; #259 is the proof). A ticket body
is permanent in SQL (tg_tickets_immutable_identity, migrations/0029_ticket_authorization
.sql:111-117), so that body could not be repaired — this ticket carries the fix instead, and
the original is halted by a stop ticket rather than erased. Do the work HERE.
Rewrite ONE unique forge module as entirely new, behavior-preserving, installable,
control-config'd Python, per the FORGE-wide refactor standard #200 (#std).
This ticket covers ONE module — the one identified below — and nothing else. It was filed
against the module's DEDUPLICATED identity, so if that module was copy-pasted across the
forge, every copy is covered by this single ticket.
MODULE KEY : forge-rw:84dcc08236ce2fd7
(the ticketer's idempotency marker — it lives in this ticket's TITLE. Do
not edit the title, or the module will be re-filed as a duplicate.)
IDENTITY : AST-NORMALIZED — codebean's `_ast_norm` (#183), i.e.
ast.dump(ast.parse(ast.unparse(ast.parse(src)))).
Copies of this module that differ only in comments, blank lines,
quoting or spacing carry the SAME module-sha and are covered by THIS
ticket. A differing docstring is real content, so a module whose
docstring differs is a DIFFERENT module with its own ticket.
SIZE : 11716 bytes
EXTENSION : .py
CANONICAL SOURCE (the location this ticket is named for):
repo : code__code-invalid__coupler
path : coupler/why5.py
ref : refs/heads/stable (as recorded when this module was first filed)
blob : e5e5d778c4eb20084ef64c9888bdee4eabb57439 (git blob sha1)
REFERENCE ONLY — you do NOT need this command. This module's bytes are embedded
COMPLETE in THE ORIGINAL SOURCE block below, and that is what the rewrite is based
on. For a reader who does have forge access:
git --git-dir=/Users/stevenpeterson/code/installed/jazz-project/config/forge/remotes/code__code-invalid__coupler.git cat-file blob e5e5d778c4eb20084ef64c9888bdee4eabb57439
THE SAME MODULE ALSO LIVES AT THESE FORGE TIPS: none — this module is unique to
code__code-invalid__coupler among the repos scanned so far.
HISTORICAL OCCURRENCES (omni-git `occ`): none to add — either the index has no
row for this blob (it is newer than the index's last snapshot, or the index does
not cover its repo), or every place it records is already listed live above.
THE ORIGINAL SOURCE, EMBEDDED — YOU NEED NO FORGE ACCESS TO DO THIS TICKET.
The module's own bytes are reproduced below, inside this ticket. THEY are the
authoritative copy and the behavior-preserving basis: rewrite from them. The
`git --git-dir=... cat-file blob` command under CANONICAL SOURCE above is kept only as a
REFERENCE for a reader who happens to have forge access — running it is NOT part of this
ticket. (Why the bytes are here at all: the pair_loop worker that executes this ticket is
sandboxed to the ticket's own congruency worktree and cannot read the forge's bare repos.
Ticket #346 is the fix that put them on the ticket.)
The block BEGINS at the line
===ORIGINAL SOURCE (verbatim, the behavior-preserving basis)===
and ENDS at the first line
===END SOURCE===
Everything strictly between those two lines is the module — 11716 byte(s), byte for byte, with no re-indentation, no re-wrapping and nothing
elided.
===ORIGINAL SOURCE (verbatim, the behavior-preserving basis)===
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 testmonkeyalpha — Claude & Co.
"""
why5.py — verified root cause analysis via Z3.
A Why5 session is a causal chain of 5 links. Each link asserts:
observation → cause
Z3 verifies the full chain is consistent with known facts.
If any link is wrong, Z3 returns unsat and names the bad step.
(Ported into the coupler package from jazz/jazz/agent/why5.py during the
jazz→jazz-new fold: z3_mcp is now the installed forge package, not a relative
path into the jazz tree.)
Usage:
w = Why5("ci-failure-abc123", db_path=db_path)
w.fact("ci_failed", True)
w.fact("llm_is_down", False)
w.link("why1", cause="test_file_broken", because="ci_failed")
...
result = w.check()
w.save() # writes to notes-db
"""
import sys
import os
try:
from dz_debug import Logger
_log = Logger("jazz-why5")
if not hasattr(_log, "warning"):
_log.warning = _log.warn if hasattr(_log, "warn") else _log.info
except ImportError:
import logging
_log = logging.getLogger("jazz-why5")
# z3_mcp is an installed package (forge/work/z3_mcp) — import its solver directly
try:
from z3_mcp.solver import new_session, add_variable, add_constraint, check, reset_session
_z3_available = True
except ImportError:
_z3_available = False
_log.warning("z3_mcp not available — why5 will record chains but cannot verify them")
COLLECTION = "why5-analyses"
class Why5:
"""
A verified causal chain. Build it with .fact() and .link(), then .check().
"""
def __init__(self, session_id: str, db_path: str = None, trigger: str = ""):
self.session_id = session_id
self.db_path = db_path
self.trigger = trigger
self._facts = {} # name -> bool value
self._links = [] # ordered list of (label, cause, because)
self._result = None
if _z3_available:
new_session(session_id)
def fact(self, name: str, value: bool):
"""Assert a known fact — something we can observe directly."""
self._facts[name] = value
if _z3_available:
add_variable(self.session_id, name, "bool")
add_constraint(
self.session_id,
f"fact-{name}",
f"{name} == {'True' if value else 'False'}"
)
return self
def link(self, label: str, cause: str, because: str):
"""
Assert: if `because` is true, then `cause` must be true.
because → cause (this is one step in the why chain)
"""
self._links.append((label, cause, because))
if _z3_available:
# ensure both variables exist
for var in (cause, because):
if var not in self._facts:
add_variable(self.session_id, var, "bool")
self._facts[var] = None # unknown, solver decides
add_constraint(
self.session_id,
label,
f"Implies({because}, {cause})"
)
return self
def check(self) -> dict:
"""Verify the chain. Returns result dict with sat/unsat and bad step if any."""
if not _z3_available:
self._result = {"sat": None, "error": "z3 not available", "chain": self._links}
return self._result
raw = check(self.session_id)
if raw["result"] == "sat":
self._result = {
"sat": True,
"session": self.session_id,
"trigger": self.trigger,
"chain": [(label, because, cause) for label, cause, because in self._links],
"root_cause": self._links[-1][1] if self._links else None,
"model": raw.get("model", {}),
}
elif raw["result"] == "unsat":
bad = raw.get("bad_rules", [])
# find the first link label that appears in bad_rules
bad_step = next(
((label, cause, because) for label, cause, because in self._links
if label in bad),
None
)
self._result = {
"sat": False,
"session": self.session_id,
"trigger": self.trigger,
"chain": [(label, because, cause) for label, cause, because in self._links],
"bad_step": bad_step,
"bad_rules": bad,
"verdict": (
f"Chain broken at '{bad_step[0]}': "
f"'{bad_step[2]}' does not imply '{bad_step[1]}'"
) if bad_step else "Chain is inconsistent",
}
else:
self._result = {"sat": None, "result": "unknown", "session": self.session_id}
return self._result
def save(self) -> str | None:
"""Write the result to notes-db. Returns note id or None."""
if not self.db_path or not self._result:
return None
try:
from notes_db_mcp import client
cols = {c["name"] for c in client.list_collections()}
if COLLECTION not in cols:
client.create_collection(COLLECTION, owner="system")
sat = self._result.get("sat")
status = "verified" if sat else ("broken" if sat is False else "unknown")
root = self._result.get("root_cause") or self._result.get("verdict", "")
note = client.add_note(
collection=COLLECTION,
title=f"[{status}] {self.session_id}: {root[:80]}",
text=self._format_text(),
topic=self.session_id,
tag=status,
owner="system",
extra=self._result,
)
_log.info(f"why5 saved: {self.session_id} ({status})")
return note.get("id") if isinstance(note, dict) else None
except Exception as e:
_log.error(f"why5 save failed: {e}")
return None
def _format_text(self) -> str:
lines = [f"TRIGGER: {self.trigger}", ""]
lines.append("FACTS:")
for name, val in self._facts.items():
if val is not None:
lines.append(f" {name} = {val}")
lines.append("")
lines.append("CHAIN:")
for i, (label, cause, because) in enumerate(self._links, 1):
lines.append(f" {i}. [{label}] {because} → {cause}")
lines.append("")
if self._result:
if self._result.get("sat"):
lines.append(f"RESULT: VERIFIED ✓")
lines.append(f"ROOT CAUSE: {self._result.get('root_cause')}")
elif self._result.get("sat") is False:
lines.append(f"RESULT: BROKEN ✗")
lines.append(f"BAD STEP: {self._result.get('verdict')}")
else:
lines.append("RESULT: UNKNOWN")
return "\n".join(lines)
def from_ci_failure(session_id: str, branch: str, pre_sha: str,
pytest_output: str, db_path: str = None) -> Why5:
"""
Build a why5 chain from a CI failure.
Facts are derived from what we know at pipeline failure time.
"""
w = Why5(
session_id=session_id,
db_path=db_path,
trigger=f"CI failed on branch {branch} at {pre_sha[:8]}"
)
# known facts from CI run
w.fact("ci_failed", True)
w.fact("tests_exist", "PASSED" in pytest_output or "FAILED" in pytest_output)
w.fact("import_error", "ImportError" in pytest_output or "ModuleNotFoundError" in pytest_output)
w.fact("assertion_error", "AssertionError" in pytest_output)
w.fact("syntax_error", "SyntaxError" in pytest_output)
# build the most likely chain based on error type
if "SyntaxError" in pytest_output:
w.link("why1", cause="test_unparseable", because="ci_failed")
w.link("why2", cause="syntax_error", because="test_unparseable")
w.link("why3", cause="code_not_verified_before_commit", because="syntax_error")
w.link("why4", cause="no_self_check_in_loop", because="code_not_verified_before_commit")
w.link("why5", cause="loop_missing_verify_step", because="no_self_check_in_loop")
elif "ImportError" in pytest_output or "ModuleNotFoundError" in pytest_output:
w.link("why1", cause="missing_dependency", because="ci_failed")
w.link("why2", cause="import_error", because="missing_dependency")
w.link("why3", cause="dependency_not_declared", because="import_error")
w.link("why4", cause="no_install_check_before_commit", because="dependency_not_declared")
w.link("why5", cause="agents_assume_env_complete", because="no_install_check_before_commit")
elif "AssertionError" in pytest_output:
w.link("why1", cause="test_assertion_failed", because="ci_failed")
w.link("why2", cause="assertion_error", because="test_assertion_failed")
w.link("why3", cause="code_changed_test_not_updated", because="assertion_error")
w.link("why4", cause="no_dependency_graph_between_code_and_tests", because="code_changed_test_not_updated")
w.link("why5", cause="agents_work_without_shared_state", because="no_dependency_graph_between_code_and_tests")
else:
w.link("why1", cause="test_failed", because="ci_failed")
w.link("why2", cause="code_incorrect", because="test_failed")
w.link("why3", cause="no_verification_before_commit", because="code_incorrect")
w.link("why4", cause="agent_loop_lacks_feedback", because="no_verification_before_commit")
w.link("why5", cause="consequences_not_felt_at_agent_level", because="agent_loop_lacks_feedback")
return w
def from_commit_failure(session_id: str, agent_name: str, file_path: str,
error: str, db_path: str = None) -> Why5:
"""Build a why5 chain from a failed commit or write in the agent loop."""
w = Why5(
session_id=session_id,
db_path=db_path,
trigger=f"{agent_name} failed to commit {file_path}"
)
w.fact("commit_failed", True)
w.fact("merge_conflict", "conflict" in error.lower())
w.fact("nothing_to_commit", "nothing to commit" in error.lower())
w.fact("push_rejected", "rejected" in error.lower() or "non-fast-forward" in error.lower())
if "conflict" in error.lower():
w.link("why1", cause="conflicting_changes", because="commit_failed")
w.link("why2", cause="merge_conflict", because="conflicting_changes")
w.link("why3", cause="two_agents_edited_same_file", because="merge_conflict")
w.link("why4", cause="no_file_ownership_model", because="two_agents_edited_same_file")
w.link("why5", cause="queue_assigns_same_file_to_multiple_agents", because="no_file_ownership_model")
elif "rejected" in error.lower():
w.link("why1", cause="push_rejected", because="commit_failed")
w.link("why2", cause="remote_ahead_of_local", because="push_rejected")
w.link("why3", cause="pull_rebase_not_run", because="remote_ahead_of_local")
w.link("why4", cause="push_called_without_sync", because="pull_rebase_not_run")
w.link("why5", cause="git_ops_push_missing_rebase", because="push_called_without_sync")
else:
w.link("why1", cause="write_failed", because="commit_failed")
w.link("why2", cause="file_not_written", because="write_failed")
w.link("why3", cause="llm_output_not_parseable", because="file_not_written")
w.link("why4", cause="output_format_not_enforced", because="llm_output_not_parseable")
w.link("why5", cause="prompt_missing_format_requirement", because="output_format_not_enforced")
return w
===END SOURCE===
THE CALLABLE SURFACE — every callable in the original, lowered with codebean's
`lower_node` (#183). The rewrite must present THIS surface (same names, same
parameters, same defaults) so callers of the original keep working:
Why5.__init__(self, session_id: str, db_path: str=None, trigger: str='')
Why5.fact(self, name: str, value: bool)
Why5.link(self, label: str, cause: str, because: str)
Why5.check(self) -> dict
Why5.save(self) -> str | None
Why5._format_text(self) -> str
from_ci_failure(session_id: str, branch: str, pre_sha: str, pytest_output: str, db_path: str=None) -> Why5
from_commit_failure(session_id: str, agent_name: str, file_path: str, error: str, db_path: str=None) -> Why5
CAPTURED BEHAVIORAL EXEMPLARS: none. no function in this module is minable — criteria_miner mines only
self-contained pure functions (it refuses anything that mentions a
world-touching or non-deterministic name, plus methods and no-arg
functions), and nothing here qualified
This does NOT weaken the ticket's bar — it MOVES the work: you must write the
equivalence exemplars by hand from the original's behavior before rewriting, and
they are what settles this ticket.
THE STANDARD — #200's four requirements, which this ticket exists to enforce. All four are
the standard's own words; none is optional:
1. BEHAVIOR-PRESERVING, VERIFIED NOT ASSERTED — the deliverable is ENTIRELY NEW source
code (rewritten, not copied). It must reproduce the ORIGINAL's behavior, and the proof
is the exemplars above (captured via codebean lower_* #183 + criteria_miner
IO-exemplars, exactly as #200 specifies).
2. A PYTHON MODULE — regardless of the source language. This module's source is `.py`;
the rewrite is Python either way.
3. INSTALLABLE — a proper package: pyproject.toml, `pip install -e .` works, console
entry points where applicable.
4. CONTROL-CONFIG'D — NO hardcoded paths or params. A JSON/registry config object drives
it (the install.json / registry.json idiom of this repo), and per note-for-claude a
tool that cannot see its config THROWS rather than guessing a default.
ACCEPTANCE (#200's, per rewrite ticket) — this ticket settles when ALL of:
a. the new source reproduces every captured exemplar above, plus the edge cases you add
for what sampling cannot reach (paste the runs as evidence);
b. it presents the callable surface above, so existing callers keep working;
c. `pip install -e .` installs it clean from its own pyproject.toml;
d. it runs off a config object with ZERO hardcoded settings, and throws without one.
Kind is `build` (a pair: coder + adversarial tester). This ticket hangs off #200 by a
`spawned` edge, so a stop ticket aimed at #200 brakes every rewrite in this programme.
Filed mechanically by checkouts/current/congruency/tools/forge_refactor_ticketer.py under ticket #201, from the forge bare repos at
/Users/stevenpeterson/code/installed/jazz-project/config/forge/remotes. Its original source is embedded above (#346), so this ticket is complete on its own.