Skip to content

graflo.architecture.evolution.commit

This replaces the linear Revision chain. The old model was already "a git log, not an Alembic script" -- but it still spoke Alembic (down_revision, upgrade/downgrade) and it could only be a line. A world model that can be merged needs a DAG: a commit has a list of parents, empty for a root, one for an ordinary edit, two or more for a three-way merge or a merge.

What carries over unchanged is the property that made the chain worth having: each commit records the content hash before and after it, so replay is verified rather than assumed. A history that no longer describes the manifest it was generated from fails loudly instead of producing a plausible wrong answer.

Merge commits are materialized, git-style

A merge commit's ops are the diff from its first parent's state to the merged result -- not some interleaving of both sides. That single decision is what keeps everything else simple: first-parent replay and hash verification work identically for edit and merge commits, so History needs no special case and neither does the store. The declarative record of how the merge was resolved rides alongside as a :class:MergeRecipe, which is what a re-merge reads. Interleaving two sides' ops within one commit is deliberately not expressible, and is not needed.

A lift commit records the aspects a manifest was lifted with the same way: its ops are the planned lift, and a LiftRecipe -- the aspects, their versions and the spec -- rides alongside for a re-lift to read.

Attributes

COMMIT_ID_LENGTH = 12 module-attribute

COMMIT_KINDS = frozenset({'root', 'edit', 'merge', 'merge3', 'revert', 'lift'}) module-attribute

CommitKind = str module-attribute

RECIPE_KINDS = {'merge': frozenset({'merge'}), 'merge3': frozenset({'merge3'}), 'lift': frozenset({'lift'})} module-attribute

__all__ = ['COMMIT_ID_LENGTH', 'COMMIT_KINDS', 'RECIPE_KINDS', 'Commit', 'CommitError', 'CommitKind', 'RecipeRef', 'build_commit', 'build_multi_parent_commit', 'build_revert_commit', 'compute_commit_id'] module-attribute

logger = logging.getLogger(__name__) module-attribute

Classes

Commit

Bases: ConfigBaseModel

One change set, its ordered parents, and the trees it moves between.

