Activate and restore a NAMS ontology

We will capture the workspace’s active ontology version, clone the healthcare template, activate and inspect a strict revision, then restore the exact previous binding. After restoration is verified, we will delete only the created clone and confirm its absence in a fresh process.

client.ontology is not NAMS-only. Since 0.7.0 the same methods run against your own Neo4j database, where versions are stored as :Ontology / :OntologyVersion nodes and the active document drives local extraction and resolution. For that workflow, see drive extraction from an ontology.

This lesson uses neo4j-agent-memory 0.7.0 from PyPI and the complete programs on this page in a POSIX shell. It uses the NAMS ontology API; Aura connection credentials cannot replace a NAMS workspace key. The checks below verify activation and restoration against your hosted workspace.

Before you begin

  • Python 3.10 or newer and a POSIX shell.

  • A dedicated NAMS test workspace with its workspace-scoped data key and permission to create, activate and delete lesson ontologies.

  • A healthcare template and an active ontology with authoritative version metadata. The program stops before mutation if either is unavailable.

Use a workspace reserved for this exercise. Activation changes the schema binding for the entire workspace, so no other client or ontology editor may use it during the lesson. Establish who can recover that binding if the process is interrupted. A valid workspace key grants data access; it does not by itself establish isolation or permission to disrupt another application.

Create a local folder and virtual environment for the examples on this page. The commands reuse an existing environment without changing its files:

mkdir -p ~/agent-memory-tutorials
cd ~/agent-memory-tutorials
if [ -e .venv ]; then
  printf '%s\n' 'Using the existing virtual environment.'
else
  python3 -m venv .venv
fi
source .venv/bin/activate

Expected: ~/agent-memory-tutorials is your working directory and its virtual environment is active. Install the published SDK with the command below.

Each complete code block labelled Save as names a file to create in this folder using your editor. Copy the entire block, including imports and the entry point. Expand each helper disclosure and use Copy code to copy its full source. Keep all files together so their imports resolve.

When continuing from another tutorial or guide, retain the existing environment, configuration, session files, and .tutorial-state/. Reuse unchanged helper files; compare an existing file before replacing it, and finish any pending cleanup or recovery before changing the code that owns its state.

python -m pip install 'neo4j-agent-memory[nams]==0.7.0'
python -c "from importlib.metadata import version; \
import neo4j_agent_memory; \
assert version('neo4j-agent-memory') == '0.7.0'; \
print('SDK 0.7.0 import verified')"

Expected: SDK 0.7.0 import verified. Configure the dedicated workspace:

export MEMORY_API_KEY="replace-with-the-dedicated-workspace-key"
export MEMORY_ENDPOINT="https://memory.neo4jlabs.com/v1"
unset MEMORY_WORKSPACE_ID
python -c "from neo4j_agent_memory import NamsSettings; \
s=NamsSettings(); \
print(s.backend, s.nams.endpoint)"

Expected: nams https://memory.neo4jlabs.com/v1. This checks configuration; the workspace key selects the dedicated workspace. Keep the same credentials for every command and run from ~/agent-memory-tutorials with the virtual environment active. See Authentication for credential categories.

Before seeding, identify the workspace name or ID in the dashboard and its responsible operator. Supply those non-secret labels in the seed command below. They are saved as operator notes; they do not change request routing or prove workspace identity. Because this lesson unsets MEMORY_WORKSPACE_ID, the ledger’s selector can be null: the workspace key selects the workspace. Never put a key in either note.

Step 1: Understand the restoration guard

See Ontologies for what an ontology version and active binding are; this step covers only what the exercise checks.

The private .tutorial-state/ontology.json file records the endpoint and credential identity, original active version, each returned clone/revision ID, and unfinished operations. It is written before advancing to the next stage. Keep it out of version control and do not remove it to bypass an incomplete exercise.

The guard captures the version actually bound to the workspace, which can be older than the ontology’s latest revision. Missing or contradictory binding metadata is an error; the program never chooses the latest revision as a restoration fallback.

SDK 0.7.0’s client.ontology.get_active() reports the bound revision, but it returns None metadata, rather than failing, when the service response carries no version record. (Releases before 0.7.0 could report the latest revision instead of the bound one.) This tutorial therefore reads the binding directly through its read_active_binding() helper, which validates it and stops before any mutation when it is missing or inconsistent. The remaining ontology operations use the published SDK.

Restoration precedes clone deletion. If restoration or its readback fails, the clone and local state are retained for recovery. If another client changes the active binding unexpectedly, the program stops instead of overwriting that client’s work.

Step 2: Read the complete exercise

