From 8e5f74af9fee7ca0d712979ebcebb18698cb1129 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:52:22 +1000 Subject: [PATCH 1/5] feat(entity-assoc): expose issue lifecycle facts on the reverse-lookup (warpline b-ii) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/entity-associations?entity_id= (and the MCP/db reverse lookup) now enriches each binding row with the bound issue's claimed_at, closed_at, status, and status_category, so warpline can correlate "changed since the issue was claimed/closed" against its own changed-set in one round trip. closed_at is the proven-good signal ("issue closed at commit X"); Filigree exposes the resolution timestamp verbatim and stores no commit SHA — warpline maps timestamp->commit on its side. Implemented as a separate enriched projection (EntityAssociationByEntityRow) via a LEFT JOIN to issues — the shared _row_to_entity_association mapper, the forward list_entity_associations, the add-response, and governance.py are all untouched and byte-identical. LEFT (not INNER) JOIN keeps an orphaned binding (issue row absent) in the result with null facts rather than dropping it. Additive and Loomweave-safe: the Loomweave consumer (parse_entity_associations_response) has no serde deny_unknown_fields and is tested to ignore unknown fields, so the v1 consumer still parses v2. Contract fixture bumped to fixture_version 2. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/filigree/db_entity_associations.py | 77 ++++++++++++++++--- tests/core/test_entity_associations.py | 63 +++++++++++++++ .../entity-associations-response.json | 12 ++- 3 files changed, 139 insertions(+), 13 deletions(-) diff --git a/src/filigree/db_entity_associations.py b/src/filigree/db_entity_associations.py index dfc3f0e5..9df9e716 100644 --- a/src/filigree/db_entity_associations.py +++ b/src/filigree/db_entity_associations.py @@ -23,7 +23,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from filigree.db_base import DBMixinProtocol, _in_immediate_tx, _now_iso, _retry_busy from filigree.types.core import ( @@ -31,6 +31,7 @@ ISOTimestamp, IssueId, LoomweaveEntityId, + StatusCategory, make_content_hash, make_issue_id, make_loomweave_entity_id, @@ -71,6 +72,30 @@ class EntityAssociationRow(TypedDict): signed_content_hash: ContentHash | None +class EntityAssociationByEntityRow(EntityAssociationRow): + """A reverse-lookup row (``list_associations_by_entity``) enriched with the + bound issue's lifecycle facts. + + The warpline<->filigree seam (B contract): warpline reads the reverse lookup + to learn which issues are bound to a code entity, then correlates "changed + since the issue was claimed/closed" against its own changed-set. Carrying the + lifecycle anchors here lets it do that in one round trip. ``closed_at`` is the + proven-good signal ("issue closed at commit X"); Filigree exposes the + resolution timestamp verbatim and stores no commit SHA — warpline maps the + timestamp to a commit on its own side. + + All four fields are ``None`` for an *orphaned* binding (the issue row is + absent — a LEFT JOIN, not INNER, so the binding is still returned rather than + dropped). These keys appear ONLY on the reverse projection; the forward + per-issue list (``list_entity_associations``) stays a pure binding row. + """ + + claimed_at: ISOTimestamp | None + closed_at: ISOTimestamp | None + status: str | None + status_category: StatusCategory | None + + class GovernedAssociationRemovalError(ValueError): """Refused: a caller tried to delete a Legis-signed (governed) binding. @@ -390,12 +415,38 @@ def list_entity_associations(self, issue_id: IssueId) -> list[EntityAssociationR ).fetchall() return [_row_to_entity_association(r) for r in rows] + def _row_to_by_entity_association( + self, r: Mapping[str, Any], *, current_content_hash: str | None = None + ) -> EntityAssociationByEntityRow: + """Enrich a reverse-lookup row with the bound issue's lifecycle facts. + + The base binding projection is byte-identical to the forward list (the + shared :func:`_row_to_entity_association` is untouched); only the reverse + surface carries the LEFT-JOINed ``issues`` columns. ``status`` / + ``closed_at`` / ``claimed_at`` are ``None`` for an orphaned binding (issue + row absent), and ``status_category`` is then ``None`` too, so the row is + still returned rather than dropped. + """ + base = _row_to_entity_association(r, current_content_hash=current_content_hash) + status = r["issue_status"] + issue_type = r["issue_type"] + claimed_at = r["issue_claimed_at"] + closed_at = r["issue_closed_at"] + enriched = cast("EntityAssociationByEntityRow", dict(base)) + enriched["claimed_at"] = ISOTimestamp(claimed_at) if claimed_at else None + enriched["closed_at"] = ISOTimestamp(closed_at) if closed_at else None + enriched["status"] = status + enriched["status_category"] = ( + self._resolve_status_category(issue_type, status) if status is not None and issue_type is not None else None + ) + return enriched + def list_associations_by_entity( self, entity_id: LoomweaveEntityId, *, current_content_hash: ContentHash | str | None = None, - ) -> list[EntityAssociationRow]: + ) -> list[EntityAssociationByEntityRow]: """Return all issue bindings for a given Loomweave entity. The reverse of :meth:`list_entity_associations`: given an @@ -414,16 +465,24 @@ def list_associations_by_entity( entity_id = make_loomweave_entity_id(entity_id) if current_content_hash is not None: current_content_hash = make_content_hash(current_content_hash) + # LEFT JOIN (not INNER) so an orphaned binding — issue row gone, a state + # warpline_consumer explicitly contemplates ("binding outlived its + # issue") — is still returned with null lifecycle facts rather than + # silently dropped. The issue columns are aliased to avoid any collision + # and to read explicitly in the enrichment mapper. rows = self.conn.execute( """ - SELECT issue_id, loomweave_entity_id, entity_kind, content_hash_at_attach, - attached_at, attached_by, migration_orphaned_at, signature, signoff_seq, - signed_content_hash - FROM entity_associations - WHERE loomweave_entity_id = ? - ORDER BY attached_at ASC, issue_id ASC + SELECT ea.issue_id, ea.loomweave_entity_id, ea.entity_kind, ea.content_hash_at_attach, + ea.attached_at, ea.attached_by, ea.migration_orphaned_at, ea.signature, + ea.signoff_seq, ea.signed_content_hash, + i.claimed_at AS issue_claimed_at, i.closed_at AS issue_closed_at, + i.status AS issue_status, i.type AS issue_type + FROM entity_associations ea + LEFT JOIN issues i ON i.id = ea.issue_id + WHERE ea.loomweave_entity_id = ? + ORDER BY ea.attached_at ASC, ea.issue_id ASC """, (entity_id,), ).fetchall() current_hash = str(current_content_hash) if current_content_hash is not None else None - return [_row_to_entity_association(r, current_content_hash=current_hash) for r in rows] + return [self._row_to_by_entity_association(r, current_content_hash=current_hash) for r in rows] diff --git a/tests/core/test_entity_associations.py b/tests/core/test_entity_associations.py index 465b03d0..5a1e6e64 100644 --- a/tests/core/test_entity_associations.py +++ b/tests/core/test_entity_associations.py @@ -363,6 +363,69 @@ def test_attach_persists_signature_and_signoff_seq(self, db: FiligreeDB) -> None assert by_entity[0]["signature"] == "deadbeef" assert by_entity[0]["signoff_seq"] == 7 + +class TestReverseLookupLifecycleFacts: + """b-ii (warpline seam): the reverse lookup enriches each binding with the + bound issue's lifecycle facts — ``claimed_at``, ``closed_at``, ``status``, + and ``status_category`` — so warpline can correlate "changed since + claimed/closed" against its own changed-set in one round trip. closed_at is + the proven-good signal (issue closed at commit X); warpline maps the + timestamp to a commit on its side. + + The forward per-issue list (``list_entity_associations``) stays a pure + binding projection and must NOT grow these keys — the shared mapper is + untouched; only the reverse query joins ``issues``. + """ + + def test_open_issue_row_exposes_null_close_and_open_category(self, db: FiligreeDB) -> None: + issue = db.create_issue("open work", priority=2) + db.add_entity_association(issue.id, "py:func:open-target", content_hash="h1", actor="alice") + (row,) = db.list_associations_by_entity("py:func:open-target") + assert row["closed_at"] is None + assert row["claimed_at"] is None + assert row["status"] == issue.status + assert row["status_category"] == "open" + + def test_closed_issue_row_exposes_closed_at_and_done_category(self, db: FiligreeDB) -> None: + issue = db.create_issue("to close", priority=2) + db.add_entity_association(issue.id, "py:func:closed-target", content_hash="h1", actor="alice") + db.close_issue(issue.id, reason="done") + (row,) = db.list_associations_by_entity("py:func:closed-target") + assert row["closed_at"] is not None + assert row["status_category"] == "done" + + def test_claimed_issue_row_exposes_claimed_at(self, db: FiligreeDB) -> None: + issue = db.create_issue("to claim", priority=2) + db.add_entity_association(issue.id, "py:func:claimed-target", content_hash="h1", actor="alice") + db.claim_issue(issue.id, assignee="alice") + (row,) = db.list_associations_by_entity("py:func:claimed-target") + assert row["claimed_at"] is not None + + def test_orphaned_binding_still_returns_with_null_facts(self, db: FiligreeDB) -> None: + """LEFT JOIN, not INNER: a binding whose issue row is absent must still + appear (warpline_consumer contemplates 'binding outlived its issue'), + with null lifecycle facts rather than being dropped.""" + issue = db.create_issue("soon orphaned", priority=2) + db.add_entity_association(issue.id, "py:func:orphan-target", content_hash="h1", actor="alice") + # Delete the issue row directly, leaving the binding (bypassing cascade). + db.conn.execute("PRAGMA foreign_keys = OFF") + db.conn.execute("DELETE FROM issues WHERE id = ?", (issue.id,)) + db.conn.execute("PRAGMA foreign_keys = ON") + (row,) = db.list_associations_by_entity("py:func:orphan-target") + assert row["issue_id"] == issue.id + assert row["closed_at"] is None + assert row["status"] is None + assert row["status_category"] is None + + def test_forward_list_omits_lifecycle_facts(self, db: FiligreeDB) -> None: + issue = db.create_issue("forward", priority=2) + db.add_entity_association(issue.id, "py:func:fwd-target", content_hash="h1", actor="alice") + (fwd,) = db.list_entity_associations(issue.id) + assert "closed_at" not in fwd + assert "claimed_at" not in fwd + assert "status" not in fwd + assert "status_category" not in fwd + def test_attach_without_signature_stores_null(self, db: FiligreeDB) -> None: issue = db.create_issue("Ungoverned work", priority=2) row = db.add_entity_association(issue.id, "sei:plain", content_hash="h1", actor="x") diff --git a/tests/fixtures/contracts/entity-associations-response.json b/tests/fixtures/contracts/entity-associations-response.json index 54dbf341..082a1267 100644 --- a/tests/fixtures/contracts/entity-associations-response.json +++ b/tests/fixtures/contracts/entity-associations-response.json @@ -2,7 +2,7 @@ "_meta": { "contract": "filigree-entity-associations-response", "endpoint": "GET /api/entity-associations?entity_id={entity_id}", - "fixture_version": 1, + "fixture_version": 2, "stability": "normative", "authority": "Weft G15 / ADR-029 EntityAssociation producer-consumer conformance", "producer": "Filigree dashboard_routes.entities.api_list_associations_by_entity", @@ -12,8 +12,8 @@ "loomweave/docs/federation/fixtures/filigree-entity-associations-response.json" ], "verification": "Filigree: uv run pytest tests/api/test_entity_associations.py -q; Loomweave: cargo test -p loomweave-federation filigree::tests::parses_canonical_filigree_entity_association_fixture", - "updated": "2026-06-13", - "description": "Canonical live Filigree reverse-lookup response for EntityAssociation rows. The producer test emits this body through Filigree's HTTP route; the consumer test deserializes the same body in Loomweave." + "updated": "2026-06-24", + "description": "Canonical live Filigree reverse-lookup response for EntityAssociation rows. The producer test emits this body through Filigree's HTTP route; the consumer test deserializes the same body in Loomweave. v2 (warpline seam): each row now also carries the bound issue's lifecycle facts (claimed_at, closed_at, status, status_category) for warpline's 'changed since claimed/closed' correlation; all four are null for an orphaned binding. Additive — Loomweave's consumer ignores unknown fields (no serde deny_unknown_fields), so a v1 consumer still parses v2. The loomweave repo-local copy should be synced for documentation parity (non-breaking)." }, "shape_decl": { "kind": "filigree-entity-associations-fixture-shapes", @@ -54,7 +54,11 @@ "freshness_status": "unknown", "signature": null, "signoff_seq": null, - "signed_content_hash": null + "signed_content_hash": null, + "claimed_at": null, + "closed_at": null, + "status": "open", + "status_category": "open" } ] } From 06ac96e56e32e33a252220198306c4221b668f8f Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:55:28 +1000 Subject: [PATCH 2/5] fix(warpline): file reverify issue + bind SEI atomically (no FILED-but-UNBOUND) ingest_reverify_worklist created the issue and bound its SEI in two separate @_in_immediate_tx transactions, so a non-retryable storage error on the bind left the issue FILED-but-UNBOUND. Because the loop-closure contract keys on the SEI association warpline reads back, the next ingest then saw the entity as untracked and re-filed a duplicate. Route the bind through create_issue's existing atomic inline-bind path (entity_id / content_hash / entity_kind), so file+bind commit together in one transaction; a bind failure rolls the whole item back. The warpline content sentinel is preserved verbatim. Regression test simulates a bind failure and asserts no orphaned issue remains. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/filigree/warpline_consumer.py | 19 ++++++++++++------- tests/test_warpline_consumer.py | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/filigree/warpline_consumer.py b/src/filigree/warpline_consumer.py index fc4e104c..ed1c89b6 100644 --- a/src/filigree/warpline_consumer.py +++ b/src/filigree/warpline_consumer.py @@ -35,7 +35,7 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any -from filigree.types.core import make_content_hash, make_issue_id, make_loomweave_entity_id +from filigree.types.core import make_loomweave_entity_id if TYPE_CHECKING: from filigree.core import FiligreeDB @@ -203,6 +203,15 @@ def ingest_reverify_worklist( result["prior_closed_issue_ids"] = closed_issue_ids if apply: + # File the issue and bind its SEI in a SINGLE transaction via + # create_issue's inline ADR-029 bind (SEAM SEI-on-create). A separate + # add_entity_association call would commit the issue first, so a + # storage failure on the bind would leave the issue FILED-but-UNBOUND + # — and the next ingest would re-file a duplicate, because the + # loop-closure contract keys on the SEI binding. The inline path makes + # file+bind atomic, and (re)applies the same UNVERIFIED sentinel for a + # blank hash. content_hash here is already resolved (a real hash or the + # warpline sentinel), so it is preserved verbatim. issue = db.create_issue( f"Reverify: {locator if locator else sei}", type="task", @@ -210,12 +219,8 @@ def ingest_reverify_worklist( description=_build_description(raw, sei, locator), labels=list(PRODUCER_LABELS), actor=actor, - ) - db.add_entity_association( - make_issue_id(issue.id), - entity_id, - make_content_hash(content_hash), - actor=actor, + entity_id=str(entity_id), + content_hash=content_hash, entity_kind=ENTITY_KIND, ) result["issue_id"] = issue.id diff --git a/tests/test_warpline_consumer.py b/tests/test_warpline_consumer.py index 0eebadc9..f7f8aca3 100644 --- a/tests/test_warpline_consumer.py +++ b/tests/test_warpline_consumer.py @@ -10,6 +10,8 @@ from typing import Any +import pytest + from filigree.core import FiligreeDB from filigree.warpline_consumer import ( ENTITY_KIND, @@ -158,3 +160,27 @@ def test_mixed_worklist_summary(self, db: FiligreeDB) -> None: apply=True, ) assert report["summary"] == {"filed": 1, "linked": 1, "skipped": 1, "total": 3} + + +class TestFileAndBindAtomicity: + """A filed item must create the issue and bind its SEI in a SINGLE + transaction. If the bind fails, the whole item rolls back — no issue may be + left FILED-but-UNBOUND, otherwise the next ingest sees the SEI as untracked + and re-files a duplicate, breaking the loop-closure contract. + """ + + def test_bind_failure_rolls_back_the_filed_issue(self, db: FiligreeDB, monkeypatch: pytest.MonkeyPatch) -> None: + before = len(db.list_issues()) + + def boom(*args: Any, **kwargs: Any) -> None: + raise RuntimeError("simulated non-retryable storage failure on bind") + + monkeypatch.setattr(db, "add_entity_association", boom) + + with pytest.raises(RuntimeError, match="storage failure on bind"): + ingest_reverify_worklist(db, _worklist(_item("loomweave:eid:ATOMIC")), apply=True) + + # Atomic: the issue insert rolled back together with the failed bind — + # no orphaned FILED-but-UNBOUND task, and nothing bound to the SEI. + assert len(db.list_issues()) == before + assert db.list_associations_by_entity("loomweave:eid:ATOMIC") == [] From 7b450dee5fa31beca3a56b94d99e87998ac07aa0 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:08:55 +1000 Subject: [PATCH 3/5] docs(changelog): record warpline reverse-lookup facts + atomic reverify ingest Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da931213..f6e0c692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Warpline seam — issue lifecycle facts on the entity-association + reverse-lookup.** `GET /api/entity-associations?entity_id=` (and the MCP + and data-layer reverse lookups) now enriches each binding row with the bound + issue's `claimed_at`, `closed_at`, `status`, and `status_category`, so warpline + can correlate "changed since the issue was claimed/closed" against its own + changed-set in a single round trip. `closed_at` is the proven-good signal + ("issue closed at commit X"); Filigree exposes the resolution timestamp and + stores no commit SHA (warpline maps timestamp→commit on its side). Implemented + as a separate enriched projection over a `LEFT JOIN` to `issues` — the shared + mapper, the forward per-issue list, the add-response, and the governance + closure gate are untouched and byte-identical; an orphaned binding still + returns (null facts). Additive and Loomweave-safe (the consumer ignores + unknown fields); the entity-associations contract fixture is bumped to v2. + +### Fixed + +- **Warpline reverify ingest filed an issue and bound its SEI + non-atomically.** `warpline_worklist_ingest` created the issue and attached + its SEI association in two separate transactions, so a non-retryable storage + error on the bind left the issue FILED-but-UNBOUND — and the next ingest then + re-filed a duplicate because the loop-closure contract keys on the SEI + binding. File + bind now commit together in one transaction via + `create_issue`'s inline ADR-029 bind path. + ## [3.0.1] - 2026-06-18 ### Fixed From 8ad682639662afea3197e41d3509cccfde67f17b Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:04:47 +1000 Subject: [PATCH 4/5] feat(issues): per-issue commit anchor at claim/close (warpline seam, schema v29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warpline correlates "changed since the issue was claimed/closed" on commits, not wall-clock timestamps. New nullable issues.claim_commit / issues.close_commit columns hold an opaque caller-supplied branch@sha the issue was claimed/closed at; Filigree stores it verbatim and never parses it (git/CI is Legis's domain, matching how it treats content_hash on entity associations). - Schema v28->v29: additive nullable columns + idempotent migrate_v28_to_v29. - Capture: optional `commit` arg on close_issue/claim_issue/start_work/reclaim and update_issue (claim_commit/close_commit); plumbed through the MCP (issue_close/work_claim/work_start), CLI (--commit on close/claim/start-work), and HTTP close/claim routes. - Mirror invariant: the anchor is set wherever its timestamp is set and cleared wherever its timestamp is cleared — done-entry/leaving-done, assignee set/clear, claim (COALESCE-preserve), release, reclaim (OVERWRITE so the prior holder's anchor never survives), and the undo/restore paths (NULL — no commit to restore). A stale anchor can never outlive its claim/close. - Exposed on the issue read (classic + weft) and the entity-association reverse-lookup; NULL when no commit supplied -> warpline falls back to the timestamp. - Additive + Loomweave-safe; entity-associations contract fixture -> v3. With no commit supplied every existing flow is byte-identical (warpline-absent parity). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++ src/filigree/cli_commands/issues.py | 23 ++- src/filigree/dashboard_routes/issues.py | 18 +- src/filigree/db_base.py | 3 + src/filigree/db_entity_associations.py | 30 +++- src/filigree/db_events.py | 23 ++- src/filigree/db_issues.py | 66 ++++++- src/filigree/db_planning.py | 2 + src/filigree/db_schema.py | 4 +- src/filigree/generations/weft/adapters.py | 2 + src/filigree/generations/weft/types.py | 2 + src/filigree/issue_payloads.py | 2 + src/filigree/mcp_tools/issues.py | 32 ++++ src/filigree/migrations.py | 18 ++ src/filigree/models.py | 6 + src/filigree/types/api.py | 4 + src/filigree/types/core.py | 2 + src/filigree/types/inputs.py | 6 + src/filigree/types/planning.py | 2 + tests/cli/test_commit_anchor_verbs.py | 55 ++++++ tests/cli/test_files_commands.py | 2 + tests/core/test_commit_anchor.py | 163 ++++++++++++++++++ tests/core/test_crud.py | 2 + tests/core/test_entity_associations.py | 37 ++++ tests/core/test_schema.py | 54 +++++- .../entity-associations-response.json | 6 +- .../contracts/weft/issues-claim-next.json | 2 + tests/mcp/test_commit_anchor_verbs.py | 62 +++++++ 28 files changed, 611 insertions(+), 31 deletions(-) create mode 100644 tests/cli/test_commit_anchor_verbs.py create mode 100644 tests/core/test_commit_anchor.py create mode 100644 tests/mcp/test_commit_anchor_verbs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e0c692..353ac8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 closure gate are untouched and byte-identical; an orphaned binding still returns (null facts). Additive and Loomweave-safe (the consumer ignores unknown fields); the entity-associations contract fixture is bumped to v2. +- **Warpline seam — per-issue commit anchor at claim/close (schema v29).** New + nullable `issues.claim_commit` / `issues.close_commit` columns hold an opaque, + caller-supplied `branch@sha` the issue was claimed/closed at, so warpline can + correlate on commits rather than wall-clock timestamps. Filigree stores the + anchor verbatim and never parses it (git/CI is Legis's domain) — the + `commit` argument is optional on `close`, `claim`, and `start-work` (CLI, MCP, + and HTTP), and on the underlying `update_issue`/`reclaim` paths. The anchor is + mirrored at every `claimed_at`/`closed_at` set **and** clear site (so a stale + anchor never survives a release, reopen, unassign, reclaim, or undo), exposed + on the issue read (classic + weft) and the entity-association reverse-lookup, + and `NULL` when no commit is supplied — in which case warpline falls back to + the timestamp. Additive and Loomweave-safe; the entity-associations contract + fixture is bumped to v3. With no `commit` supplied, every existing flow is + byte-identical. ### Fixed diff --git a/src/filigree/cli_commands/issues.py b/src/filigree/cli_commands/issues.py index 116c85ad..c78cec07 100644 --- a/src/filigree/cli_commands/issues.py +++ b/src/filigree/cli_commands/issues.py @@ -714,6 +714,11 @@ def update_issue_cmd( ), ) @click.option("--expected-assignee", default=None, help="Expected current holder for coordinator writes") +@click.option( + "--commit", + default=None, + help="Opaque branch@sha commit anchor (warpline seam); stored verbatim as close_commit.", +) @click.option("--json", "as_json", is_flag=True, help="Output as JSON") @click.pass_context def close( @@ -723,6 +728,7 @@ def close( status: str | None, force: bool, expected_assignee: str | None, + commit: str | None, as_json: bool, ) -> None: """Close one or more issues.""" @@ -749,6 +755,7 @@ def close( actor=ctx.obj["actor"], expected_assignee=expected_assignee, force=force, + commit=commit, ) if as_json: item: dict[str, Any] = { @@ -871,9 +878,14 @@ def reopen(ctx: click.Context, issue_ids: tuple[str, ...], as_json: bool) -> Non @click.command(cls=ActorCommand) @click.argument("issue_id") @click.option("--assignee", default=None, help="Who is claiming (agent name); defaults to an explicitly provided --actor") +@click.option( + "--commit", + default=None, + help="Opaque branch@sha commit anchor (warpline seam); stored verbatim as claim_commit.", +) @click.option("--json", "as_json", is_flag=True, help="Output as JSON") @click.pass_context -def claim(ctx: click.Context, issue_id: str, assignee: str | None, as_json: bool) -> None: +def claim(ctx: click.Context, issue_id: str, assignee: str | None, commit: str | None, as_json: bool) -> None: """Atomically claim an open issue or released in-progress handoff.""" # FIL-3: --assignee defaults from an explicit --actor (group-level or # post-verb); with neither, fail naming both options. @@ -888,7 +900,7 @@ def claim(ctx: click.Context, issue_id: str, assignee: str | None, as_json: bool sys.exit(1) with get_db() as db: try: - issue = db.claim_issue(issue_id, assignee=assignee, actor=ctx.obj["actor"]) + issue = db.claim_issue(issue_id, assignee=assignee, actor=ctx.obj["actor"], commit=commit) except KeyError: if as_json: click.echo(json_mod.dumps({"error": f"Not found: {issue_id}", "code": ErrorCode.NOT_FOUND})) @@ -1442,6 +1454,11 @@ def delete_issue_cmd(ctx: click.Context, issue_id: str, force: bool, as_json: bo @click.option("--target-status", default=None, help="Override wip status (defaults to reachable wip target)") @click.option("--actor", default=None, help="Actor for audit trail (defaults to --assignee; also fills an omitted --assignee)") @click.option("--advance", is_flag=True, help="Walk soft transitions to the nearest wip state (e.g. triage->confirmed->fixing)") +@click.option( + "--commit", + default=None, + help="Opaque branch@sha commit anchor (warpline seam); stored verbatim as claim_commit.", +) @click.option("--json", "as_json", is_flag=True, help="Output as JSON") @click.pass_context def start_work( @@ -1451,6 +1468,7 @@ def start_work( target_status: str | None, actor: str | None, advance: bool, + commit: str | None, as_json: bool, ) -> None: """Atomically claim an issue and transition it to its wip status.""" @@ -1475,6 +1493,7 @@ def start_work( target_status=target_status, actor=resolved_actor, advance=advance, + commit=commit, ) except KeyError: if as_json: diff --git a/src/filigree/dashboard_routes/issues.py b/src/filigree/dashboard_routes/issues.py index 07792fb7..9f5dbecc 100644 --- a/src/filigree/dashboard_routes/issues.py +++ b/src/filigree/dashboard_routes/issues.py @@ -615,6 +615,9 @@ async def api_close_issue(issue_id: str, request: Request, db: FiligreeDB = Depe status_field = body.get("status") if status_field is not None and not isinstance(status_field, str): return _error_response("status must be a string", ErrorCode.VALIDATION, 400) + commit = body.get("commit") + if commit is not None and not isinstance(commit, str): + return _error_response("commit must be a string", ErrorCode.VALIDATION, 400) fields = body.get("fields") try: gate = governance.evaluate_closure_gate(db, issue_id) @@ -628,6 +631,7 @@ async def api_close_issue(issue_id: str, request: Request, db: FiligreeDB = Depe actor=actor, fields=fields, expected_assignee=expected_assignee, + commit=commit, ) except KeyError: return _error_response(f"Issue not found: {issue_id}", ErrorCode.NOT_FOUND, 404) @@ -893,8 +897,11 @@ async def api_claim_issue(issue_id: str, request: Request, db: FiligreeDB = Depe actor, actor_err = _validate_actor(body.get("actor", "dashboard")) if actor_err: return actor_err + commit = body.get("commit") + if commit is not None and not isinstance(commit, str): + return _error_response("commit must be a string", ErrorCode.VALIDATION, 400) try: - issue = db.claim_issue(issue_id, assignee=assignee, actor=actor) + issue = db.claim_issue(issue_id, assignee=assignee, actor=actor, commit=commit) except KeyError: return _error_response(f"Issue not found: {issue_id}", ErrorCode.NOT_FOUND, 404) except WrongProjectError as e: @@ -1460,6 +1467,9 @@ async def api_weft_close_issue(issue_id: str, request: Request, db: FiligreeDB = status_field = body.get("status") if status_field is not None and not isinstance(status_field, str): return _error_response("status must be a string", ErrorCode.VALIDATION, 400) + commit = body.get("commit") + if commit is not None and not isinstance(commit, str): + return _error_response("commit must be a string", ErrorCode.VALIDATION, 400) fields = body.get("fields") ready_before = {i.id for i in db.get_ready()} try: @@ -1474,6 +1484,7 @@ async def api_weft_close_issue(issue_id: str, request: Request, db: FiligreeDB = actor=actor, fields=fields, expected_assignee=expected_assignee, + commit=commit, ) except KeyError: return _error_response(f"Issue not found: {issue_id}", ErrorCode.NOT_FOUND, 404) @@ -1527,8 +1538,11 @@ async def api_weft_claim_issue(issue_id: str, request: Request, db: FiligreeDB = actor, actor_err = _validate_actor(body.get("actor", "dashboard")) if actor_err: return actor_err + commit = body.get("commit") + if commit is not None and not isinstance(commit, str): + return _error_response("commit must be a string", ErrorCode.VALIDATION, 400) try: - issue = db.claim_issue(issue_id, assignee=assignee, actor=actor) + issue = db.claim_issue(issue_id, assignee=assignee, actor=actor, commit=commit) except KeyError: return _error_response(f"Issue not found: {issue_id}", ErrorCode.NOT_FOUND, 404) except WrongProjectError as e: diff --git a/src/filigree/db_base.py b/src/filigree/db_base.py index b173822d..7f782f02 100644 --- a/src/filigree/db_base.py +++ b/src/filigree/db_base.py @@ -395,6 +395,8 @@ def update_issue( expected_assignee: str | None = None, force_overwrite_corrupt: bool = False, mode: TransitionMode = TransitionMode.FORWARD, + claim_commit: str | None = None, + close_commit: str | None = None, _skip_begin: bool = False, ) -> Issue: ... @@ -408,6 +410,7 @@ def close_issue( fields: dict[str, Any] | None = None, expected_assignee: str | None = None, force: bool = False, + commit: str | None = None, _skip_begin: bool = False, ) -> Issue: ... diff --git a/src/filigree/db_entity_associations.py b/src/filigree/db_entity_associations.py index 9df9e716..f33ae437 100644 --- a/src/filigree/db_entity_associations.py +++ b/src/filigree/db_entity_associations.py @@ -80,18 +80,26 @@ class EntityAssociationByEntityRow(EntityAssociationRow): to learn which issues are bound to a code entity, then correlates "changed since the issue was claimed/closed" against its own changed-set. Carrying the lifecycle anchors here lets it do that in one round trip. ``closed_at`` is the - proven-good signal ("issue closed at commit X"); Filigree exposes the - resolution timestamp verbatim and stores no commit SHA — warpline maps the + proven-good signal ("issue closed at commit X"); ``close_commit`` / + ``claim_commit`` carry the caller-supplied ``branch@sha`` anchor when present + so warpline can correlate on the COMMIT directly, and otherwise it maps the timestamp to a commit on its own side. - All four fields are ``None`` for an *orphaned* binding (the issue row is - absent — a LEFT JOIN, not INNER, so the binding is still returned rather than - dropped). These keys appear ONLY on the reverse projection; the forward + All fields are ``None`` for an *orphaned* binding (the issue row is absent — a + LEFT JOIN, not INNER, so the binding is still returned rather than dropped), + and the commit anchors are also ``None`` when no commit was supplied at + claim / close. These keys appear ONLY on the reverse projection; the forward per-issue list (``list_entity_associations``) stays a pure binding row. """ claimed_at: ISOTimestamp | None closed_at: ISOTimestamp | None + # Opaque ``branch@sha`` commit anchors (warpline seam, contract B): the + # caller-supplied commit the issue was claimed / closed at, stored verbatim. + # ``None`` for an orphaned binding (issue row absent) or when no commit was + # supplied — warpline then falls back to the timestamp. + claim_commit: str | None + close_commit: str | None status: str | None status_category: StatusCategory | None @@ -423,9 +431,10 @@ def _row_to_by_entity_association( The base binding projection is byte-identical to the forward list (the shared :func:`_row_to_entity_association` is untouched); only the reverse surface carries the LEFT-JOINed ``issues`` columns. ``status`` / - ``closed_at`` / ``claimed_at`` are ``None`` for an orphaned binding (issue - row absent), and ``status_category`` is then ``None`` too, so the row is - still returned rather than dropped. + ``closed_at`` / ``claimed_at`` / ``close_commit`` / ``claim_commit`` are + ``None`` for an orphaned binding (issue row absent), and + ``status_category`` is then ``None`` too, so the row is still returned + rather than dropped. """ base = _row_to_entity_association(r, current_content_hash=current_content_hash) status = r["issue_status"] @@ -435,6 +444,10 @@ def _row_to_by_entity_association( enriched = cast("EntityAssociationByEntityRow", dict(base)) enriched["claimed_at"] = ISOTimestamp(claimed_at) if claimed_at else None enriched["closed_at"] = ISOTimestamp(closed_at) if closed_at else None + # Opaque commit anchors, stored verbatim (None for an orphaned binding or + # when no commit was supplied). Echoed exactly like the timestamps. + enriched["claim_commit"] = r["issue_claim_commit"] + enriched["close_commit"] = r["issue_close_commit"] enriched["status"] = status enriched["status_category"] = ( self._resolve_status_category(issue_type, status) if status is not None and issue_type is not None else None @@ -476,6 +489,7 @@ def list_associations_by_entity( ea.attached_at, ea.attached_by, ea.migration_orphaned_at, ea.signature, ea.signoff_seq, ea.signed_content_hash, i.claimed_at AS issue_claimed_at, i.closed_at AS issue_closed_at, + i.claim_commit AS issue_claim_commit, i.close_commit AS issue_close_commit, i.status AS issue_status, i.type AS issue_type FROM entity_associations ea LEFT JOIN issues i ON i.id = ea.issue_id diff --git a/src/filigree/db_events.py b/src/filigree/db_events.py index 94d11e76..b7fbd1f0 100644 --- a/src/filigree/db_events.py +++ b/src/filigree/db_events.py @@ -355,9 +355,11 @@ def undo_last(self, issue_id: str, *, actor: str = "") -> UndoResult: # Maintain closed_at consistency with the restored status old_cat = self._resolve_status_category(current.type, old_status) if old_cat == "done": - # Restoring to a done state — set closed_at + # Restoring to a done state — set closed_at. The undo path + # has no commit to restore, so clear close_commit to NULL + # (warpline falls back to the restored timestamp). self.conn.execute( - "UPDATE issues SET closed_at = ? WHERE id = ?", + "UPDATE issues SET closed_at = ?, close_commit = NULL WHERE id = ?", (now, issue_id), ) else: @@ -369,7 +371,7 @@ def undo_last(self, issue_id: str, *, actor: str = "") -> UndoResult: from filigree.db_issues import _REOPEN_CLEAR_FIELDS self.conn.execute( - "UPDATE issues SET closed_at = NULL WHERE id = ?", + "UPDATE issues SET closed_at = NULL, close_commit = NULL WHERE id = ?", (issue_id,), ) existing_fields = current.fields or {} @@ -402,32 +404,37 @@ def undo_last(self, issue_id: str, *, actor: str = "") -> UndoResult: case "assignee_changed": old_assignee = row["old_value"] or "" + # The undo path has no commit to restore, so claim_commit is + # cleared to NULL on both branches (warpline falls back to the + # restored claimed_at timestamp); mirrors claimed_at exactly. if old_assignee: self.conn.execute( - "UPDATE issues SET assignee = ?, claimed_at = ?, last_heartbeat_at = ?, " + "UPDATE issues SET assignee = ?, claimed_at = ?, claim_commit = NULL, last_heartbeat_at = ?, " "claim_expires_at = ?, updated_at = ? WHERE id = ?", (old_assignee, now, now, _undo_claim_expiry(now), now, issue_id), ) else: self.conn.execute( - "UPDATE issues SET assignee = '', claimed_at = NULL, last_heartbeat_at = NULL, " + "UPDATE issues SET assignee = '', claimed_at = NULL, claim_commit = NULL, last_heartbeat_at = NULL, " "claim_expires_at = NULL, updated_at = ? WHERE id = ?", (now, issue_id), ) case "claimed": # Restore: revert to the assignee before the claim (usually '' but - # preserves prior assignee if the claim re-assigned from another agent) + # preserves prior assignee if the claim re-assigned from another agent). + # The undo path has no commit to restore, so claim_commit is cleared + # to NULL on both branches; mirrors claimed_at exactly. old_assignee = row["old_value"] if row["old_value"] is not None else "" if old_assignee: self.conn.execute( - "UPDATE issues SET assignee = ?, claimed_at = ?, last_heartbeat_at = ?, " + "UPDATE issues SET assignee = ?, claimed_at = ?, claim_commit = NULL, last_heartbeat_at = ?, " "claim_expires_at = ?, updated_at = ? WHERE id = ?", (old_assignee, now, now, _undo_claim_expiry(now), now, issue_id), ) else: self.conn.execute( - "UPDATE issues SET assignee = '', claimed_at = NULL, last_heartbeat_at = NULL, " + "UPDATE issues SET assignee = '', claimed_at = NULL, claim_commit = NULL, last_heartbeat_at = NULL, " "claim_expires_at = NULL, updated_at = ? WHERE id = ?", (now, issue_id), ) diff --git a/src/filigree/db_issues.py b/src/filigree/db_issues.py index 1fa0481c..6731552d 100644 --- a/src/filigree/db_issues.py +++ b/src/filigree/db_issues.py @@ -759,6 +759,8 @@ def _build_issues_batch(self, issue_ids: list[str]) -> list[Issue]: created_at=row["created_at"], updated_at=row["updated_at"], closed_at=row["closed_at"], + claim_commit=row["claim_commit"], + close_commit=row["close_commit"], description=row["description"], notes=row["notes"], fields=_safe_fields_json(row["fields"], iid), @@ -798,6 +800,8 @@ def update_issue( expected_assignee: str | None = None, force_overwrite_corrupt: bool = False, mode: TransitionMode = TransitionMode.FORWARD, + claim_commit: str | None = None, + close_commit: str | None = None, _skip_begin: bool = False, ) -> Issue: """Update issue fields, workflow status, assignment, and parent links. @@ -835,6 +839,11 @@ def update_issue( declared reverse/escape transitions and audits the shortcut with ``transition_forced``. ``TransitionMode.FORWARD`` (default) uses the normal workflow lane. + claim_commit: Opaque ``branch@sha`` commit anchor (warpline seam, + contract B), stored verbatim wherever ``claimed_at`` is set; NULL + otherwise. Filigree never parses it. + close_commit: Opaque ``branch@sha`` commit anchor stored verbatim + wherever ``closed_at`` is set (done-entry); NULL otherwise. Returns: The freshly loaded issue. Soft transition data warnings are also @@ -1024,11 +1033,18 @@ def update_issue( if is_done: updates.append("closed_at = ?") params.append(now) + # Mirror closed_at: capture the caller-supplied commit anchor + # (NULL when none supplied -> warpline falls back to the timestamp). + updates.append("close_commit = ?") + params.append(close_commit) else: # Clear closed_at when leaving a done-category state old_cat = self.templates.get_category(current.type, current.status) if (old_cat or self._infer_status_category(current.type, current.status)) == "done": updates.append("closed_at = NULL") + # Mirror closed_at clear: a stale close anchor must not + # survive leaving a done state (reopen/release). + updates.append("close_commit = NULL") if priority is not None and priority != current.priority: self._record_event( @@ -1048,8 +1064,15 @@ def update_issue( if assignee: updates.extend(["claimed_at = ?", "last_heartbeat_at = ?", "claim_expires_at = ?"]) params.extend([now, now, _claim_expiry(now)]) + # Mirror claimed_at: capture the caller-supplied claim anchor + # (NULL when none supplied -> warpline falls back to the timestamp). + updates.append("claim_commit = ?") + params.append(claim_commit) else: updates.extend(["claimed_at = NULL", "last_heartbeat_at = NULL", "claim_expires_at = NULL"]) + # Mirror claimed_at clear: a stale claim anchor must not survive + # an unassign. + updates.append("claim_commit = NULL") if description is not None and description != current.description: self._record_event( @@ -1168,6 +1191,7 @@ def close_issue( fields: dict[str, Any] | None = None, expected_assignee: str | None = None, force: bool = False, + commit: str | None = None, _skip_begin: bool = False, ) -> Issue: """Close an issue. @@ -1188,6 +1212,11 @@ def close_issue( state on the caller's behalf — that hid intent (a feature in ``building`` that's actually shipped should not become ``deferred`` just because that's the only reachable done-state). + + ``commit`` is an opaque ``branch@sha`` commit anchor (warpline seam, + contract B) persisted as ``close_commit`` when the issue enters the + done-category state; NULL when omitted. Filigree stores it verbatim and + never parses it. """ if fields is not None and not isinstance(fields, dict): msg = "fields must be a dict" @@ -1236,6 +1265,7 @@ def close_issue( actor=actor, expected_assignee=expected_assignee, mode=mode, + close_commit=commit, _skip_begin=_skip_begin, ) @@ -1488,7 +1518,7 @@ def _table_exists(self, name: str) -> bool: @_retry_busy() @_in_immediate_tx("claim_issue") - def claim_issue(self, issue_id: str, *, assignee: str, actor: str = "", _skip_begin: bool = False) -> Issue: + def claim_issue(self, issue_id: str, *, assignee: str, actor: str = "", commit: str | None = None, _skip_begin: bool = False) -> Issue: """Atomically claim an open/wip-category issue with optimistic locking. Sets assignee only — does NOT change status. Agent uses update_issue @@ -1502,6 +1532,12 @@ def claim_issue(self, issue_id: str, *, assignee: str, actor: str = "", _skip_be Composed callers (``start_work``, ``_claim_next_with_prior``) pass the decorator's ``_skip_begin=True`` so this method runs inside the outer IMMEDIATE transaction. + + ``commit`` is an opaque ``branch@sha`` commit anchor (warpline seam, + contract B) stored as ``claim_commit`` alongside ``claimed_at`` on a + fresh claim; like ``claimed_at`` it is set via COALESCE so a same-agent + re-claim preserves the original anchor. NULL when omitted. Stored + verbatim, never parsed. """ # filigree-694f7e9bf8: enforce the same trimmed-identity invariant as # create_issue/update_issue. Without normalization, claiming with @@ -1537,10 +1573,11 @@ def claim_issue(self, issue_id: str, *, assignee: str, actor: str = "", _skip_be claim_expires_at = _claim_expiry(now) cursor = self.conn.execute( f"UPDATE issues SET assignee = ?, claimed_at = COALESCE(claimed_at, ?), " + f"claim_commit = COALESCE(claim_commit, ?), " f"last_heartbeat_at = ?, claim_expires_at = ?, updated_at = ? " f"WHERE id = ? AND status IN ({status_ph}) " f"AND (assignee = '' OR assignee IS NULL OR assignee = ?)", - [assignee, now, now, claim_expires_at, now, issue_id, *claimable_states, assignee], + [assignee, now, commit, now, claim_expires_at, now, issue_id, *claimable_states, assignee], ) if cursor.rowcount == 0: @@ -1700,12 +1737,16 @@ def release_claim( updates = [ "assignee = ''", "claimed_at = NULL", + # Mirror claimed_at clear: a stale claim anchor must not survive a release. + "claim_commit = NULL", "last_heartbeat_at = NULL", "claim_expires_at = NULL", ] params: list[Any] = [] if target is not None: - updates.extend(["status = ?", "closed_at = NULL"]) + # Mirror closed_at clear: a wip-revert that clears closed_at clears + # the close anchor too (a released issue is no longer closed). + updates.extend(["status = ?", "closed_at = NULL", "close_commit = NULL"]) params.append(target) updates.append("updated_at = ?") params.append(now) @@ -1981,6 +2022,7 @@ def reclaim_issue( reason: str, actor: str = "", lease_hours: int = DEFAULT_CLAIM_LEASE_HOURS, + commit: str | None = None, ) -> Issue: """Atomically transfer a stale claim to a new assignee. @@ -1989,6 +2031,11 @@ def reclaim_issue( ``claimed_at``, ``last_heartbeat_at``, ``claim_expires_at``, and ``updated_at`` together and records a ``reclaimed`` event. + ``commit`` is the new holder's opaque ``branch@sha`` claim anchor + (warpline seam, contract B). Unlike ``claim_issue``'s COALESCE, reclaim + OVERWRITES ``claim_commit`` (and NULLs it when omitted): the claim is now + a different holder's, so the prior holder's anchor must not survive. + Args: issue_id: Claimed issue to reclaim. The id prefix must belong to this project for write operations. @@ -2036,9 +2083,9 @@ def reclaim_issue( now = _now_iso() claim_expires_at = _claim_expiry(now, lease_hours) cursor = self.conn.execute( - "UPDATE issues SET assignee = ?, claimed_at = ?, last_heartbeat_at = ?, " + "UPDATE issues SET assignee = ?, claimed_at = ?, claim_commit = ?, last_heartbeat_at = ?, " "claim_expires_at = ?, updated_at = ? WHERE id = ? AND assignee = ?", - (assignee, now, now, claim_expires_at, now, issue_id, expected_assignee), + (assignee, now, commit, now, claim_expires_at, now, issue_id, expected_assignee), ) if cursor.rowcount == 0: current = self.conn.execute("SELECT assignee FROM issues WHERE id = ?", (issue_id,)).fetchone() @@ -2152,6 +2199,7 @@ def start_work( target_status: str | None = None, actor: str = "", advance: bool = False, + commit: str | None = None, ) -> Issue: """Atomically claim an issue and transition it to a working status. @@ -2180,6 +2228,10 @@ def start_work( identity — if the issue was already owned by ``assignee`` before the call, a transition failure must leave the claim in place rather than wiping out an unrelated, pre-existing claim. + + ``commit`` is an opaque ``branch@sha`` claim anchor (warpline seam, + contract B) forwarded to the inner ``claim_issue`` and stored as + ``claim_commit``; NULL when omitted. Stored verbatim, never parsed. """ actor = actor or assignee self._check_id_prefix(issue_id) @@ -2190,6 +2242,7 @@ def start_work( assignee=assignee, target_path=target_path, actor=actor, + commit=commit, ) except _StartCandidateUnclaimableError as exc: # Public API contract: surface the underlying claim error. @@ -2347,6 +2400,7 @@ def _start_work_locked( assignee: str, target_path: list[str], actor: str, + commit: str | None = None, ) -> Issue: """Private critical section for ``start_work`` / ``start_next_work``. @@ -2376,7 +2430,7 @@ def _start_work_locked( ``severity`` warning from the ``triage -> confirmed`` hop (filigree-406e6b7ee0). """ try: - result = self.claim_issue(issue_id, assignee=assignee, actor=actor, _skip_begin=True) + result = self.claim_issue(issue_id, assignee=assignee, actor=actor, commit=commit, _skip_begin=True) except (ClaimConflictError, KeyError) as exc: raise _StartCandidateUnclaimableError(issue_id) from exc except ValueError as exc: diff --git a/src/filigree/db_planning.py b/src/filigree/db_planning.py index 4a4a6b9e..9e8214bf 100644 --- a/src/filigree/db_planning.py +++ b/src/filigree/db_planning.py @@ -195,6 +195,8 @@ def _truncated_issue_sentinel(issue_id: str) -> IssueDict: created_at=_EMPTY_TS, updated_at=_EMPTY_TS, closed_at=None, + claim_commit=None, + close_commit=None, description="", notes="", fields={}, diff --git a/src/filigree/db_schema.py b/src/filigree/db_schema.py index 32378438..fced1659 100644 --- a/src/filigree/db_schema.py +++ b/src/filigree/db_schema.py @@ -24,6 +24,8 @@ description TEXT DEFAULT '', notes TEXT DEFAULT '', fields TEXT DEFAULT '{}', + claim_commit TEXT, + close_commit TEXT, CHECK (priority BETWEEN 0 AND 4) ); @@ -607,4 +609,4 @@ END; """ -CURRENT_SCHEMA_VERSION = 28 +CURRENT_SCHEMA_VERSION = 29 diff --git a/src/filigree/generations/weft/adapters.py b/src/filigree/generations/weft/adapters.py index 8e2cf3c5..07182499 100644 --- a/src/filigree/generations/weft/adapters.py +++ b/src/filigree/generations/weft/adapters.py @@ -88,6 +88,8 @@ def issue_to_weft(issue: Issue) -> IssueWeft: created_at=classic["created_at"], updated_at=classic["updated_at"], closed_at=classic["closed_at"], + claim_commit=classic["claim_commit"], + close_commit=classic["close_commit"], description=classic["description"], notes=classic["notes"], fields=classic["fields"], diff --git a/src/filigree/generations/weft/types.py b/src/filigree/generations/weft/types.py index afa8ded3..149e5c0c 100644 --- a/src/filigree/generations/weft/types.py +++ b/src/filigree/generations/weft/types.py @@ -114,6 +114,8 @@ class IssueWeft(TypedDict): created_at: ISOTimestamp updated_at: ISOTimestamp closed_at: ISOTimestamp | None + claim_commit: str | None + close_commit: str | None description: str notes: str fields: dict[str, Any] diff --git a/src/filigree/issue_payloads.py b/src/filigree/issue_payloads.py index d822d991..9990c900 100644 --- a/src/filigree/issue_payloads.py +++ b/src/filigree/issue_payloads.py @@ -71,6 +71,8 @@ def issue_to_public(issue: Issue) -> PublicIssue: created_at=classic["created_at"], updated_at=classic["updated_at"], closed_at=classic["closed_at"], + claim_commit=classic["claim_commit"], + close_commit=classic["close_commit"], description=classic["description"], notes=classic["notes"], fields=classic["fields"], diff --git a/src/filigree/mcp_tools/issues.py b/src/filigree/mcp_tools/issues.py index bc2539ad..f069357e 100644 --- a/src/filigree/mcp_tools/issues.py +++ b/src/filigree/mcp_tools/issues.py @@ -448,6 +448,14 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: "transition_forced is recorded before status_changed." ), }, + "commit": { + "type": "string", + "description": ( + "Opaque branch@sha commit anchor (warpline seam). Stored verbatim as " + "close_commit so warpline can correlate 'changed since closed' on the " + "commit, not the clock. Omit to leave it null." + ), + }, }, "required": ["issue_id"], }, @@ -564,6 +572,12 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: "type": "string", "description": "Agent/user identity for audit trail (defaults to assignee)", }, + "commit": { + "type": "string", + "description": ( + "Opaque branch@sha commit anchor (warpline seam). Stored verbatim as claim_commit. Omit to leave it null." + ), + }, }, "required": ["issue_id"], }, @@ -805,6 +819,12 @@ def register() -> tuple[list[Tool], dict[str, Callable[..., Any]]]: "hard edges are never auto-walked. Default false." ), }, + "commit": { + "type": "string", + "description": ( + "Opaque branch@sha commit anchor (warpline seam). Stored verbatim as claim_commit. Omit to leave it null." + ), + }, }, "required": ["issue_id"], }, @@ -1225,6 +1245,9 @@ async def _handle_close_issue(arguments: dict[str, Any]) -> list[TextContent]: force = args.get("force", False) if not isinstance(force, bool): return _text(ErrorResponse(error="force must be a boolean", code=ErrorCode.VALIDATION)) + commit = args.get("commit") + if commit is not None and not isinstance(commit, str): + return _text(ErrorResponse(error="commit must be a string", code=ErrorCode.VALIDATION)) tracker = get_db() try: gate = governance.evaluate_closure_gate(tracker, args["issue_id"]) @@ -1240,6 +1263,7 @@ async def _handle_close_issue(arguments: dict[str, Any]) -> list[TextContent]: fields=args.get("fields"), expected_assignee=expected_assignee, force=force, + commit=commit, ) refresh_summary() ready_after = tracker.get_ready() @@ -1369,12 +1393,16 @@ async def _handle_claim_issue(arguments: dict[str, Any]) -> list[TextContent]: actor, actor_err = _validate_actor(args.get("actor", assignee)) if actor_err: return actor_err + commit = args.get("commit") + if commit is not None and not isinstance(commit, str): + return _text(ErrorResponse(error="commit must be a string", code=ErrorCode.VALIDATION)) tracker = get_db() try: issue = tracker.claim_issue( args["issue_id"], assignee=assignee, actor=actor, + commit=commit, ) refresh_summary() return _text(issue_to_public(issue)) @@ -1764,6 +1792,9 @@ async def _handle_start_work(arguments: dict[str, Any]) -> list[TextContent]: advance = args.get("advance", False) if not isinstance(advance, bool): return _text(ErrorResponse(error="advance must be a boolean", code=ErrorCode.VALIDATION)) + commit = args.get("commit") + if commit is not None and not isinstance(commit, str): + return _text(ErrorResponse(error="commit must be a string", code=ErrorCode.VALIDATION)) tracker = get_db() try: issue = tracker.start_work( @@ -1772,6 +1803,7 @@ async def _handle_start_work(arguments: dict[str, Any]) -> list[TextContent]: target_status=args.get("target_status"), actor=actor, advance=advance, + commit=commit, ) except KeyError: return _text(ErrorResponse(error=f"Issue not found: {args['issue_id']}", code=ErrorCode.NOT_FOUND)) diff --git a/src/filigree/migrations.py b/src/filigree/migrations.py index fac3dafb..2f646c8d 100644 --- a/src/filigree/migrations.py +++ b/src/filigree/migrations.py @@ -947,6 +947,23 @@ def migrate_v27_to_v28(conn: sqlite3.Connection) -> None: ) +def migrate_v28_to_v29(conn: sqlite3.Connection) -> None: + """v28 -> v29: Add the per-issue commit-anchor columns (warpline seam, contract B). + + Warpline correlates "changed since the issue was claimed/closed" against + COMMITS, not clocks. ``issues.claim_commit`` and ``issues.close_commit`` + hold an opaque ``branch@sha`` string the CALLER supplies at claim / close; + Filigree stores it verbatim and never parses it (git/CI is Legis's domain). + + Both columns are nullable: an absent ``commit`` param leaves the column NULL + and warpline falls back to the existing ``claimed_at`` / ``closed_at`` + timestamp. Additive + idempotent (``add_column`` no-ops if present); existing + rows read NULL, so every WARPLINE-ABSENT flow is byte-identical to today. + """ + add_column(conn, "issues", "claim_commit", "TEXT", default=None) + add_column(conn, "issues", "close_commit", "TEXT", default=None) + + MIGRATIONS: dict[int, MigrationFn] = { 1: migrate_v1_to_v2, 2: migrate_v2_to_v3, @@ -975,6 +992,7 @@ def migrate_v27_to_v28(conn: sqlite3.Connection) -> None: 25: migrate_v25_to_v26, 26: migrate_v26_to_v27, 27: migrate_v27_to_v28, + 28: migrate_v28_to_v29, } diff --git a/src/filigree/models.py b/src/filigree/models.py index c49218d2..16345c2a 100644 --- a/src/filigree/models.py +++ b/src/filigree/models.py @@ -51,6 +51,10 @@ class Issue: created_at: ISOTimestamp = _EMPTY_TS updated_at: ISOTimestamp = _EMPTY_TS closed_at: ISOTimestamp | None = None + # Opaque ``branch@sha`` commit anchors (warpline seam, contract B). Set at + # claim / close from a caller-supplied value, stored verbatim, never parsed. + claim_commit: str | None = None + close_commit: str | None = None description: str = "" notes: str = "" fields: dict[str, Any] = field(default_factory=dict) @@ -97,6 +101,8 @@ def to_dict(self) -> IssueDict: created_at=self.created_at, updated_at=self.updated_at, closed_at=self.closed_at, + claim_commit=self.claim_commit, + close_commit=self.close_commit, description=self.description, notes=self.notes, fields=fields, diff --git a/src/filigree/types/api.py b/src/filigree/types/api.py index 70341752..ee74ad1e 100644 --- a/src/filigree/types/api.py +++ b/src/filigree/types/api.py @@ -120,6 +120,10 @@ class PublicIssue(TypedDict): created_at: ISOTimestamp updated_at: ISOTimestamp closed_at: ISOTimestamp | None + # Opaque branch@sha commit anchors (warpline seam, contract B): the + # caller-supplied commit the issue was claimed / closed at, stored verbatim. + claim_commit: str | None + close_commit: str | None description: str notes: str fields: dict[str, Any] diff --git a/src/filigree/types/core.py b/src/filigree/types/core.py index e5d510a6..6608b672 100644 --- a/src/filigree/types/core.py +++ b/src/filigree/types/core.py @@ -164,6 +164,8 @@ class IssueDict(TypedDict): created_at: ISOTimestamp updated_at: ISOTimestamp closed_at: ISOTimestamp | None + claim_commit: str | None + close_commit: str | None description: str notes: str fields: dict[str, Any] diff --git a/src/filigree/types/inputs.py b/src/filigree/types/inputs.py index 6ad2cc4a..8d6818b2 100644 --- a/src/filigree/types/inputs.py +++ b/src/filigree/types/inputs.py @@ -107,6 +107,8 @@ class CloseIssueArgs(TypedDict): fields: NotRequired[dict[str, Any]] expected_assignee: NotRequired[str] force: NotRequired[bool] + # Opaque branch@sha commit anchor (warpline seam, contract B) -> close_commit. + commit: NotRequired[str] class DeleteIssueArgs(TypedDict): @@ -132,6 +134,8 @@ class ClaimIssueArgs(TypedDict): issue_id: str assignee: NotRequired[str] actor: NotRequired[str] + # Opaque branch@sha commit anchor (warpline seam, contract B) -> claim_commit. + commit: NotRequired[str] class ReleaseClaimArgs(TypedDict): @@ -188,6 +192,8 @@ class StartWorkArgs(TypedDict): target_status: NotRequired[str] actor: NotRequired[str] advance: NotRequired[bool] + # Opaque branch@sha commit anchor (warpline seam, contract B) -> claim_commit. + commit: NotRequired[str] class StartNextWorkArgs(TypedDict): diff --git a/src/filigree/types/planning.py b/src/filigree/types/planning.py index 28aaadac..e801a7ca 100644 --- a/src/filigree/types/planning.py +++ b/src/filigree/types/planning.py @@ -108,6 +108,8 @@ class ReleaseSummaryItem(TypedDict): created_at: ISOTimestamp updated_at: ISOTimestamp closed_at: ISOTimestamp | None + claim_commit: str | None + close_commit: str | None description: str notes: str fields: dict[str, Any] diff --git a/tests/cli/test_commit_anchor_verbs.py b/tests/cli/test_commit_anchor_verbs.py new file mode 100644 index 00000000..bc6665c9 --- /dev/null +++ b/tests/cli/test_commit_anchor_verbs.py @@ -0,0 +1,55 @@ +"""CLI verb plumbing for the commit anchor (warpline seam, contract B). + +``close``, ``claim``, and ``start-work`` accept an optional ``--commit`` option +that threads to the DB layer and persists as ``close_commit`` / ``claim_commit``. +""" + +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner + +from filigree.cli import cli +from filigree.cli_common import get_db +from tests.cli.conftest import _extract_id + + +def _anchors(issue_id: str) -> tuple[str | None, str | None]: + with get_db() as db: + row = db.conn.execute("SELECT claim_commit, close_commit FROM issues WHERE id = ?", (issue_id,)).fetchone() + return row["claim_commit"], row["close_commit"] + + +class TestCommitAnchorCLI: + def test_close_commit_option_persists(self, cli_in_project: tuple[CliRunner, Path]) -> None: + runner, _ = cli_in_project + issue_id = _extract_id(runner.invoke(cli, ["create", "Close w/ commit"]).output) + result = runner.invoke(cli, ["close", issue_id, "--reason", "done", "--commit", "main@abc123"]) + assert result.exit_code == 0, result.output + _, close_commit = _anchors(issue_id) + assert close_commit == "main@abc123" + + def test_close_without_commit_leaves_null(self, cli_in_project: tuple[CliRunner, Path]) -> None: + runner, _ = cli_in_project + issue_id = _extract_id(runner.invoke(cli, ["create", "Close no commit"]).output) + result = runner.invoke(cli, ["close", issue_id, "--reason", "done"]) + assert result.exit_code == 0, result.output + _, close_commit = _anchors(issue_id) + assert close_commit is None + + def test_claim_commit_option_persists(self, cli_in_project: tuple[CliRunner, Path]) -> None: + runner, _ = cli_in_project + issue_id = _extract_id(runner.invoke(cli, ["create", "Claim w/ commit"]).output) + result = runner.invoke(cli, ["claim", issue_id, "--assignee", "alice", "--commit", "main@c0ffee"]) + assert result.exit_code == 0, result.output + claim_commit, _ = _anchors(issue_id) + assert claim_commit == "main@c0ffee" + + def test_start_work_commit_option_persists(self, cli_in_project: tuple[CliRunner, Path]) -> None: + runner, _ = cli_in_project + issue_id = _extract_id(runner.invoke(cli, ["create", "Start w/ commit"]).output) + result = runner.invoke(cli, ["start-work", issue_id, "--assignee", "alice", "--commit", "main@1234abcd"]) + assert result.exit_code == 0, result.output + claim_commit, _ = _anchors(issue_id) + assert claim_commit == "main@1234abcd" diff --git a/tests/cli/test_files_commands.py b/tests/cli/test_files_commands.py index e227d47a..0beaf629 100644 --- a/tests/cli/test_files_commands.py +++ b/tests/cli/test_files_commands.py @@ -150,6 +150,8 @@ "created_at", "updated_at", "closed_at", + "claim_commit", + "close_commit", "description", "notes", "fields", diff --git a/tests/core/test_commit_anchor.py b/tests/core/test_commit_anchor.py new file mode 100644 index 00000000..c33df657 --- /dev/null +++ b/tests/core/test_commit_anchor.py @@ -0,0 +1,163 @@ +"""Commit-anchor capture tests (warpline seam, contract B). + +Filigree stores an opaque ``branch@sha`` commit anchor supplied by the caller at +claim and at close, alongside ``claimed_at`` / ``closed_at``. It is stored +verbatim and never parsed (git/CI is Legis's domain). These tests pin the +capture + the clear-point mirror invariant: ``claim_commit`` is cleared wherever +``claimed_at`` is cleared; ``close_commit`` wherever ``closed_at`` is cleared. + +Reads go straight to the raw columns so the capture invariant is decoupled from +the read-exposure surface (Issue dataclass / weft / reverse-lookup), which has +its own tests. +""" + +from __future__ import annotations + +import pytest + +from filigree.core import FiligreeDB + + +def _anchors(db: FiligreeDB, issue_id: str) -> tuple[str | None, str | None]: + row = db.conn.execute("SELECT claim_commit, close_commit FROM issues WHERE id = ?", (issue_id,)).fetchone() + return row["claim_commit"], row["close_commit"] + + +class TestCloseCommitCapture: + def test_close_with_commit_sets_close_commit(self, db: FiligreeDB) -> None: + issue = db.create_issue("to close", priority=2) + db.close_issue(issue.id, reason="done", commit="main@abc123") + _, close_commit = _anchors(db, issue.id) + assert close_commit == "main@abc123" + + def test_close_without_commit_leaves_close_commit_null(self, db: FiligreeDB) -> None: + """WARPLINE-ABSENT parity: a no-commit close behaves exactly as today.""" + issue = db.create_issue("to close", priority=2) + db.close_issue(issue.id, reason="done") + _, close_commit = _anchors(db, issue.id) + assert close_commit is None + + def test_reopen_clears_close_commit(self, db: FiligreeDB) -> None: + """Mirror invariant: reopen clears ``closed_at`` (via the done->non-done + status hop in update_issue), so ``close_commit`` must be cleared too — a + stale anchor surviving a reopen is a bug.""" + issue = db.create_issue("to close+reopen", priority=2) + db.close_issue(issue.id, reason="done", commit="main@abc123") + assert _anchors(db, issue.id)[1] == "main@abc123" + db.reopen_issue(issue.id) + _, close_commit = _anchors(db, issue.id) + assert close_commit is None + + def test_update_issue_close_commit_threaded_on_done_entry(self, db: FiligreeDB) -> None: + issue = db.create_issue("done via update", priority=2) + # task: open -> in_progress (wip) -> closed (done) + db.update_issue(issue.id, status="in_progress") + db.update_issue(issue.id, status="closed", close_commit="feat@deadbeef") + _, close_commit = _anchors(db, issue.id) + assert close_commit == "feat@deadbeef" + + +class TestClaimCommitCapture: + def test_claim_with_commit_sets_claim_commit(self, db: FiligreeDB) -> None: + issue = db.create_issue("to claim", priority=2) + db.claim_issue(issue.id, assignee="alice", commit="main@c0ffee") + claim_commit, _ = _anchors(db, issue.id) + assert claim_commit == "main@c0ffee" + + def test_claim_without_commit_leaves_claim_commit_null(self, db: FiligreeDB) -> None: + """WARPLINE-ABSENT parity: a no-commit claim behaves exactly as today.""" + issue = db.create_issue("to claim", priority=2) + db.claim_issue(issue.id, assignee="alice") + claim_commit, _ = _anchors(db, issue.id) + assert claim_commit is None + + def test_start_work_with_commit_sets_claim_commit(self, db: FiligreeDB) -> None: + issue = db.create_issue("to start", priority=2) + db.start_work(issue.id, assignee="alice", commit="main@1234abcd") + claim_commit, _ = _anchors(db, issue.id) + assert claim_commit == "main@1234abcd" + + def test_release_clears_claim_commit(self, db: FiligreeDB) -> None: + """Mirror invariant: release_claim clears ``claimed_at``, so it must + clear ``claim_commit`` too.""" + issue = db.create_issue("to claim+release", priority=2) + db.claim_issue(issue.id, assignee="alice", commit="main@c0ffee") + assert _anchors(db, issue.id)[0] == "main@c0ffee" + db.release_claim(issue.id, actor="alice") + claim_commit, _ = _anchors(db, issue.id) + assert claim_commit is None + + def test_update_unassign_clears_claim_commit(self, db: FiligreeDB) -> None: + """update_issue(assignee='') clears ``claimed_at`` -> clear claim_commit.""" + issue = db.create_issue("to unassign", priority=2) + db.claim_issue(issue.id, assignee="alice", commit="main@c0ffee") + db.update_issue(issue.id, assignee="", expected_assignee="alice") + claim_commit, _ = _anchors(db, issue.id) + assert claim_commit is None + + def test_update_assignee_set_sets_claim_commit(self, db: FiligreeDB) -> None: + issue = db.create_issue("to assign", priority=2) + db.update_issue(issue.id, assignee="bob", claim_commit="main@feed") + claim_commit, _ = _anchors(db, issue.id) + assert claim_commit == "main@feed" + + def test_reclaim_overwrites_claim_commit(self, db: FiligreeDB) -> None: + """Reclaim transfers to a NEW holder with a fresh claimed_at; the prior + holder's commit must NOT survive (overwrite, not COALESCE).""" + issue = db.create_issue("to reclaim", priority=2) + db.claim_issue(issue.id, assignee="alice", commit="main@old") + db.reclaim_issue( + issue.id, + assignee="bob", + expected_assignee="alice", + reason="stale", + commit="main@new", + ) + claim_commit, _ = _anchors(db, issue.id) + assert claim_commit == "main@new" + + def test_reclaim_without_commit_nulls_claim_commit(self, db: FiligreeDB) -> None: + """Reclaim with no commit must NULL the anchor, not leave the prior + holder's stale commit.""" + issue = db.create_issue("to reclaim", priority=2) + db.claim_issue(issue.id, assignee="alice", commit="main@old") + db.reclaim_issue(issue.id, assignee="bob", expected_assignee="alice", reason="stale") + claim_commit, _ = _anchors(db, issue.id) + assert claim_commit is None + + +class TestCommitAnchorReadExposure: + """The commit anchors are exposed on every issue read surface (classic + dataclass / to_dict, weft projection) so warpline can read them.""" + + def test_issue_to_dict_carries_commit_anchors(self, db: FiligreeDB) -> None: + issue = db.create_issue("read", priority=2) + db.claim_issue(issue.id, assignee="alice", commit="main@claimsha") + d = db.get_issue(issue.id).to_dict() + assert d["claim_commit"] == "main@claimsha" + assert d["close_commit"] is None + + def test_issue_to_dict_null_when_absent(self, db: FiligreeDB) -> None: + issue = db.create_issue("read-null", priority=2) + d = db.get_issue(issue.id).to_dict() + assert d["claim_commit"] is None + assert d["close_commit"] is None + + def test_classic_read_carries_close_commit(self, db: FiligreeDB) -> None: + issue = db.create_issue("read-close", priority=2) + db.close_issue(issue.id, reason="done", commit="main@closesha") + loaded = db.get_issue(issue.id) + assert loaded.close_commit == "main@closesha" + + def test_weft_projection_carries_commit_anchors(self, db: FiligreeDB) -> None: + from filigree.generations.weft.adapters import issue_to_weft + + issue = db.create_issue("weft", priority=2) + db.claim_issue(issue.id, assignee="alice", commit="main@weftsha") + weft = issue_to_weft(db.get_issue(issue.id)) + assert weft["claim_commit"] == "main@weftsha" + assert weft["close_commit"] is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/core/test_crud.py b/tests/core/test_crud.py index affc38b5..2596fcc5 100644 --- a/tests/core/test_crud.py +++ b/tests/core/test_crud.py @@ -778,6 +778,8 @@ def test_to_dict_has_all_fields(self, db: FiligreeDB) -> None: "created_at", "updated_at", "closed_at", + "claim_commit", + "close_commit", "description", "notes", "fields", diff --git a/tests/core/test_entity_associations.py b/tests/core/test_entity_associations.py index 5a1e6e64..eab7f9b5 100644 --- a/tests/core/test_entity_associations.py +++ b/tests/core/test_entity_associations.py @@ -425,6 +425,43 @@ def test_forward_list_omits_lifecycle_facts(self, db: FiligreeDB) -> None: assert "claimed_at" not in fwd assert "status" not in fwd assert "status_category" not in fwd + # The commit anchors are reverse-only too (forward list stays a pure binding row). + assert "claim_commit" not in fwd + assert "close_commit" not in fwd + + def test_closed_with_commit_row_exposes_close_commit(self, db: FiligreeDB) -> None: + """The reverse row carries the close commit anchor (warpline correlates + 'changed since closed' on the COMMIT, not the clock).""" + issue = db.create_issue("close w/ commit", priority=2) + db.add_entity_association(issue.id, "py:func:cc-target", content_hash="h1", actor="alice") + db.close_issue(issue.id, reason="done", commit="main@deadbeef") + (row,) = db.list_associations_by_entity("py:func:cc-target") + assert row["close_commit"] == "main@deadbeef" + assert row["claim_commit"] is None + + def test_claimed_with_commit_row_exposes_claim_commit(self, db: FiligreeDB) -> None: + issue = db.create_issue("claim w/ commit", priority=2) + db.add_entity_association(issue.id, "py:func:claimcc-target", content_hash="h1", actor="alice") + db.claim_issue(issue.id, assignee="alice", commit="main@c0ffee") + (row,) = db.list_associations_by_entity("py:func:claimcc-target") + assert row["claim_commit"] == "main@c0ffee" + + def test_open_row_exposes_null_commit_anchors(self, db: FiligreeDB) -> None: + issue = db.create_issue("open no commit", priority=2) + db.add_entity_association(issue.id, "py:func:openocc-target", content_hash="h1", actor="alice") + (row,) = db.list_associations_by_entity("py:func:openocc-target") + assert row["claim_commit"] is None + assert row["close_commit"] is None + + def test_orphaned_binding_has_null_commit_anchors(self, db: FiligreeDB) -> None: + issue = db.create_issue("orphan cc", priority=2) + db.add_entity_association(issue.id, "py:func:orphancc-target", content_hash="h1", actor="alice") + db.conn.execute("PRAGMA foreign_keys = OFF") + db.conn.execute("DELETE FROM issues WHERE id = ?", (issue.id,)) + db.conn.execute("PRAGMA foreign_keys = ON") + (row,) = db.list_associations_by_entity("py:func:orphancc-target") + assert row["claim_commit"] is None + assert row["close_commit"] is None def test_attach_without_signature_stores_null(self, db: FiligreeDB) -> None: issue = db.create_issue("Ungoverned work", priority=2) diff --git a/tests/core/test_schema.py b/tests/core/test_schema.py index 2827edb3..efdcb347 100644 --- a/tests/core/test_schema.py +++ b/tests/core/test_schema.py @@ -521,6 +521,56 @@ def test_fresh_schema_has_v27_signed_content_hash_column(self, tmp_path: Path) - assert "signed_content_hash" in _get_table_columns(conn, "entity_associations") conn.close() + def test_migration_v28_to_v29_adds_commit_anchor_columns(self, tmp_path: Path) -> None: + """v28->v29 (warpline commit-anchor seam, contract B): ALTER adds the + two nullable per-issue commit-anchor columns ``issues.claim_commit`` and + ``issues.close_commit`` (TEXT). Existing rows read NULL — warpline falls + back to the timestamp when no anchor is present.""" + conn = _make_db(tmp_path) + conn.executescript(SCHEMA_SQL) + # Simulate a true v28 DB: drop the new columns and stamp the prior version. + conn.execute("ALTER TABLE issues DROP COLUMN claim_commit") + conn.execute("ALTER TABLE issues DROP COLUMN close_commit") + conn.execute("PRAGMA user_version = 28") + conn.execute( + "INSERT INTO issues (id, title, created_at, updated_at) VALUES ('iss-1', 't', ?, ?)", + ("2026-06-01T00:00:00+00:00", "2026-06-01T00:00:00+00:00"), + ) + conn.commit() + assert "claim_commit" not in _get_table_columns(conn, "issues") + assert "close_commit" not in _get_table_columns(conn, "issues") + + applied = apply_pending_migrations(conn, 29) + assert applied == 1 + assert _get_schema_version(conn) == 29 + assert "claim_commit" in _get_table_columns(conn, "issues") + assert "close_commit" in _get_table_columns(conn, "issues") + row = conn.execute("SELECT claim_commit, close_commit FROM issues WHERE id = 'iss-1'").fetchone() + assert row["claim_commit"] is None + assert row["close_commit"] is None + conn.close() + + def test_migration_v28_to_v29_idempotent(self, tmp_path: Path) -> None: + from filigree.migrations import migrate_v28_to_v29 + + conn = _make_db(tmp_path) + conn.executescript(SCHEMA_SQL) + conn.commit() + # Columns already present (fresh schema) — re-running add_column is a no-op. + migrate_v28_to_v29(conn) + assert "claim_commit" in _get_table_columns(conn, "issues") + assert "close_commit" in _get_table_columns(conn, "issues") + conn.close() + + def test_fresh_schema_has_v29_commit_anchor_columns(self, tmp_path: Path) -> None: + conn = _make_db(tmp_path) + conn.executescript(SCHEMA_SQL) + conn.commit() + cols = _get_table_columns(conn, "issues") + assert "claim_commit" in cols + assert "close_commit" in cols + conn.close() + def test_migration_v25_to_v26_renames_column_and_rewrites_prefixes(self, tmp_path: Path) -> None: """v25→v26 (Loomweave/Weft rebrand data pass): rename the entity-association column ``loomweave_entity_id`` -> ``loomweave_entity_id`` @@ -2182,8 +2232,8 @@ class TestDeletedIssuesTombstoneSchema: """v19 -> v20: the ``deleted_issues`` tombstone (F5); v20 -> v21 adds the ``entity_ids`` column (F5 entity-association amplifier, filigree-f3bf56554c).""" - def test_current_schema_version_is_28(self) -> None: - assert CURRENT_SCHEMA_VERSION == 28 + def test_current_schema_version_is_29(self) -> None: + assert CURRENT_SCHEMA_VERSION == 29 def test_fresh_schema_contains_deleted_issues_table(self, tmp_path: Path) -> None: conn = _make_db(tmp_path) diff --git a/tests/fixtures/contracts/entity-associations-response.json b/tests/fixtures/contracts/entity-associations-response.json index 082a1267..387979a5 100644 --- a/tests/fixtures/contracts/entity-associations-response.json +++ b/tests/fixtures/contracts/entity-associations-response.json @@ -2,7 +2,7 @@ "_meta": { "contract": "filigree-entity-associations-response", "endpoint": "GET /api/entity-associations?entity_id={entity_id}", - "fixture_version": 2, + "fixture_version": 3, "stability": "normative", "authority": "Weft G15 / ADR-029 EntityAssociation producer-consumer conformance", "producer": "Filigree dashboard_routes.entities.api_list_associations_by_entity", @@ -13,7 +13,7 @@ ], "verification": "Filigree: uv run pytest tests/api/test_entity_associations.py -q; Loomweave: cargo test -p loomweave-federation filigree::tests::parses_canonical_filigree_entity_association_fixture", "updated": "2026-06-24", - "description": "Canonical live Filigree reverse-lookup response for EntityAssociation rows. The producer test emits this body through Filigree's HTTP route; the consumer test deserializes the same body in Loomweave. v2 (warpline seam): each row now also carries the bound issue's lifecycle facts (claimed_at, closed_at, status, status_category) for warpline's 'changed since claimed/closed' correlation; all four are null for an orphaned binding. Additive — Loomweave's consumer ignores unknown fields (no serde deny_unknown_fields), so a v1 consumer still parses v2. The loomweave repo-local copy should be synced for documentation parity (non-breaking)." + "description": "Canonical live Filigree reverse-lookup response for EntityAssociation rows. The producer test emits this body through Filigree's HTTP route; the consumer test deserializes the same body in Loomweave. v2 (warpline seam): each row carries the bound issue's lifecycle facts (claimed_at, closed_at, status, status_category) for warpline's 'changed since claimed/closed' correlation; all four are null for an orphaned binding. v3 (warpline commit-anchor seam, contract B): each row also carries the opaque per-issue commit anchors (claim_commit, close_commit) — the caller-supplied branch@sha the issue was claimed/closed at, stored verbatim and never parsed by Filigree; null for an orphaned binding or when no commit was supplied (warpline then falls back to the timestamp). Additive — Loomweave's consumer ignores unknown fields (no serde deny_unknown_fields), so a v1/v2 consumer still parses v3. The loomweave repo-local copy should be synced for documentation parity (non-breaking)." }, "shape_decl": { "kind": "filigree-entity-associations-fixture-shapes", @@ -57,6 +57,8 @@ "signed_content_hash": null, "claimed_at": null, "closed_at": null, + "claim_commit": null, + "close_commit": null, "status": "open", "status_category": "open" } diff --git a/tests/fixtures/contracts/weft/issues-claim-next.json b/tests/fixtures/contracts/weft/issues-claim-next.json index e151c4e9..492847c1 100644 --- a/tests/fixtures/contracts/weft/issues-claim-next.json +++ b/tests/fixtures/contracts/weft/issues-claim-next.json @@ -45,6 +45,8 @@ "created_at": "2026-04-26T00:00:00+00:00", "updated_at": "2026-04-26T00:00:00+00:00", "closed_at": null, + "claim_commit": null, + "close_commit": null, "description": "", "notes": "", "fields": {}, diff --git a/tests/mcp/test_commit_anchor_verbs.py b/tests/mcp/test_commit_anchor_verbs.py new file mode 100644 index 00000000..1701dee2 --- /dev/null +++ b/tests/mcp/test_commit_anchor_verbs.py @@ -0,0 +1,62 @@ +"""MCP verb plumbing for the commit anchor (warpline seam, contract B). + +``close_issue``, ``claim_issue``, and ``start_work`` accept an optional +``commit`` input that threads through to the DB layer and persists as +``close_commit`` / ``claim_commit``. +""" + +from __future__ import annotations + +import pytest + +from filigree.core import FiligreeDB +from filigree.mcp_tools.issues import ( + _handle_claim_issue, + _handle_close_issue, + _handle_start_work, +) +from tests.mcp._helpers import _parse + + +def _anchors(db: FiligreeDB, issue_id: str) -> tuple[str | None, str | None]: + row = db.conn.execute("SELECT claim_commit, close_commit FROM issues WHERE id = ?", (issue_id,)).fetchone() + return row["claim_commit"], row["close_commit"] + + +@pytest.mark.asyncio +async def test_close_issue_persists_commit(mcp_db: FiligreeDB) -> None: + issue = mcp_db.create_issue("close via mcp", priority=2) + data = _parse(await _handle_close_issue({"issue_id": issue.id, "reason": "done", "commit": "main@abc123"})) + assert "error" not in data, data + _, close_commit = _anchors(mcp_db, issue.id) + assert close_commit == "main@abc123" + # Read-side exposure: the public projection carries it. + assert data["close_commit"] == "main@abc123" + + +@pytest.mark.asyncio +async def test_close_issue_without_commit_leaves_null(mcp_db: FiligreeDB) -> None: + issue = mcp_db.create_issue("close no commit", priority=2) + data = _parse(await _handle_close_issue({"issue_id": issue.id, "reason": "done"})) + assert "error" not in data, data + _, close_commit = _anchors(mcp_db, issue.id) + assert close_commit is None + + +@pytest.mark.asyncio +async def test_claim_issue_persists_commit(mcp_db: FiligreeDB) -> None: + issue = mcp_db.create_issue("claim via mcp", priority=2) + data = _parse(await _handle_claim_issue({"issue_id": issue.id, "assignee": "alice", "commit": "main@c0ffee"})) + assert "error" not in data, data + claim_commit, _ = _anchors(mcp_db, issue.id) + assert claim_commit == "main@c0ffee" + assert data["claim_commit"] == "main@c0ffee" + + +@pytest.mark.asyncio +async def test_start_work_persists_commit(mcp_db: FiligreeDB) -> None: + issue = mcp_db.create_issue("start via mcp", priority=2) + data = _parse(await _handle_start_work({"issue_id": issue.id, "assignee": "alice", "commit": "main@1234abcd"})) + assert "error" not in data, data + claim_commit, _ = _anchors(mcp_db, issue.id) + assert claim_commit == "main@1234abcd" From c23f71c58437c9f764d279a371a80d5489fa7fb7 Mon Sep 17 00:00:00 2001 From: John Morrissey <544926+tachyon-beep@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:53:28 +1000 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=203.1.0=20=E2=80=94=20warpl?= =?UTF-8?q?ine=20reverse-lookup=20facts,=20commit=20anchor,=20atomic=20ing?= =?UTF-8?q?est?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 353ac8d8..128dbbef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.1.0] - 2026-06-25 ### Added diff --git a/pyproject.toml b/pyproject.toml index 4fa0eaa5..e3dd8f89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "filigree" -version = "3.0.1" +version = "3.1.0" description = "Agent-native issue tracker with convention-based project discovery" requires-python = ">=3.11" license = "MIT"