Source code in graflo/architecture/evolution/commit.py
class Commit(ConfigBaseModel):
    """One change set, its ordered parents, and the trees it moves between."""

    id: str = PydanticField(..., description="Content-derived id of this commit.")
    parents: list[str] = PydanticField(
        default_factory=list,
        description=(
            "Parent commit ids in position order: empty for a root, one for an "
            "edit, two or more for a merge or merge3. The first parent is the "
            "one this commit's ops are materialized against."
        ),
    )
    ops: list[RevisionOp] = PydanticField(
        default_factory=list,
        description=(
            "Ordered operations, as a diff from the first parent's tree. Empty "
            "only on a root, which names a tree rather than deriving one."
        ),
    )
    tree_before: str | None = PydanticField(
        default=None,
        description=(
            "Content hash of the first parent's manifest, or ``None`` on a root. "
            "Redundant against the parent's own `tree` on purpose: replay drift "
            "fails here, loudly, rather than producing a plausible wrong manifest."
        ),
    )
    tree: str = PydanticField(
        ..., description="Content hash of the manifest this commit produces."
    )
    kind: str = PydanticField(
        default="edit",
        description="One of root / edit / merge / merge3 / revert / lift.",
    )
    label: str | None = PydanticField(
        default=None, description="Short human-readable name."
    )
    created_at: str | None = PydanticField(
        default=None, description="ISO-8601 timestamp, supplied by the caller."
    )
    reversible: bool = PydanticField(
        default=True, description="Whether every op in this commit has a total inverse."
    )
    recipe: RecipeRef | None = PydanticField(
        default=None,
        description=(
            "The declaration that produced this commit, for a replay to read: "
            "how a merge or merge3 was resolved, or what a lift added."
        ),
    )
    notes: str | None = None

    @model_validator(mode="after")
    def _a_recipe_matches_its_commit(self) -> Commit:
        """A recipe is read by its commit's kind, so the two must agree.

        A re-merge parses a merge recipe and a re-lift a lift recipe; a recipe
        of the other flavour on the commit would fail there instead of here.
        A lift adds to one manifest, so it has exactly one parent.
        """
        if self.recipe is not None:
            allowed = RECIPE_KINDS.get(self.kind)
            if allowed is None:
                raise ValueError(
                    f"commit '{self.id}' is a {self.kind} commit, which carries "
                    "no recipe"
                )
            if self.recipe.kind not in allowed:
                raise ValueError(
                    f"commit '{self.id}' is a {self.kind} commit but carries a "
                    f"{self.recipe.kind} recipe"
                )
        if self.kind == "lift" and len(self.parents) != 1:
            raise ValueError(
                f"lift commit '{self.id}' has {len(self.parents)} parents; a lift "
                "adds to one manifest, so it has exactly one"
            )
        return self

    @model_validator(mode="after")
    def _only_a_root_may_derive_nothing(self) -> Commit:
        """A commit with a parent must say how it got there.

        Relaxing ``ops`` to allow a root is not a licence for an empty edit: an
        edit whose ops are empty claims a transition it cannot replay, which is
        the one failure the verified-replay design exists to prevent.

        Two parentless shapes are both legal and must not be confused. A
        *derived* root has ops and a ``tree_before`` -- the base a change set was
        authored against. A *naming* root has neither: it asserts that a tree
        exists, which is the only thing that can be said about an artifact that
        simply arrived.
        """
        if self.parents and not self.ops:
            raise ValueError(
                f"commit '{self.id}' has parents but no operations; only a root "
                "commit may name a tree without deriving it"
            )
        if not self.ops and self.tree_before is not None:
            raise ValueError(
                f"commit '{self.id}' derives nothing, so it has no tree to start "
                "from; a naming root carries a tree and no tree_before"
            )
        return self

    @property
    def is_root(self) -> bool:
        """Whether this commit has no parent."""
        return not self.parents

    @property
    def is_multi_parent(self) -> bool:
        """Whether this commit joins two or more lineages.

        True for both `merge` and `merge3` kinds: joining unrelated lineages
        is as multi-parent as reconciling related ones. Not a test for
        `kind == "merge"` -- read `kind` for that.
        """
        return len(self.parents) > 1

    @property
    def first_parent(self) -> str | None:
        """The parent this commit's ops are materialized against."""
        return self.parents[0] if self.parents else None

    @property
    def slug(self) -> str:
        """Filesystem-safe label fragment used in the stored filename."""
        base = _SLUG_STRIP.sub("_", (self.label or "commit").lower()).strip("_")
        return base or "commit"

    def short(self) -> str:
        """The id, abbreviated the way a log line shows it."""
        return self.id[:8]

Attributes

created_at = PydanticField(default=None, description='ISO-8601 timestamp, supplied by the caller.') class-attribute instance-attribute
first_parent property

The parent this commit's ops are materialized against.

id = PydanticField(..., description='Content-derived id of this commit.') class-attribute instance-attribute
is_multi_parent property

Whether this commit joins two or more lineages.

True for both merge and merge3 kinds: joining unrelated lineages is as multi-parent as reconciling related ones. Not a test for kind == "merge" -- read kind for that.

is_root property

Whether this commit has no parent.

kind = PydanticField(default='edit', description='One of root / edit / merge / merge3 / revert / lift.') class-attribute instance-attribute
label = PydanticField(default=None, description='Short human-readable name.') class-attribute instance-attribute
notes = None class-attribute instance-attribute
ops = PydanticField(default_factory=list, description="Ordered operations, as a diff from the first parent's tree. Empty only on a root, which names a tree rather than deriving one.") class-attribute instance-attribute
parents = PydanticField(default_factory=list, description="Parent commit ids in position order: empty for a root, one for an edit, two or more for a merge or merge3. The first parent is the one this commit's ops are materialized against.") class-attribute instance-attribute
recipe = PydanticField(default=None, description='The declaration that produced this commit, for a replay to read: how a merge or merge3 was resolved, or what a lift added.') class-attribute instance-attribute
reversible = PydanticField(default=True, description='Whether every op in this commit has a total inverse.') class-attribute instance-attribute
slug property

Filesystem-safe label fragment used in the stored filename.

tree = PydanticField(..., description='Content hash of the manifest this commit produces.') class-attribute instance-attribute
tree_before = PydanticField(default=None, description="Content hash of the first parent's manifest, or ``None`` on a root. Redundant against the parent's own `tree` on purpose: replay drift fails here, loudly, rather than producing a plausible wrong manifest.") class-attribute instance-attribute

Methods:

short()

The id, abbreviated the way a log line shows it.