Create the shared state and restoration helpers below, then save the complete ontology_quickstart.py program. Keep all three files in ~/agent-memory-tutorials:

Copy these files into the same lesson folder. The final row is the program we will run; the other files are tutorial support, not SDK APIs. Reuse a helper only when its contents match. Finish pending cleanup/recovery before replacing code that owns an existing ledger.

File Purpose Continuing reader

hosted_tutorial_state.py

Private ledger and local inspection

Reuse unchanged

hosted_tutorial_helpers.py

Authoritative active binding, restoration and bounded polling

Reuse unchanged

ontology_quickstart.py

The learner-facing activation and recovery commands

New lesson file

Open the helper below and save its complete source as hosted_tutorial_state.py in ~/agent-memory-tutorials.

Show hosted_tutorial_state.py
Save as hosted_tutorial_state.py
"""Private, restartable state for the hosted REST tutorials; no I/O at import."""

from __future__ import annotations

import hashlib
import json
import os
import re
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from uuid import uuid4


def read_state(path):
    """Read a local ledger without credentials, SDK imports, or service access."""
    path = Path(path)
    if path.is_symlink():
        raise RuntimeError("Refusing a symlink for tutorial state")
    data = json.loads(path.read_text())
    if not isinstance(data, dict) or data.get("schema_version") != 1:
        raise RuntimeError("Unsupported or incomplete tutorial state")
    if not isinstance(data.get("identity"), dict):
        raise RuntimeError("Missing tutorial identity")
    if (
        not isinstance(data.get("resources"), dict)
        or not data.get("run_token")
        or "pending" not in data
    ):
        raise RuntimeError("Incomplete tutorial state")
    if any(not isinstance(entries, dict) for entries in data["resources"].values()):
        raise RuntimeError("Invalid resource ledger")
    if any(
        not isinstance(entry, dict)
        for entries in data["resources"].values()
        for entry in entries.values()
    ):
        raise RuntimeError("Invalid resource disposition")
    datetime.fromisoformat(data["started_at"])
    return data


def new_run_status(data):
    """Classify recorded evidence only; never infer absence from an empty ledger."""
    if data.get("pending"):
        return "needs_reconciliation"
    resources = data["resources"]
    entries = [entry for group in resources.values() for entry in group.values()]
    if (
        data.get("remote_write_started") is False
        and not entries
        and not any(data.get(key) for key in ("conversation_id", "run_id", "skill_id"))
        and not data.get("ontology", {}).get("clone_id")
    ):
        return "no_remote_write_started"
    disposed = data.get("cleanup_complete") is True or (
        data.get("ontology", {}).get("status") == "complete"
        and data["ontology"].get("restored") is True
        and data["ontology"].get("deleted") is True
    )
    if (
        disposed
        and entries
        and all(entry.get("status") in {"deleted", "deleted_with_ontology"} for entry in entries)
    ):
        return "recorded_disposition_complete"
    return "owner_disposition_required"


# Restoration target and exercise outcome shown by inspect-file.
ONTOLOGY_SUMMARY_KEYS = (
    "previous",
    "strict",
    "clone_id",
    "template",
    "status",
    "restored",
    "deleted",
    "exercise_error",
)


def inspect_file(path):
    """Return a redacted local summary; this never authenticates or enables writes."""
    data = read_state(path)
    endpoint = urlsplit(str(data["identity"].get("endpoint", "")))
    # Do not expose credentials/query parameters even from a manually altered file.
    endpoint = urlunsplit(
        (endpoint.scheme, endpoint.netloc.rsplit("@", 1)[-1], endpoint.path, "", "")
    )
    summary = {
        "local_only": True,
        "service_checked": False,
        "lesson": data.get("lesson"),
        "run_token": data["run_token"],
        "started_at": data["started_at"],
        "identity": {
            "endpoint": endpoint,
            "workspace_id": data["identity"].get("workspace_id"),
        },
        "operator_context": data.get("operator_context", {}),
        "pending": data.get("pending"),
        "new_run_status": new_run_status(data),
        "resources": {
            kind: {
                resource_id: {
                    key: entry[key] for key in ("status", "owned", "disposition") if key in entry
                }
                for resource_id, entry in entries.items()
            }
            for kind, entries in data["resources"].items()
        },
    }
    ontology = data.get("ontology")
    if isinstance(ontology, dict):
        # The owner needs the saved prior binding to restore the workspace.
        # Schema documents are omitted; the IDs identify them exactly.
        binding_keys = ("version_id", "ontology_id", "revision", "validation_mode")
        summary["ontology"] = {
            key: (
                {k: value[k] for k in binding_keys if k in value}
                if isinstance(value, dict)
                else value
            )
            for key, value in ontology.items()
            if key in ONTOLOGY_SUMMARY_KEYS
        }
    return summary


