Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ 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).

## [3.1.0] - 2026-06-25

### Added

- **Warpline seam — issue lifecycle facts on the entity-association
reverse-lookup.** `GET /api/entity-associations?entity_id=<sei>` (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.
- **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

- **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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 21 additions & 2 deletions src/filigree/cli_commands/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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."""
Expand All @@ -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] = {
Expand Down Expand Up @@ -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.
Expand All @@ -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}))
Expand Down Expand Up @@ -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(
Expand All @@ -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."""
Expand All @@ -1475,6 +1493,7 @@ def start_work(
target_status=target_status,
actor=resolved_actor,
advance=advance,
commit=commit,
)
except KeyError:
if as_json:
Expand Down
18 changes: 16 additions & 2 deletions src/filigree/dashboard_routes/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/filigree/db_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...

Expand All @@ -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: ...

Expand Down
91 changes: 82 additions & 9 deletions src/filigree/db_entity_associations.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@
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 (
ContentHash,
ISOTimestamp,
IssueId,
LoomweaveEntityId,
StatusCategory,
make_content_hash,
make_issue_id,
make_loomweave_entity_id,
Expand Down Expand Up @@ -71,6 +72,38 @@ 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"); ``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 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


class GovernedAssociationRemovalError(ValueError):
"""Refused: a caller tried to delete a Legis-signed (governed) binding.

Expand Down Expand Up @@ -390,12 +423,43 @@ 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`` / ``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"]
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
# 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
)
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
Expand All @@ -414,16 +478,25 @@ 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.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
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]
Loading
Loading