Source code in graflo/architecture/evolution/commit.py
def short(self) -> str:
    """The id, abbreviated the way a log line shows it."""
    return self.id[:8]

CommitError

Bases: RuntimeError

A commit is malformed, or a replay did not reproduce a recorded hash.

Source code in graflo/architecture/evolution/commit.py
class CommitError(RuntimeError):
    """A commit is malformed, or a replay did not reproduce a recorded hash."""

RecipeRef

Bases: ConfigBaseModel

A recorded recipe, stored inline on its commit, plus its content hash.

The recipe models live beside the machinery that replays them -- MergeRecipe in merge3, LiftRecipe in aspects.recipe -- and are kept here as a serialized payload. That keeps commit.py importable without either, which matters because the store and the history DAG only ever need the hash.

Source code in graflo/architecture/evolution/commit.py
class RecipeRef(ConfigBaseModel):
    """A recorded recipe, stored inline on its commit, plus its content hash.

    The recipe models live beside the machinery that replays them --
    ``MergeRecipe`` in ``merge3``, ``LiftRecipe`` in ``aspects.recipe`` -- and
    are kept here as a serialized payload. That keeps ``commit.py`` importable
    without either, which matters because the store and the history DAG only
    ever need the *hash*.
    """

    hash: str = PydanticField(..., description="Content hash of the recipe.")
    kind: str = PydanticField(..., description="Recipe flavour: merge3, merge or lift.")
    payload: dict[str, Any] = PydanticField(
        default_factory=dict,
        description="The serialized recipe, as produced by its ``to_dict()``.",
    )

Attributes

hash = PydanticField(..., description='Content hash of the recipe.') class-attribute instance-attribute
kind = PydanticField(..., description='Recipe flavour: merge3, merge or lift.') class-attribute instance-attribute
payload = PydanticField(default_factory=dict, description='The serialized recipe, as produced by its ``to_dict()``.') class-attribute instance-attribute

Functions:

build_commit(base, ops, *, parents=None, kind='edit', label=None, created_at=None, notes=None, recipe=None)

Apply ops to base and record the result as a :class:Commit.

The ops are applied here rather than trusted, so both trees describe a transition that actually happened. Refuses an empty change set and one that leaves the manifest unchanged: a commit records a transition, and one that changes nothing would claim a change that did not happen.

Parameters:

Name Type Description Default
base GraphManifest

The first parent's manifest state.

required
ops list[ManifestOp]

The change set to apply.

required
parents list[str] | None

Parent commit ids, first parent first.

None
kind str

One of edit, merge, merge3, revert, lift.

'edit'
label str | None

Short human-readable name.

None
created_at str | None

ISO-8601 timestamp.

None
notes str | None

Free-form annotation.

None
recipe RecipeRef | None

The recorded declaration, on merge, merge3 and lift commits.

None

Raises:

Type Description
CommitError

The change set is empty, is a no-op, or kind is unknown.

Source code in graflo/architecture/evolution/commit.py
def build_commit(
    base: GraphManifest,
    ops: list[ManifestOp],
    *,
    parents: list[str] | None = None,
    kind: str = "edit",
    label: str | None = None,
    created_at: str | None = None,
    notes: str | None = None,
    recipe: RecipeRef | None = None,
) -> Commit:
    """Apply *ops* to *base* and record the result as a :class:`Commit`.

    The ops are applied here rather than trusted, so both trees describe a
    transition that actually happened. Refuses an empty change set and one that
    leaves the manifest unchanged: a commit records a transition, and one that
    changes nothing would claim a change that did not happen.

    Args:
        base: The first parent's manifest state.
        ops: The change set to apply.
        parents: Parent commit ids, first parent first.
        kind: One of ``edit``, ``merge``, ``merge3``, ``revert``, ``lift``.
        label: Short human-readable name.
        created_at: ISO-8601 timestamp.
        notes: Free-form annotation.
        recipe: The recorded declaration, on merge, merge3 and lift commits.

    Raises:
        CommitError: The change set is empty, is a no-op, or *kind* is unknown.
    """
    from .apply import apply_evolution
    from .inverse import irreversible_reason

    if not ops:
        raise CommitError("a commit needs at least one operation")
    if kind not in COMMIT_KINDS:
        raise CommitError(
            f"unknown commit kind '{kind}'; expected one of {sorted(COMMIT_KINDS)}"
        )

    parent_ids = list(parents or [])
    before = manifest_hash(base)
    applied = apply_evolution(base, ops, bump_version=False, finish_init=False)
    after = manifest_hash(applied)
    if after == before:
        raise CommitError(
            "the operations leave the manifest unchanged; there is nothing to record"
        )

    return Commit(
        id=compute_commit_id(ops, parent_ids),
        parents=parent_ids,
        # Round-tripped through the codec on the way in, so a commit can only
        # ever hold ops that survive serialization -- the property replay
        # depends on. Also rejects the binary merge op, which no single
        # manifest transition can express.
        ops=ops_from_dicts(ops_to_dicts(list(ops))),
        tree_before=before,
        tree=after,
        kind=kind,
        label=label,
        created_at=created_at,
        reversible=all(irreversible_reason(op) is None for op in ops),
        recipe=recipe,
        notes=notes,
    )

