← back to Terminal Status
TK-11317: fix deferential-set bypass + ticket-rebind hole; add carry age ceiling + revision continuity
f171fc92450b46f61631f93ce58ab9213d60648c · 2026-09-26 09:15:57 -0700 · Steve
Contrarian review of b594dbb found a reproducible CRITICAL hole: working-state.sh's
per-prompt UserPromptSubmit hook fires `set green WORKING --deferential --boot` on
every prompt. In set(), the deferential-yield guard requires a truthy `previous`, but
load(owner) returns None for reason owner_changed -- so the very first prompt after a
tty reuse silently overwrote a carried purple/orange/yellow/lightblue with plain green,
bypassing settle_owner_change entirely.
Fix: set() is now a thin lock-acquiring wrapper around a new _set_locked() write body.
When _set_locked sees previous is None, reason is owner_changed, and the caller is
deferential, it settles the owner change FIRST -- via a new _settle_locked() lock-free
write body (never re-enters self.lock(), which would deadlock; flock is not re-entrant
within one process) -- and uses the settled record as `previous`. Its colour is always
>= yellow priority, so the existing deferential-yield guard right after always fires and
repaints the settled record instead of falling through to plain green. A non-deferential
explicit set (e.g. /greendot on a genuinely new session) is unchanged -- the new branch
only engages for deferential=True.
Same-class hole #2: the interactive `ticket` command and auto-bind's _default_ticket_set
both did `previous, _ = store.read(owner)` and silently rebuilt the record as color
"none" whenever reason was actually owner_changed, dropping a carried colour the instant
a ticket got rebound before settle ever ran. Fixed via a new Store.effective_previous(),
backed by a shared Store._settlement_preview() (also now used by row()'s pre-backfill
preview and settle_owner_change itself, so all three can never disagree).
HIGH: prior_attention() now gates every candidate by CARRY_MAX_AGE_S (default 86400s /
24h, env TERMINAL_STATUS_CARRY_MAX_AGE_S) -- a source older than that, or one whose age
can't be determined, is never carried, floors to the yellow "New owner - status needed"
state instead (never green, never none, no ticket). Age is measured from the ORIGINAL
event: a record that is itself already a carry reports its age from its own
carried_from["at"], so a relay of owner_changed hops can't keep resetting the clock.
carried_from is now {"pid","started","at","hops"} -- pid/started/at are the ORIGINAL
carrier's identity (preserved across every hop), hops counts the relay length.
LOW: a carry write now continues the raw prior record's own revision (+1) instead of
resetting to 1, via a new revision_override param threaded through set()/_set_locked.
16 new tests (deferential-bypass repro for both the carried-attention and
nothing-carried cases, explicit-set-still-wins, staleness ceiling for both JSON and
legacy .dot sources, a 3-generation hop chain preserving original provenance, revision
continuity, effective_previous() passthrough, and the ticket-command/auto-bind repro) +
1 new selftest case (19/19). Negative control: 15/45 new tests fail/error against
b594dbb, including both deferential-bypass repros
(verification/TK-11317-integration/negative-control-v2.txt). Full suite green after the
fix (verification/TK-11317-integration/after-tests-v2.txt): test_terminal_status.py (75),
test_owner_change_carry.py (45), test_departed_asks.py (20), selftest (19/19),
test_stop_verdict.py (40), test_tk_required_fp_metric.py (13).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqLpisVAyfYM4zoVP7fFya
Files touched
M terminal_status.pyM test_owner_change_carry.pyA verification/TK-11317-integration/after-tests-v2.txtA verification/TK-11317-integration/negative-control-v2.txt
Diff
commit f171fc92450b46f61631f93ce58ab9213d60648c
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Sep 26 09:15:57 2026 -0700
TK-11317: fix deferential-set bypass + ticket-rebind hole; add carry age ceiling + revision continuity
Contrarian review of b594dbb found a reproducible CRITICAL hole: working-state.sh's
per-prompt UserPromptSubmit hook fires `set green WORKING --deferential --boot` on
every prompt. In set(), the deferential-yield guard requires a truthy `previous`, but
load(owner) returns None for reason owner_changed -- so the very first prompt after a
tty reuse silently overwrote a carried purple/orange/yellow/lightblue with plain green,
bypassing settle_owner_change entirely.
Fix: set() is now a thin lock-acquiring wrapper around a new _set_locked() write body.
When _set_locked sees previous is None, reason is owner_changed, and the caller is
deferential, it settles the owner change FIRST -- via a new _settle_locked() lock-free
write body (never re-enters self.lock(), which would deadlock; flock is not re-entrant
within one process) -- and uses the settled record as `previous`. Its colour is always
>= yellow priority, so the existing deferential-yield guard right after always fires and
repaints the settled record instead of falling through to plain green. A non-deferential
explicit set (e.g. /greendot on a genuinely new session) is unchanged -- the new branch
only engages for deferential=True.
Same-class hole #2: the interactive `ticket` command and auto-bind's _default_ticket_set
both did `previous, _ = store.read(owner)` and silently rebuilt the record as color
"none" whenever reason was actually owner_changed, dropping a carried colour the instant
a ticket got rebound before settle ever ran. Fixed via a new Store.effective_previous(),
backed by a shared Store._settlement_preview() (also now used by row()'s pre-backfill
preview and settle_owner_change itself, so all three can never disagree).
HIGH: prior_attention() now gates every candidate by CARRY_MAX_AGE_S (default 86400s /
24h, env TERMINAL_STATUS_CARRY_MAX_AGE_S) -- a source older than that, or one whose age
can't be determined, is never carried, floors to the yellow "New owner - status needed"
state instead (never green, never none, no ticket). Age is measured from the ORIGINAL
event: a record that is itself already a carry reports its age from its own
carried_from["at"], so a relay of owner_changed hops can't keep resetting the clock.
carried_from is now {"pid","started","at","hops"} -- pid/started/at are the ORIGINAL
carrier's identity (preserved across every hop), hops counts the relay length.
LOW: a carry write now continues the raw prior record's own revision (+1) instead of
resetting to 1, via a new revision_override param threaded through set()/_set_locked.
16 new tests (deferential-bypass repro for both the carried-attention and
nothing-carried cases, explicit-set-still-wins, staleness ceiling for both JSON and
legacy .dot sources, a 3-generation hop chain preserving original provenance, revision
continuity, effective_previous() passthrough, and the ticket-command/auto-bind repro) +
1 new selftest case (19/19). Negative control: 15/45 new tests fail/error against
b594dbb, including both deferential-bypass repros
(verification/TK-11317-integration/negative-control-v2.txt). Full suite green after the
fix (verification/TK-11317-integration/after-tests-v2.txt): test_terminal_status.py (75),
test_owner_change_carry.py (45), test_departed_asks.py (20), selftest (19/19),
test_stop_verdict.py (40), test_tk_required_fp_metric.py (13).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqLpisVAyfYM4zoVP7fFya
---
terminal_status.py | 540 ++++++++++++++-------
test_owner_change_carry.py | 232 ++++++++-
.../TK-11317-integration/after-tests-v2.txt | 250 ++++++++++
.../TK-11317-integration/negative-control-v2.txt | 197 ++++++++
4 files changed, 1048 insertions(+), 171 deletions(-)
diff --git a/terminal_status.py b/terminal_status.py
index 5d1001f..1e61f14 100644
--- a/terminal_status.py
+++ b/terminal_status.py
@@ -75,6 +75,27 @@ ATTENTION = frozenset(("lightblue", "orange", "purple", "yellow"))
# "unverified" yellow that the session's own hooks refine on its next turn.
OWNER_STATUS_LABEL = "New owner — status needed"
+# TK-11317 contrarian review (2026-09-26): a carried ATTENTION colour must have an AGE
+# CEILING -- without one, an owner-change chain could carry a colour from days or weeks
+# ago forever, which is just a slower version of the original blind-green bug (an
+# unmeasured, no-longer-live claim being shown as current). Default 24h; override for
+# testing/tuning via TERMINAL_STATUS_CARRY_MAX_AGE_S. A source older than this (or whose
+# age cannot even be determined) is NEVER carried -- it falls through to the yellow
+# "New owner - status needed" floor exactly like "nothing pending" does.
+CARRY_MAX_AGE_S = float(os.environ.get("TERMINAL_STATUS_CARRY_MAX_AGE_S", "86400"))
+
+
+def _epoch_of(iso_text):
+ """Best-effort epoch seconds for a record's `updated_at` ISO string, or None on any
+ parse failure -- callers must treat None as 'age unknown' (never carried), not as
+ 'fresh', per CLAUDE.md TK-11431 (an unmeasured input is never treated as good)."""
+ if not iso_text:
+ return None
+ try:
+ return dt.datetime.fromisoformat(iso_text).timestamp()
+ except (ValueError, TypeError):
+ return None
+
# Steve's 2026-09-15 dot-flash directive: green (WORKING) and pink (PARKED) stay SOLID; the
# four needs-Steve states PULSE. A true timed blink of the TAB itself is impossible without a
# background per-tab loop -- and that loop IS the orphaned-stuck-color mechanism Steve's memory
@@ -704,6 +725,30 @@ def selftest():
case("backfill missing floor still has no owner_status_unverified flag",
green_rec is not None and "owner_status_unverified" not in green_rec)
+ # 5f. TK-11317 CONTRARIAN REVIEW (2026-09-26), CRITICAL hole #1: the injected
+ # fault is EXACTLY working-state.sh's per-prompt UserPromptSubmit hook --
+ # `set green WORKING --deferential --boot` -- landing on a tty whose only
+ # record belongs to a PRIOR owner (reason owner_changed). Before the fix,
+ # set()'s deferential-yield guard required a truthy `previous`, but
+ # load(owner) returns None for owner_changed, so this call silently built
+ # a brand-new PLAIN GREEN record over a carried purple. Runs headless, its
+ # own tempdir Store (never touches the real state dir or a tty).
+ with tempfile.TemporaryDirectory() as df_dir:
+ df_old = Process(6464, 1, "ttys095", "Wed Sep 9 08:35:08 2026", "/usr/bin/claude")
+ df_new = Process(6565, 1, "ttys095", "Thu Sep 10 09:00:00 2026", "/usr/bin/claude")
+ df_old_store = Store(user_root=df_dir, process_provider=lambda **_: {6464: df_old},
+ painter=lambda owner, record: None)
+ df_old_store.set(df_old.owner(), "purple", "TK-11317 gated reason", rows={6464: df_old})
+ df_store = Store(user_root=df_dir, process_provider=lambda **_: {6565: df_new},
+ painter=lambda owner, record: None)
+ df_record = df_store.set(df_new.owner(), "green", "WORKING",
+ deferential=True, rows={6565: df_new})
+ df_on_disk, df_reason = df_store.load(df_new.owner())
+ case("deferential first-prompt paint carries purple, never plain green",
+ df_record["state"] == "purple" and df_on_disk is not None
+ and df_reason == "canonical" and df_on_disk["state"] == "purple"
+ and "carried_from" in df_record)
+
# 6. TK-11870 heartbeat honesty (CLAUDE.md TK-11431: an unmeasured input is
# NEVER green; severity maps to capability). The injected faults are the
# two false-green shapes the old hardcoded-PASS heartbeat masked.
@@ -960,49 +1005,102 @@ class Store:
except (OSError, ValueError, KeyError, TypeError):
return None
- def prior_attention(self, owner):
- """TK-11317: what a PRIOR owner (before a tty-reuse owner_changed) left on
- this tty, if it was an ATTENTION (needs-Steve) colour. Read-only; never
- writes. Checks BOTH the canonical JSON record at this tty's path (owner
- mismatch tolerated on purpose -- see _read_raw_for_prior) and the legacy
- .dot mirror at ANY mtime: at the point this runs the NEW owner has not
- painted anything yet, so anything sitting in either place can only be the
- prior owner's. Returns {"color","label","ticket"} for whichever source is
- HIGHER priority (PRIORITY: lower index wins), or None if neither carried
- attention. The ticket is carried ONLY when the winning colour came from the
- JSON record (it is the record that names the specific outstanding ask); a
- legacy-.dot-only carry never invents a ticket id from its label text."""
- candidates = []
+ def prior_attention(self, owner, *, now=None):
+ """TK-11317 (contrarian review, 2026-09-26): what a PRIOR owner (before a
+ tty-reuse owner_changed) left on this tty, if it was a FRESH ATTENTION
+ (needs-Steve) colour. Read-only; never writes.
+
+ Checks BOTH the canonical JSON record at this tty's path (owner mismatch
+ tolerated on purpose -- see _read_raw_for_prior) and the legacy .dot mirror,
+ each gated by CARRY_MAX_AGE_S: a source older than that (or one whose age
+ cannot even be determined) is NEVER carried -- it falls through exactly like
+ "nothing pending" does, floored to yellow "New owner - status needed" by
+ whichever caller resolves this into a write.
+
+ Age is measured from the ORIGINAL event, not the most recent hop: a JSON
+ record that is ITSELF already a carry (has `carried_from`) reports its age
+ from `carried_from["at"]`, so a relay of owner_changed hops cannot keep
+ resetting the clock and outrun the ceiling.
+
+ Returns None if nothing FRESH carried attention, else:
+ {"color", "label", "ticket", "carried_from", "prior_revision"}
+ `ticket` is carried ONLY when the winning colour came from the JSON record
+ (never invented from a legacy-.dot label). `carried_from` is
+ {"pid","started","at","hops"} -- pid/started are the ORIGINAL carrier's
+ identity when known (None for a legacy-.dot-only source, which has no
+ stored process identity); hops counts how many owner_changed carries this
+ attention has survived. `prior_revision` is the raw JSON record's own
+ `revision` (so a write can CONTINUE the revision sequence instead of
+ resetting to 1), or None when the winning source has no revision to
+ continue (legacy-only)."""
+ now = now if now is not None else time.time()
+ candidates = [] # (priority, color, label, ticket, carried_from, prior_revision)
+
r = self._read_raw_for_prior(owner)
if r is not None and r["state"] in ATTENTION:
- label, _ = label_ticket(r["label"])
- candidates.append((PRIORITY[r["state"]], r["state"], label, r.get("ticket", "")))
+ origin = r.get("carried_from") if isinstance(r.get("carried_from"), dict) else None
+ if origin is not None and isinstance(origin.get("at"), (int, float)):
+ at = origin["at"]
+ hops = int(origin.get("hops", 1)) + 1
+ pid, started = origin.get("pid"), origin.get("started")
+ else:
+ at = _epoch_of(r.get("updated_at"))
+ hops = 1
+ owner_field = r.get("owner") if isinstance(r.get("owner"), dict) else {}
+ pid, started = owner_field.get("pid"), owner_field.get("started")
+ if at is not None and (now - at) <= CARRY_MAX_AGE_S:
+ label, _ = label_ticket(r["label"])
+ candidates.append((PRIORITY[r["state"]], r["state"], label, r.get("ticket", ""),
+ {"pid": pid, "started": started, "at": at, "hops": hops},
+ r.get("revision")))
+ # else: unparseable or stale -- NOT carried (CLAUDE.md TK-11431: an
+ # unmeasured/too-old input is never treated as good).
+
legacy_path = self.legacy[owner.runtime] / (owner.tty + ".dot")
try:
+ st = legacy_path.stat()
title = legacy_path.read_text().strip()
except OSError:
- title = ""
- if title and valid_label(title):
+ title, st = "", None
+ if (title and valid_label(title) and st is not None
+ and (now - st.st_mtime) <= CARRY_MAX_AGE_S):
color = color_of(title)
if color in ATTENTION:
raw_label = title[len(COLORS[color][0]):].strip()
label, _ = label_ticket(raw_label)
- candidates.append((PRIORITY[color], color, label, ""))
+ candidates.append((PRIORITY[color], color, label, "",
+ {"pid": None, "started": None, "at": st.st_mtime, "hops": 1},
+ None))
if not candidates:
return None
candidates.sort(key=lambda c: c[0])
- _, color, label, ticket = candidates[0]
- return {"color": color, "label": label, "ticket": ticket}
-
- def prior_owner_info(self, owner):
- """Best-effort identity of the prior owner whose record occupied this tty,
- for the settled record's `carried_from` provenance. None when there is no
- JSON record to read it from (e.g. a legacy-.dot-only carry has no stored
- pid/started)."""
- r = self._read_raw_for_prior(owner)
- if r is not None and isinstance(r.get("owner"), dict):
- return {"pid": r["owner"].get("pid"), "started": r["owner"].get("started")}
- return None
+ _, color, label, ticket, carried_from, prior_revision = candidates[0]
+ return {"color": color, "label": label, "ticket": ticket,
+ "carried_from": carried_from, "prior_revision": prior_revision}
+
+ def _settlement_preview(self, owner):
+ """The (unwritten) record settle_owner_change() would persist for this tty
+ RIGHT NOW -- the single shared computation behind row()'s <20s pre-backfill
+ preview, effective_previous() (ticket-only rebinds), and settle_owner_change's
+ own write, so all three can never disagree about what "the settled state" is.
+ Shaped like a loaded record's relevant fields: state/label/ticket/ticket_at/
+ ticket_source/variant, plus carried_from (attention carry) or
+ owner_status_unverified (nothing fresh pending), plus next_revision when the
+ winning source has a revision to continue from."""
+ prior = self.prior_attention(owner)
+ if prior is not None:
+ color = prior["color"]
+ label = prior["label"] or COLORS[color][2]
+ rec = {"state": color, "label": label, "ticket": prior["ticket"],
+ "ticket_at": time.time() if prior["ticket"] else 0,
+ "ticket_source": "carried" if prior["ticket"] else "unbound",
+ "variant": "", "carried_from": prior["carried_from"]}
+ if prior.get("prior_revision") is not None:
+ rec["next_revision"] = prior["prior_revision"] + 1
+ return rec
+ return {"state": "yellow", "label": OWNER_STATUS_LABEL,
+ "ticket": "", "ticket_at": 0, "ticket_source": "unbound",
+ "variant": "", "owner_status_unverified": True}
def _prior_raw_texts(self, owner):
"""Raw label/title text the prior owner left in BOTH sources, so ticket ids a
@@ -1033,33 +1131,57 @@ class Store:
except (OSError, ValueError, KeyError, TypeError):
return None
+ def _settle_locked(self, owner, *, rows=None):
+ """Write settle_owner_change()'s outcome using the CURRENTLY HELD lock --
+ never calls self.lock() (flock is not re-entrant within one process; a
+ second acquire from here would deadlock). Used both by the public, locking
+ settle_owner_change() below, and by _set_locked() itself (TK-11317
+ contrarian fix) when an automatic deferential caller -- the per-prompt
+ working-state.sh hook's `set green WORKING --deferential --boot` -- hits
+ reason owner_changed: previously `previous` was simply None there, so the
+ deferential-yield guard (which requires a truthy `previous`) never fired
+ and a carried purple/orange/yellow/lightblue was silently overwritten with
+ plain green. Settling first, inside the SAME lock, closes that hole."""
+ preview = self._settlement_preview(owner)
+ carried_from = preview.get("carried_from")
+ owner_status_unverified = preview.get("owner_status_unverified")
+ revision_override = preview.get("next_revision")
+ if carried_from is not None:
+ self.record_departed(owner, {"color": preview["state"], "label": preview["label"],
+ "ticket": preview["ticket"]}, carried_from)
+ try:
+ return self._set_locked(owner, preview["state"], preview["label"],
+ issued_ns=issued_clock(), ticket_update=preview["ticket"] or "",
+ variant="", deferential=False, rows=rows,
+ carried_from=carried_from,
+ owner_status_unverified=owner_status_unverified,
+ revision_override=revision_override)
+ except StatusError:
+ # An unknown/stale carried ticket id must never block restoring the
+ # carried ATTENTION colour itself -- drop the ticket, keep the colour.
+ return self._set_locked(owner, preview["state"], preview["label"],
+ issued_ns=issued_clock(), ticket_update="",
+ variant="", deferential=False, rows=rows,
+ carried_from=carried_from,
+ owner_status_unverified=owner_status_unverified,
+ revision_override=revision_override)
+
def settle_owner_change(self, owner, *, rows=None, lock_wait=None):
"""TK-11317 (Steve's TK-11620 guard ruling, 2026-09-26): a tty-reuse
owner_changed record must NEVER silently blank or green-floor a prior
- needs-Steve colour. If the prior owner left attention pending (JSON record
- or legacy .dot mirror), CARRY it forward onto the new owner's record with
- provenance (`carried_from`: the prior owner's pid/started, when known).
- Otherwise floor to yellow "New owner - status needed"
- (`owner_status_unverified`) rather than a blind green -- a brand-new
- session's real state has not been observed yet either, so green would be
- just as unmeasured a claim as silence was."""
- prior = self.prior_attention(owner)
- info = self.prior_owner_info(owner)
- if prior is not None:
- self.record_departed(owner, prior, info)
- label = prior["label"] or COLORS[prior["color"]][2]
- try:
- return self.set(owner, prior["color"], label,
- ticket_update=prior["ticket"] or "",
- rows=rows, lock_wait=lock_wait, carried_from=info)
- except StatusError:
- # An unknown/stale carried ticket id must never block restoring the
- # carried ATTENTION colour itself -- drop the ticket, keep the colour.
- return self.set(owner, prior["color"], label,
- ticket_update="", rows=rows, lock_wait=lock_wait,
- carried_from=info)
- return self.set(owner, "yellow", OWNER_STATUS_LABEL,
- rows=rows, lock_wait=lock_wait, owner_status_unverified=True)
+ needs-Steve colour. If the prior owner left FRESH attention pending (JSON
+ record or legacy .dot mirror, within CARRY_MAX_AGE_S), CARRY it forward
+ onto the new owner's record with provenance (`carried_from`: the ORIGINAL
+ carrier's pid/started/at, plus a hop count). Otherwise floor to yellow
+ "New owner - status needed" (`owner_status_unverified`) rather than a
+ blind green -- a brand-new session's real state has not been observed yet
+ either, so green would be just as unmeasured a claim as silence was.
+
+ Public, LOCKING entry point for callers that do not already hold this
+ tty's lock (backfill, the `start` command). See _settle_locked for the
+ actual write logic, shared with _set_locked's own-lock presettle path."""
+ with self.lock(owner, rows=rows, wait=lock_wait):
+ return self._settle_locked(owner, rows=rows)
def legacy_status(self, owner, live_title=""):
# Only the owning runtime's legacy file can be considered during rollout.
@@ -1086,10 +1208,56 @@ class Store:
# A clear tombstone, corruption, or reused tty can never fall back.
return r, reason
+ def effective_previous(self, owner, live_title=""):
+ """TK-11317 (contrarian review, 2026-09-26): resolve "what does this tty
+ already show" for TICKET-ONLY / preserve-style writers that must never
+ treat owner_changed as "nothing here". Before this, both the `ticket`
+ command and auto-bind's ticket_set did `previous, _ = store.read(owner)`
+ and silently defaulted to a BLANK "none" record whenever reason was
+ actually owner_changed -- so rebinding a ticket on a reused tty dropped a
+ carried needs-Steve colour the instant it ran, even though the colour
+ itself was never touched otherwise.
+
+ Returns (record, reason) shaped exactly like read(): canonical/legacy/
+ missing pass through unchanged; owner_changed returns the SAME (unwritten)
+ settlement preview settle_owner_change() would persist, so
+ `effective_previous(owner)[0]["state"]` is always the tab's true
+ current-or-about-to-be-settled colour, never "none"."""
+ r, reason = self.load(owner)
+ if reason == "owner_changed":
+ return self._settlement_preview(owner), "owner_changed"
+ if reason == "missing":
+ return self.legacy_status(owner, live_title)
+ return r, reason
+
def set(self, owner, color, label="", *, issued_ns=None, ticket_update=None,
variant="", deferential=False, rows=None, lock_wait=None,
- carried_from=None, owner_status_unverified=None):
+ carried_from=None, owner_status_unverified=None, revision_override=None):
+ # TK-11835: a cross-tty caller passes the already-resolved `rows` (so the
+ # in-lock assert_owner reuses main's table instead of re-scanning) and a
+ # short `lock_wait` (so a busy target is skipped fast, not waited on 65s).
+ # Both default to None -> byte-identical to the prior self-paint path.
issued_ns = issued_ns if issued_ns is not None else issued_clock()
+ with self.lock(owner, rows=rows, wait=lock_wait):
+ return self._set_locked(owner, color, label, issued_ns=issued_ns,
+ ticket_update=ticket_update, variant=variant,
+ deferential=deferential, rows=rows,
+ carried_from=carried_from,
+ owner_status_unverified=owner_status_unverified,
+ revision_override=revision_override)
+
+ def _set_locked(self, owner, color, label, *, issued_ns, ticket_update, variant,
+ deferential, rows, carried_from, owner_status_unverified,
+ revision_override=None):
+ """The full write body of set() -- caller MUST already hold self.lock(owner)
+ (set() itself, or _settle_locked() via _set_locked's own presettle branch
+ below). Factored out of set() (TK-11317 contrarian fix, CRITICAL hole #1):
+ a deferential AUTOMATIC caller (working-state.sh's per-prompt
+ `set green WORKING --deferential --boot`) hitting reason owner_changed can
+ now settle the owner change FIRST, inside the SAME already-held lock,
+ WITHOUT re-entering self.lock() -- flock is not re-entrant within one
+ process, so a second acquire here would deadlock; _settle_locked() is the
+ lock-free write body shared with the public settle_owner_change()."""
if color not in COLORS or not valid_label(label) or variant not in ("", "monitoring", "stopped", "waiting"):
raise StatusError("Invalid status or label")
label, embedded_ticket = label_ticket(label)
@@ -1105,103 +1273,127 @@ class Store:
# paints its own reason — otherwise the new ticket lands on the OLD label.
caller_gave_reason = bool(label) or ticket_update is not None
label = "" if color == "none" else (label or COLORS[color][2])
- # TK-11835: a cross-tty caller passes the already-resolved `rows` (so the
- # in-lock assert_owner reuses main's table instead of re-scanning) and a
- # short `lock_wait` (so a busy target is skipped fast, not waited on 65s).
- # Both default to None -> byte-identical to the prior self-paint path.
- with self.lock(owner, rows=rows, wait=lock_wait):
- previous, _ = self.load(owner)
- if previous and issued_ns <= previous["issued_ns"]:
- raise StatusError("Obsolete status update; a newer event already won")
- # T1-c (TK-11779) — an EXPLICIT lightblue must not ERASE an underlying
- # needs-Steve reason. lightblue is the UMBRELLA "this stop needs Steve"; when
- # a SPECIFIC reason already stands on the tab — purple (gated memo), orange
- # (paste waiting), yellow (question) — express "needs Steve" as the additive
- # 🔵 stopped marker ON that base colour instead of overwriting it. Clearing the
- # marker later (session resumes) then REVEALS the still-pending reason instead
- # of a dead/dotless tab that silently lost its gated memo. A green/teal/pink/
- # none/lightblue base has no reason to preserve, so it takes solid lightblue as
- # before. `variant == "stopped"` guards against re-routing the additive path
- # itself (the Stop hook already sets the marker directly).
- if (color == "lightblue" and variant != "stopped" and not caller_gave_reason
- and previous and previous.get("state") in ("purple", "orange", "yellow")):
- color = previous["state"]
- variant = "stopped"
- label = label_ticket(previous.get("label", ""))[0]
- # Defensive coherence (TK-11826 / TK-11779 Finding 2): the branch guard
- # `not caller_gave_reason` already guarantees ticket_update is None here
- # (caller_gave_reason is True whenever ticket_update is not None), so the
- # overwrite below cannot fire on this path today. Pin it to None anyway so
- # the preserved reason stays coherent as a WHOLE — colour + label + ticket —
- # even if a future edit ever loosens the branch condition; never the
- # mismatch of an OLD preserved label carrying a NEW caller ticket.
- ticket_update = None
- # TK-11378 (DTD verdict A) — an AUTOMATIC paint must never erase a pending
- # request for Steve's attention. PRIORITY already encodes the ordering
- # (orange > purple > yellow > green > pink > none) but until now was only
- # used to sort scan() output, never to arbitrate a paint. "Sticky" = anything
- # ranked above green, i.e. the three colors that mean a human is blocked.
- # Only deferential callers (the per-prompt working-state hook) yield; every
- # EXPLICIT paint (/greendot, /color, /pinkdot, --off) stays authoritative, so
- # a session can still clear its own dot or reuse the tab for new work.
- if (deferential and previous and color == "green"
- and PRIORITY.get(previous.get("state"), PRIORITY["none"]) < PRIORITY["green"]):
- self.assert_owner(owner, rows)
- try:
- self.painter(owner, previous)
- except (OSError, StatusError):
- pass # keep the record authoritative even if the repaint fails
- return previous
- ticket = self.ticket_info(owner, previous)
- if ticket_update is not None:
- ticket = {"id": ticket_update, "at": time.time(), "source": "explicit"}
- record = {
- "version": VERSION, "owner": dataclasses.asdict(owner),
- "state": color, "label": label,
- "title": status_title(color, label, ticket["id"],
- variant if (color == "green" or variant == "stopped") else ""),
- "ticket": ticket["id"], "ticket_at": ticket["at"],
- "ticket_source": ticket["source"],
- # "stopped" is colour-AGNOSTIC: it is an additive 🔵 marker beside ANY base
- # dot, and the blocked colours (purple/yellow/orange) are its whole point.
- # "monitoring" stays green-only because it is the teal TINT of green.
- "variant": variant if (color == "green" or variant == "stopped") else "",
- "revision": previous["revision"] + 1 if previous else 1,
- "event_id": uuid.uuid4().hex, "issued_ns": issued_ns,
- "updated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
- "render_status": "pending", "mirror_errors": [],
- }
- # TK-11317: both fields are OPT-IN per call, never inherited from
- # `previous` implicitly -- an ordinary explicit set() (e.g. /greendot)
- # that doesn't pass them builds a record without them, which is exactly
- # how "only an explicit non-deferential set clears owner_status_unverified"
- # is satisfied. A caller that means to PRESERVE either field across a
- # rebuild (settle_owner_change's carry, the `ticket` command's rebind)
- # must pass it through explicitly.
- if carried_from is not None:
- record["carried_from"] = carried_from
- if owner_status_unverified:
- record["owner_status_unverified"] = True
+ previous, load_reason = self.load(owner)
+ # TK-11317 contrarian fix (CRITICAL hole #1, 2026-09-26): previously
+ # `previous` was simply None whenever reason was owner_changed (the tty's
+ # only on-disk record belongs to a PRIOR owner), so the deferential-yield
+ # guard just below -- which requires a truthy `previous` -- never fired,
+ # and an AUTOMATIC deferential green call went on to build a brand-new
+ # green record, silently overwriting whatever the prior owner had left
+ # pending. Settle the owner change first (carry attention, or the yellow
+ # "New owner - status needed" floor) and use THAT as `previous`. Its
+ # colour is always >= yellow priority (never green/pink/none), so the
+ # deferential-yield guard right below this will always fire on it and
+ # repaint the settled record -- it never falls through to overwrite with
+ # green. A NON-deferential explicit caller (e.g. /greendot on a genuinely
+ # new session) is UNCHANGED -- this branch only engages for `deferential`.
+ if previous is None and load_reason == "owner_changed" and deferential:
+ previous = self._settle_locked(owner, rows=rows)
+ # The settle write's OWN issued_ns is necessarily fresher than the
+ # issued_ns this call captured before presettling (it is a later
+ # event, in the same call), so without this bump the very next line
+ # would see `issued_ns <= previous["issued_ns"]` and misreport this
+ # call as obsolete against its OWN side effect. +1 guarantees strictly
+ # newer regardless of clock resolution ties.
+ issued_ns = max(issued_ns, previous["issued_ns"] + 1)
+ if previous and issued_ns <= previous["issued_ns"]:
+ raise StatusError("Obsolete status update; a newer event already won")
+ # T1-c (TK-11779) — an EXPLICIT lightblue must not ERASE an underlying
+ # needs-Steve reason. lightblue is the UMBRELLA "this stop needs Steve"; when
+ # a SPECIFIC reason already stands on the tab — purple (gated memo), orange
+ # (paste waiting), yellow (question) — express "needs Steve" as the additive
+ # 🔵 stopped marker ON that base colour instead of overwriting it. Clearing the
+ # marker later (session resumes) then REVEALS the still-pending reason instead
+ # of a dead/dotless tab that silently lost its gated memo. A green/teal/pink/
+ # none/lightblue base has no reason to preserve, so it takes solid lightblue as
+ # before. `variant == "stopped"` guards against re-routing the additive path
+ # itself (the Stop hook already sets the marker directly).
+ if (color == "lightblue" and variant != "stopped" and not caller_gave_reason
+ and previous and previous.get("state") in ("purple", "orange", "yellow")):
+ color = previous["state"]
+ variant = "stopped"
+ label = label_ticket(previous.get("label", ""))[0]
+ # Defensive coherence (TK-11826 / TK-11779 Finding 2): the branch guard
+ # `not caller_gave_reason` already guarantees ticket_update is None here
+ # (caller_gave_reason is True whenever ticket_update is not None), so the
+ # overwrite below cannot fire on this path today. Pin it to None anyway so
+ # the preserved reason stays coherent as a WHOLE — colour + label + ticket —
+ # even if a future edit ever loosens the branch condition; never the
+ # mismatch of an OLD preserved label carrying a NEW caller ticket.
+ ticket_update = None
+ # TK-11378 (DTD verdict A) — an AUTOMATIC paint must never erase a pending
+ # request for Steve's attention. PRIORITY already encodes the ordering
+ # (orange > purple > yellow > green > pink > none) but until now was only
+ # used to sort scan() output, never to arbitrate a paint. "Sticky" = anything
+ # ranked above green, i.e. the three colors that mean a human is blocked.
+ # Only deferential callers (the per-prompt working-state hook) yield; every
+ # EXPLICIT paint (/greendot, /color, /pinkdot, --off) stays authoritative, so
+ # a session can still clear its own dot or reuse the tab for new work.
+ if (deferential and previous and color == "green"
+ and PRIORITY.get(previous.get("state"), PRIORITY["none"]) < PRIORITY["green"]):
self.assert_owner(owner, rows)
- atomic_write(self.path(owner), json.dumps(record, indent=2) + "\n")
- error = None
try:
- self.assert_owner(owner, rows)
- self.painter(owner, record)
- record["render_status"] = "applied"
- except (OSError, StatusError) as exc:
- record["render_status"] = "failed"
- record["render_error"] = str(exc)
- error = exc
- for directory in self.legacy.values():
- try:
- atomic_write(directory / (owner.tty + ".dot"), record["title"])
- except OSError as exc:
- record["mirror_errors"].append(str(exc))
- atomic_write(self.path(owner), json.dumps(record, indent=2) + "\n")
- if error or record["mirror_errors"]:
- raise StatusError("Status saved, but display/mirror update failed; run audit")
- return record
+ self.painter(owner, previous)
+ except (OSError, StatusError):
+ pass # keep the record authoritative even if the repaint fails
+ return previous
+ ticket = self.ticket_info(owner, previous)
+ if ticket_update is not None:
+ ticket = {"id": ticket_update, "at": time.time(), "source": "explicit"}
+ record = {
+ "version": VERSION, "owner": dataclasses.asdict(owner),
+ "state": color, "label": label,
+ "title": status_title(color, label, ticket["id"],
+ variant if (color == "green" or variant == "stopped") else ""),
+ "ticket": ticket["id"], "ticket_at": ticket["at"],
+ "ticket_source": ticket["source"],
+ # "stopped" is colour-AGNOSTIC: it is an additive 🔵 marker beside ANY base
+ # dot, and the blocked colours (purple/yellow/orange) are its whole point.
+ # "monitoring" stays green-only because it is the teal TINT of green.
+ "variant": variant if (color == "green" or variant == "stopped") else "",
+ # TK-11317 item 4 (LOW, contrarian review): `revision_override` lets a CARRY
+ # write (settle_owner_change / effective_previous-driven ticket rebinds)
+ # CONTINUE the raw prior record's own revision sequence instead of resetting
+ # to 1 -- the carry is a continuation of the same outstanding ask, not a new
+ # one. None (the default for every ordinary call) preserves the original
+ # "previous+1 or 1" behaviour byte-for-byte.
+ "revision": revision_override if revision_override is not None else (
+ previous["revision"] + 1 if previous else 1),
+ "event_id": uuid.uuid4().hex, "issued_ns": issued_ns,
+ "updated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
+ "render_status": "pending", "mirror_errors": [],
+ }
+ # TK-11317: both fields are OPT-IN per call, never inherited from
+ # `previous` implicitly -- an ordinary explicit set() (e.g. /greendot)
+ # that doesn't pass them builds a record without them, which is exactly
+ # how "only an explicit non-deferential set clears owner_status_unverified"
+ # is satisfied. A caller that means to PRESERVE either field across a
+ # rebuild (settle_owner_change's carry, the `ticket` command's rebind)
+ # must pass it through explicitly.
+ if carried_from is not None:
+ record["carried_from"] = carried_from
+ if owner_status_unverified:
+ record["owner_status_unverified"] = True
+ self.assert_owner(owner, rows)
+ atomic_write(self.path(owner), json.dumps(record, indent=2) + "\n")
+ error = None
+ try:
+ self.assert_owner(owner, rows)
+ self.painter(owner, record)
+ record["render_status"] = "applied"
+ except (OSError, StatusError) as exc:
+ record["render_status"] = "failed"
+ record["render_error"] = str(exc)
+ error = exc
+ for directory in self.legacy.values():
+ try:
+ atomic_write(directory / (owner.tty + ".dot"), record["title"])
+ except OSError as exc:
+ record["mirror_errors"].append(str(exc))
+ atomic_write(self.path(owner), json.dumps(record, indent=2) + "\n")
+ if error or record["mirror_errors"]:
+ raise StatusError("Status saved, but display/mirror update failed; run audit")
+ return record
def repaint(self, owner, *, rows=None, lock_wait=None):
with self.lock(owner, rows=rows, wait=lock_wait):
@@ -1312,19 +1504,16 @@ class Store:
if reason == "owner_changed":
# TK-11317: this is the <20s window before backfill's next cycle
# actually settles the record -- render the WOULD-BE settled state
- # (settle_owner_change's own logic, read-only here) instead of a bare
- # ⚪ "status not set", so a carried needs-Steve colour is never even
- # MOMENTARILY invisible.
- prior = self.prior_attention(owner)
- if prior is not None:
- color = prior["color"]
- label = prior["label"] or COLORS[color][2]
- tk_id = prior["ticket"] or ticket["id"]
- else:
- color, label, tk_id = "yellow", OWNER_STATUS_LABEL, ticket["id"]
+ # (the SAME _settlement_preview() effective_previous() and
+ # settle_owner_change() itself use, so all three can never
+ # disagree) instead of a bare ⚪ "status not set", so a carried
+ # needs-Steve colour is never even MOMENTARILY invisible.
+ preview = self._settlement_preview(owner)
+ tk_id = preview["ticket"] or ticket["id"]
result.update(
- color=color,
- label=status_title(color, label, tk_id or "TK REQUIRED", "")
+ color=preview["state"],
+ label=status_title(preview["state"], preview["label"],
+ tk_id or "TK REQUIRED", "")
or "⚪ " + (tk_id or "TK REQUIRED") + " · Status cleared",
warnings=["owner_status_unverified"])
return result
@@ -1719,11 +1908,20 @@ def _default_tk_new(owner, topic, project):
def _default_ticket_set(store, owner, ticket_id):
"""Same effect as the `ticket` subcommand: bind explicitly, preserving
- whatever color/label the session already has."""
- previous, _ = store.read(owner)
+ whatever color/label the session already has. TK-11317 contrarian review:
+ uses effective_previous() (not read()) so a reused tty whose only record
+ belongs to a PRIOR owner never gets silently rebound to "none" -- this is
+ the AUTOMATIC auto-bind path (backgrounded from working-state.sh's
+ UserPromptSubmit hook), so it must never blank a carried attention colour
+ any more than the deferential --boot paint may."""
+ previous, reason = store.effective_previous(owner)
store.set(owner, previous["state"] if previous else "none",
label_ticket(previous["label"])[0] if previous else "",
- ticket_update=ticket_id, variant=previous.get("variant", "") if previous else "")
+ ticket_update=ticket_id, variant=previous.get("variant", "") if previous else "",
+ carried_from=previous.get("carried_from") if previous else None,
+ owner_status_unverified=previous.get("owner_status_unverified") if previous else None,
+ revision_override=(previous.get("next_revision")
+ if previous and reason == "owner_changed" else None))
# header() is a cosmetic tab-title refresh (adds the "up Nh Mm" suffix) on
# top of the dot that set() already painted -- the ticket is already bound
# in the record at this point, so a pty write hiccup here must not be
@@ -2121,16 +2319,22 @@ def main(argv=None):
ticket = "" if ticket in ("CLEAR", "NONE") else ("TK-" + ticket if ticket.isdigit() else tickets.short(ticket))
if not ticket and args.ticket.upper() not in ("CLEAR", "NONE"):
raise StatusError("Expected a canonical TK number or clear")
- previous, _ = store.read(caller)
+ # TK-11317 contrarian review: effective_previous() (not read()) -- a reused
+ # tty whose only record belongs to a PRIOR owner must never rebind to
+ # "none" just because a ticket command ran before the owner change settled.
+ previous, reason = store.effective_previous(caller)
# TK-11317: the ticket command only REBINDS the ticket id -- it must preserve
# a carried needs-Steve colour and the owner_status_unverified flag exactly
# as store.set()'s opt-in-per-call contract requires (they are never
- # inherited implicitly from `previous`).
+ # inherited implicitly from `previous`), and CONTINUE the revision sequence
+ # when this is the moment a carry first lands on disk.
record = store.set(caller, previous["state"] if previous else "none",
label_ticket(previous["label"])[0] if previous else "",
ticket_update=ticket, variant=previous.get("variant", "") if previous else "",
carried_from=previous.get("carried_from") if previous else None,
- owner_status_unverified=previous.get("owner_status_unverified") if previous else None)
+ owner_status_unverified=previous.get("owner_status_unverified") if previous else None,
+ revision_override=(previous.get("next_revision")
+ if previous and reason == "owner_changed" else None))
store.header(caller)
print(f'/terminal-ticket → {caller.tty} {record["ticket"] or "TK REQUIRED"}')
return 0
diff --git a/test_owner_change_carry.py b/test_owner_change_carry.py
index ecaaa28..a04e502 100644
--- a/test_owner_change_carry.py
+++ b/test_owner_change_carry.py
@@ -8,7 +8,11 @@ against a tempdir Store with a fake in-memory painter -- never a real tty, never
the shared ~/.local/state/abrams-terminal-status store.
"""
import dataclasses
+import datetime as dt
+import json
+import os
import tempfile
+import time
import unittest
from pathlib import Path
@@ -37,6 +41,30 @@ class OwnerChangeCarryTests(unittest.TestCase):
path.write_text(title)
return path
+ def age_old_record(self, seconds_ago):
+ """Rewrite the OLD owner's on-disk `updated_at` to look `seconds_ago` in
+ the past -- the freshness clock prior_attention() reads for a JSON
+ source -- AND age its legacy .dot mirrors to match (Store.set() always
+ writes ALL runtimes' mirrors, so a same-tty realistic "this leftover
+ state is N days old" scenario ages both together; aging only the JSON
+ would leave the still-fresh .dot mirror as an unaged fallback source,
+ silently defeating the point of the test). Bypasses Store.set() (which
+ always stamps "now") so tests can exercise CARRY_MAX_AGE_S deterministically."""
+ path = self.store.root / (self.old_owner.tty + ".json")
+ data = json.loads(path.read_text())
+ aged = dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=seconds_ago)
+ data["updated_at"] = aged.isoformat()
+ path.write_text(json.dumps(data, indent=2) + "\n")
+ when = time.time() - seconds_ago
+ for directory in self.store.legacy.values():
+ dot_path = directory / (self.old_owner.tty + ".dot")
+ if dot_path.exists():
+ os.utime(dot_path, (when, when))
+
+ def age_legacy(self, path, seconds_ago):
+ when = time.time() - seconds_ago
+ os.utime(path, (when, when))
+
def make_old_record(self, color, label="reason"):
"""Write a canonical record for the OLD owner directly (no lock/assert_owner
contention with the new owner, since the fixture's process table only
@@ -52,7 +80,15 @@ class OwnerChangeCarryTests(unittest.TestCase):
def test_prior_purple_record_carried(self):
self.make_old_record("purple", "gated memo")
prior = self.store.prior_attention(self.new_owner)
- self.assertEqual(prior, {"color": "purple", "label": "gated memo", "ticket": ""})
+ self.assertEqual(prior["color"], "purple")
+ self.assertEqual(prior["label"], "gated memo")
+ self.assertEqual(prior["ticket"], "")
+ # TK-11317 contrarian follow-up: carried_from/prior_revision provenance.
+ self.assertEqual(prior["carried_from"]["pid"], self.old_owner.pid)
+ self.assertEqual(prior["carried_from"]["started"], self.old_owner.started)
+ self.assertEqual(prior["carried_from"]["hops"], 1)
+ self.assertIsInstance(prior["carried_from"]["at"], (int, float))
+ self.assertEqual(prior["prior_revision"], 1)
def test_prior_orange_record_carried(self):
self.make_old_record("orange", "paste waiting")
@@ -164,8 +200,10 @@ class OwnerChangeCarryTests(unittest.TestCase):
def test_carried_from_records_prior_owner_identity(self):
self.make_old_record("purple", "gated memo")
record = self.store.settle_owner_change(self.new_owner, rows=self.rows)
- self.assertEqual(record["carried_from"],
- {"pid": self.old_owner.pid, "started": self.old_owner.started})
+ self.assertEqual(record["carried_from"]["pid"], self.old_owner.pid)
+ self.assertEqual(record["carried_from"]["started"], self.old_owner.started)
+ self.assertEqual(record["carried_from"]["hops"], 1)
+ self.assertIsInstance(record["carried_from"]["at"], (int, float))
def test_load_accepts_records_with_and_without_carried_from(self):
# load() must validate a record carrying the new optional fields exactly as
@@ -326,6 +364,194 @@ class OwnerChangeCarryTests(unittest.TestCase):
with self.assertRaises(ts.StatusError):
self.store.set(self.old_owner, "orange", "stale write", rows=self.rows)
+ # ---- CONTRARIAN REVIEW FOLLOW-UP (2026-09-26): CRITICAL hole #1 --------
+ # working-state.sh's per-prompt UserPromptSubmit hook fires
+ # `set green WORKING --deferential --boot` on EVERY prompt. Before this fix,
+ # set()'s deferential-yield guard required a truthy `previous`, but
+ # load(owner) returns None for reason owner_changed -- so the FIRST prompt
+ # after a tty reuse silently overwrote a carried purple/orange/yellow/
+ # lightblue with plain green. These tests are the exact repro.
+
+ def test_deferential_first_prompt_carries_purple_not_green(self):
+ self.make_old_record("purple", "gated memo")
+ record = self.store.set(self.new_owner, "green", "WORKING",
+ deferential=True, rows=self.rows)
+ self.assertEqual(record["state"], "purple")
+ self.assertIn("gated memo", record["label"])
+ self.assertIn("carried_from", record)
+ # The record actually ON DISK (not just the return value) is the carry.
+ on_disk, reason = self.store.load(self.new_owner)
+ self.assertEqual(reason, "canonical")
+ self.assertEqual(on_disk["state"], "purple")
+
+ def test_deferential_first_prompt_unverified_yellow_stays_yellow(self):
+ # Prior owner had nothing pending (plain green) -- the deferential green
+ # call must land on the yellow "New owner - status needed" floor, not
+ # silently succeed at plain green.
+ self.make_old_record("green")
+ record = self.store.set(self.new_owner, "green", "WORKING",
+ deferential=True, rows=self.rows)
+ self.assertEqual(record["state"], "yellow")
+ self.assertTrue(record["owner_status_unverified"])
+ on_disk, reason = self.store.load(self.new_owner)
+ self.assertEqual(on_disk["state"], "yellow")
+
+ def test_explicit_non_deferential_set_still_wins_over_owner_changed(self):
+ # "A NON-deferential explicit set by the new owner still wins" -- the
+ # presettle branch only engages for deferential=True. An explicit
+ # /greendot-style call is the new owner's own real status and must NOT
+ # be redirected into a carry.
+ self.make_old_record("purple", "gated memo")
+ record = self.store.set(self.new_owner, "green", "WORKING",
+ deferential=False, rows=self.rows)
+ self.assertEqual(record["state"], "green")
+ self.assertNotIn("carried_from", record)
+
+ # ---- CONTRARIAN REVIEW FOLLOW-UP: age ceiling + provenance --------------
+
+ def test_stale_prior_purple_does_not_carry_falls_to_yellow(self):
+ self.make_old_record("purple", "TK-11317 · gated memo")
+ self.age_old_record(21 * 86400) # 21 days -- far past the 24h ceiling
+ self.assertIsNone(self.store.prior_attention(self.new_owner))
+ record = self.store.settle_owner_change(self.new_owner, rows=self.rows)
+ self.assertEqual(record["state"], "yellow")
+ self.assertTrue(record["owner_status_unverified"])
+ self.assertEqual(record["ticket"], "")
+
+ def test_fresh_prior_purple_just_under_ceiling_still_carries(self):
+ self.make_old_record("purple", "gated memo")
+ self.age_old_record(ts.CARRY_MAX_AGE_S - 60)
+ prior = self.store.prior_attention(self.new_owner)
+ self.assertIsNotNone(prior)
+ self.assertEqual(prior["color"], "purple")
+
+ def test_prior_just_over_ceiling_does_not_carry(self):
+ self.make_old_record("purple", "gated memo")
+ self.age_old_record(ts.CARRY_MAX_AGE_S + 60)
+ self.assertIsNone(self.store.prior_attention(self.new_owner))
+
+ def test_fresh_legacy_dot_carried(self):
+ self.write_legacy("claude", "\U0001F7E0 paste waiting") # freshly written = now
+ prior = self.store.prior_attention(self.new_owner)
+ self.assertIsNotNone(prior)
+ self.assertEqual(prior["color"], "orange")
+
+ def test_stale_legacy_dot_not_carried(self):
+ path = self.write_legacy("claude", "\U0001F7E0 paste waiting")
+ self.age_legacy(path, 21 * 86400)
+ self.assertIsNone(self.store.prior_attention(self.new_owner))
+
+ def test_three_generation_chain_keeps_original_carried_from_and_hops(self):
+ # Gen 1: owner A (old_owner) gets gated purple.
+ self.make_old_record("purple", "gated memo")
+ # Gen 2: owner B (new_owner, tty reused) settles, carrying A's attention.
+ gen2 = self.store.settle_owner_change(self.new_owner, rows=self.rows)
+ self.assertEqual(gen2["carried_from"]["pid"], self.old_owner.pid)
+ self.assertEqual(gen2["carried_from"]["hops"], 1)
+ # Gen 3: the SAME tty is reused again (owner B departs, owner C arrives).
+ gen3_process = ts.Process(333, 1, "ttys050", "Fri Sep 11 10:00:00 2026",
+ "/usr/bin/claude")
+ gen3_owner = gen3_process.owner()
+ gen3_rows = {333: gen3_process}
+ store3 = ts.Store(self.root, lambda **_: gen3_rows, lambda o, r: None)
+ prior = store3.prior_attention(gen3_owner)
+ self.assertIsNotNone(prior)
+ self.assertEqual(prior["color"], "purple")
+ # ORIGINAL provenance preserved -- pid/started/at are A's, not B's --
+ # only hops increments.
+ self.assertEqual(prior["carried_from"]["pid"], self.old_owner.pid)
+ self.assertEqual(prior["carried_from"]["started"], self.old_owner.started)
+ self.assertEqual(prior["carried_from"]["at"], gen2["carried_from"]["at"])
+ self.assertEqual(prior["carried_from"]["hops"], 2)
+ gen3 = store3.settle_owner_change(gen3_owner, rows=gen3_rows)
+ self.assertEqual(gen3["carried_from"]["pid"], self.old_owner.pid)
+ self.assertEqual(gen3["carried_from"]["started"], self.old_owner.started)
+ self.assertEqual(gen3["carried_from"]["hops"], 2)
+ self.assertEqual(gen3["revision"], 3) # 1 (gen1) -> 2 (gen2) -> 3 (gen3)
+
+ # ---- CONTRARIAN REVIEW FOLLOW-UP: revision continuity (LOW) -------------
+
+ def test_revision_continues_across_carry_not_reset_to_one(self):
+ old_rec = self.make_old_record("purple", "gated memo")
+ self.assertEqual(old_rec["revision"], 1)
+ settled = self.store.settle_owner_change(self.new_owner, rows=self.rows)
+ self.assertEqual(settled["revision"], 2) # continues, not reset to 1
+
+ def test_revision_resets_to_one_when_nothing_carried(self):
+ self.make_old_record("green")
+ settled = self.store.settle_owner_change(self.new_owner, rows=self.rows)
+ self.assertEqual(settled["revision"], 1)
+
+ # ---- CONTRARIAN REVIEW FOLLOW-UP: automatic-writer audit -----------------
+ # The `ticket` command handler AND auto-bind's _default_ticket_set both did
+ # `previous, _ = store.read(owner)` and silently defaulted to color "none"
+ # whenever reason was actually owner_changed -- the SECOND identified hole.
+ # effective_previous() closes it for both call sites.
+
+ def test_effective_previous_returns_settlement_preview_for_owner_changed(self):
+ self.make_old_record("purple", "gated memo")
+ record, reason = self.store.load(self.new_owner)
+ self.assertIsNone(record)
+ self.assertEqual(reason, "owner_changed")
+ previous, eff_reason = self.store.effective_previous(self.new_owner)
+ self.assertEqual(eff_reason, "owner_changed")
+ self.assertEqual(previous["state"], "purple")
+ self.assertIn("carried_from", previous)
+ self.assertEqual(previous["next_revision"], 2)
+
+ def test_effective_previous_passes_through_canonical_and_missing(self):
+ self.store.set(self.new_owner, "green", rows=self.rows)
+ previous, reason = self.store.effective_previous(self.new_owner)
+ self.assertEqual(reason, "canonical")
+ self.assertEqual(previous["state"], "green")
+ lone = ts.Process(444, 1, "ttys077", "Sat Sep 12 08:00:00 2026", "/usr/bin/claude")
+ previous2, reason2 = self.store.effective_previous(lone.owner())
+ self.assertEqual(reason2, "missing")
+ self.assertIsNone(previous2)
+
+ def test_ticket_command_preserves_carry_when_settle_has_not_run_yet(self):
+ # Reproduces the SECOND identified hole directly: binding a ticket on a
+ # reused tty BEFORE backfill/the deferential hook ever settled it must
+ # not blank the carried colour to "none".
+ self.make_old_record("purple", "gated memo")
+ record, reason = self.store.load(self.new_owner)
+ self.assertIsNone(record)
+ self.assertEqual(reason, "owner_changed") # settle has NOT run yet
+ previous, eff_reason = self.store.effective_previous(self.new_owner)
+ self.assertEqual(eff_reason, "owner_changed")
+ bound = self.store.set(
+ self.new_owner, previous["state"],
+ ts.label_ticket(previous["label"])[0],
+ ticket_update="TK-22222",
+ variant=previous.get("variant", ""),
+ carried_from=previous.get("carried_from"),
+ owner_status_unverified=previous.get("owner_status_unverified"),
+ revision_override=previous.get("next_revision"),
+ rows=self.rows)
+ self.assertEqual(bound["state"], "purple")
+ self.assertEqual(bound["ticket"], "TK-22222")
+ self.assertIn("carried_from", bound)
+ self.assertEqual(bound["revision"], 2) # continued, not reset to 1
+
+ def test_default_ticket_set_preserves_carried_purple_on_owner_changed(self):
+ self.make_old_record("purple", "gated memo")
+ ts._default_ticket_set(self.store, self.new_owner, "TK-33333")
+ record, reason = self.store.load(self.new_owner)
+ self.assertEqual(reason, "canonical")
+ self.assertEqual(record["state"], "purple")
+ self.assertEqual(record["ticket"], "TK-33333")
+ self.assertIn("carried_from", record)
+ self.assertEqual(record["revision"], 2)
+
+ def test_default_ticket_set_floors_to_yellow_when_nothing_carried(self):
+ self.make_old_record("green")
+ ts._default_ticket_set(self.store, self.new_owner, "TK-44444")
+ record, reason = self.store.load(self.new_owner)
+ self.assertEqual(reason, "canonical")
+ self.assertEqual(record["state"], "yellow")
+ self.assertTrue(record["owner_status_unverified"])
+ self.assertEqual(record["ticket"], "TK-44444")
+
if __name__ == "__main__":
unittest.main()
diff --git a/verification/TK-11317-integration/after-tests-v2.txt b/verification/TK-11317-integration/after-tests-v2.txt
new file mode 100644
index 0000000..6cbcd3c
--- /dev/null
+++ b/verification/TK-11317-integration/after-tests-v2.txt
@@ -0,0 +1,250 @@
+=== python3 test_terminal_status.py ===
+test_ambiguous_multiple_tickets_in_prompt_falls_through_to_create (__main__.AutoBindTests.test_ambiguous_multiple_tickets_in_prompt_falls_through_to_create) ... ok
+test_concurrent_prompt_on_same_tty_cannot_double_create (__main__.AutoBindTests.test_concurrent_prompt_on_same_tty_cannot_double_create) ... ok
+test_explicit_ticket_in_prompt_binds_without_creating (__main__.AutoBindTests.test_explicit_ticket_in_prompt_binds_without_creating) ... ok
+test_explicit_ticket_not_in_known_ledger_fails_open (__main__.AutoBindTests.test_explicit_ticket_not_in_known_ledger_fails_open) ... ok
+test_fails_open_when_tk_errors_and_rate_limits_the_retry (__main__.AutoBindTests.test_fails_open_when_tk_errors_and_rate_limits_the_retry) ... ok
+test_never_binds_an_empty_ticket_id (__main__.AutoBindTests.test_never_binds_an_empty_ticket_id) ... ok
+test_never_creates_a_duplicate_for_the_same_session (__main__.AutoBindTests.test_never_creates_a_duplicate_for_the_same_session) ... ok
+test_slash_command_with_arguments_is_substantive (__main__.AutoBindTests.test_slash_command_with_arguments_is_substantive) ... ok
+test_trivial_prompt_never_consumes_the_rate_limit_window (__main__.AutoBindTests.test_trivial_prompt_never_consumes_the_rate_limit_window) ... ok
+test_trivial_prompts_never_create_a_ticket (__main__.AutoBindTests.test_trivial_prompts_never_create_a_ticket) ... ok
+test_unbound_plus_substantive_prompt_creates_and_binds (__main__.AutoBindTests.test_unbound_plus_substantive_prompt_creates_and_binds) ... ok
+test_cache_key_is_pid_reuse_safe (__main__.EnrichmentCacheTests.test_cache_key_is_pid_reuse_safe) ... ok
+test_inconclusive_read_is_never_cached_as_a_negative (__main__.EnrichmentCacheTests.test_inconclusive_read_is_never_cached_as_a_negative) ... ok
+test_merge_never_downgrades_a_peer_value (__main__.EnrichmentCacheTests.test_merge_never_downgrades_a_peer_value) ... ok
+test_merge_reads_fresh_and_keeps_a_concurrent_peers_entry (__main__.EnrichmentCacheTests.test_merge_reads_fresh_and_keeps_a_concurrent_peers_entry) ... ok
+test_non_string_cache_values_are_not_served (__main__.EnrichmentCacheTests.test_non_string_cache_values_are_not_served) ... ok
+test_stale_entries_age_out (__main__.EnrichmentCacheTests.test_stale_entries_age_out) ... ok
+test_unreadable_cache_degrades_to_the_uncached_answer (__main__.EnrichmentCacheTests.test_unreadable_cache_degrades_to_the_uncached_answer) ... ok
+test_warm_cache_is_exact_and_shells_out_zero_times (__main__.EnrichmentCacheTests.test_warm_cache_is_exact_and_shells_out_zero_times) ... ok
+test_another_users_cache_file_is_never_trusted (__main__.ItermEnumCacheTests.test_another_users_cache_file_is_never_trusted) ... ok
+test_blind_falls_back_to_the_cached_map_not_green (__main__.ItermEnumCacheTests.test_blind_falls_back_to_the_cached_map_not_green) ... ok
+test_empty_successful_enum_is_not_cached_so_siblings_re_enumerate (__main__.ItermEnumCacheTests.test_empty_successful_enum_is_not_cached_so_siblings_re_enumerate) ... ok
+test_fresh_forces_a_live_enumeration_past_the_cache (__main__.ItermEnumCacheTests.test_fresh_forces_a_live_enumeration_past_the_cache) ... ok
+test_single_flight_peer_holding_lock_coalesces_and_fires_no_osascript (__main__.ItermEnumCacheTests.test_single_flight_peer_holding_lock_coalesces_and_fires_no_osascript) ... ok
+test_single_flight_peer_lock_no_fresh_map_blinds_without_osascript (__main__.ItermEnumCacheTests.test_single_flight_peer_lock_no_fresh_map_blinds_without_osascript) ... ok
+test_stale_map_past_the_bound_is_not_trusted (__main__.ItermEnumCacheTests.test_stale_map_past_the_bound_is_not_trusted) ... ok
+test_success_enumerates_once_then_siblings_hit_the_cache (__main__.ItermEnumCacheTests.test_success_enumerates_once_then_siblings_hit_the_cache) ... ok
+test_truly_blind_with_no_cache_records_enum_blind_and_is_unavailable (__main__.ItermEnumCacheTests.test_truly_blind_with_no_cache_records_enum_blind_and_is_unavailable) ... ok
+test_all_colors_write_canonical_and_identical_mirrors (__main__.StatusTests.test_all_colors_write_canonical_and_identical_mirrors) ... ok
+test_ambiguous_legacy_gate_never_guesses_green (__main__.StatusTests.test_ambiguous_legacy_gate_never_guesses_green) ... ok
+test_busy_target_skips_fast_with_short_lock_wait (__main__.StatusTests.test_busy_target_skips_fast_with_short_lock_wait) ... ok
+test_canonical_green_beats_stale_orange_title_and_cache (__main__.StatusTests.test_canonical_green_beats_stale_orange_title_and_cache) ... ok
+test_clear_tombstone_prevents_title_cache_and_repaint_resurrection (__main__.StatusTests.test_clear_tombstone_prevents_title_cache_and_repaint_resurrection) ... ok
+test_complete_osc_packet_contains_one_consistent_title_and_badge (__main__.StatusTests.test_complete_osc_packet_contains_one_consistent_title_and_badge) ... ok
+test_concurrent_writers_keep_the_newest_event_and_valid_json (__main__.StatusTests.test_concurrent_writers_keep_the_newest_event_and_valid_json) ... ok
+test_control_sequences_cannot_be_injected_through_labels (__main__.StatusTests.test_control_sequences_cannot_be_injected_through_labels) ... ok
+test_corrupt_record_never_falls_back_to_green (__main__.StatusTests.test_corrupt_record_never_falls_back_to_green) ... ok
+test_dead_writer_cannot_modify_new_terminal_owner (__main__.StatusTests.test_dead_writer_cannot_modify_new_terminal_owner) ... ok
+test_declared_agent_binds_driving_ticket_when_slug_embeds_a_second_id (__main__.StatusTests.test_declared_agent_binds_driving_ticket_when_slug_embeds_a_second_id) ... ok
+test_declared_agent_needs_an_export_and_exactly_one_declaration (__main__.StatusTests.test_declared_agent_needs_an_export_and_exactly_one_declaration) ... ok
+test_declared_agent_never_invents_a_ticket_nor_launders_a_bare_session (__main__.StatusTests.test_declared_agent_never_invents_a_ticket_nor_launders_a_bare_session) ... ok
+test_event_clock_is_comparable_between_python_processes (__main__.StatusTests.test_event_clock_is_comparable_between_python_processes) ... ok
+test_explicit_lightblue_reason_wins_over_preserved_base (__main__.StatusTests.test_explicit_lightblue_reason_wins_over_preserved_base) ... ok
+test_headless_nested_agent_cannot_paint_parent (__main__.StatusTests.test_headless_nested_agent_cannot_paint_parent) ... ok
+test_ledger_discovery_rejects_reused_pid_and_nested_agent (__main__.StatusTests.test_ledger_discovery_rejects_reused_pid_and_nested_agent) ... ok
+test_legacy_is_runtime_scoped_and_stale_records_are_rejected (__main__.StatusTests.test_legacy_is_runtime_scoped_and_stale_records_are_rejected) ... ok
+test_lightblue_is_solid_when_no_reason_to_preserve (__main__.StatusTests.test_lightblue_is_solid_when_no_reason_to_preserve) ... ok
+test_lightblue_never_mismatches_old_label_with_new_ticket (__main__.StatusTests.test_lightblue_never_mismatches_old_label_with_new_ticket) ... ok
+test_lightblue_preserves_underlying_needs_steve_reason (__main__.StatusTests.test_lightblue_preserves_underlying_needs_steve_reason) ... ok
+test_monitoring_shade_remains_green_and_survives_repaint (__main__.StatusTests.test_monitoring_shade_remains_green_and_survives_repaint) ... ok
+test_new_assignment_wins_and_generic_hook_preserves_binding (__main__.StatusTests.test_new_assignment_wins_and_generic_hook_preserves_binding) ... ok
+test_old_request_cannot_overwrite_a_newer_clear (__main__.StatusTests.test_old_request_cannot_overwrite_a_newer_clear) ... ok
+test_owner_for_paint_bridge_marker_never_rescues_an_intermediate_agent (__main__.StatusTests.test_owner_for_paint_bridge_marker_never_rescues_an_intermediate_agent) ... ok
+test_owner_for_paint_bridge_marker_never_rescues_undeterminable_ownership (__main__.StatusTests.test_owner_for_paint_bridge_marker_never_rescues_undeterminable_ownership) ... ok
+test_owner_for_paint_bridge_session_paints_when_it_owns_its_tty_directly (__main__.StatusTests.test_owner_for_paint_bridge_session_paints_when_it_owns_its_tty_directly) ... ok
+test_owner_for_paint_rail_paints_top_level_refuses_subagent (__main__.StatusTests.test_owner_for_paint_rail_paints_top_level_refuses_subagent) ... ok
+test_paint_failure_is_reported_and_auditable (__main__.StatusTests.test_paint_failure_is_reported_and_auditable) ... ok
+test_pid_reuse_and_runtime_change_fail_closed (__main__.StatusTests.test_pid_reuse_and_runtime_change_fail_closed) ... ok
+test_pulse_marker_toggles_across_legitimate_repaints (__main__.StatusTests.test_pulse_marker_toggles_across_legitimate_repaints) ... ok
+test_repaint_never_adopts_unbound_legacy_state (__main__.StatusTests.test_repaint_never_adopts_unbound_legacy_state) ... ok
+test_repaint_never_changes_record_or_mirror_timestamps (__main__.StatusTests.test_repaint_never_changes_record_or_mirror_timestamps) ... ok
+test_runtime_identification_is_exact_not_command_substrings (__main__.StatusTests.test_runtime_identification_is_exact_not_command_substrings) ... ok
+test_scan_elevates_stopped_tab_above_same_colour_and_higher_colours (__main__.StatusTests.test_scan_elevates_stopped_tab_above_same_colour_and_higher_colours) ... ok
+test_scan_survives_terminal_api_outage_and_drops_dead_sessions (__main__.StatusTests.test_scan_survives_terminal_api_outage_and_drops_dead_sessions) ... ok
+test_session_ledger_binds_resumed_session_via_term_session_id (__main__.StatusTests.test_session_ledger_binds_resumed_session_via_term_session_id) ... ok
+test_session_ledger_is_non_vacuous_and_never_launders (__main__.StatusTests.test_session_ledger_is_non_vacuous_and_never_launders) ... ok
+test_ticket_header_is_read_only_and_does_not_resurrect_clear (__main__.StatusTests.test_ticket_header_is_read_only_and_does_not_resurrect_clear) ... ok
+test_ticket_survives_every_generic_color_and_clear (__main__.StatusTests.test_ticket_survives_every_generic_color_and_clear) ... ok
+test_ticket_validation_and_tty_reuse (__main__.StatusTests.test_ticket_validation_and_tty_reuse) ... ok
+test_two_ttys_do_not_share_status (__main__.StatusTests.test_two_ttys_do_not_share_status) ... ok
+test_waiting_shade_is_brighter_green_and_survives_repaint (__main__.StatusTests.test_waiting_shade_is_brighter_green_and_survives_repaint) ... ok
+test_waiting_states_pulse_and_solid_states_stay_solid (__main__.StatusTests.test_waiting_states_pulse_and_solid_states_stay_solid) ... ok
+test_explicit_ticket_extraction (__main__.TicketPromptHelperTests.test_explicit_ticket_extraction) ... ok
+test_short_topic_collapses_and_caps (__main__.TicketPromptHelperTests.test_short_topic_collapses_and_caps) ... ok
+test_trivial_prompt_guard (__main__.TicketPromptHelperTests.test_trivial_prompt_guard) ... ok
+
+----------------------------------------------------------------------
+Ran 75 tests in 4.933s
+
+OK
+
+=== python3 -m unittest test_owner_change_carry -v ===
+test_backfill_carries_purple_through_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_backfill_carries_purple_through_owner_changed) ... ok
+test_backfill_never_flattens_carried_attention_to_green (test_owner_change_carry.OwnerChangeCarryTests.test_backfill_never_flattens_carried_attention_to_green) ... ok
+test_carried_from_records_prior_owner_identity (test_owner_change_carry.OwnerChangeCarryTests.test_carried_from_records_prior_owner_identity) ... ok
+test_default_ticket_set_floors_to_yellow_when_nothing_carried (test_owner_change_carry.OwnerChangeCarryTests.test_default_ticket_set_floors_to_yellow_when_nothing_carried) ... ok
+test_default_ticket_set_preserves_carried_purple_on_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_default_ticket_set_preserves_carried_purple_on_owner_changed) ... ok
+test_deferential_first_prompt_carries_purple_not_green (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_first_prompt_carries_purple_not_green) ... ok
+test_deferential_first_prompt_unverified_yellow_stays_yellow (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_first_prompt_unverified_yellow_stays_yellow) ... ok
+test_deferential_green_keeps_carried_attention (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_green_keeps_carried_attention) ... ok
+test_deferential_green_keeps_owner_status_unverified (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_green_keeps_owner_status_unverified) ... ok
+test_effective_previous_passes_through_canonical_and_missing (test_owner_change_carry.OwnerChangeCarryTests.test_effective_previous_passes_through_canonical_and_missing) ... ok
+test_effective_previous_returns_settlement_preview_for_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_effective_previous_returns_settlement_preview_for_owner_changed) ... ok
+test_explicit_non_deferential_set_still_wins_over_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_explicit_non_deferential_set_still_wins_over_owner_changed) ... ok
+test_explicit_set_green_clears_carried_from (test_owner_change_carry.OwnerChangeCarryTests.test_explicit_set_green_clears_carried_from) ... ok
+test_explicit_set_green_clears_owner_status_unverified (test_owner_change_carry.OwnerChangeCarryTests.test_explicit_set_green_clears_owner_status_unverified) ... ok
+test_fresh_legacy_dot_carried (test_owner_change_carry.OwnerChangeCarryTests.test_fresh_legacy_dot_carried) ... ok
+test_fresh_prior_purple_just_under_ceiling_still_carries (test_owner_change_carry.OwnerChangeCarryTests.test_fresh_prior_purple_just_under_ceiling_still_carries) ... ok
+test_legacy_dot_only_attention_carried_when_json_prior_is_green (test_owner_change_carry.OwnerChangeCarryTests.test_legacy_dot_only_attention_carried_when_json_prior_is_green) ... ok
+test_legacy_dot_only_attention_carried_when_no_json_record_at_all (test_owner_change_carry.OwnerChangeCarryTests.test_legacy_dot_only_attention_carried_when_no_json_record_at_all) ... ok
+test_load_accepts_records_with_and_without_carried_from (test_owner_change_carry.OwnerChangeCarryTests.test_load_accepts_records_with_and_without_carried_from) ... ok
+test_missing_reason_still_green_floors_via_backfill (test_owner_change_carry.OwnerChangeCarryTests.test_missing_reason_still_green_floors_via_backfill) ... ok
+test_no_prior_record_at_all_settles_to_yellow_status_needed (test_owner_change_carry.OwnerChangeCarryTests.test_no_prior_record_at_all_settles_to_yellow_status_needed) ... ok
+test_old_owner_cannot_write_after_tty_reuse (test_owner_change_carry.OwnerChangeCarryTests.test_old_owner_cannot_write_after_tty_reuse) ... ok
+test_prior_green_yields_none_and_settles_to_yellow_status_needed (test_owner_change_carry.OwnerChangeCarryTests.test_prior_green_yields_none_and_settles_to_yellow_status_needed) ... ok
+test_prior_just_over_ceiling_does_not_carry (test_owner_change_carry.OwnerChangeCarryTests.test_prior_just_over_ceiling_does_not_carry) ... ok
+test_prior_lightblue_record_carried (test_owner_change_carry.OwnerChangeCarryTests.test_prior_lightblue_record_carried) ... ok
+test_prior_orange_record_carried (test_owner_change_carry.OwnerChangeCarryTests.test_prior_orange_record_carried) ... ok
+test_prior_pink_yields_none_and_settles_to_yellow_status_needed (test_owner_change_carry.OwnerChangeCarryTests.test_prior_pink_yields_none_and_settles_to_yellow_status_needed) ... ok
+test_prior_purple_record_carried (test_owner_change_carry.OwnerChangeCarryTests.test_prior_purple_record_carried) ... ok
+test_prior_record_ticket_is_carried_from_json_source (test_owner_change_carry.OwnerChangeCarryTests.test_prior_record_ticket_is_carried_from_json_source) ... ok
+test_prior_yellow_record_carried (test_owner_change_carry.OwnerChangeCarryTests.test_prior_yellow_record_carried) ... ok
+test_priority_choice_lightblue_outranks_everything (test_owner_change_carry.OwnerChangeCarryTests.test_priority_choice_lightblue_outranks_everything) ... ok
+test_priority_choice_when_json_and_legacy_disagree (test_owner_change_carry.OwnerChangeCarryTests.test_priority_choice_when_json_and_legacy_disagree) ... ok
+test_revision_continues_across_carry_not_reset_to_one (test_owner_change_carry.OwnerChangeCarryTests.test_revision_continues_across_carry_not_reset_to_one) ... ok
+test_revision_resets_to_one_when_nothing_carried (test_owner_change_carry.OwnerChangeCarryTests.test_revision_resets_to_one_when_nothing_carried) ... ok
+test_row_for_canonical_owner_status_unverified_record_carries_warning (test_owner_change_carry.OwnerChangeCarryTests.test_row_for_canonical_owner_status_unverified_record_carries_warning) ... ok
+test_row_for_owner_changed_shows_carried_colour_not_none (test_owner_change_carry.OwnerChangeCarryTests.test_row_for_owner_changed_shows_carried_colour_not_none) ... ok
+test_row_for_owner_changed_with_no_prior_attention_shows_yellow (test_owner_change_carry.OwnerChangeCarryTests.test_row_for_owner_changed_with_no_prior_attention_shows_yellow) ... ok
+test_row_never_renders_none_for_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_row_never_renders_none_for_owner_changed) ... ok
+test_stale_legacy_dot_not_carried (test_owner_change_carry.OwnerChangeCarryTests.test_stale_legacy_dot_not_carried) ... ok
+test_stale_prior_purple_does_not_carry_falls_to_yellow (test_owner_change_carry.OwnerChangeCarryTests.test_stale_prior_purple_does_not_carry_falls_to_yellow) ... ok
+test_start_command_settles_owner_changed_via_carry (test_owner_change_carry.OwnerChangeCarryTests.test_start_command_settles_owner_changed_via_carry) ... ok
+test_three_generation_chain_keeps_original_carried_from_and_hops (test_owner_change_carry.OwnerChangeCarryTests.test_three_generation_chain_keeps_original_carried_from_and_hops) ... ok
+test_ticket_binding_preserves_carried_attention_and_flag (test_owner_change_carry.OwnerChangeCarryTests.test_ticket_binding_preserves_carried_attention_and_flag) ... ok
+test_ticket_binding_preserves_owner_status_unverified (test_owner_change_carry.OwnerChangeCarryTests.test_ticket_binding_preserves_owner_status_unverified) ... ok
+test_ticket_command_preserves_carry_when_settle_has_not_run_yet (test_owner_change_carry.OwnerChangeCarryTests.test_ticket_command_preserves_carry_when_settle_has_not_run_yet) ... ok
+
+----------------------------------------------------------------------
+Ran 45 tests in 1.133s
+
+OK
+
+=== python3 test_departed_asks.py -v ===
+test_ack_resolves_and_unknown_ack_is_refused (__main__.DepartedAsksTests.test_ack_resolves_and_unknown_ack_is_refused) ... ok
+test_cli_exit_code_tracks_unresolved (__main__.DepartedAsksTests.test_cli_exit_code_tracks_unresolved) ... ok
+test_cli_hides_resolved_unless_all (__main__.DepartedAsksTests.test_cli_hides_resolved_unless_all) ... ok
+test_deep_body_mention_is_weak_evidence (__main__.DepartedAsksTests.test_deep_body_mention_is_weak_evidence) ... ok
+test_done_ticket_without_memo_is_resolved (__main__.DepartedAsksTests.test_done_ticket_without_memo_is_resolved) ... ok
+test_filed_memo_is_not_open (__main__.DepartedAsksTests.test_filed_memo_is_not_open) ... ok
+test_legacy_only_carry_still_captures_ticket_mention (__main__.DepartedAsksTests.test_legacy_only_carry_still_captures_ticket_mention) ... ok
+test_memo_body_mention_counts (__main__.DepartedAsksTests.test_memo_body_mention_counts) ... ok
+test_mentions_word_boundaries (__main__.DepartedAsksTests.test_mentions_word_boundaries) ... ok
+test_open_memo_beats_done_ticket (__main__.DepartedAsksTests.test_open_memo_beats_done_ticket) ... ok
+test_open_memo_makes_ask_outstanding (__main__.DepartedAsksTests.test_open_memo_makes_ask_outstanding) ... ok
+test_open_ticket_without_memo_is_unverified_not_resolved (__main__.DepartedAsksTests.test_open_ticket_without_memo_is_unverified_not_resolved) ... ok
+test_register_failure_never_blocks_the_carry (__main__.DepartedAsksTests.test_register_failure_never_blocks_the_carry) ... ok
+test_register_is_idempotent_per_departed_owner (__main__.DepartedAsksTests.test_register_is_idempotent_per_departed_owner) ... ok
+test_register_survives_new_owner_explicit_set (__main__.DepartedAsksTests.test_register_survives_new_owner_explicit_set) ... ok
+test_seed_registers_carried_record_under_original_owner (__main__.DepartedAsksTests.test_seed_registers_carried_record_under_original_owner) ... ok
+test_seed_registers_dead_owner_attention_records_only (__main__.DepartedAsksTests.test_seed_registers_dead_owner_attention_records_only) ... ok
+test_settle_with_attention_registers_departed_ask (__main__.DepartedAsksTests.test_settle_with_attention_registers_departed_ask) ... ok
+test_settle_without_attention_registers_nothing (__main__.DepartedAsksTests.test_settle_without_attention_registers_nothing) ... ok
+test_unknown_ticket_and_no_ticket_are_unverified (__main__.DepartedAsksTests.test_unknown_ticket_and_no_ticket_are_unverified) ... ok
+
+----------------------------------------------------------------------
+Ran 20 tests in 0.164s
+
+OK
+
+=== python3 terminal_status.py selftest ===
+ PASS top-level interactive session PAINTS
+ PASS subagent REFUSES
+ PASS session-hook --boot PAINTS
+ PASS --force opt-in PAINTS
+ PASS bridge session owning its tty directly PAINTS
+ PASS intermediate claude process REFUSES
+ PASS undeterminable ownership REFUSES
+ PASS backfill repaints the owner WITH a record
+ PASS backfill NEVER flattens a valid gated dot to green
+ PASS backfill green-floors the recordless live owner
+ PASS backfill carries a prior purple record through owner_changed
+ PASS backfill floors owner_changed-from-green to yellow status-needed
+ PASS backfill owner_changed floors both new owners (f2 == 2)
+ PASS backfill missing floor still has no owner_status_unverified flag
+ PASS deferential first-prompt paint carries purple, never plain green
+ PASS heartbeat WARN + NOT-MEASURED when the ps scan failed
+ PASS heartbeat WARN when live tabs exist but NONE could be asserted
+ PASS heartbeat PASS when it acted on the live tabs
+ PASS heartbeat PASS on a measured 0-of-0 (no live tabs), still carrying the count
+selftest: PASS (19/19)
+
+=== python3 test_stop_verdict.py ===
+test_fixture_flag_with_base_override_never_touches_engine (__main__.CliTests.test_fixture_flag_with_base_override_never_touches_engine) ... ok
+test_injected_fault_goes_red (__main__.CliTests.test_injected_fault_goes_red) ... ok
+test_selftest_passes (__main__.CliTests.test_selftest_passes) ... ok
+test_stdin_missing_transcript_exits_zero (__main__.CliTests.test_stdin_missing_transcript_exits_zero) ... ok
+test_done_beats_silent_spinning_task (__main__.DecideTests.test_done_beats_silent_spinning_task) ... ok
+test_done_labels (__main__.DecideTests.test_done_labels) ... ok
+test_explicit_park_beats_waiting_on_steve (__main__.DecideTests.test_explicit_park_beats_waiting_on_steve) ... ok
+test_idle_pink_toggle (__main__.DecideTests.test_idle_pink_toggle) ... ok
+test_labels_respect_engine_valid_label (__main__.DecideTests.test_labels_respect_engine_valid_label) ... ok
+test_needs_steve_base_only_gets_stopped_variant (__main__.DecideTests.test_needs_steve_base_only_gets_stopped_variant) ... ok
+test_next_check_beats_done (__main__.DecideTests.test_next_check_beats_done) ... ok
+test_order_gated_beats_question (__main__.DecideTests.test_order_gated_beats_question) ... ok
+test_order_paste_beats_everything (__main__.DecideTests.test_order_paste_beats_everything) ... ok
+test_order_question_beats_waiting (__main__.DecideTests.test_order_question_beats_waiting) ... ok
+test_order_waiting_on_steve_beats_monitoring_and_done (__main__.DecideTests.test_order_waiting_on_steve_beats_monitoring_and_done) ... ok
+test_output_contract_keys (__main__.DecideTests.test_output_contract_keys) ... ok
+test_parked_base_only_repaints (__main__.DecideTests.test_parked_base_only_repaints) ... ok
+test_parked_beats_hold_and_done (__main__.DecideTests.test_parked_beats_hold_and_done) ... ok
+test_s3_keeps_base_label_when_no_time (__main__.DecideTests.test_s3_keeps_base_label_when_no_time) ... ok
+test_silent_spinning_task_alone_stays_green (__main__.DecideTests.test_silent_spinning_task_alone_stays_green) ... ok
+test_unknown_base_is_evaluated_like_green (__main__.DecideTests.test_unknown_base_is_evaluated_like_green) ... ok
+test_clean_view_strips_fences_code_tables_rules_emphasis (__main__.MatchTests.test_clean_view_strips_fences_code_tables_rules_emphasis) ... ok
+test_d1_captures_time (__main__.MatchTests.test_d1_captures_time) ... ok
+test_done_anywhere_in_closing_message (__main__.MatchTests.test_done_anywhere_in_closing_message) ... ok
+test_question_is_last_line_only (__main__.MatchTests.test_question_is_last_line_only) ... ok
+test_s2_time_is_the_next_check_not_the_done_time (__main__.MatchTests.test_s2_time_is_the_next_check_not_the_done_time) ... ok
+test_s3_comes_from_base_variant (__main__.MatchTests.test_s3_comes_from_base_variant) ... ok
+test_signal_table (__main__.MatchTests.test_signal_table) ... ok
+test_weekly_limit_matches_nothing (__main__.MatchTests.test_weekly_limit_matches_nothing) ... ok
+test_null_text_block_is_skipped (__main__.NullTextBlockTest.test_null_text_block_is_skipped) ... ok
+test_done_with_time_beats_parked_word (__main__.StdinFirstTests.test_done_with_time_beats_parked_word) ... ok
+test_live_background_task_keeps_green (__main__.StdinFirstTests.test_live_background_task_keeps_green) ... ok
+test_message_from_stdin_beats_missing_transcript (__main__.StdinFirstTests.test_message_from_stdin_beats_missing_transcript) ... ok
+test_no_message_returns_none (__main__.StdinFirstTests.test_no_message_returns_none) ... ok
+test_closing_text_is_after_last_user_line (__main__.TranscriptTests.test_closing_text_is_after_last_user_line) ... ok
+test_malformed_lines_are_skipped_and_no_text_is_none (__main__.TranscriptTests.test_malformed_lines_are_skipped_and_no_text_is_none) ... ok
+test_missing_transcript_is_benign (__main__.TranscriptTests.test_missing_transcript_is_benign) ... ok
+test_sidechain_and_noise_records_are_skipped (__main__.TranscriptTests.test_sidechain_and_noise_records_are_skipped) ... ok
+test_spinning_pairs_bg_start_with_notification (__main__.TranscriptTests.test_spinning_pairs_bg_start_with_notification) ... ok
+test_tail_lines_reads_only_the_end (__main__.TranscriptTests.test_tail_lines_reads_only_the_end) ... ok
+
+----------------------------------------------------------------------
+Ran 40 tests in 0.304s
+
+OK
+
+=== python3 test_tk_required_fp_metric.py ===
+test_argv_channel_also_contradicts (__main__.MetricTests.test_argv_channel_also_contradicts) ... ok
+test_clean_bound_session_passes (__main__.MetricTests.test_clean_bound_session_passes) ... ok
+test_contradicted_row_goes_red (__main__.MetricTests.test_contradicted_row_goes_red) ... ok
+test_empty_enumeration_is_warn_not_pass (__main__.MetricTests.test_empty_enumeration_is_warn_not_pass) ... ok
+test_launch_grace_defers_young_sessions_to_pending (__main__.MetricTests.test_launch_grace_defers_young_sessions_to_pending) ... ok
+test_ledger_ticket_must_be_known (__main__.MetricTests.test_ledger_ticket_must_be_known) ... ok
+test_not_measured_is_never_an_accusation_and_does_not_fail (__main__.MetricTests.test_not_measured_is_never_an_accusation_and_does_not_fail) ... ok
+test_only_green_sessions_are_scored (__main__.MetricTests.test_only_green_sessions_are_scored) ... ok
+test_proc_ages_resolve_for_a_live_pid (__main__.MetricTests.test_proc_ages_resolve_for_a_live_pid) ... ok
+test_read_events_are_noise_not_tracking (__main__.MetricTests.test_read_events_are_noise_not_tracking) ... ok
+test_self_test_entrypoint_runs (__main__.MetricTests.test_self_test_entrypoint_runs) ... ok
+test_unknown_age_is_not_hidden_as_young (__main__.MetricTests.test_unknown_age_is_not_hidden_as_young) ... ok
+test_unmeasured_input_is_warn_not_pass (__main__.MetricTests.test_unmeasured_input_is_warn_not_pass) ... ok
+
+----------------------------------------------------------------------
+Ran 13 tests in 0.010s
+
+OK
+self-test OK: contradicted→FAIL, clean→PASS, not_measured→PASS, young→pending
diff --git a/verification/TK-11317-integration/negative-control-v2.txt b/verification/TK-11317-integration/negative-control-v2.txt
new file mode 100644
index 0000000..5391c91
--- /dev/null
+++ b/verification/TK-11317-integration/negative-control-v2.txt
@@ -0,0 +1,197 @@
+test_backfill_carries_purple_through_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_backfill_carries_purple_through_owner_changed) ... ok
+test_backfill_never_flattens_carried_attention_to_green (test_owner_change_carry.OwnerChangeCarryTests.test_backfill_never_flattens_carried_attention_to_green) ... ok
+test_carried_from_records_prior_owner_identity (test_owner_change_carry.OwnerChangeCarryTests.test_carried_from_records_prior_owner_identity) ... ERROR
+test_default_ticket_set_floors_to_yellow_when_nothing_carried (test_owner_change_carry.OwnerChangeCarryTests.test_default_ticket_set_floors_to_yellow_when_nothing_carried) ... FAIL
+test_default_ticket_set_preserves_carried_purple_on_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_default_ticket_set_preserves_carried_purple_on_owner_changed) ... FAIL
+test_deferential_first_prompt_carries_purple_not_green (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_first_prompt_carries_purple_not_green) ... FAIL
+test_deferential_first_prompt_unverified_yellow_stays_yellow (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_first_prompt_unverified_yellow_stays_yellow) ... FAIL
+test_deferential_green_keeps_carried_attention (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_green_keeps_carried_attention) ... ok
+test_deferential_green_keeps_owner_status_unverified (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_green_keeps_owner_status_unverified) ... ok
+test_effective_previous_passes_through_canonical_and_missing (test_owner_change_carry.OwnerChangeCarryTests.test_effective_previous_passes_through_canonical_and_missing) ... ERROR
+test_effective_previous_returns_settlement_preview_for_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_effective_previous_returns_settlement_preview_for_owner_changed) ... ERROR
+test_explicit_non_deferential_set_still_wins_over_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_explicit_non_deferential_set_still_wins_over_owner_changed) ... ok
+test_explicit_set_green_clears_carried_from (test_owner_change_carry.OwnerChangeCarryTests.test_explicit_set_green_clears_carried_from) ... ok
+test_explicit_set_green_clears_owner_status_unverified (test_owner_change_carry.OwnerChangeCarryTests.test_explicit_set_green_clears_owner_status_unverified) ... ok
+test_fresh_legacy_dot_carried (test_owner_change_carry.OwnerChangeCarryTests.test_fresh_legacy_dot_carried) ... ok
+test_fresh_prior_purple_just_under_ceiling_still_carries (test_owner_change_carry.OwnerChangeCarryTests.test_fresh_prior_purple_just_under_ceiling_still_carries) ... ERROR
+test_legacy_dot_only_attention_carried_when_json_prior_is_green (test_owner_change_carry.OwnerChangeCarryTests.test_legacy_dot_only_attention_carried_when_json_prior_is_green) ... ok
+test_legacy_dot_only_attention_carried_when_no_json_record_at_all (test_owner_change_carry.OwnerChangeCarryTests.test_legacy_dot_only_attention_carried_when_no_json_record_at_all) ... ok
+test_load_accepts_records_with_and_without_carried_from (test_owner_change_carry.OwnerChangeCarryTests.test_load_accepts_records_with_and_without_carried_from) ... ok
+test_missing_reason_still_green_floors_via_backfill (test_owner_change_carry.OwnerChangeCarryTests.test_missing_reason_still_green_floors_via_backfill) ... ok
+test_no_prior_record_at_all_settles_to_yellow_status_needed (test_owner_change_carry.OwnerChangeCarryTests.test_no_prior_record_at_all_settles_to_yellow_status_needed) ... ok
+test_old_owner_cannot_write_after_tty_reuse (test_owner_change_carry.OwnerChangeCarryTests.test_old_owner_cannot_write_after_tty_reuse) ... ok
+test_prior_green_yields_none_and_settles_to_yellow_status_needed (test_owner_change_carry.OwnerChangeCarryTests.test_prior_green_yields_none_and_settles_to_yellow_status_needed) ... ok
+test_prior_just_over_ceiling_does_not_carry (test_owner_change_carry.OwnerChangeCarryTests.test_prior_just_over_ceiling_does_not_carry) ... ERROR
+test_prior_lightblue_record_carried (test_owner_change_carry.OwnerChangeCarryTests.test_prior_lightblue_record_carried) ... ok
+test_prior_orange_record_carried (test_owner_change_carry.OwnerChangeCarryTests.test_prior_orange_record_carried) ... ok
+test_prior_pink_yields_none_and_settles_to_yellow_status_needed (test_owner_change_carry.OwnerChangeCarryTests.test_prior_pink_yields_none_and_settles_to_yellow_status_needed) ... ok
+test_prior_purple_record_carried (test_owner_change_carry.OwnerChangeCarryTests.test_prior_purple_record_carried) ... ERROR
+test_prior_record_ticket_is_carried_from_json_source (test_owner_change_carry.OwnerChangeCarryTests.test_prior_record_ticket_is_carried_from_json_source) ... ok
+test_prior_yellow_record_carried (test_owner_change_carry.OwnerChangeCarryTests.test_prior_yellow_record_carried) ... ok
+test_priority_choice_lightblue_outranks_everything (test_owner_change_carry.OwnerChangeCarryTests.test_priority_choice_lightblue_outranks_everything) ... ok
+test_priority_choice_when_json_and_legacy_disagree (test_owner_change_carry.OwnerChangeCarryTests.test_priority_choice_when_json_and_legacy_disagree) ... ok
+test_revision_continues_across_carry_not_reset_to_one (test_owner_change_carry.OwnerChangeCarryTests.test_revision_continues_across_carry_not_reset_to_one) ... FAIL
+test_revision_resets_to_one_when_nothing_carried (test_owner_change_carry.OwnerChangeCarryTests.test_revision_resets_to_one_when_nothing_carried) ... ok
+test_row_for_canonical_owner_status_unverified_record_carries_warning (test_owner_change_carry.OwnerChangeCarryTests.test_row_for_canonical_owner_status_unverified_record_carries_warning) ... ok
+test_row_for_owner_changed_shows_carried_colour_not_none (test_owner_change_carry.OwnerChangeCarryTests.test_row_for_owner_changed_shows_carried_colour_not_none) ... ok
+test_row_for_owner_changed_with_no_prior_attention_shows_yellow (test_owner_change_carry.OwnerChangeCarryTests.test_row_for_owner_changed_with_no_prior_attention_shows_yellow) ... ok
+test_row_never_renders_none_for_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_row_never_renders_none_for_owner_changed) ... ok
+test_stale_legacy_dot_not_carried (test_owner_change_carry.OwnerChangeCarryTests.test_stale_legacy_dot_not_carried) ... FAIL
+test_stale_prior_purple_does_not_carry_falls_to_yellow (test_owner_change_carry.OwnerChangeCarryTests.test_stale_prior_purple_does_not_carry_falls_to_yellow) ... FAIL
+test_start_command_settles_owner_changed_via_carry (test_owner_change_carry.OwnerChangeCarryTests.test_start_command_settles_owner_changed_via_carry) ... ok
+test_three_generation_chain_keeps_original_carried_from_and_hops (test_owner_change_carry.OwnerChangeCarryTests.test_three_generation_chain_keeps_original_carried_from_and_hops) ... ERROR
+test_ticket_binding_preserves_carried_attention_and_flag (test_owner_change_carry.OwnerChangeCarryTests.test_ticket_binding_preserves_carried_attention_and_flag) ... ok
+test_ticket_binding_preserves_owner_status_unverified (test_owner_change_carry.OwnerChangeCarryTests.test_ticket_binding_preserves_owner_status_unverified) ... ok
+test_ticket_command_preserves_carry_when_settle_has_not_run_yet (test_owner_change_carry.OwnerChangeCarryTests.test_ticket_command_preserves_carry_when_settle_has_not_run_yet) ... ERROR
+
+======================================================================
+ERROR: test_carried_from_records_prior_owner_identity (test_owner_change_carry.OwnerChangeCarryTests.test_carried_from_records_prior_owner_identity)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 205, in test_carried_from_records_prior_owner_identity
+ self.assertEqual(record["carried_from"]["hops"], 1)
+ ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^
+KeyError: 'hops'
+
+======================================================================
+ERROR: test_effective_previous_passes_through_canonical_and_missing (test_owner_change_carry.OwnerChangeCarryTests.test_effective_previous_passes_through_canonical_and_missing)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 504, in test_effective_previous_passes_through_canonical_and_missing
+ previous, reason = self.store.effective_previous(self.new_owner)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'Store' object has no attribute 'effective_previous'
+
+======================================================================
+ERROR: test_effective_previous_returns_settlement_preview_for_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_effective_previous_returns_settlement_preview_for_owner_changed)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 496, in test_effective_previous_returns_settlement_preview_for_owner_changed
+ previous, eff_reason = self.store.effective_previous(self.new_owner)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'Store' object has no attribute 'effective_previous'
+
+======================================================================
+ERROR: test_fresh_prior_purple_just_under_ceiling_still_carries (test_owner_change_carry.OwnerChangeCarryTests.test_fresh_prior_purple_just_under_ceiling_still_carries)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 423, in test_fresh_prior_purple_just_under_ceiling_still_carries
+ self.age_old_record(ts.CARRY_MAX_AGE_S - 60)
+ ^^^^^^^^^^^^^^^^^^
+AttributeError: module 'terminal_status' has no attribute 'CARRY_MAX_AGE_S'
+
+======================================================================
+ERROR: test_prior_just_over_ceiling_does_not_carry (test_owner_change_carry.OwnerChangeCarryTests.test_prior_just_over_ceiling_does_not_carry)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 430, in test_prior_just_over_ceiling_does_not_carry
+ self.age_old_record(ts.CARRY_MAX_AGE_S + 60)
+ ^^^^^^^^^^^^^^^^^^
+AttributeError: module 'terminal_status' has no attribute 'CARRY_MAX_AGE_S'
+
+======================================================================
+ERROR: test_prior_purple_record_carried (test_owner_change_carry.OwnerChangeCarryTests.test_prior_purple_record_carried)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 87, in test_prior_purple_record_carried
+ self.assertEqual(prior["carried_from"]["pid"], self.old_owner.pid)
+ ~~~~~^^^^^^^^^^^^^^^^
+KeyError: 'carried_from'
+
+======================================================================
+ERROR: test_three_generation_chain_keeps_original_carried_from_and_hops (test_owner_change_carry.OwnerChangeCarryTests.test_three_generation_chain_keeps_original_carried_from_and_hops)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 450, in test_three_generation_chain_keeps_original_carried_from_and_hops
+ self.assertEqual(gen2["carried_from"]["hops"], 1)
+ ~~~~~~~~~~~~~~~~~~~~^^^^^^^^
+KeyError: 'hops'
+
+======================================================================
+ERROR: test_ticket_command_preserves_carry_when_settle_has_not_run_yet (test_owner_change_carry.OwnerChangeCarryTests.test_ticket_command_preserves_carry_when_settle_has_not_run_yet)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 520, in test_ticket_command_preserves_carry_when_settle_has_not_run_yet
+ previous, eff_reason = self.store.effective_previous(self.new_owner)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AttributeError: 'Store' object has no attribute 'effective_previous'
+
+======================================================================
+FAIL: test_default_ticket_set_floors_to_yellow_when_nothing_carried (test_owner_change_carry.OwnerChangeCarryTests.test_default_ticket_set_floors_to_yellow_when_nothing_carried)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 551, in test_default_ticket_set_floors_to_yellow_when_nothing_carried
+ self.assertEqual(record["state"], "yellow")
+ ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AssertionError: 'none' != 'yellow'
+- none
++ yellow
+
+
+======================================================================
+FAIL: test_default_ticket_set_preserves_carried_purple_on_owner_changed (test_owner_change_carry.OwnerChangeCarryTests.test_default_ticket_set_preserves_carried_purple_on_owner_changed)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 541, in test_default_ticket_set_preserves_carried_purple_on_owner_changed
+ self.assertEqual(record["state"], "purple")
+ ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AssertionError: 'none' != 'purple'
+- none
++ purple
+
+
+======================================================================
+FAIL: test_deferential_first_prompt_carries_purple_not_green (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_first_prompt_carries_purple_not_green)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 379, in test_deferential_first_prompt_carries_purple_not_green
+ self.assertEqual(record["state"], "purple")
+ ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AssertionError: 'green' != 'purple'
+- green
++ purple
+
+
+======================================================================
+FAIL: test_deferential_first_prompt_unverified_yellow_stays_yellow (test_owner_change_carry.OwnerChangeCarryTests.test_deferential_first_prompt_unverified_yellow_stays_yellow)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 394, in test_deferential_first_prompt_unverified_yellow_stays_yellow
+ self.assertEqual(record["state"], "yellow")
+ ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AssertionError: 'green' != 'yellow'
+- green
++ yellow
+
+
+======================================================================
+FAIL: test_revision_continues_across_carry_not_reset_to_one (test_owner_change_carry.OwnerChangeCarryTests.test_revision_continues_across_carry_not_reset_to_one)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 478, in test_revision_continues_across_carry_not_reset_to_one
+ self.assertEqual(settled["revision"], 2) # continues, not reset to 1
+ ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
+AssertionError: 1 != 2
+
+======================================================================
+FAIL: test_stale_legacy_dot_not_carried (test_owner_change_carry.OwnerChangeCarryTests.test_stale_legacy_dot_not_carried)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 442, in test_stale_legacy_dot_not_carried
+ self.assertIsNone(self.store.prior_attention(self.new_owner))
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AssertionError: {'color': 'orange', 'label': 'paste waiting', 'ticket': ''} is not None
+
+======================================================================
+FAIL: test_stale_prior_purple_does_not_carry_falls_to_yellow (test_owner_change_carry.OwnerChangeCarryTests.test_stale_prior_purple_does_not_carry_falls_to_yellow)
+----------------------------------------------------------------------
+Traceback (most recent call last):
+ File "/private/tmp/claude-501/-Users-macstudio3/81614591-265b-48ed-97b7-6518070fa586/scratchpad/b594dbb-check/test_owner_change_carry.py", line 415, in test_stale_prior_purple_does_not_carry_falls_to_yellow
+ self.assertIsNone(self.store.prior_attention(self.new_owner))
+ ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AssertionError: {'color': 'purple', 'label': 'gated memo', 'ticket': 'TK-11317'} is not None
+
+----------------------------------------------------------------------
+Ran 45 tests in 2.365s
+
+FAILED (failures=7, errors=8)
← 468627f TK-12326: durable departed-asks register + resolver so a reu
·
back to Terminal Status
·
(newest)