# prov 2.5.0 Bug Fixes, Release & Conda Feedstock Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix the five issues in the 2.5.0 milestone (#254, #240, #239, #236, #154), publish 2.5.0 to PyPI, and bring the conda-forge `prov-feedstock` up to date (fix + merge the outstanding 2.4.0 PR, then bump it to 2.5.0).

**Architecture:** One focused PR per issue on `trungdong/prov` (established roadmap process: branch from up-to-date master, green CI, merge, pull), then a release-prep PR, then the gated publish sequence (TestPyPI dry-run → GitHub release → PyPI trusted publishing). Feedstock work happens in the sibling checkout `../prov-feedstock` against `conda-forge/prov-feedstock`.

**Tech Stack:** Python 3.10+, uv, pytest, ruff, mypy --strict, GitHub CLI (`gh`), conda-forge feedstock (meta.yaml, azure CI).

**User decisions (already made):**
- #239 scope: **all three modes** — catch any per-attempt exception (XML auto-detects), raw-content-string fallback, and empty-parse rejection in auto-detect (user, 2026-07-12, this session).
- Conda feedstock added to scope: "fix the Conda release … there is an outstanding PR for 2.4.0" (user, 2026-07-12).
- Publish gate: merge release PR, then **AskUserQuestion maintainer go-ahead before ANY publish** (established process, phase-2/3 memory).
- Never add AI attribution to commits/PRs (CLAUDE.md).
- 2.x public-API freeze: additive changes only; each fix touches only already-failing paths except where the user opted in above (#239 empty-source mode).

**Process invariants (from phase-2/3 execution memory, apply to every repo task):**
- Single shared checkout, strictly sequential, no worktrees. Branch from up-to-date master.
- PR body includes `Fixes #<issue>` so the milestone issue auto-closes on merge.
- CI: `gh run list --branch <branch> --workflow CI` for the head run id, then `gh run watch <id> --exit-status` (GitHub can take ~20–30 s to register a run — poll, don't assume it didn't trigger).
- Merge with `gh pr merge --merge --delete-branch` once the required checks (pytest matrix, lint, typecheck, minimal-install, package) are green; Codacy failures are advisory — the merge succeeds despite `mergeStateStatus: UNSTABLE`, retry once required checks are green.
- After merge: `git checkout master && git pull`.
- Verify commands for every code task: `uv run pytest`, `uv run ruff check src/`, `uv run ruff format --check src/`, `uv run mypy src`.

**Baseline (master 024c033):** suite 1131 passed / 22 skipped / 16 xfailed; ruff + mypy clean.

---

### Task 1: Fix #254 — PROV-XML deserializer raises ProvXMLException instead of UnboundLocalError/silent corruption

**Goal:** `_extract_attributes` raises `ProvXMLException` when a child element carries XML attributes but none of them is `prov:ref`/`xsi:type`/`xml:lang`, instead of leaking `UnboundLocalError` (first child) or silently reusing the previous sibling's value.

**Files:**
- Modify: `src/prov/serializers/provxml.py:400-458` (`_extract_attributes`)
- Modify: `src/prov/tests/test_xml.py` (two new tests near `test_unrepresentable_sub_element_attribute_warns_and_is_ignored`, ~line 432)
- Modify: `src/prov/tests/test_unification_constraints.py:14-17,110-117` (module docstring + skip reason wording)
- Modify: `HISTORY.rst` (create the `2.5.0 (unreleased)` section with this task's bullet)

**Acceptance Criteria:**
- [ ] Deserializing XML whose first record child has only unrecognised attributes raises `ProvXMLException` (was `UnboundLocalError`).
- [ ] Deserializing XML where a later child has only unrecognised attributes raises `ProvXMLException` (was silent reuse of the previous sibling's value).
- [ ] Existing `test_unrepresentable_sub_element_attribute_warns_and_is_ignored` still passes (mixed recognised+unrecognised attributes keep the warn-and-ignore behaviour).
- [ ] The 3 `bundle-*` corpus skips in `test_unification_constraints.py` remain skipped with a reason that no longer claims an `UnboundLocalError` crash.
- [ ] Full suite, ruff, mypy green.

**Verify:** `uv run pytest src/prov/tests/test_xml.py src/prov/tests/test_unification_constraints.py -v && uv run pytest -q && uv run ruff check src/ && uv run mypy src` → all pass.

**Steps:**

- [ ] **Step 1: Branch**

```bash
git checkout master && git pull && git checkout -b fix-254-provxml-unbound-value
```

- [ ] **Step 2: Write the two failing tests** in `src/prov/tests/test_xml.py`, after `test_unrepresentable_sub_element_attribute_warns_and_is_ignored`:

```python
def test_unrecognised_only_attribute_on_first_sub_element_raises():
    # #254 mode 1: a child whose only XML attribute is unrecognised used to
    # leak a raw UnboundLocalError when it was the record's first child.
    xml_string = """<?xml version="1.0" encoding="UTF-8"?>
    <prov:document
        xmlns:prov="http://www.w3.org/ns/prov#"
        xmlns:ex="http://example.com/ns/ex#">
      <prov:entity prov:id="ex:e1">
        <ex:only ex:junk="x">value</ex:only>
      </prov:entity>
    </prov:document>
    """
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", UserWarning)
        with (
            pytest.raises(ProvXMLException) as ctx,
            io.StringIO(xml_string) as xml,
        ):
            prov.ProvDocument.deserialize(source=xml, format="xml")
    assert "no representable value" in str(ctx.value)


def test_unrecognised_only_attribute_after_sibling_raises_not_reuses_value():
    # #254 mode 2: with a previous sibling, the stale value used to be
    # silently reused ("world" lost, "hello" duplicated).
    xml_string = """<?xml version="1.0" encoding="UTF-8"?>
    <prov:document
        xmlns:prov="http://www.w3.org/ns/prov#"
        xmlns:ex="http://example.com/ns/ex#">
      <prov:entity prov:id="ex:e1">
        <ex:first>hello</ex:first>
        <ex:second ex:junk="x">world</ex:second>
      </prov:entity>
    </prov:document>
    """
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", UserWarning)
        with (
            pytest.raises(ProvXMLException) as ctx,
            io.StringIO(xml_string) as xml,
        ):
            prov.ProvDocument.deserialize(source=xml, format="xml")
    assert "no representable value" in str(ctx.value)
```

(`io`, `warnings`, `pytest`, `prov`, and `ProvXMLException` are already imported at the top of `test_xml.py` — check and add any that aren't.)

- [ ] **Step 3: Run to verify they fail**

Run: `uv run pytest src/prov/tests/test_xml.py -k unrecognised_only -v`
Expected: 2 FAIL — mode 1 with `UnboundLocalError`, mode 2 with `Failed: DID NOT RAISE`.

- [ ] **Step 4: Fix `_extract_attributes`** in `src/prov/serializers/provxml.py`. Replace the loop body (lines 424–458) with a per-child sentinel so `_v` is provably assigned before use:

```python
    attributes = []  # type: list[tuple[prov.identifier.QualifiedName, Any]]
    _unassigned = object()
    for subel in element:
        sqname = etree.QName(subel)
        qname_str = (
            f"{subel.prefix}:{sqname.localname}" if subel.prefix else sqname.localname
        )
        _t = xml_qname_to_QualifiedName(subel, qname_str)

        _v: Any = _unassigned
        for key, value in subel.attrib.items():
            value_str = value.decode("utf-8") if isinstance(value, bytes) else value
            if key == _ns_prov("ref"):
                _v = xml_qname_to_QualifiedName(subel, value_str)
            elif key == _ns_xsi("type"):
                datatype = xml_qname_to_QualifiedName(subel, value_str)
                if datatype == XSD_QNAME:
                    _v = xml_qname_to_QualifiedName(subel, subel.text)  # type: ignore[arg-type]
                else:
                    _v = prov.model.Literal(subel.text, datatype)
            elif key == _ns_xml("lang"):
                _v = prov.model.Literal(subel.text, langtag=value_str)
            else:
                warnings.warn(
                    f"The element '{_t}' contains an attribute {key!s}='{value!s}' "
                    "which is not representable in the prov module's "
                    "internal data model and will thus be ignored.",
                    UserWarning,
                    stacklevel=2,
                )

        if not subel.attrib:
            _v = subel.text

        if _v is _unassigned:
            raise ProvXMLException(
                f"The element '{_t}' has no representable value: it carries "
                "XML attributes, but none of them is prov:ref, xsi:type, or "
                "xml:lang."
            )
        attributes.append((_t, _v))

    return attributes
```

Update the function docstring: extend the `Warns:` note and add a `Raises:` section — `ProvXMLException: If a child element carries XML attributes but none of them is ``prov:ref``, ``xsi:type``, or ``xml:lang``, so no attribute value can be determined.`

- [ ] **Step 5: Run tests to verify they pass**

Run: `uv run pytest src/prov/tests/test_xml.py -v`
Expected: all pass, including the two new tests and the existing warn-and-ignore test.

- [ ] **Step 6: Update the corpus-skip wording** in `src/prov/tests/test_unification_constraints.py`: in the module docstring (lines 16–17) and the `pytest.mark.skip` reason (~lines 113–116), replace the `UnboundLocalError` description with e.g. `"prov cannot parse: ProvToolbox's <prov:bundle> dialect is rejected with ProvXMLException (crash fixed under #254) — recorded in the gap analysis"`.

Run: `uv run pytest src/prov/tests/test_unification_constraints.py -q`
Expected: same tally as before (the 3 files remain skipped).

- [ ] **Step 7: Changelog** — add to `HISTORY.rst` above the `2.4.0` section:

```rst
2.5.0 (unreleased)
^^^^^^^^^^^^^^^^^^
* The PROV-XML deserializer now raises ``ProvXMLException`` when a record's
  child element carries only unrecognised XML attributes, instead of leaking a
  raw ``UnboundLocalError`` or silently reusing the previous attribute's value
  (#254)
```

- [ ] **Step 8: Full verify, commit, PR, CI, merge**

```bash
uv run pytest -q && uv run ruff check src/ && uv run ruff format --check src/ && uv run mypy src
git add -A && git commit -m "Raise ProvXMLException on unrecognised-only XML attributes

Fixes #254"
git push -u origin fix-254-provxml-unbound-value
gh pr create --fill --body "… Fixes #254"   # describe both failure modes; milestone 2.5.0
# watch CI, merge per process invariants, pull master
```

---

### Task 2: Fix #240 — serialize()/deserialize() duck-type file-like objects

**Goal:** `ProvDocument.serialize()` writes to any object with a `write()` method (and `deserialize()` reads from any object with `read()`), instead of `isinstance(…, IOBase)` — so `tempfile.NamedTemporaryFile` and similar wrappers no longer fall into the file-path branch and write to a repr-named junk file.

**Files:**
- Modify: `src/prov/model/bundle.py:1596-1646` (`serialize`) and `:1648-1693` (`deserialize`)
- Modify: `src/prov/tests/test_conformance_dm.py:107-128` (flip the #240 strict xfail into a passing regression test)
- Modify: `HISTORY.rst` (add bullet to `2.5.0 (unreleased)`)

**Acceptance Criteria:**
- [ ] `d.serialize(NamedTemporaryFile(...), format="json")` writes the serialization into that file; no repr-named file appears in the CWD.
- [ ] `ProvDocument.deserialize(source=<NamedTemporaryFile>)` reads from the wrapper (same defect class, same `isinstance` test on the read side — state this explicitly in the PR).
- [ ] `test_serialize_writes_to_named_temporary_file` passes with its `xfail` decorator removed.
- [ ] Existing stream/path destinations behave identically (full suite green).
- [ ] mypy --strict stays clean (use `typing.cast` where `hasattr` can't narrow).

**Verify:** `uv run pytest src/prov/tests/test_conformance_dm.py -v && uv run pytest -q && uv run mypy src` → all pass.

**Steps:**

- [ ] **Step 1: Branch** — `git checkout -b fix-240-serialize-duck-typing` from updated master.

- [ ] **Step 2: Flip the regression test.** In `src/prov/tests/test_conformance_dm.py`, delete the `@pytest.mark.xfail(...)` decorator (lines 110–117) and the preceding Python-3.14 comment block (lines 107–109) from `test_serialize_writes_to_named_temporary_file`, and strengthen the final assertion to check content, not just size:

```python
def test_serialize_writes_to_named_temporary_file(tmp_path, monkeypatch):
    # #240 regression: non-io.IOBase writables (e.g. NamedTemporaryFile's
    # _TemporaryFileWrapper proxy) must be treated as streams, not paths.
    monkeypatch.chdir(tmp_path)  # any junk repr-named file would land here
    document = _doc()
    document.entity("ex:e1")
    with tempfile.NamedTemporaryFile(
        mode="w", suffix=".json", dir=tmp_path, delete=False
    ) as stream:
        document.serialize(stream, format="json")
        name = stream.name
    assert ProvDocument.deserialize(name, format="json") == document
    junk = [p for p in os.listdir(tmp_path) if p.startswith("<")]
    assert junk == []
```

Add alongside it a deserialize-side regression test:

```python
def test_deserialize_reads_from_named_temporary_file(tmp_path):
    # #240 (read side): the same isinstance(…, IOBase) test lived in
    # deserialize(); any object with read() must be accepted as a stream.
    document = _doc()
    document.entity("ex:e1")
    with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", dir=tmp_path) as stream:
        stream.write(document.serialize(format="json"))
        stream.flush()
        stream.seek(0)
        assert ProvDocument.deserialize(stream, format="json") == document
```

- [ ] **Step 3: Run to verify failure** — `uv run pytest src/prov/tests/test_conformance_dm.py -k temporary_file -v` → both FAIL (junk file / repr-path error).

- [ ] **Step 4: Fix `bundle.py`.** In `serialize()` replace the `isinstance` branch:

```python
        if hasattr(destination, "write"):
            # Any writable object counts as a stream; common file-likes such
            # as tempfile.NamedTemporaryFile are not io.IOBase instances.
            stream = cast(io.IOBase, destination)
            serializer.serialize(stream, **args)
```

In `deserialize()` replace `isinstance(source, io.IOBase)` symmetrically:

```python
        if source is not None:
            if hasattr(source, "read"):
                return serializer.deserialize(cast(io.IOBase, source), **args)
            else:
                with open(cast("PathLike", source)) as f:
                    return serializer.deserialize(f, **args)
```

Add `cast` to the `typing` import; widen both signatures' annotations from `io.IOBase | PathLike | None` to `io.IOBase | IO[Any] | PathLike | None` (import `IO` from `typing`) and update the two docstrings ("A writable stream (any object with a ``write`` method) or a local file path…" / "A readable stream (any object with a ``read`` method) or a file path…"). If the `Serializer.serialize`/`deserialize` parameter annotations disagree with `io.IOBase`, adjust the `cast` target to whatever those signatures actually declare — do not change the serializer ABC.

- [ ] **Step 5: Verify** — `uv run pytest src/prov/tests/test_conformance_dm.py -v && uv run pytest -q && uv run mypy src && uv run ruff check src/` → green. Note the suite's xfail tally drops by 1 (16 → 15).

- [ ] **Step 6: Changelog bullet** in `2.5.0 (unreleased)`:

```rst
* ``ProvDocument.serialize()`` and ``deserialize()`` now accept any writable/
  readable file-like object (e.g. ``tempfile.NamedTemporaryFile``), instead of
  only ``io.IOBase`` instances; previously such destinations were silently
  treated as file paths, writing to a repr-named file in the working
  directory (#240)
```

- [ ] **Step 7: Commit, PR (`Fixes #240`), CI, merge, pull master.**

**Execution-time deviation (2026-07-12, coordinator decision):** implementation revealed that #240's own filed repro (a *text-mode* `NamedTemporaryFile`, `format="xml"`) still fails after the routing fix: all four serializers classify text-vs-binary streams with `isinstance(stream, io.TextIOBase)`, which is also false for `tempfile._TemporaryFileWrapper`. Scope expanded to change those sites to the monotone check `isinstance(stream, io.TextIOBase) or hasattr(stream, "encoding")` (everything classified text today stays text; only currently-crashing proxy streams are reclassified — still within the 2.x already-failing-paths rule). One extra regression test (text-mode + xml, the issue's repro shape). Files added to scope: `src/prov/serializers/provjson.py`, `provn.py`, `provrdf.py`, `provxml.py` (and optionally a shared helper in `serializers/__init__.py`).

---

### Task 3: Fix #239 — read() auto-detection: XML, raw content, empty input (all three modes)

**Goal:** `prov.read()` auto-detects valid PROV-XML (any per-attempt exception is caught), accepts raw-content strings as documented, and raises `TypeError` on input no deserializer meaningfully parses (instead of silently returning an empty document).

**Files:**
- Modify: `src/prov/__init__.py:22-75` (`read`)
- Modify: `src/prov/tests/test_read.py` (rewrite 2 behaviour-pinning tests, add ~5)
- Modify: `src/prov/tests/test_conformance_dm.py:92-104` (flip the #239 xfail)
- Modify: `src/prov/tests/test_malformed.py` (the read()-garbage case pinning BadSyntax propagation — locate with `grep -n "read" src/prov/tests/test_malformed.py`)
- Modify: `HISTORY.rst`

**Acceptance Criteria:**
- [ ] `prov.read(path_to_valid_prov_xml)` returns the document (no BadSyntax escape).
- [ ] `prov.read('{"prefix": …}')` (raw content, not a path) parses; works with and without explicit `format=`.
- [ ] `prov.read(path_to_empty_file)` and `prov.read("")` raise `TypeError` (no silent empty document).
- [ ] Explicit-`format=` behaviour for streams and existing paths is unchanged.
- [ ] `test_read_autodetects_prov_xml` in `test_conformance_dm.py` passes without its xfail.
- [ ] Full suite, ruff, mypy green.

**Verify:** `uv run pytest src/prov/tests/test_read.py src/prov/tests/test_conformance_dm.py src/prov/tests/test_malformed.py -v && uv run pytest -q` → all pass.

**Steps:**

- [ ] **Step 1: Branch** — `git checkout -b fix-239-read-autodetect` from updated master.

- [ ] **Step 2: Rewrite the two behaviour-pinning tests in `test_read.py`:**

`test_read_auto_detect_of_xml_hits_uncaught_rdf_syntax_error` (lines 91–107) becomes:

```python
def test_read_auto_detects_xml(document, tmp_path):
    # #239: the rdf candidate raises rdflib's BadSyntax (a SyntaxError) on
    # XML input; read() must treat any candidate failure as "try the next
    # format" so the xml deserializer is reached.
    path = _write(document, tmp_path, "xml", "auto.xml")
    result = prov.read(str(path))
    assert result == document
```

`test_read_auto_detect_only_swallows_the_documented_exception_types` (lines 143–159) becomes:

```python
def test_read_auto_detect_swallows_any_deserializer_error():
    # Since #239, ANY exception from a candidate deserializer means "not
    # this format"; when every candidate fails, read() raises its own
    # TypeError rather than leaking the last candidate's error.
    def boom(self, stream, **kwargs):
        raise RuntimeError("arbitrary deserializer failure")

    with (
        mock.patch.object(ProvJSONSerializer, "deserialize", boom),
        mock.patch.object(ProvRDFSerializer, "deserialize", boom),
        mock.patch.object(ProvNSerializer, "deserialize", boom),
        mock.patch.object(ProvXMLSerializer, "deserialize", boom),
        pytest.raises(TypeError) as ctx,
    ):
        prov.read(io.StringIO("garbage"))
    assert "specify the format" in str(ctx.value)
```

Add the new coverage:

```python
def test_read_accepts_raw_content_string_with_explicit_format(document):
    content = document.serialize(format="json")
    assert prov.read(content, format="json") == document


def test_read_auto_detects_raw_content_string(document):
    content = document.serialize(format="json")
    assert prov.read(content) == document


def test_read_empty_file_raises_type_error(tmp_path):
    # #239: rdflib parses empty (trig) input successfully, which used to
    # yield a silent empty document; an empty parse is not a detection.
    path = tmp_path / "empty.json"
    path.touch()
    with pytest.raises(TypeError):
        prov.read(str(path))


def test_read_empty_string_raises_type_error():
    with pytest.raises(TypeError):
        prov.read("")


def test_read_garbage_content_raises_type_error():
    # A str that is not an existing file path is treated as raw content.
    with pytest.raises(TypeError):
        prov.read("no/such/file.json")
```

Also update `test_read_raises_type_error_when_all_serializers_fail_to_parse` (lines 116–140): its docstring's claim that the branch "can only be reached" via mocks is now false — either delete it (superseded by `test_read_garbage_content_raises_type_error` + the swallow test above) or rewrite the docstring; prefer deleting and noting the replacement in the commit message.

- [ ] **Step 3: Flip the conformance xfail** — remove the `@pytest.mark.xfail` decorator from `test_read_autodetects_prov_xml` in `test_conformance_dm.py` (lines 92–98).

- [ ] **Step 4: Reconcile `test_malformed.py`** — the case asserting BadSyntax propagates from `read()` on garbage now expects `TypeError`; update its assertion and comment.

- [ ] **Step 5: Run to verify failures** — `uv run pytest src/prov/tests/test_read.py -v` → new/rewritten tests FAIL against current `read()`.

- [ ] **Step 6: Rewrite `read()`** in `src/prov/__init__.py`:

```python
def read(
    source: io.IOBase | IO[Any] | str | bytes | os.PathLike[str],
    format: str | None = None,
) -> ProvDocument | None:
    """Read a :class:`~prov.model.ProvDocument` from a file, path, or string.

    A ``str``/``bytes`` source naming an existing file is read from that
    file; any other ``str``/``bytes`` is parsed as raw document content.

    If ``format`` is not given, the format is detected by trying each
    registered deserializer in turn and returning the first that both
    succeeds and produces a non-empty document (a parse yielding no records,
    bundles, or namespace declarations — e.g. an RDF parse of empty input —
    is treated as "not this format"). Auto-detection swallows all
    deserializer errors; pass ``format`` explicitly to get the actual
    traceback from the matching deserializer.

    Args:
        source: File-like stream, path to an existing file, or raw content.
        format: Serialization format to use (e.g. ``"json"``, ``"xml"``,
            ``"rdf"``, ``"provn"``). If ``None``, every registered format is
            tried in turn.

    Returns:
        The deserialized :class:`~prov.model.ProvDocument`.

    Raises:
        TypeError: If ``format`` is ``None`` and no registered serializer
            produced a non-empty document from ``source``.
    """
    # Lazy imports to not globber the namespace.
    from prov.model import ProvDocument
    from prov.serializers import Registry

    Registry.load_serializers()
    assert Registry.serializers is not None  # populated by load_serializers()
    serializers = Registry.serializers.keys()

    src: io.IOBase | IO[Any] | str | bytes | os.PathLike[str] | None = source
    content: str | bytes | None = None
    if isinstance(src, (str, bytes)) and not os.path.isfile(src):
        # Not a path to an existing file: treat the string itself as raw
        # document content.
        content, src = src, None

    if format:
        return ProvDocument.deserialize(
            source=src, content=content, format=format.lower()
        )

    for format in serializers:
        try:
            document = ProvDocument.deserialize(
                source=src, content=content, format=format
            )
        except Exception:
            # Any failure from a candidate deserializer means "not this
            # format" -- move on to the next candidate.
            continue
        if (
            document.get_records()
            or document.has_bundles()
            or document.get_registered_namespaces()
        ):
            return document
        # A parse producing a completely empty document (e.g. rdflib
        # accepts empty input) is treated as not detected.
    raise TypeError(
        "Could not read from the source. To get a proper "
        "error message, specify the format with the 'format' "
        "parameter."
    )
```

Add `import io` and `from typing import IO, Any, TYPE_CHECKING` at the top. Verify `has_bundles()` and `get_registered_namespaces()` exist on `ProvDocument` (they are `ProvBundle`/`ProvDocument` methods in `bundle.py`); if the names differ, use the actual bundle/namespace accessors — the intent is records ∪ bundles ∪ registered namespaces ≠ ∅.

Note for the implementer: `ProvDocument.deserialize(source=…)` with a str/PathLike opens the file per attempt, so multi-attempt auto-detection is safe for paths and content; a *stream* source is still consumed by the first attempt (pre-existing limitation, unchanged, out of scope).

- [ ] **Step 7: Verify** — `uv run pytest src/prov/tests/test_read.py src/prov/tests/test_conformance_dm.py src/prov/tests/test_malformed.py -v`, then full suite + ruff + mypy. Expected xfail tally drops by 1 (15 → 14 after Task 2).

- [ ] **Step 8: Changelog bullet** in `2.5.0 (unreleased)`:

```rst
* ``prov.read()`` fixes (#239): valid PROV-XML is now auto-detected (the RDF
  parser's ``BadSyntax`` no longer aborts detection); a ``str``/``bytes``
  source that is not an existing file path is parsed as raw content, as the
  documentation always advertised; and input that no deserializer can
  meaningfully parse (e.g. an empty file) now raises ``TypeError`` instead of
  silently returning an empty document
```

- [ ] **Step 9: Commit, PR (`Fixes #239` — call out the empty-source behaviour change and its maintainer sign-off), CI, merge, pull master.**

---

### Task 4: Fix #236 — document the QualifiedName prov:type idiom

**Goal:** All documentation and examples assert agent/entity subtypes with `QualifiedName`s (`PROV["Person"]`), never the string `"prov:Person"`, and the docs say explicitly that a quoted string stays a string.

**Files:**
- Modify: `docs/explanation/prov-dm.md:111-114`
- Modify: `docs/tutorial/getting-started.md:52` (+ regenerate every affected output block below it)
- Modify: `docs/reference/conformance.md:121-129` (`agent(… "prov:Person")` in the Component-3 finding) and `:168-173` (`prov:type: "prov:EmptyCollection"` in the Component-6 finding)
- Modify: `src/prov/tests/examples.py:266` (`"prov:Plan"` → `PROV["Plan"]`)
- Modify: `HISTORY.rst`

**Acceptance Criteria:**
- [ ] `grep -rn '"prov:P\|"prov:E\|"prov:O\|"prov:S' docs/ src/prov/tests/examples.py` shows no remaining stringly-typed pre-defined PROV subtype values (`prov:Person`, `prov:Plan`, `prov:EmptyCollection`, …) in doc prose/code or examples.py. (Non-PROV-namespace strings like `"rec54:WD"` are out of scope — #236 covers the *pre-defined* `prov:` types only.)
- [ ] The tutorial's shown PROV-N output matches what the updated code actually prints (`prov:type='prov:Person'`, single quotes) — every code block re-run, every output block updated verbatim.
- [ ] prov-dm.md explicitly states that a plain string is stored as a string and does not denote the pre-defined type.
- [ ] Sphinx docs build clean: 0 warnings.
- [ ] Full suite green (examples.py feeds the round-trip matrix and dot smoke; it is self-consistent either way).

**Verify:** `uv run pytest -q && uv run --group docs --extra rdf --extra xml sphinx-build -b html docs docs/_build/html` → suite green, build finishes with 0 warnings.

**Steps:**

- [ ] **Step 1: Branch** — `git checkout -b fix-236-prov-type-idiom` from updated master.

- [ ] **Step 2: `docs/explanation/prov-dm.md`** — replace lines 111–114 with:

```markdown
PROV-DM also defines agent *subtypes* — Person, Organization, and SoftwareAgent — and the
Plan entity subtype used with associations. These are not separate classes or factories in
`prov`; you express them by adding a `prov:type` attribute whose value is the pre-defined
*qualified name* (for example, `agent("ag", {prov.PROV_TYPE: prov.PROV["Person"]})`).
Note that the value must be a qualified name: a plain string such as `"prov:Person"` is
stored (and serialized) as just that string, which per PROV-DM §5.7.2.4 does not denote
the pre-defined type at all.
```

(The page's code convention is `import prov.model as prov` — confirm `prov.PROV` is how the namespace is referenced elsewhere on the page and adjust to match its idiom.)

- [ ] **Step 3: `docs/tutorial/getting-started.md`** — line 52 becomes:

```python
document.agent("ag2", {prov.PROV_TYPE: prov.PROV["Person"], "ex:name": "Bob"})
```

Then **re-run every code block in the tutorial in order** (they are designed to run as pasted) and update every output block that changed — at minimum the PROV-N block (line 71 becomes `agent(ag2, [prov:type='prov:Person', ex:name="Bob"])`) and any later PROV-JSON output. If the tutorial embeds a pre-generated diagram image whose content changed, regenerate it the way the tutorial says it was produced.

- [ ] **Step 4: `docs/reference/conformance.md`** — in the Component-3 finding (~line 123): `you express them with agent("ag", {PROV_TYPE: PROV["Person"]})` (and keep the sentence pointing to #260 for factories). In the Component-6 finding (~line 171): `you would add prov:type: PROV["EmptyCollection"] by hand via other_attributes`.

- [ ] **Step 5: `src/prov/tests/examples.py:266`** — `g.entity("process:rec-advance", {"prov:type": PROV["Plan"]})` (`PROV` is already imported in this module).

- [ ] **Step 6: Sweep** — run the acceptance-criteria grep; fix any further pre-defined-`prov:`-subtype strings it finds in `docs/` or `examples.py`.

- [ ] **Step 7: Verify** — full suite + ruff + mypy + Sphinx build (0 warnings).

- [ ] **Step 8: Changelog bullet** in `2.5.0 (unreleased)`:

```rst
* Documentation: the agent-subtype idiom now correctly uses qualified names
  (``PROV["Person"]``) for ``prov:type`` values; the previously documented
  string form asserted a plain string, which does not denote the pre-defined
  PROV type (#236)
```

- [ ] **Step 9: Commit, PR (`Fixes #236`), CI, merge, pull master.** After merge, verify RTD built master: `curl -s "https://readthedocs.org/api/v3/projects/prov/builds/?limit=1"` (no PR status check exists for RTD).

---

### Task 5: Fix #154 — record-level chaining convenience methods

**Goal:** Add the five missing chaining methods: `ProvEntity.wasRevisionOf` / `wasQuotedFrom` / `hadPrimarySource` / `mentionOf`, and `wasInfluencedBy` on `ProvEntity`, `ProvActivity`, and `ProvAgent` — each delegating to the existing `ProvBundle` factory with `self` as first formal argument and returning `self`, exactly like the existing `wasDerivedFrom`.

**Files:**
- Modify: `src/prov/model/records.py` (`ProvEntity` after `hadMember` ~line 897; `ProvActivity` after `wasAssociatedWith` ~line 1054; `ProvAgent` after `actedOnBehalfOf` ~line 1160)
- Create: `src/prov/tests/test_chaining.py`
- Modify: `HISTORY.rst`

**Acceptance Criteria:**
- [ ] Each new method produces a document equal to the one built via the corresponding `ProvBundle` factory, and returns `self` (chainable).
- [ ] Docstrings in the established napoleon style of the sibling methods.
- [ ] mypy --strict clean; `dir(prov.model)` unchanged (methods, not module names — the public-API smoke test must stay green).
- [ ] Full suite green.

**Verify:** `uv run pytest src/prov/tests/test_chaining.py src/prov/tests/test_public_api.py -v && uv run pytest -q && uv run mypy src` → all pass.

**Steps:**

- [ ] **Step 1: Branch** — `git checkout -b feat-154-chaining-methods` from updated master.

- [ ] **Step 2: Write the failing tests** — create `src/prov/tests/test_chaining.py`:

```python
"""Record-level chaining convenience methods added by #154.

Each method must (a) produce a document equal to the one built via the
corresponding ProvBundle factory and (b) return self for chaining.
"""

from prov.model import ProvDocument

EX = "http://example.org/"


def _doc():
    document = ProvDocument()
    document.add_namespace("ex", EX)
    return document


def _factory_doc(factory, *args):
    document = _doc()
    getattr(document, factory)(*args)
    return document


def test_entity_wasRevisionOf():
    document = _doc()
    e2 = document.entity("ex:e2")
    assert e2.wasRevisionOf("ex:e1") is e2
    assert document == _factory_doc("revision", "ex:e2", "ex:e1")


def test_entity_wasQuotedFrom():
    document = _doc()
    e2 = document.entity("ex:e2")
    assert e2.wasQuotedFrom("ex:e1") is e2
    assert document == _factory_doc("quotation", "ex:e2", "ex:e1")


def test_entity_hadPrimarySource():
    document = _doc()
    e2 = document.entity("ex:e2")
    assert e2.hadPrimarySource("ex:e1") is e2
    assert document == _factory_doc("primary_source", "ex:e2", "ex:e1")


def test_entity_mentionOf():
    document = _doc()
    e2 = document.entity("ex:e2")
    assert e2.mentionOf("ex:e1", "ex:b") is e2
    assert document == _factory_doc("mention", "ex:e2", "ex:e1", "ex:b")


def test_entity_wasInfluencedBy():
    document = _doc()
    e2 = document.entity("ex:e2")
    assert e2.wasInfluencedBy("ex:e1") is e2
    assert document == _factory_doc("influence", "ex:e2", "ex:e1")


def test_activity_wasInfluencedBy():
    document = _doc()
    a2 = document.activity("ex:a2")
    assert a2.wasInfluencedBy("ex:a1") is a2
    expected = _doc()
    expected.activity("ex:a2")
    expected.influence("ex:a2", "ex:a1")
    assert document == expected


def test_agent_wasInfluencedBy():
    document = _doc()
    ag2 = document.agent("ex:ag2")
    assert ag2.wasInfluencedBy("ex:ag1") is ag2
    expected = _doc()
    expected.agent("ex:ag2")
    expected.influence("ex:ag2", "ex:ag1")
    assert document == expected
```

(`_factory_doc` creates no element, so for the entity cases add the entity to the expected document too — mirror the pattern of the activity/agent tests: build `expected` explicitly with `expected.entity("ex:e2")` then the factory call. Adjust the four entity tests accordingly; document equality is record-set based, so both documents must contain the element **and** the relation.)

Run: `uv run pytest src/prov/tests/test_chaining.py -v` → FAIL with `AttributeError` per method.

- [ ] **Step 3: Implement.** In `src/prov/model/records.py`, append to `ProvEntity` (after `hadMember`):

```python
    def wasRevisionOf(
        self,
        usedEntity: EntityRef,
        activity: ActivityRef | None = None,
        generation: GenrationRef | None = None,
        usage: UsageRef | None = None,
        attributes: RecordAttributesArg | None = None,
    ) -> ProvEntity:
        """Create a new revision record for this entity from a used entity.

        Args:
            usedEntity: The original entity (or its string identifier).
            activity: The activity (or its string identifier) involved in the
                revision (default: ``None``).
            generation: Optional generation record qualifying the derivation
                (default: ``None``).
            usage: Optional usage record qualifying the derivation
                (default: ``None``).
            attributes: Optional extra attributes for the record, as a dict or
                an iterable of ``(name, value)`` pairs (default: ``None``).

        Returns:
            This entity (to allow chaining).
        """
        self._bundle.revision(
            self, usedEntity, activity, generation, usage, other_attributes=attributes
        )
        return self

    def wasQuotedFrom(
        self,
        usedEntity: EntityRef,
        activity: ActivityRef | None = None,
        generation: GenrationRef | None = None,
        usage: UsageRef | None = None,
        attributes: RecordAttributesArg | None = None,
    ) -> ProvEntity:
        """Create a new quotation record for this entity from a quoted entity.

        Args:
            usedEntity: The quoted entity (or its string identifier).
            activity: The activity (or its string identifier) involved in the
                quotation (default: ``None``).
            generation: Optional generation record qualifying the derivation
                (default: ``None``).
            usage: Optional usage record qualifying the derivation
                (default: ``None``).
            attributes: Optional extra attributes for the record, as a dict or
                an iterable of ``(name, value)`` pairs (default: ``None``).

        Returns:
            This entity (to allow chaining).
        """
        self._bundle.quotation(
            self, usedEntity, activity, generation, usage, other_attributes=attributes
        )
        return self

    def hadPrimarySource(
        self,
        usedEntity: EntityRef,
        activity: ActivityRef | None = None,
        generation: GenrationRef | None = None,
        usage: UsageRef | None = None,
        attributes: RecordAttributesArg | None = None,
    ) -> ProvEntity:
        """Create a new primary-source record for this entity.

        Args:
            usedEntity: The primary-source entity (or its string identifier).
            activity: The activity (or its string identifier) involved
                (default: ``None``).
            generation: Optional generation record qualifying the derivation
                (default: ``None``).
            usage: Optional usage record qualifying the derivation
                (default: ``None``).
            attributes: Optional extra attributes for the record, as a dict or
                an iterable of ``(name, value)`` pairs (default: ``None``).

        Returns:
            This entity (to allow chaining).
        """
        self._bundle.primary_source(
            self, usedEntity, activity, generation, usage, other_attributes=attributes
        )
        return self

    def mentionOf(self, generalEntity: EntityRef, bundle: EntityRef) -> ProvEntity:
        """Create a new mention record of this entity from a general entity.

        Args:
            generalEntity: The general entity (or its string identifier), the
                relationship destination.
            bundle: The bundle (or its string identifier) that the general
                entity is described in.

        Returns:
            This entity (to allow chaining).
        """
        self._bundle.mention(self, generalEntity, bundle)
        return self

    def wasInfluencedBy(
        self,
        influencer: EntityRef | ActivityRef | AgentRef,
        attributes: RecordAttributesArg | None = None,
    ) -> ProvEntity:
        """Create a new influence record on this entity by an influencer.

        Args:
            influencer: The influencing entity, activity or agent (or its
                string identifier).
            attributes: Optional extra attributes for the record, as a dict or
                an iterable of ``(name, value)`` pairs (default: ``None``).

        Returns:
            This entity (to allow chaining).
        """
        self._bundle.influence(self, influencer, other_attributes=attributes)
        return self
```

Append to `ProvActivity` (after `wasAssociatedWith`) and to `ProvAgent` (after `actedOnBehalfOf`) the same `wasInfluencedBy` body with return types `ProvActivity` / `ProvAgent` respectively (docstring "this activity" / "this agent"). All referenced type aliases (`EntityRef`, `ActivityRef`, `AgentRef`, `GenrationRef` — note the historic spelling — `UsageRef`, `RecordAttributesArg`) already exist in `records.py`; the bundle factories (`revision`, `quotation`, `primary_source`, `mention`, `influence`) already exist on `ProvBundle`.

- [ ] **Step 4: Verify** — chaining tests pass, then full suite + ruff + mypy + public-API smoke.

- [ ] **Step 5: Changelog bullet** in `2.5.0 (unreleased)`:

```rst
* New record-level chaining convenience methods (#154):
  ``ProvEntity.wasRevisionOf()``, ``.wasQuotedFrom()``, ``.hadPrimarySource()``,
  ``.mentionOf()``, and ``.wasInfluencedBy()`` on ``ProvEntity``,
  ``ProvActivity`` and ``ProvAgent`` — mirroring the existing
  ``e1.wasDerivedFrom(e2)`` style for the remaining relation types
```

- [ ] **Step 6: Commit, PR (`Fixes #154`), CI, merge, pull master.**

---

### Task 6: Release prep PR — version 2.5.0, changelog date, roadmap

**Goal:** One PR that bumps the version to 2.5.0, dates the changelog section, and marks the ROADMAP row released.

**Files:**
- Modify: `src/prov/__init__.py:11` (`__version__ = "2.5.0"` — pyproject reads it dynamically)
- Modify: `HISTORY.rst` (`2.5.0 (unreleased)` → `2.5.0 (2026-07-12)` — use the actual date)
- Modify: `ROADMAP.md:36` (2.5.0 row → `| **2.5.0** *(released YYYY-MM-DD)* | …`, matching the 2.2.0/2.3.0/2.4.0 convention)

**Acceptance Criteria:**
- [ ] `uv run python -c "import prov; print(prov.__version__)"` → `2.5.0`.
- [ ] HISTORY.rst 2.5.0 section contains all five issue bullets (#154, #236, #239, #240, #254) with a dated header.
- [ ] ROADMAP.md row matches the released-row convention of earlier releases.
- [ ] Full suite + CI green.

**Verify:** `uv run python -c "import prov; print(prov.__version__)"` → `2.5.0`; `uv run pytest -q` → green.

**Steps:**

- [ ] **Step 1: Branch** `release-2.5.0` from updated master; make the three edits (review the accumulated `2.5.0 (unreleased)` bullets for wording/order while dating the header — put the #154 feature bullet first, then the fixes).
- [ ] **Step 2: Full local verify** (suite, ruff, mypy).
- [ ] **Step 3: Commit** (`Prepare release 2.5.0`), push, PR (no `Fixes` footer), CI, merge, pull master.

---

### Task 7: Publish 2.5.0 (coordinator-run, gated)

**Goal:** 2.5.0 live on PyPI via the trusted-publishing pipeline, GitHub release created, milestone closed, tracking issue updated.

**USER-ORDERED GATE — NON-SKIPPABLE.** Publishing requires an explicit maintainer go-ahead collected via AskUserQuestion after the release PR is merged and before ANY publish action (established process from the 2.3.0/2.4.0 releases). This task MUST be executed by the coordinator in-session (it needs AskUserQuestion and release permissions), never dispatched to a subagent.

**Files:** none (process task; operates via `gh` and PyPI APIs)

**Acceptance Criteria:**
- [ ] Maintainer approval recorded in the conversation (AskUserQuestion answer) before any publish step.
- [ ] TestPyPI dry-run succeeded: `curl -s https://test.pypi.org/pypi/prov/json | jq -r '.info.version'` → `2.5.0` (or the dry-run run log shows successful upload).
- [ ] GitHub release `2.5.0` exists (tag `2.5.0`, no `v` prefix, target master).
- [ ] `curl -s https://pypi.org/pypi/prov/2.5.0/json | jq -r '.urls[].packagetype'` → `bdist_wheel` and `sdist`.
- [ ] Milestone 2.5.0 closed with 0 open issues; tracking issue #181 has a release summary comment.

**Verify:** `curl -s https://pypi.org/pypi/prov/2.5.0/json | jq -r '.urls[].packagetype'` → both artifact types listed.

**Steps:**

- [ ] **Step 1: Confirm all five milestone issues are closed** (`gh issue list -R trungdong/prov --milestone 2.5.0 --state open` → empty; the `Fixes #N` footers close them on merge — close any straggler manually with a comment linking its PR).
- [ ] **Step 2: STOP — AskUserQuestion maintainer go-ahead** ("Release PR merged, suite green. Publish 2.5.0 to TestPyPI dry-run then PyPI?"). Do not proceed on anything but an explicit yes.
- [ ] **Step 3: TestPyPI dry-run** — `gh workflow run release.yml --ref master` (workflow_dispatch publishes to TestPyPI); watch the run; verify 2.5.0 wheel+sdist on test.pypi.org via the JSON API.
- [ ] **Step 4: GitHub release** — extract the HISTORY.rst 2.5.0 section, convert RST→Markdown (match the 2.4.0 release-notes style), then `gh release create 2.5.0 --target master --title 2.5.0 --notes-file <notes.md>`. This triggers the `release: published` publish job.
- [ ] **Step 5: Verify PyPI** — watch the release run to success; check `https://pypi.org/pypi/prov/2.5.0/json`; download the wheel and sdist from the `urls[].url` entries and spot-check contents (`py.typed` present, `prov/model/` package present).
- [ ] **Step 6: Close out** — close the 2.5.0 milestone (`gh api repos/trungdong/prov/milestones/<n> -X PATCH -f state=closed`; find `<n>` via `gh api repos/trungdong/prov/milestones --jq '.[]|select(.title=="2.5.0").number'`); comment a release summary on tracking issue #181.

```json:metadata
{"userGate": true, "tags": ["user-gate"], "requiresUserSpecification": false, "verifyCommand": "curl -s https://pypi.org/pypi/prov/2.5.0/json | jq -r '.urls[].packagetype'", "acceptanceCriteria": ["AskUserQuestion maintainer approval recorded before any publish step", "GitHub release 2.5.0 exists targeting master", "pypi.org/pypi/prov/2.5.0/json lists bdist_wheel and sdist", "milestone 2.5.0 closed, #181 commented"], "modelTier": "frontier", "coordinatorOnly": true}
```

---

### Task 8: Conda feedstock — fix and merge the 2.4.0 PR (#21)

**Goal:** conda-forge `prov` package updated to 2.4.0 with corrected dependency metadata (the bot PR only bumped version/sha256; the recipe still lists `six` and pre-2.x pins).

**Files (in `/Users/tdh/projects/ProvPy/prov-feedstock`):**
- Modify: `recipe/meta.yaml` (on the bot's PR branch, PR #21, head `2.4.0_h3c1817`)

**Acceptance Criteria:**
- [ ] Recipe version `2.4.0`, sha256 matches the PyPI 2.4.0 sdist (`curl -s https://pypi.org/pypi/prov/2.4.0/json | jq -r '.urls[]|select(.packagetype=="sdist").digests.sha256'`), build number 0.
- [ ] `six` removed everywhere; host is build-only (`pip`, `python {{ python_min }}`, `setuptools`, `wheel`); run deps mirror pyproject: `python >={{ python_min }}`, `python-dateutil >=2.2`, `networkx >=2.0`, `pydot >=1.2.0`, plus the optional extras as hard conda deps (no extras in conda; keeps the package full-featured as it has always been): `lxml >=3.3.5`, `rdflib >=6.0.0,<8`.
- [ ] Summary updated to the current pyproject description ("A library for W3C Provenance Data Model supporting PROV-JSON, PROV-XML and PROV-O (RDF)").
- [ ] Feedstock CI (linux_64 + linter) green on the PR; PR merged.

**Verify:** `gh pr checks 21 -R conda-forge/prov-feedstock` → all green; then merged.

**Steps:**

- [ ] **Step 1: Check out the bot branch** — in `/Users/tdh/projects/ProvPy/prov-feedstock`: `git fetch origin && gh pr checkout 21` (gh configures push access to the bot's branch; maintainers may push to it per the PR body).
- [ ] **Step 2: Inspect the bot's diff** (`git diff origin/main -- recipe/meta.yaml`) — confirm version/sha256/build-number changes, verify the sha256 against the PyPI JSON API yourself.
- [ ] **Step 3: Edit `recipe/meta.yaml`** requirements/test/about sections:

```yaml
requirements:
  host:
    - pip
    - python {{ python_min }}
    - setuptools
    - wheel

  run:
    - python >={{ python_min }}
    - python-dateutil >=2.2
    - networkx >=2.0
    - pydot >=1.2.0
    - lxml >=3.3.5
    - rdflib >=6.0.0,<8

test:
  requires:
    - python {{ python_min }}
    - pip
  imports:
    - prov
    - prov.serializers
    - prov.model
  commands:
    - pip check
    - prov-convert --help
    - prov-compare --help
```

Keep `noarch: python` and the `{{ python_min }}` (CFEP-25) syntax exactly as the migrated recipe has it. Drop the `prov.tests` import if CI shows it needs test-only deps; otherwise it may stay. Update `about.summary` per the acceptance criteria. Check `prov-convert --help`/`--compare --help` exit 0 locally first (`uv run prov-convert --help`); if they exit non-zero for `--help`, use `prov-convert --version` or drop the command tests.
- [ ] **Step 4: Push** — `git commit -am "Update dependencies for prov 2.4.0" && git push` (to the bot branch; `gh pr checkout` set up the push remote). If the linter requests a re-render, comment `@conda-forge-admin, please rerender` on the PR.
- [ ] **Step 5: Watch CI, merge** — `gh pr checks 21 -R conda-forge/prov-feedstock --watch`; when green, `gh pr merge 21 -R conda-forge/prov-feedstock --squash`. Merging publishes the conda package (user-authorized: "fix the Conda release").

---

### Task 9: Conda feedstock — bump to 2.5.0

**Goal:** conda-forge `prov` at 2.5.0, so conda users get the new release without waiting on the autotick bot.

**Files (in `/Users/tdh/projects/ProvPy/prov-feedstock`):**
- Modify: `recipe/meta.yaml` (version, sha256, build number)

**Acceptance Criteria:**
- [ ] PR against `conda-forge/prov-feedstock` main: version `2.5.0`, sha256 = PyPI 2.5.0 sdist digest, `build: number: 0`; dependency block unchanged from Task 8.
- [ ] Feedstock CI green; PR merged.

**Verify:** `curl -s https://pypi.org/pypi/prov/2.5.0/json | jq -r '.urls[]|select(.packagetype=="sdist").digests.sha256'` matches the recipe; PR checks green.

**Steps:**

- [ ] **Step 0: Precondition** — Task 7 done (2.5.0 live on PyPI) and Task 8 merged. **First check whether the autotick bot already opened a 2.5.0 PR** (`gh pr list -R conda-forge/prov-feedstock --state open`); if it has, apply this task to that PR (verify sha256, deps already correct from Task 8) instead of opening a new one.
- [ ] **Step 1: Branch** — `git checkout main && git pull && git checkout -b bump-2.5.0`.
- [ ] **Step 2: Edit `recipe/meta.yaml`** — `{% set version = "2.5.0" %}`; `sha256:` from the PyPI JSON API (command in Verify); `build: number: 0`.
- [ ] **Step 3: Open the PR** — push the branch to the user's fork (`gh repo fork conda-forge/prov-feedstock --clone=false` if none exists; `git remote add fork …; git push -u fork bump-2.5.0`), then `gh pr create -R conda-forge/prov-feedstock --title "prov v2.5.0" --body "Version bump to 2.5.0 (deps updated in #21)."` (conda-forge convention: PRs from forks, not feedstock branches.)
- [ ] **Step 4: Watch CI, merge** — as in Task 8 Step 5.

---

## Task dependencies

- Tasks 1–5: sequential in the order written (single shared checkout; each rebases on the previous merge).
- Task 6 blocked by Tasks 1–5. Task 7 blocked by Task 6.
- Task 8: independent — may run at any point (different repo).
- Task 9 blocked by Tasks 7 and 8.

## Self-review notes

- Spec coverage: five milestone issues → Tasks 1–5; "cut the release" → Tasks 6–7; user's feedstock request → Tasks 8–9. The rdflib `<8` widening mentioned at triage time already shipped in 2.3.0 (#207) — nothing to do.
- The #239 empty-parse rejection changes one currently-non-raising path (auto-detect of empty/namespace-less input); this is the user's explicit "all three modes" decision and must be called out in the PR body and changelog.
- Suite-tally expectations: Task 2 and Task 3 each convert one strict xfail to a pass (16 → 14 xfailed); Task 1 adds 2 tests; Task 5 adds 7.
- Type names verified against the codebase: `EntityRef`/`ActivityRef`/`AgentRef`/`GenrationRef`/`UsageRef`/`RecordAttributesArg` exist in `records.py`; `revision`/`quotation`/`primary_source`/`mention`/`influence` exist on `ProvBundle` with the argument orders used here; `has_bundles()`/`get_registered_namespaces()` to be confirmed by the Task 3 implementer (intent stated inline).