build_multi_parent_commit(first_parent, merged, *, parents, kind='merge3', label=None, created_at=None, notes=None, recipe=None, lead_ops=None)

Record a merge as the diff from its first parent to merged.

This is what makes a merge commit replayable by exactly the same machinery as an edit: the stored ops move first-parent → result, so first-parent replay and hash verification need no special case anywhere downstream.

The diff is verified before it is stored -- if replaying the derived ops does not reproduce merged, that is raised here rather than written into history.

Parameters:

Name Type Description Default
first_parent GraphManifest

Manifest state of the parent the ops are diffed from.

required
merged GraphManifest

The merge result.

required
parents list[str]

All parent commit ids, first parent first (at least two).

required
kind str

merge or merge3.

'merge3'
label str | None

Short human-readable name.

None
created_at str | None

ISO-8601 timestamp.

None
notes str | None

Free-form annotation.

None
recipe RecipeRef | None

The recorded resolution, so a re-merge can replay it.

None
lead_ops list[Any] | None

Ops the merge itself applied to the first parent, recorded ahead of the derived diff. A merge relabels each side before the union -- renames, and folds of several classes into one -- and a plain diff can see a fold only as an unrelated add and remove. The diff is then taken from the relabelled first parent.

None

Raises:

Type Description
CommitError

Fewer than two parents, lead ops that do not apply, or the derived diff does not reproduce merged.

Source code in graflo/architecture/evolution/commit.py
def build_multi_parent_commit(
    first_parent: GraphManifest,
    merged: GraphManifest,
    *,
    parents: list[str],
    kind: str = "merge3",
    label: str | None = None,
    created_at: str | None = None,
    notes: str | None = None,
    recipe: RecipeRef | None = None,
    lead_ops: list[Any] | None = None,
) -> Commit:
    """Record a merge as the diff from its **first parent** to *merged*.

    This is what makes a merge commit replayable by exactly the same machinery
    as an edit: the stored ops move first-parent → result, so first-parent
    replay and hash verification need no special case anywhere downstream.

    The diff is verified before it is stored -- if replaying the derived ops
    does not reproduce *merged*, that is raised here rather than written into
    history.

    Args:
        first_parent: Manifest state of the parent the ops are diffed from.
        merged: The merge result.
        parents: All parent commit ids, first parent first (at least two).
        kind: ``merge`` or ``merge3``.
        label: Short human-readable name.
        created_at: ISO-8601 timestamp.
        notes: Free-form annotation.
        recipe: The recorded resolution, so a re-merge can replay it.
        lead_ops: Ops the merge itself applied to the first parent, recorded
            ahead of the derived diff. A merge relabels each side before the
            union -- renames, and folds of several classes into one -- and a
            plain diff can see a fold only as an unrelated add and remove. The
            diff is then taken from the relabelled first parent.

    Raises:
        CommitError: Fewer than two parents, lead ops that do not apply, or
            the derived diff does not reproduce *merged*.
    """
    from .apply import apply_evolution
    from .autogenerate import diff_manifests_verified
    from .hashing import manifest_hash

    if len(parents) < 2:
        raise CommitError(
            f"a {kind} commit needs at least two parents, got {len(parents)}"
        )

    lead = list(lead_ops or [])
    diff_base = first_parent
    if lead:
        try:
            diff_base = apply_evolution(
                first_parent, lead, bump_version=False, finish_init=False
            )
        except Exception as exc:
            raise CommitError(
                f"the merge's relabel of its first parent does not apply: {exc}"
            ) from exc

    derived, warnings = diff_manifests_verified(diff_base, merged)
    ops = [*lead, *derived]
    if not ops:
        raise CommitError(
            "the merge result is identical to its first parent; there is "
            "nothing to record"
        )
    if warnings:
        raise CommitError(
            "the derived merge diff does not fully reproduce the merged "
            "manifest, so it would not replay: " + "; ".join(warnings)
        )
    if lead and manifest_hash(
        apply_evolution(first_parent, ops, bump_version=False, finish_init=False)
    ) != manifest_hash(merged):
        raise CommitError(
            "the merge's relabel followed by the derived diff does not reproduce "
            "the merged manifest, so it would not replay"
        )

    return build_commit(
        first_parent,
        ops,
        parents=list(parents),
        kind=kind,
        label=label,
        created_at=created_at,
        notes=notes,
        recipe=recipe,
    )