def check_new_run(path, next_state):
    """Permit a separate path only after no-write proof or recorded full disposal."""
    status = new_run_status(read_state(path))
    if status not in {"no_remote_write_started", "recorded_disposition_complete"}:
        raise RuntimeError(
            "No automatic new run: uncertain, retained, or legacy state needs owner disposition"
        )
    next_state = Path(next_state)
    if next_state.exists() or next_state.is_symlink():
        raise RuntimeError("Choose an unused state path; the previous run must remain intact")
    if next_state.parent.resolve() == Path(path).parent.resolve():
        raise RuntimeError("Use a separate run directory so reports and archives cannot collide")
    if next_state.parent.exists() and any(next_state.parent.iterdir()):
        raise RuntimeError("Choose a new or empty run directory; preserve its existing reports")
    return status


def digest(text: str) -> str:
    return hashlib.sha256(text.encode()).hexdigest()


def connection(settings):
    """Use the same resolved NamsSettings for SDK and tutorial HTTP requests."""
    config = settings.nams
    parts = urlsplit(str(config.endpoint).rstrip("/"))
    if parts.scheme not in {"http", "https"} or not parts.netloc:
        raise ValueError("A hosted HTTP(S) endpoint is required")
    if parts.username or parts.password or parts.query or parts.fragment:
        raise ValueError("Endpoint must not contain credentials, a query, or a fragment")
    if config.transport_mode == "bridge" or (
        config.transport_mode == "auto" and not re.search(r"/v\d+(?:/|$)", parts.path)
    ):
        raise ValueError("These tutorials require the hosted REST API")
    endpoint = urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", ""))
    key = config.api_key.get_secret_value() if config.api_key else ""
    if not key:
        raise ValueError("Set MEMORY_API_KEY before starting the tutorial")
    # Tutorial identity must not differ between the SDK and direct HTTP calls.
    headers = dict(config.headers)
    if any(k.lower() in {"authorization", "x-workspace-id"} for k in headers):
        raise ValueError("Use api_key/workspace_id settings, not overriding auth/workspace headers")
    headers["Authorization"] = f"Bearer {key}"
    if config.workspace_id:
        headers["X-Workspace-Id"] = config.workspace_id
    resolved = {"endpoint": endpoint, "workspace_id": config.workspace_id}
    return resolved, headers, config.timeout, key


def credential_digest(key: str, salt: str) -> str:
    """Bind state to the credential that seeded it, without keeping a directly
    comparable digest of that credential on disk. A per-run salt and a
    deliberately slow KDF mean the stored value answers one question -- "is this
    the same key as last time?" -- and is not worth attacking offline. Message
    content keeps ``digest``: it is a change detector, not a secret."""
    return hashlib.scrypt(
        key.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1, maxmem=64 * 1024 * 1024, dklen=32
    ).hex()


def identity(settings, salt=None):
    resolved, _headers, _timeout, key = connection(settings)
    salt = salt or os.urandom(16).hex()
    return {**resolved, "credential_salt": salt, "credential_digest": credential_digest(key, salt)}


def http_client(settings, **kwargs):
    import httpx

    resolved, headers, timeout, _key = connection(settings)
    return httpx.AsyncClient(
        base_url=resolved["endpoint"] + "/", headers=headers, timeout=timeout, **kwargs
    )


def private_json(path: Path, data, *, exclusive=False):
    """Write without exposing partial JSON or replacing a previous exercise."""
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    if path.is_symlink():
        raise RuntimeError("Refusing a symlink for tutorial state")
    content = json.dumps(data, indent=2, allow_nan=False) + "\n"
    if exclusive:
        try:
            fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
        except FileExistsError as exc:
            raise RuntimeError("State already exists; inspect or recover the prior run") from exc
        with os.fdopen(fd, "w") as output:
            output.write(content)
            output.flush()
            os.fsync(output.fileno())
        return
    fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
    try:
        with os.fdopen(fd, "w") as output:
            output.write(content)
            output.flush()
            os.fsync(output.fileno())
        os.replace(temporary, path)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


