Skip to content

Commit 877e72b

Browse files
paddymulclaude
andcommitted
feat(#135): reject revisions that reference their own alias; steer toward inline / pin-by-hash
references_own_alias (dependents) detects a parent edge whose ref is the alias being revised. catalog_revise (MCP) and put_code (UI edit) reject such a revise before advancing the alias — recording it so it shows in the activity log — and the catalog_revise docstring now steers toward a self-contained recipe: extend the entry's current code; inline the source for a source-shaped entry, or pin the previous version by hash for an expensive one (lever b + guard). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 23caf89 commit 877e72b

3 files changed

Lines changed: 64 additions & 5 deletions

File tree

src/tallyman_companion/app.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,6 +1542,21 @@ async def put_code(project: str, alias: str, payload: dict):
15421542
rec = _record_error(project, code=code, message=str(exc), tool="api_code")
15431543
await publish({"kind": "build_failed", "error_id": rec["id"], "tool": "api_code"})
15441544
raise HTTPException(400, str(exc))
1545+
from tallyman_xorq.dependents import references_own_alias # noqa: PLC0415
1546+
1547+
if references_own_alias(project, res.content_hash, alias):
1548+
from tallyman_core import record_error as _record_error # noqa: PLC0415
1549+
1550+
msg = (
1551+
f"this revision references its own alias {alias!r}, which makes an opaque, "
1552+
f"permanently-stale (follows-its-own-head) entry. Write a self-contained recipe: "
1553+
f"inline the source (read_project_file / read_csv …) for a source-shaped entry, or "
1554+
f"reference the previous version by hash with pinned_expr_from_alias({prev_hash!r}) "
1555+
f"for an expensive one."
1556+
)
1557+
rec = _record_error(project, code=code, message=msg, tool="api_code")
1558+
await publish({"kind": "build_failed", "error_id": rec["id"], "tool": "api_code"})
1559+
raise HTTPException(400, msg)
15451560
info = _set_alias(project, alias, res.content_hash, expect_exists=True)
15461561
# Chart + display config are keyed by content hash; seed the new version
15471562
# from the previous one so they follow the alias across revisions.

src/tallyman_mcp/server.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
)
5151
from tallyman_core.events import record_event
5252
from tallyman_xorq import BuildError, build_and_persist, full_diff, list_entries, staleness
53+
from tallyman_xorq.dependents import references_own_alias
5354
from tallyman_xorq.recalc import classify_orphans, recalc
5455

5556
log = logging.getLogger("tallyman_mcp")
@@ -617,12 +618,22 @@ def catalog_create(name: str, code: str, prompt: str = "") -> dict:
617618
def catalog_revise(name: str, code: str, prompt: str = "") -> dict:
618619
"""Persist a NEW VERSION of an existing alias.
619620
620-
The alias is repointed to the new content hash. The previous hash remains
621-
in the catalog as a forensic artifact and is recorded in alias_history.
621+
The alias is repointed to the new content hash; the previous hash remains as
622+
a forensic artifact in alias_history.
622623
623-
Code conventions and gotchas are documented in `catalog_run`. The same
624-
rules apply here. Use `tracked_expr_from_alias(name)` to chain off the previous
625-
version of the alias (or any other named entry).
624+
Write the revision as a SELF-CONTAINED recipe — start from this entry's
625+
current code and extend it, so the code tab reads as the full state of this
626+
version in one view. A revision that references its OWN alias by name
627+
(`tracked_expr_from_alias("<name>")` / `pinned_expr_from_alias("<name>")`) is
628+
rejected (#135) — it makes an opaque, follows-its-own-head entry. Two good
629+
shapes instead:
630+
- source-shaped entry -> inline the source: `read_project_file(...)` /
631+
`read_csv` plus the transforms, then your change.
632+
- expensive parent (Aggregate/Join/Sort/window/UDF) -> reference the
633+
previous version by its content HASH: `pinned_expr_from_alias("<hash>")`,
634+
which reads its baked snapshot instead of re-running the computation.
635+
636+
Code conventions and gotchas are documented in `catalog_run`.
626637
627638
Args:
628639
name: An existing alias.
@@ -636,6 +647,26 @@ def catalog_revise(name: str, code: str, prompt: str = "") -> dict:
636647
out = _run_and_record(project, code, prompt, tool="catalog_revise")
637648
if "error" in out:
638649
return out
650+
if references_own_alias(project, out["hash"], name):
651+
msg = (
652+
f"this revision references its own alias {name!r}, which makes an opaque, "
653+
f"permanently-stale (follows-its-own-head) entry. Write a self-contained recipe: "
654+
f"inline the source (read_project_file / read_csv …) for a source-shaped entry, or "
655+
f"reference the previous version by hash with pinned_expr_from_alias({prev_hash!r}) "
656+
f"for an expensive one."
657+
)
658+
rec = record_error(project, code=code, message=msg, prompt=prompt or None, tool="catalog_revise")
659+
record_event(
660+
project,
661+
"build_error",
662+
session=SESSION_ID,
663+
tool="catalog_revise",
664+
prompt=prompt or None,
665+
code=code,
666+
message=msg,
667+
error_id=rec["id"],
668+
)
669+
return {"error": msg, "error_id": rec["id"]}
639670
info = set_alias(project, name, out["hash"], expect_exists=True)
640671
record_event(
641672
project, "alias_set", session=SESSION_ID, tool="catalog_revise",

src/tallyman_xorq/dependents.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ def parents_of(project: str, content_hash: str) -> list[ParentRef]:
3131
return list(read_manifest(entry_dir(project, content_hash)).parents or [])
3232

3333

34+
def references_own_alias(project: str, content_hash: str, alias: str) -> bool:
35+
"""True if the entry references its own ``alias`` *by name* (#135).
36+
37+
A revision of alias X whose recipe calls ``tracked_expr_from_alias("X")`` /
38+
``pinned_expr_from_alias("X")`` records a parent edge with ``ref == X``. That
39+
makes the entry an opaque self-reference — and a permanently-stale,
40+
follows-its-own-head entry — so ``catalog_revise`` rejects it. A reference to
41+
a prior version *by hash* records ``ref == <hash>`` (not the alias name) and
42+
is allowed.
43+
"""
44+
return any(p.ref == alias for p in parents_of(project, content_hash))
45+
46+
3447
def sources_of(project: str, content_hash: str) -> dict[str, str] | None:
3548
"""The recorded source-leaf digests of one entry (``{rel_path: digest}``).
3649

0 commit comments

Comments
 (0)