build_revert_commit(current, commit, *, before, parents, label=None, created_at=None)

A new commit that undoes commit, applied on top of current.

Git-shaped, and deliberately not a "downgrade": history is append-only, so undoing a change is a new commit that moves forward, never an edit to what was recorded. Everything downstream -- replay, verification, the DAG -- then needs no notion of rewinding at all.

Each inverse is derived against the state its op was applied to, rebuilt from before, and is exact or absent: an op with no inverse refuses the revert rather than produce a manifest that merely resembles the earlier state. The inverses are then applied to current; a later commit that changed what they touch makes that fail, and the revert is refused.

Parameters:

Name Type Description Default
current GraphManifest

The manifest to apply the reverting ops to, normally the head.

required
commit Commit

The commit being undone.

required
before GraphManifest

The manifest commit was applied to (see :func:~graflo.architecture.evolution.history.checkout_parent).

required
parents list[str]

Parent commit ids for the new commit -- normally the head.

required
label str | None

Short human-readable name.

None
created_at str | None

ISO-8601 timestamp.

None

Raises:

Type Description
CommitError

before is not commit's starting tree, an operation has no inverse, or the inverses do not apply to current.

Source code in graflo/architecture/evolution/commit.py
def build_revert_commit(
    current: GraphManifest,
    commit: Commit,
    *,
    before: GraphManifest,
    parents: list[str],
    label: str | None = None,
    created_at: str | None = None,
) -> Commit:
    """A new commit that undoes *commit*, applied on top of *current*.

    Git-shaped, and deliberately not a "downgrade": history is append-only, so
    undoing a change is a new commit that moves forward, never an edit to what
    was recorded. Everything downstream -- replay, verification, the DAG -- then
    needs no notion of rewinding at all.

    Each inverse is derived against the state its op was applied to, rebuilt
    from *before*, and is exact or absent: an op with no inverse refuses the
    revert rather than produce a manifest that merely resembles the earlier
    state. The inverses are then applied to *current*; a later commit that
    changed what they touch makes that fail, and the revert is refused.

    Args:
        current: The manifest to apply the reverting ops to, normally the head.
        commit: The commit being undone.
        before: The manifest *commit* was applied to (see
            :func:`~graflo.architecture.evolution.history.checkout_parent`).
        parents: Parent commit ids for the new commit -- normally the head.
        label: Short human-readable name.
        created_at: ISO-8601 timestamp.

    Raises:
        CommitError: *before* is not *commit*'s starting tree, an operation has
            no inverse, or the inverses do not apply to *current*.
    """
    from .hashing import manifest_hash
    from .inverse import invert_ops

    if commit.tree_before is not None and manifest_hash(before) != commit.tree_before:
        raise CommitError(
            f"commit '{commit.short()}' starts from tree {commit.tree_before[:12]}, "
            f"not from the manifest given as its pre-state "
            f"({manifest_hash(before)[:12]})"
        )
    inverses, blockers = invert_ops(list(commit.ops), manifest=before)
    if blockers:
        raise CommitError(
            f"commit '{commit.short()}' cannot be reverted -- "
            + "; ".join(blockers)
            + ". Check out its parent instead."
        )

    return build_commit(
        current,
        inverses,
        parents=list(parents),
        kind="revert",
        label=label or f"revert {commit.short()}",
        created_at=created_at,
    )

build_root_commit(manifest, *, scope=None, label=None, created_at=None, notes=None)

Record manifest as a root that names its tree.

