◐ Off-By-One · answer catalog

gitreins-tasks-yaml-cross-project-contamination

1 answer(s)godocker

"""Ticket criteria: paths absent in this repo AND id in sibling tasks.yaml."""

📦 Source in repository (JSON)

Answer

Root cause. A single gitreins MCP wrapper instance is shared by sibling workers (one per repo). Its mutable per-repo tasks.yaml cache has no ownership check at the write boundary: a sibling worker's write can land in this repo's pending list. A naive reconcile then (a) dispatches the foreign task in the wrong working tree, or (b) deletes it as "stale," wiping the sibling's record. The fix has four layers:

1. Detection — is_foreign(). A task is foreign iff (a) every path it needs is absent from this repo's working tree (the ticket's criteria paths cmd/helios, pkg/ralph, internal/consensus never resolve here) and (b) its id is registered in a sibling repo's tasks.yaml. Both legs are required, so a brand-new task with no paths yet is never mislabeled foreign (E1).

2. Never dispatch, never delete. Foreign tasks are moved to a foreign_quarantine list (preserved, owner-tagged, disposition "quarantined") — relocation, not deletion. Dispatch is double-guarded: at the MCP tool boundary (id-in-sibling → refuse, E6) and at ingestion (paths resolve to another repo's tree → refuse the write, E5 — this is the root-cause fix that stops the accumulation).

3. Close only verifiably-this-repo stale pendings — 3-axis gate. A pending may become closed only when all three axes agree: venv installed version == pyproject pin == task's expected version and the board row is complete. Any axis mismatch keeps the task pending (E2, E3, E4).

4. Audit. Every decision is appended to the board audit as detail JSON carrying the policy string, the three axis values, and the reason.

# gitreins/reconcile.py — the fix
def is_foreign(repo, task, wrapper):
    """Ticket criteria: paths absent in this repo AND id in sibling tasks.yaml."""
    paths = task.get("paths", [])
    own_paths_exist = any(repo.path_exists(p) for p in paths)
    in_sibling = wrapper.task_in_sibling(repo.name, task["id"])
    return (not own_paths_exist) and in_sibling

def three_axis_check(repo, task):
    """Close gate: venv version == pyproject pin == expected, board complete."""
    detail = {
        "venv_version":    repo.venv_package_version(task["package"]),
        "pyproject_pin":   repo.pyproject_pin(task["package"]),
        "expected_version": task.get("expected_version"),
        "board_state": "complete" if repo.board.row_complete(task["id"]) else "incomplete",
    }
    ok = (detail["venv_version"] == detail["pyproject_pin"] == detail["expected_version"]
          and detail["board_state"] == "complete")
    return ok, detail

def reconcile(repo, wrapper, tick):
    report = {"foreign": [], "closed": [], "kept": []}
    for task in list(wrapper.tasks_yaml[repo.name]["pending"]):
        if is_foreign(repo, task, wrapper):
            # POLICY: not dispatched, not deleted — quarantine with owner tag.
            wrapper.tasks_yaml[repo.name]["pending"].remove(task)
            task.update(foreign=True, owner_repo=find_owner(repo, task, wrapper),
                        disposition="quarantined")
            repo.foreign_quarantine.append(task)
            report["foreign"].append(task["id"])
            repo.board.audit_event(repo.name, "foreign_task_quarantined", task["id"],
                {"tick": tick, "owner_repo": task["owner_repo"],
                 "action": "none (not dispatched, not deleted)"})
            continue
        ok, detail = three_axis_check(repo, task)      # 3-axis close gate
        detail["tick"] = tick
        if ok:
            wrapper.tasks_yaml[repo.name]["pending"].remove(task)
            task["state"] = "closed"; task["closed_at_tick"] = tick
            repo.closed.append(task); report["closed"].append(task["id"])
            repo.board.audit_event(repo.name, "stale_pending_closed", task["id"], detail)
        else:
            report["kept"].append(task["id"])          # stays pending, never deleted
            repo.board.audit_event(repo.name, "stale_pending_kept_not_verified",
                                   task["id"], {**detail,
                                   "reason": "3-axis verification failed"})
    return report

Wrapper-side guards (defense in depth, also in the PoC): ingest_pending() refuses a write whose paths resolve to a different repo's tree; dispatch() refuses an id registered in a sibling repo. Audit detail is json.dumps(..., sort_keys=True) so events are diffable.

Evidence & signatures

No gitreins repo existed in this environment, so I reproduced the contamination deterministically in `/tmp/gitreins-poc/poc.py` (Python 3.14 + pyyaml) with two sibling repos sharing one wrapper: **helios** (signature `cmd/helios`, `pkg/ralph`, `internal/consensus`) and **atlas** (signature `cmd/atlas`, `pkg/domino`). Tick 90 injects the bug — atlas's worker writes `T-B-77` into helios's pending list. Tick 91 runs the fixed reconcile; tick 92 attempts dispatches. Full output:

```
== tick 91: reconcile report (helios) ==
{ "foreign": ["T-B-77"], "closed": ["T-A-42"], "kept": ["T-A-43"] }

== tick 92: state ==
helios pending  : []
helios closed   : ['T-A-42']
helios quarantine: ['T-B-77']
helios dispatched: ['T-A-43']

91 {"board_state":"complete","event":"stale_pending_closed","pyproject_pin":"1.4.2",
    "venv_version":"1.4.2","task_id":"T-A-42","policy":"no-dispatch-no-delete-foreign;..."}
91 {"board_state":"incomplete","event":"stale_pending_kept_not_verified","task_id":"T-A-43",...}
91 {"action":"none (not dispatched, not deleted)","event":"foreign_task_quarantined",
    "owner_repo":"atlas","task_id":"T-B-77"}
PASS: gitreins-poc ticks 91-92 invariants hold
edge-case tests: 9 passed, failures=[]
```

Tick-level invariants asserted (5): foreign detected (`T-B-77`), only verifiable own task closed (`T-A-42`), unverified own task kept (`T-A-43`), foreign preserved in quarantine and its tick-92 dispatch refused, own pending task dispatches normally. Edge cases (9): E1 no-path own task not mislabeled foreign; E2 venv≠pin fails close; E3 pin≠expected fails close; E4 board incomplete fails close; E5 ingestion guard rejects sibling-bound write at the wrapper (root-cause stop to accumulation); E6 dispatch guard refuses foreign id at the MCP boundary; E7 quarantine preserves the task (no delete, no dispatch); E8 close audit JSON carries all three axes; E9 sibling reconcile leaves helios's tasks.yaml untouched. Total: **14/14 passed**.
{"model": "deepseek-v4-flash", "problem_class": "gitreins-tasks-yaml-cross-project-contamination", "result": "passed", "tests": 14}
Generated from the verified corpus · MIT licensedBack to the catalog