class TutorialState:
    def __init__(self, path, data):
        self.path = Path(path)
        self.data = data

    @classmethod
    def create(cls, path, settings, lesson, *, workspace_label=None, workspace_owner=None):
        data = {
            "schema_version": 1,
            "lesson": lesson,
            "identity": identity(settings),
            "run_token": uuid4().hex,
            "started_at": datetime.now(timezone.utc).isoformat(),
            "resources": {},
            "pending": None,
            # Written durably by begin() before the first remote mutation.
            # Missing in an older ledger means unknown, never proof of no writes.
            "remote_write_started": False,
            "operator_context": {
                "workspace_label": workspace_label,
                "workspace_owner": workspace_owner,
            },
        }
        private_json(Path(path), data, exclusive=True)
        return cls(path, data)

    @classmethod
    def load(cls, path, settings, lesson=None):
        path = Path(path)
        data = read_state(path)
        stored = data["identity"]
        salt = stored.get("credential_salt")
        if not isinstance(salt, str) or not re.fullmatch(r"[0-9a-f]{32}", salt):
            raise RuntimeError("Tutorial identity is missing its credential salt; do not reseed")
        if stored != identity(settings, salt):
            raise RuntimeError(
                "Endpoint, workspace, or credential changed; verify ownership before recovery"
            )
        if lesson is not None and data.get("lesson") != lesson:
            raise RuntimeError("State belongs to another tutorial")
        return cls(path, data)

    def save(self):
        private_json(self.path, self.data)

    def record(self, kind, resource_id, **metadata):
        if not isinstance(resource_id, str) or not resource_id.strip():
            raise RuntimeError(f"Missing returned {kind} ID; retained pending operation")
        resources = self.data["resources"].setdefault(kind, {})
        resources.setdefault(resource_id, {"status": "retained"}).update(metadata)
        self.save()

    def begin(self, operation):
        if self.data.get("pending"):
            raise RuntimeError("Unresolved write; inspect the retained state before retrying")
        self.data["remote_write_started"] = True
        self.data["pending"] = operation
        self.save()

    def finish(self):
        self.data["pending"] = None
        self.save()

    def inspect(self):
        data = json.loads(json.dumps(self.data))
        data["identity"].pop("credential_salt", None)
        data["identity"].pop("credential_digest", None)
        return data


def remember_message(state, message):
    role = getattr(message.role, "value", message.role)
    state.record("message", str(message.id), content_sha256=digest(message.content), role=role)


async def verify_messages(client, state):
    """Read-only exact-ID/content verification; safe to run in a fresh process."""
    expected = state.data["resources"].get("message", {})
    if not expected or not state.data.get("conversation_id"):
        raise RuntimeError("No complete message seed to verify")
    history = await client.short_term.get_conversation(state.data["conversation_id"])
    actual = {str(m.id): m for m in history.messages}
    if set(actual) != set(expected):
        raise RuntimeError("Conversation has missing or unrecorded messages; retained state")
    for message_id, entry in expected.items():
        message = actual[message_id]
        role = getattr(message.role, "value", message.role)
        if digest(message.content) != entry["content_sha256"] or role != entry["role"]:
            raise RuntimeError(f"Message readback differs: {message_id}")
    print(f"Verified: exact IDs, roles and content for {len(expected)} stored messages")


def main(argv=None):
    """Local diagnostics only; importing this module still performs no I/O."""
    import argparse

    parser = argparse.ArgumentParser(description="Inspect hosted lesson state without credentials")
    commands = parser.add_subparsers(dest="command", required=True)
    inspect = commands.add_parser("inspect-file", help="Print a redacted local summary; no network")
    inspect.add_argument("state", type=Path)
    retry = commands.add_parser("check-new-run", help="Check a separate path; preserve both files")
    retry.add_argument("state", type=Path)
    retry.add_argument("--next-state", type=Path, required=True)
    args = parser.parse_args(argv)
    if args.command == "inspect-file":
        print(json.dumps(inspect_file(args.state), indent=2))
    else:
        status = check_new_run(args.state, args.next_state)
        print(f"Recorded new-run check: {status}; original state preserved")
        print(f"Use --state {args.next_state} only after rechecking the lesson prerequisites")
        print("No service state was checked and no new file was created")


if __name__ == "__main__":
    main()

Open the helper below and save its complete source as hosted_tutorial_helpers.py in ~/agent-memory-tutorials.

Show hosted_tutorial_helpers.py
Save as hosted_tutorial_helpers.py
"""Bounded control flow shared by the hosted documentation exercises.

These helpers know only the documented run outcomes and SDK ontology methods.
They do not contact a service at import time.
"""

from __future__ import annotations

import asyncio
import json
import time
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any