The counterpart to :func:build_commit for an artifact that was not derived from anything this history holds -- pushed to a registry, seeded from a pack, or otherwise simply present. There is no base to diff against, so there are no ops: the commit asserts the tree and stops.

That is deliberately not "a diff from an empty manifest". An empty GraphManifest is not constructible (it must carry at least one block), and the op vocabulary cannot build every block from nothing -- so a synthesized construction diff would fail for exactly the manifests a registry most often holds.

Parameters:

Name Type Description Default
manifest GraphManifest

The artifact this root names.

required
scope str | None

Lineage discriminator folded into the id; see :func:compute_root_commit_id.

None
label str | None

Short human-readable name.

None
created_at str | None

ISO-8601 timestamp.

None
notes str | None

Free-form annotation.

None
Source code in graflo/architecture/evolution/commit.py
def build_root_commit(
    manifest: GraphManifest,
    *,
    scope: str | None = None,
    label: str | None = None,
    created_at: str | None = None,
    notes: str | None = None,
) -> Commit:
    """Record *manifest* as a root that names its tree.

    The counterpart to :func:`build_commit` for an artifact that was not derived
    from anything this history holds -- pushed to a registry, seeded from a
    pack, or otherwise simply present. There is no base to diff against, so
    there are no ops: the commit asserts the tree and stops.

    That is deliberately not "a diff from an empty manifest". An empty
    ``GraphManifest`` is not constructible (it must carry at least one block),
    and the op vocabulary cannot build every block from nothing -- so a
    synthesized construction diff would fail for exactly the manifests a
    registry most often holds.

    Args:
        manifest: The artifact this root names.
        scope: Lineage discriminator folded into the id; see
            :func:`compute_root_commit_id`.
        label: Short human-readable name.
        created_at: ISO-8601 timestamp.
        notes: Free-form annotation.
    """
    tree = manifest_hash(manifest)
    return Commit(
        id=compute_root_commit_id(tree, scope),
        parents=[],
        ops=[],
        tree_before=None,
        tree=tree,
        kind="root",
        label=label,
        created_at=created_at,
        reversible=True,
        notes=notes,
    )

compute_commit_id(ops, parents)

Content-derived id: sha256 over the canonical ops plus the parent ids.

Deterministic on purpose -- the same change set on the same parents always yields the same id, so a regenerated commit is recognisably the same one rather than a duplicate under a fresh random name.

Parent order is part of the id, because it is part of the meaning: a merge materialized against A-then-B is not the commit materialized against B-then-A.

Source code in graflo/architecture/evolution/commit.py
def compute_commit_id(ops: list[ManifestOp], parents: list[str]) -> str:
    """Content-derived id: sha256 over the canonical ops plus the parent ids.

    Deterministic on purpose -- the same change set on the same parents always
    yields the same id, so a regenerated commit is recognisably *the same one*
    rather than a duplicate under a fresh random name.

    Parent order is part of the id, because it is part of the meaning: a merge
    materialized against A-then-B is not the commit materialized against
    B-then-A.
    """
    return suthing.stable_hash(
        {"ops": ops_to_dicts(list(ops)), "parents": list(parents)},
        length=COMMIT_ID_LENGTH,
    )

compute_root_commit_id(tree, scope=None)

Content-derived id for a root that names tree.

:func:compute_commit_id hashes the ops and the parents, both of which are empty on a naming root -- so every root in existence would share one id. The tree is what a root asserts, so the tree is what identifies it.

scope separates two roots that name the same tree but belong to different lineages. A registry keyed by artifact passes the artifact id: without it, two artifacts whose first version has identical content collide, the store dedupes the second against the first, and one of them silently ends up with no root at all.

Source code in graflo/architecture/evolution/commit.py
def compute_root_commit_id(tree: str, scope: str | None = None) -> str:
    """Content-derived id for a root that names *tree*.

    :func:`compute_commit_id` hashes the ops and the parents, both of which are
    empty on a naming root -- so every root in existence would share one id.
    The tree is what a root asserts, so the tree is what identifies it.

    *scope* separates two roots that name the same tree but belong to different
    lineages. A registry keyed by artifact passes the artifact id: without it,
    two artifacts whose first version has identical content collide, the store
    dedupes the second against the first, and one of them silently ends up with
    no root at all.
    """
    return suthing.stable_hash({"root": tree, "scope": scope}, length=COMMIT_ID_LENGTH)