async def poll_skill_run(
    fetch: Callable[[], Awaitable[dict[str, Any]]],
    *,
    timeout: float = 120.0,
    interval: float = 2.0,
    clock: Callable[[], float] = time.monotonic,
    sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> dict[str, Any]:
    """Wait through queued/running; return a documented outcome or fail closed."""
    if timeout <= 0 or interval <= 0:
        raise ValueError("timeout and interval must be positive")
    deadline = clock() + timeout
    while True:
        remaining = deadline - clock()
        if remaining <= 0:
            raise TimeoutError("Skill run did not settle before the tutorial deadline")
        run = await asyncio.wait_for(fetch(), timeout=remaining)
        outcome = run.get("outcome")
        if outcome in {"Created", "Withheld", "Failed"}:
            if outcome == "Created" and not run.get("skillId"):
                raise RuntimeError("Created run is missing its returned skillId")
            return run
        if outcome is not None or run.get("status") not in {"queued", "running"}:
            raise RuntimeError(f"Unrecognized skill run state: {run!r}")
        await sleep(min(interval, max(0.0, deadline - clock())))


def ontology_binding(active):
    """Capture authoritative IDs, mode and schema; never infer a latest revision."""
    values = {
        "version_id": active.version_id,
        "ontology_id": active.ontology_id,
        "revision": active.revision,
        "validation_mode": active.validation_mode,
    }
    if (
        not values["version_id"]
        or not values["ontology_id"]
        or type(values["revision"]) is not int
        or values["revision"] < 1
        or values["validation_mode"] not in {"permissive", "strict"}
    ):
        raise RuntimeError("No restorable authoritative active binding; no ontology was changed")
    values["document"] = active.document.model_dump(mode="json")
    return values


async def read_active_binding(http):
    """Read a restorable binding directly from the public REST response.

    SDK 0.7.0 get_active() reports the bound version but returns None metadata
    when a response has no version record (releases before 0.7.0 inferred it
    from the latest revision). This reader requires the actual binding returned
    by /ontologies/active, validated against the released public document
    model, and fails closed rather than substituting a list lookup.
    """
    from neo4j_agent_memory.nams import OntologyDocument

    response = await http.get("ontologies/active")
    response.raise_for_status()
    payload = response.json()
    version = payload.get("version") if isinstance(payload, dict) else None
    if (
        not isinstance(version, dict)
        or any(
            not isinstance(version.get(key), str) or not version[key].strip()
            for key in ("id", "ontology_id")
        )
        or type(version.get("revision")) is not int
        or version["revision"] < 1
        or version.get("validation_mode") not in {"permissive", "strict"}
    ):
        raise RuntimeError("No restorable authoritative active binding; no ontology was changed")

    def document(value):
        if isinstance(value, str):
            value = json.loads(value)
        return OntologyDocument.model_validate(value).model_dump(mode="json")

    try:
        active_document = document(payload.get("ontology"))
        version_document = version.get("schema_json")
        if version_document is not None and document(version_document) != active_document:
            raise ValueError("Active and version schemas differ")
    except (ValueError, TypeError) as exc:
        raise RuntimeError("Invalid or conflicting active ontology schema; retained state") from exc
    return {
        "version_id": version["id"],
        "ontology_id": version["ontology_id"],
        "revision": version["revision"],
        "validation_mode": version["validation_mode"],
        "document": active_document,
    }


def _version_binding(version):
    from types import SimpleNamespace

    if version.document is None:
        raise RuntimeError("Returned version has no inspectable schema; retained clone")
    return ontology_binding(
        SimpleNamespace(
            version_id=version.id,
            ontology_id=version.ontology_id,
            revision=version.revision,
            validation_mode=version.validation_mode,
            document=version.document,
        )
    )


async def _require_binding(read_active, expected):
    actual = await read_active()
    if actual != expected:
        raise RuntimeError("Active binding changed or readback differs; retained state and clone")
    return actual


async def _clone_absent(ontology, clone_id):
    from neo4j_agent_memory.core.exceptions import NotFoundError

    try:
        await ontology.get(clone_id)
    except NotFoundError:
        return True
    return False


async def verify_ontology_restoration(ontology, state, *, read_active):
    """Fresh, read-only verification of the saved binding and exact clone absence."""
    run = state.data.get("ontology", {})
    if not run.get("previous"):
        raise RuntimeError("State has no captured prior binding")
    await _require_binding(read_active, run["previous"])
    clone_id = run.get("clone_id")
    if not clone_id or not await _clone_absent(ontology, clone_id):
        raise RuntimeError("Clone absence is not verified; inspect or recover this run")
    if state.data.get("pending"):
        raise RuntimeError(
            "An operation remains unresolved; run recover before claiming completion"
        )
    print(f"Verified: original version {run['previous']['version_id']} and mode restored")
    print(f"Verified: exercise clone {clone_id} is absent")


async def recover_ontology(ontology, state, *, read_active):
    """Restore only a recognized binding, then delete the proven-owned clone.

    An interrupted clone with no returned ID cannot be reconciled automatically.
    A concurrent editor's binding is never replaced. There is no server-side
    compare-and-swap here, so the exercise requires exclusive ontology editing.
    """
    from neo4j_agent_memory.core.exceptions import NotFoundError

    run = state.data.get("ontology", {})
    previous = run.get("previous")
    if not previous:
        raise RuntimeError("State has no captured prior binding; no recovery writes were made")
    current = await read_active()
    if current != previous and current != run.get("strict"):
        raise RuntimeError("Active binding changed unexpectedly; retained state and clone")
    clone_id = run.get("clone_id")
    pending = state.data.get("pending")
    if not clone_id:
        if pending:
            raise RuntimeError(
                "Clone outcome is uncertain with no returned ID; inspect retained state"
            )
        # A prerequisite failed before the first write.
        print("Verified: original binding unchanged; no clone was created")
        return
    owned = state.data["resources"].get("ontology", {}).get(clone_id, {})
    if owned.get("created_by_run") is not True:
        raise RuntimeError("Clone ownership is not established; no recovery writes were made")
    if pending:
        if not isinstance(pending, dict) or pending.get("operation") not in {
            "clone",
            "update",
            "activate",
            "restore",
            "delete",
        }:
            raise RuntimeError("Unrecognized pending operation; inspect retained state")
        # Keep uncertainty recorded until deleting the known clone resolves all
        # its revisions. Never replay a clone or an update with an unknown result.
        run.setdefault("interrupted_operations", []).append(pending)
        state.finish()
    if current != previous:
        state.begin({"operation": "restore", "version_id": previous["version_id"]})
        await ontology.activate(previous["version_id"])
        await _require_binding(read_active, previous)
        state.finish()
    else:
        await _require_binding(read_active, previous)
    run["restored"] = True
    state.save()
    print(f"Restored active version: {previous['version_id']} ({previous['validation_mode']})")

    # Recheck immediately before deletion. This detects observed concurrent
    # changes; it cannot make an unversioned service operation atomic.
    await _require_binding(read_active, previous)
    if not await _clone_absent(ontology, clone_id):
        state.begin({"operation": "delete", "ontology_id": clone_id})
        try:
            await ontology.delete(clone_id)
        except NotFoundError:
            pass  # A retry still requires the independent GET below.
        if not await _clone_absent(ontology, clone_id):
            raise RuntimeError(f"Deletion not verified; retained clone {clone_id}")
        state.finish()
    state.record("ontology", clone_id, status="deleted")
    for version_id, entry in state.data["resources"].get("ontology_version", {}).items():
        if entry.get("ontology_id") == clone_id:
            entry["status"] = "deleted_with_ontology"
    run["deleted"] = True
    run["status"] = "complete"
    state.save()
    print(f"Deleted exercise clone: {clone_id} (absence verified)")


@asynccontextmanager
async def temporary_strict_ontology(ontology: Any, template: str, state, *, read_active):
    """Persist the restoration target before mutation and retain failed recovery."""
    if state.data.get("ontology") or state.data.get("pending"):
        raise RuntimeError("Ontology exercise already started; inspect or recover its state")
    previous = await read_active()
    catalog = await ontology.list()
    if not any(item.name == template and item.is_system for item in catalog):
        raise RuntimeError("Required system template is absent; no ontology was changed")
    existing_ids = {item.id for item in catalog}
    state.data["ontology"] = {"previous": previous, "template": template, "status": "started"}
    state.save()
    print(f"Previous active version: {previous['version_id']} ({previous['validation_mode']})")
    try:
        state.begin({"operation": "clone", "template": template})
        clone = await ontology.clone(template)
        run = state.data["ontology"]
        run["clone_id"] = clone.ontology_id
        state.record(
            "ontology", clone.ontology_id, created_by_run=clone.ontology_id not in existing_ids
        )
        state.record("ontology_version", clone.id, ontology_id=clone.ontology_id)
        state.finish()
        if clone.ontology_id in existing_ids:
            raise RuntimeError(
                "Clone response identifies a preexisting ontology; ownership is unknown"
            )
        if clone.document is None:
            raise RuntimeError("Clone has no inspectable schema")
        print(f"Exercise clone: {clone.ontology_id}")
        state.begin({"operation": "update", "ontology_id": clone.ontology_id})
        strict = await ontology.update(clone.ontology_id, clone.document, validation_mode="strict")
        state.record("ontology_version", strict.id, ontology_id=strict.ontology_id)
        run["strict"] = _version_binding(strict)
        state.finish()
        if strict.ontology_id != clone.ontology_id or strict.validation_mode != "strict":
            raise RuntimeError("Strict revision response differs from the requested clone and mode")
        await _require_binding(read_active, previous)
        state.begin({"operation": "activate", "version_id": strict.id})
        await ontology.activate(strict.id)
        await _require_binding(read_active, run["strict"])
        state.finish()
        print(f"Activated strict version: {strict.id}")
        yield strict
    except BaseException as exercise_error:
        state.data["ontology"]["exercise_error"] = type(exercise_error).__name__
        state.save()
        try:
            await recover_ontology(ontology, state, read_active=read_active)
        except BaseException as recovery_error:
            raise recovery_error from exercise_error
        raise
    else:
        await recover_ontology(ontology, state, read_active=read_active)
Save as ontology_quickstart.py
"""Activate and inspect a strict revision, then restore the exact prior binding."""

from __future__ import annotations

import argparse
import asyncio
import json
from pathlib import Path

from hosted_tutorial_helpers import (
    read_active_binding,
    recover_ontology,
    temporary_strict_ontology,
    verify_ontology_restoration,
)
from hosted_tutorial_state import TutorialState, http_client


async def exercise(client, state, *, read_active):
    async with temporary_strict_ontology(
        client.ontology, "healthcare", state, read_active=read_active
    ) as strict:
        labels = [item.label for item in strict.document.entity_types]
        print(f"Strict schema entity labels: {', '.join(labels)}")
        print("Verified: exact active revision, strict mode, and schema readback")


async def main(argv=None):
    from neo4j_agent_memory import NamsSettings, connect

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("command", choices=["seed", "inspect", "verify", "recover", "cleanup"])
    parser.add_argument("--state", type=Path, default=Path(".tutorial-state/ontology.json"))
    parser.add_argument(
        "--workspace-label", help="Operator-recorded workspace name/ID; not routing"
    )
    parser.add_argument("--workspace-owner", help="Operator responsible for resource disposition")
    args = parser.parse_args(argv)
    settings = NamsSettings()
    state = (
        TutorialState.create(
            args.state,
            settings,
            "ontology",
            workspace_label=args.workspace_label,
            workspace_owner=args.workspace_owner,
        )
        if args.command == "seed"
        else TutorialState.load(args.state, settings, "ontology")
    )
    if args.command == "inspect":
        print(json.dumps(state.inspect(), indent=2))
        return
    client = await connect(settings)
    try:
        async with http_client(settings) as http:

            async def read_active():
                return await read_active_binding(http)

            if args.command == "seed":
                await exercise(client, state, read_active=read_active)
            elif args.command == "verify":
                await verify_ontology_restoration(client.ontology, state, read_active=read_active)
            else:
                await recover_ontology(client.ontology, state, read_active=read_active)
    finally:
        await client.close()


if __name__ == "__main__":
    asyncio.run(main())

Before the first service operation, check the assembled files offline:

python -m py_compile \
    hosted_tutorial_state.py \
    hosted_tutorial_helpers.py \
    ontology_quickstart.py
python ontology_quickstart.py --help

Expected: compilation prints nothing and exits successfully; --help prints the command choices. Compilation catches syntax errors; the CLI check also loads the local imports. Neither command authenticates, contacts NAMS or creates tutorial state. Fix a missing or truncated file before continuing.

This exercise verifies activation and configuration. It creates no Patient or other entity and does not test unknown-label rejection. The Python entity adapter currently maps an unrecognized entity_type to custom, so passing an invented label through that API would not establish the intended ontology constraint. See Ontology API for the separate schema and version contracts.

Step 3: Activate, inspect and restore

python ontology_quickstart.py seed --state .tutorial-state/ontology.json \
  --workspace-label 'workspace name or ID from the dashboard' \
  --workspace-owner 'responsible workspace operator'

Check the reported IDs and milestones:

  1. Previous active version: shows the saved version and validation mode; Exercise clone: identifies the newly created ontology.

  2. Activated strict version: and Strict schema entity labels: describe that clone’s active revision. Verified: exact active revision, strict mode, and schema readback confirms the independent active readback.

  3. Restored active version: matches the saved prior version and mode.

  4. Deleted exercise clone: identifies only the created clone, with (absence verified) after its deletion check.

Illustrative successful output (placeholder IDs, not a live-service claim):

Previous active version: <original-version-id> (permissive)
Exercise clone: <new-ontology-id>
Activated strict version: <new-version-id>
Strict schema entity labels: ...
Verified: exact active revision, strict mode, and schema readback
Restored active version: <original-version-id> (permissive)
Deleted exercise clone: <new-ontology-id> (absence verified)

The clone ID identifies an ontology; the two version IDs identify specific revisions. Your original mode can be strict; restoration must match the saved mode as well as the version ID. Only the next fresh-process check establishes the later readback.

The command refuses to seed over an existing state file. A successful process exit requires the restoration and cleanup checks, not merely activation of the strict revision.

Step 4: Verify from a fresh process

python ontology_quickstart.py inspect --state .tutorial-state/ontology.json
python ontology_quickstart.py verify --state .tutorial-state/ontology.json

Inspect the saved resource disposition. The separate verify process reads the active binding and clone status without creating or activating anything. Its success establishes that the original version remains active and the lesson clone is absent at that readback. It does not restart the hosted service or prove that every strict-validation rule is enforced.

Recover an interrupted exercise

Retain the state file and inspect it before another attempt. With the same workspace credentials and exclusive use of the workspace, run:

python ontology_quickstart.py recover --state .tutorial-state/ontology.json
python ontology_quickstart.py verify --state .tutorial-state/ontology.json

Recovery uses the saved prior binding and exact created IDs. It reconciles interrupted activation, restoration or deletion when those IDs are known, and restores and verifies before deleting the clone. A clone with no returned ID, an unreconciled operation, an unexpected active binding or failed readback requires inspection by the workspace owner; do not reset the workspace, delete ontologies by a shared name, or remove the state file to suppress the failure. Keep the state until every created resource has a verified disposition.

Inspect locally after a credential change

If the original key expires or is rotated, normal lesson commands keep refusing to use its saved IDs with another credential. Use this separate local command:

python hosted_tutorial_state.py inspect-file .tutorial-state/ontology.json

It needs no key and makes no service requests. The summary omits credential fingerprints, message digests, fixture metadata and review/provenance payloads. It still contains resource IDs and operator labels, so keep it private. Its local_only: true and service_checked: false mean it reports saved evidence, not current remote state or authorization.

Give the authorized owner the workspace label, endpoint, run token, exact resource IDs and pending operation through your agreed private channel. The owner must establish the actual workspace and reconcile those resources with a valid credential. Do not edit the identity hash, substitute a key in the ledger, or remove state to unlock a write. The helper cannot migrate a run to a new key or supply an administrative deletion route.

For this lesson, the summary also has an ontology section. Its previous entry is the binding to restore: the version ID to reactivate and its validation mode. clone_id and strict identify the exercise clone and its strict revision. Give the owner that section with the resource IDs; schema documents are omitted.

Keep the old run and start a separate exercise

See Recover from an interrupted tutorial run to check the recorded state before reseeding. For a pending or uncertain mutation with known IDs, prefer the recover command above over owner reconciliation.

For a prerequisite failure before the first remote write, the new ledger’s remote_write_started: false, empty resources and absent pending operation establish that this program did not attempt a mutation. An empty ledger alone is not enough. Older empty ledgers without that marker, a lost returned ID, or any uncertain write fail the check and require owner inspection.

After correcting the prerequisite, choose this unused run directory. The first command only reads the old ledger and checks the new path. It preserves the original file and creates nothing. && prevents seed if that check fails:

python hosted_tutorial_state.py check-new-run .tutorial-state/ontology.json \
  --next-state .tutorial-state/ontology-run-2/state.json &&
python ontology_quickstart.py seed \
  --state .tutorial-state/ontology-run-2/state.json \
  --workspace-label 'workspace name or ID from the dashboard' \
  --workspace-owner 'responsible workspace operator'

Expected before seed: Recorded new-run check: no_remote_write_started for an unstarted run, or recorded_disposition_complete for fully disposed records. This is a check of saved evidence, not a new service verification. The seed still checks its own prerequisites and refuses an existing target state file. Use the new --state path for every later command; reports and archives are written alongside that new state. Never copy the old IDs into the new ledger.

What we learned

An ontology document, its revisions and the workspace’s active binding are separate concepts. The saved active binding, which may be older than the latest revision, defines what the exercise restores. Cleanup is complete only after restoration and removal of the created clone are checked.

Continue with a full lifecycle

The larger ontology lifecycle example covers import, revision diff and migration. Review its separate prerequisites and cleanup before running it. See Use ontologies for those tasks, Ontologies for the underlying concepts, or continue to distilling a skill after satisfying that lesson’s empty-workspace and resource-disposition requirements.

To work with an ontology on your own Neo4j database instead, the ontology extraction example runs on Bolt without API keys: it creates and activates an ontology, ingests messages with alias variation, and reads the typed relationships back. The ontology-driven extraction guide describes that workflow.