Skip to content
Stribog

Sovereign AI

All writing

AI Governance Best Practices: Inventory What Is Running

AI governance best practices say to inventory your AI systems. On self-hosted GPUs the serving API returns a name, not a digest, so the inventory is a diff.

Stribog19 min read

Search this phrase and the results agree to an unusual degree: name an owner, classify by risk tier, review on a cadence, maintain an inventory. Where discovery appears at all it is procurement registers, SaaS scanners and survey emails — mechanisms built for an organisation that buys AI. None enumerates a GPU node. The question starts where you run the inference tier yourself: given weights on disk, how do you produce a list you would sign, and how do you know a week later it is still true?

The instruction every framework gives, and the step none of them shows

The instruction is specific. NIST's AI Risk Management Framework states it as a subcategory under GOVERN: "Mechanisms are in place to inventory AI systems and are resourced according to organizational risk priorities." The Playbook is more concrete — "An AI system inventory is an organized database of artifacts relating to an AI system or model" — and closes the obvious escape hatch: "Typically inventories capture all organizational models or systems, as partial inventories may not provide the value of a full inventory."

The framework is voluntary; the obligation is not universally so. OMB Memorandum M-25-21, which "rescinds and replaces" M-24-10, requires that "Each agency (except for the Department of Defense and the Intelligence Community) must inventory its AI use cases at least annually, submit the inventory to OMB, and post a public version on the agency's website." That binds US federal agencies, not private companies — evidence that a regulator treats inventory as a control, and nothing more.

Between the instruction and the artefact sits a step nobody writes down. "Mechanisms are in place to inventory" is satisfiable with a spreadsheet when every AI system in scope arrived through a contract — procurement did the enumeration. When they arrived as a git clone, a model download and a Helm release, there is nothing to copy. The mechanism has to go and look.

One model, four names, and no shared identity

Start with the layer most inventories key on, the one the API hands you. vLLM's serve documentation describes --served-model-name as "The model name(s) used in the API… If not specified, the model name will be the same as the --model argument." The name is an operator's choice of string: two clusters can serve identical weights under different names, one cluster can serve different weights under the same name across a redeploy, and nothing in the API notices.

The omission is load-bearing. Read from vLLM main on 17 September 2026, the ModelCard object has eight fields: id, object, created, owned_by, root, parent, max_model_len and permission. No digest, no checksum, no revision. The serving code populates a base model's card with id=base_model.name and root=base_model.model_path, and lists each loaded LoRA adapter as its own card with id=lora.lora_name, root=lora.path and parent naming the base model. The response gives a label, a path and an adapter's lineage — not which bytes are behind the path. These are internals of a fast-moving project, not a stable contract; check them against your version.

Ollama answers a narrower question better. GET /api/tags will "List models that are available locally", returning a digest per entry; a separate GET /api/ps will "List models that are currently loaded into memory", returning digest, expires_at and size_vram. That split is the distinction an inventory needs — installed versus resident — and both halves carry content identity. vLLM has no residency endpoint, so residency there is inferred from the process and the path it mounted: weaker evidence, and it should be recorded as such. If you run both, the concurrency line between vLLM and Ollama draws a line through how far your inventory can trust each one.

Underneath both sits the Hugging Face Hub cache, which resolves identity a third way. Its documentation: "The blobs folder contains the actual files that we have downloaded. The name of each file is their hash." Above that, "The snapshots folder contains symlinks to the blobs mentioned above. It is itself made up of several folders: one per known revision!" And above that sits a pointer that moves — if a branch "gets updated with a new commit, that has bbbbbb as an identifier, then re-downloading a file from that reference will update the refs/main file to contain bbbbbb."

Four layers, one identity. The API label is a flag; the runtime tag is a mutable pointer; the revision is a commit reached through an unstable reference. Only the blob hash answers "which bytes" — and most inventories key on the label.

The serving API answers at the top layer; the governance question is asked at the bottom one.

Enumerating what is actually resident

The observed set is collected, never declared. Two passes produce it: a sweep of every serving endpoint, then a resolution pass turning each path into a content identity. Keep them separate — the sweep runs against live endpoints, the resolution on the node holding the cache. Conflating them produces a script that works on a laptop and fails on a cluster.

The sweep normalises four response shapes into one record per served entry, marking which field is an identity and which is only a label. A null identity is not a script failure; it states that the runtime did not say. Key the observed set by identity, not record count: a resident Ollama model appears in both /api/ps and /api/tags — one model in two states, not two models.

bash
#!/usr/bin/env bash
# Sweep every serving endpoint and emit one normalised record per entry.
# identity: a content digest, when the runtime provides one. null when it does not.
set -euo pipefail

VLLM_ENDPOINTS="${VLLM_ENDPOINTS:-}"      # space-separated base URLs
OLLAMA_ENDPOINTS="${OLLAMA_ENDPOINTS:-}"
OBSERVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

fetch() {
  curl --fail --silent --show-error --max-time 10 "$1"
}

for base in ${VLLM_ENDPOINTS}; do
  # id is --served-model-name, root is the model path, parent is set for adapters.
  # There is no digest field to read, so identity stays null by construction.
  fetch "${base}/v1/models" | jq -c --arg ep "$base" --arg at "$OBSERVED_AT" '
    .data[] | {
      endpoint:    $ep,
      runtime:     "vllm",
      observed_at: $at,
      label:       .id,
      locator:     .root,
      parent:      .parent,
      kind:        (if .parent == null then "base" else "adapter" end),
      residency:   "inferred",
      identity:    null
    }'
done

for base in ${OLLAMA_ENDPOINTS}; do
  # /api/ps is what is loaded now; /api/tags is everything on disk. Both carry a digest.
  fetch "${base}/api/ps" | jq -c --arg ep "$base" --arg at "$OBSERVED_AT" '
    .models[] | {
      endpoint: $ep, runtime: "ollama", observed_at: $at,
      label: .model, locator: .model, parent: null,
      kind: "resident", residency: "reported",
      identity: .digest, vram_bytes: .size_vram
    }'

  fetch "${base}/api/tags" | jq -c --arg ep "$base" --arg at "$OBSERVED_AT" '
    .models[] | {
      endpoint: $ep, runtime: "ollama", observed_at: $at,
      label: .model, locator: .model, parent: null,
      kind: "installed", residency: "on-disk",
      identity: .digest
    }'
done
observe-models.sh — collect the observed set as JSON Lines, one object per served entry.

The resolution pass closes that gap in the sweep's own schema — a second file in a second shape leaves the reconciler matching null for every vLLM model. For each record the runtime could not identify it takes the locator and walks the cache: references, then snapshots, then blobs. Point --model at a snapshot directory and it names the revision the process opened; pass a repo id and the resolver reads refs/main now, which may already have moved past the resident weights — so it labels that record rather than trusting it. Handle the no-symlink fallback too: on Windows and some shared filesystems the files are plain copies, and a resolver assuming symlinks reports nothing on exactly the storage a multi-node cluster likeliest uses.

python
#!/usr/bin/env python3
"""Fill in the identity the serving API could not report.

Reads the sweep's JSON Lines on stdin and writes the same records back out.
Every entry whose runtime supplied no digest keeps its schema and gains a
resolved one, so the reconciler downstream sees a single shape.
"""

from __future__ import annotations

import hashlib
import json
import os
import sys
from pathlib import Path

WEIGHT_SUFFIXES = (".safetensors", ".bin", ".gguf", ".pt", ".onnx")


def cache_root() -> Path:
    if "HF_HUB_CACHE" in os.environ:
        return Path(os.environ["HF_HUB_CACHE"])
    home = Path(os.environ.get("HF_HOME", Path.home() / ".cache" / "huggingface"))
    return home / "hub"


def content_identity(path: Path) -> str:
    """Prefer the cache's own naming: a blob is stored under its hash.

    Where the no-symlink fallback is in use there is no blobs/ indirection,
    so the file is hashed directly rather than reported as missing.
    """
    target = path.resolve()
    if target.parent.name == "blobs":
        return f"blob:{target.name}"
    digest = hashlib.sha256()
    with target.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return f"sha256:{digest.hexdigest()}"


def weights(root: Path) -> dict[str, str]:
    """Every weight file in the tree, nested shards and subfolders included."""
    return {
        str(f.relative_to(root)): content_identity(f)
        for f in sorted(root.rglob("*"))
        if f.is_file() and f.name.endswith(WEIGHT_SUFFIXES)
    }


def resolve(locator: str, root: Path) -> dict[str, object] | None:
    """A vLLM root is either a path on disk or a repo id to look up in the cache.

    The two are not equally trustworthy. A path is the directory the process
    actually opened. A repo id is resolved through refs/main now, which may
    already point past the commit whose weights are resident -- so that branch
    is labelled, and a mismatch on a refs-main record is a stale reference
    rather than tampering.
    """
    path = Path(locator)
    if path.is_dir():
        revision = path.name if path.parent.name == "snapshots" else None
        return {"revision": revision, "weights": weights(path), "source": "snapshot-path"}
    # References, then snapshots, then blobs -- the order the cache is built.
    entry = root / ("models--" + locator.replace("/", "--"))
    ref = entry / "refs" / "main"
    if not ref.is_file():
        return None
    revision = ref.read_text().strip()
    snapshot = entry / "snapshots" / revision
    if not snapshot.is_dir():
        return None
    return {"revision": revision, "weights": weights(snapshot), "source": "refs-main"}


def join_key(resolved: dict[str, object]) -> str:
    """The commit when the cache knows one, otherwise the bytes themselves."""
    if resolved["revision"]:
        return f"rev:{resolved['revision']}"
    manifest = json.dumps(resolved["weights"], sort_keys=True).encode()
    return f"files:{hashlib.sha256(manifest).hexdigest()}"


def main() -> int:
    root = cache_root()
    for line in sys.stdin:
        if not line.strip():
            continue
        record = json.loads(line)
        if record.get("identity") is None and record.get("locator"):
            resolved = resolve(record["locator"], root)
            if resolved is not None:
                record["revision"] = resolved["revision"]
                record["weights"] = resolved["weights"]
                record["identity"] = join_key(resolved)
                record["identity_source"] = resolved["source"]
        print(json.dumps(record, sort_keys=True))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
resolve_identity.py — read the sweep, resolve each unidentified locator, emit the same records with an identity.
json
{
  "endpoint": "http://vllm-chat-large.inference:8000",
  "runtime": "vllm",
  "observed_at": "2026-09-17T06:00:00Z",
  "label": "chat-large",
  "locator": "/hf-cache/hub/models--meta-llama--Llama-3.3-70B-Instruct/snapshots/c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff",
  "parent": null,
  "kind": "base",
  "residency": "inferred",
  "revision": "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff",
  "weights": {
    "model-00001-of-00002.safetensors": "blob:ba5eba11ba5eba11ba5eba11ba5eba11ba5eba11ba5eba11ba5eba11ba5eba11",
    "model-00002-of-00002.safetensors": "blob:d15ea5edd15ea5edd15ea5edd15ea5edd15ea5edd15ea5edd15ea5edd15ea5ed"
  },
  "identity": "rev:c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff",
  "identity_source": "snapshot-path"
}
One record after resolution: the vLLM entry from the sweep, same fields, identity no longer null. Pretty-printed here; the script emits one line.

One more tool belongs in this pass. The Hub CLI can "verify that your cached files match the checksums on the Hub" via hf cache verify, for a specific revision of a specific repository. That is the difference between recording a hash you computed and asserting it matches what upstream published — a distinction an assessor cares about, for one command. It assumes upstream is reachable; on an air-gapped cluster the check moves to the point of import and its result travels with the weights.

The declared set belongs in an ML-BOM, not a spreadsheet

The declared set is the other half of the diff: the models you intend to run, with the facts a governance question asks about them. It needs a schema, tooling, and no owner who can charge you to read your own inventory. CycloneDX qualifies. Its 1.6 schema lists machine-learning-model among the permitted values of component.type, glossed as "A model based on training data that can make predictions or decisions without being explicitly programmed to do so", and states of the model card object that it "SHOULD be specified for any component of type machine-learning-model and must not be specified for other component types". The current specification is 1.7 with both unchanged, so 1.6 stays a safe baseline.

Be exact about what this buys. No regulator named here requires an ML-BOM, and emitting one discharges no obligation under the EU AI Act, ISO/IEC 42001 or the NIST AI RMF. It holds the inventory in a format with validators, diff tools and parsers already — what makes a reconciler a hundred lines instead of a project. The argument that puts a software bill of materials under signature and admission control applies here unchanged.

json
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.7",
  "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
  "version": 1,
  "metadata": {
    "timestamp": "2026-09-17T06:00:00Z",
    "component": {
      "type": "application",
      "bom-ref": "inference-platform",
      "name": "inference-platform",
      "version": "2026.09.17"
    }
  },
  "components": [
    {
      "type": "machine-learning-model",
      "bom-ref": "model:chat-large",
      "name": "Llama-3.3-70B-Instruct",
      "version": "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff",
      "publisher": "Meta Platforms, Inc.",
      "description": "Served by vLLM as --served-model-name chat-large.",
      "licenses": [
        {
          "license": {
            "name": "Llama 3.3 Community License Agreement",
            "url": "https://github.com/meta-llama/llama-models/blob/main/models/llama3_3/LICENSE"
          }
        }
      ],
      "components": [
        {
          "type": "file",
          "bom-ref": "model:chat-large:shard-1",
          "name": "model-00001-of-00002.safetensors",
          "hashes": [{ "alg": "SHA-256", "content": "ba5eba11ba5eba11ba5eba11ba5eba11ba5eba11ba5eba11ba5eba11ba5eba11" }]
        },
        {
          "type": "file",
          "bom-ref": "model:chat-large:shard-2",
          "name": "model-00002-of-00002.safetensors",
          "hashes": [{ "alg": "SHA-256", "content": "d15ea5edd15ea5edd15ea5edd15ea5edd15ea5edd15ea5edd15ea5edd15ea5ed" }]
        }
      ],
      "externalReferences": [
        {
          "type": "distribution",
          "url": "https://huggingface.co/meta-llama/Llama-3.3-70B-Instruct"
        }
      ],
      "properties": [
        { "name": "inventory:servedName", "value": "chat-large" },
        { "name": "inventory:runtime", "value": "vllm" },
        { "name": "inventory:revisionRef", "value": "refs/main" },
        { "name": "inventory:jurisdiction", "value": "eu-central" },
        { "name": "inventory:logRetentionMonths", "value": "12" },
        { "name": "inventory:redistribution", "value": "internal-only" },
        { "name": "inventory:attributionRequired", "value": "Built with Llama" }
      ],
      "modelCard": {
        "modelParameters": {
          "task": "text-generation",
          "architectureFamily": "transformer"
        },
        "considerations": {
          "useCases": ["Internal engineering assistant"],
          "technicalLimitations": ["Not evaluated for clinical or legal advice"]
        }
      }
    }
  ]
}
inventory/ml-bom.json — the declared set. Every value here is illustrative, digests and revision most obviously; the shape is what the reconciler reads.

Two fields carry identity here, and neither is the name. version holds the commit the resolved record's rev: identity joins against; the nested file components hold one hash per weight file, which is what the straddle case below needs and a model-level digest cannot express. Both sides are per file, so the reconciler compares that list against the resolved record's weights map — after stripping the blob: or sha256: prefix the resolver attaches, because CycloneDX hashes[].content is bare hex and a literal compare would report a mismatch on every byte-identical file. Note what properties is doing. Jurisdiction, log retention, redistribution status and attribution obligations are not technical facts about the weights but commitments about how it may be used, and they belong next to the hashes, where someone looks when they matter. A separate article works through which artefacts the governance regimes ask for; this document is the fleet-level index above it.

The diff is the control, and it runs on deploy

A reconciler compares declared against observed and sorts each model into one of four outcomes. The classification is the control; the action attached to each is the policy decision, worth making once in writing rather than at 2am.

  • Match — declared identity equals observed identity. Pass, and store the run. A control that only produces output when something is wrong cannot prove it ran.
  • Unknown model present — served, but in no declared component. Quarantine the endpoint: the shadow-model case, and the one the control exists to catch.
  • Declared model absent — in the BOM, not on the fleet. Alert rather than fail; the usual cause is a stale BOM after a decommission, and failing deploys over it teaches people to disable the check.
  • Digest mismatch — the served name resolves to different bytes than declared. Fail the deploy. Nothing else in the pipeline notices, because every name still matches.

It runs in two places, and not as the same job. In CI it gates a change: the declared set or the deployment was edited, and the question is whether they agree before rollout. On a schedule it catches what happens between deploys — a model pulled onto a node by hand, a tag that moved, an adapter loaded at runtime. The deploy-time check measures your pipeline; the scheduled run measures your fleet.

yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: model-inventory-reconcile
  namespace: inference
spec:
  schedule: "*/30 * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 10
  jobTemplate:
    spec:
      backoffLimit: 0
      template:
        spec:
          restartPolicy: Never
          serviceAccountName: model-inventory
          containers:
            - name: reconcile
              image: registry.internal.example/model-inventory:2026.09.17
              args:
                - --declared=/declared/ml-bom.json
                - --evidence-out=/evidence
                - --report-stdout          # last line of stdout is the report
                - --on-unknown=quarantine
                - --on-missing=alert
                # Exits non-zero after writing the report, so a mismatch shows
                # up in failedJobsHistoryLimit instead of a Complete Job.
                - --on-digest-mismatch=fail
              env:
                - name: VLLM_ENDPOINTS
                  value: "http://vllm-chat-large.inference:8000 http://vllm-embed.inference:8000"
                - name: OLLAMA_ENDPOINTS
                  value: "http://ollama.inference:11434"
                # Without this the resolution pass has no cache to walk and
                # every vLLM record stays unidentified.
                - name: HF_HUB_CACHE
                  value: /hf-cache/hub
              securityContext:
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                runAsNonRoot: true
              volumeMounts:
                - name: declared
                  mountPath: /declared
                  readOnly: true
                - name: evidence
                  mountPath: /evidence
                - name: hf-cache
                  mountPath: /hf-cache
                  readOnly: true
          volumes:
            - name: declared
              configMap:
                name: model-inventory-declared
            - name: evidence
              persistentVolumeClaim:
                claimName: model-inventory-evidence
            - name: hf-cache
              persistentVolumeClaim:
                claimName: hf-hub-cache   # the ReadOnlyMany claim the vLLM pods mount
The scheduled half: a CronJob that sweeps the fleet between deploys. Its image wraps the sweep and the resolution pass above and prints the reconciliation report as its last line of stdout.

Where it runs is a constraint, not a detail. The resolution pass reads the Hub cache, so the pod must mount it: a shared ReadOnlyMany claim where nodes share one, a DaemonSet rather than a CronJob where each keeps its own — a CronJob pod lands on one node and sees only that node's disk. A CI runner has neither the cache nor a route to the pod network, which is why the gate below starts that job instead of repeating it.

yaml
name: model-inventory
on:
  pull_request:
    paths:
      - "inventory/ml-bom.json"
      - "deploy/**"
  push:
    branches: [main]

jobs:
  reconcile:
    runs-on: self-hosted
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7

      - name: Validate the declared set against the CycloneDX schema
        run: cyclonedx-cli validate --input-file inventory/ml-bom.json --fail-on-errors

      - name: Publish the declared set the in-cluster job will read
        run: |
          kubectl -n inference create configmap model-inventory-declared \
            --from-file=ml-bom.json=inventory/ml-bom.json \
            --dry-run=client -o yaml | kubectl apply -f -

      - name: Run the scheduled job now, unchanged
        run: |
          # Same image, same endpoints, same cache mount -- the CronJob owns all
          # three. The runner holds none of them and must not re-implement them.
          job="model-inventory-ci-$GITHUB_RUN_ID"
          kubectl -n inference create job "$job" \
            --from=cronjob/model-inventory-reconcile
          until kubectl -n inference get job "$job" \
                  -o jsonpath='{.status.conditions[*].type}' | grep -Eq 'Complete|Failed'; do
            sleep 10
          done
          kubectl -n inference logs "job/$job" > job.log
          # A Failed job still printed its report -- the mismatch exit happens
          # after the write. Keep the evidence before acting on the condition.
          tail -n 1 job.log > reconcile-report.json
          kubectl -n inference get job "$job" \
            -o jsonpath='{.status.conditions[*].type}' | grep -q Complete \
            || { tail -n 200 job.log >&2; exit 1; }

      - name: Gate on the report
        run: |
          # Fails closed. An empty observed set is a broken collector, not a
          # clean fleet, and it diffs clean against any BOM.
          jq -e '.observed.endpoints_failed == []
             and (.observed.records | length) > 0
             and .classified.unknown == []
             and .classified.digest_mismatch == []' reconcile-report.json > /dev/null

      - name: Keep the diff, pass or fail
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: model-inventory-reconcile
          path: reconcile-report.json
The deploy-time half: the runner starts the scheduled job, then gates on its report.
Declared set, observed set, and a diff that runs on every deploy and on a schedule between them.

Drift that survives a green pipeline

A naive reconciler reports clean fleets. Four failure modes get past it, each a design decision rather than a later bug.

The moving tag. refs/main is a pointer, and the cache documentation says plainly that re-downloading from that reference rewrites the file to the new commit. An inventory recording revision: main records nothing. Pin the declared version to a commit identifier and let the reconciler report when the reference moves away from it — that event is information, not noise.

The download that straddled two commits. Upstream's own warning: when a library downloads files one at a time, "two calls made a few seconds apart can land on two different commits if the repo is updated in between." The result is a snapshot directory whose files came from different revisions. Every path resolves, the model loads, and the per-file hashes match no single published revision. That is what hf cache verify is for, and why the BOM carries a hash per weight file: the mismatch is visible only file by file.

The reused served name. --served-model-name chat-large is stable across a weights change by design — that is the point of the flag, and why clients survive a model swap. It is also why a name-keyed inventory cannot detect one. If the reconciler compares labels, this case is invisible and every check passes.

Adapters nobody declared. vLLM lists loaded LoRA adapters as their own /v1/models entries, with parent naming the base model. An adapter is a model for governance purposes: it changes behaviour, carries its own training-data lineage, and is usually produced in-house — so it has no upstream digest and no model card unless the fine-tuning pipeline emits one. Treat adapter entries as first-class components with a parent relationship, or the inventory describes a base model that is no longer what answers.

Fields the inventory carries because law or licence asks

Every non-technical column should trace to a specific obligation, or it is a governance template pretending to be a control. Three worked examples, scope stated rather than implied.

The EU AI Act defines a deployer as "a natural or legal person, public authority, agency or other body using an AI system under its authority except where the AI system is used in the course of a personal non-professional activity" — most organisations running inference. Article 26 attaches duties, and the scope qualifier matters: they bind deployers of high-risk systems, which most self-hosted assistants and internal retrieval tools are not. Where they apply, Article 26(5) requires deployers to "monitor the operation of the high-risk AI system on the basis of the instructions for use", and Article 26(6) to keep automatically generated logs under their control "for a period appropriate to the intended purpose of the high-risk AI system, of at least six months". A retention figure is a legal parameter, not an ops preference — read what actually binds a deployer first.

Model licences carry obligations into production. The Llama 3.3 Community License requires a redistributor to "provide a copy of this Agreement" and to "prominently display 'Built with Llama' on a related website, user interface, blogpost, about page, or product documentation" — an attribution duty on a product surface, so it needs a field and an owner, not a wiki note. The same licence carries a version-scoped scale trigger: if monthly active users exceeded 700 million in the calendar month preceding "the Llama 3.3 version release date", a separate licence from Meta is required. Most are nowhere near it; it belongs in the inventory because you cannot evaluate an unrecorded threshold.

Google's Gemma Terms of Use — covering that page's Appendix, Gemma 3 among them, while Gemma 4 sits under a separate licence — impose a pass-through duty on redistributors. A distributor "must include the use restrictions referenced in Section 3.2 as an enforceable provision in any agreement… governing the use and/or distribution" and "must provide all third party recipients of Gemma or Model Derivatives a copy of this Agreement". Version the licence field: obligations differ by version, and the family name attaches the wrong ones.

One correction belongs here. Writing about Llama 2 in 2023, the Open Source Initiative said Meta's licence "does not meet this standard; specifically, it puts restrictions on commercial use for some users… and also restricts the use of the model and software for certain purposes". Its February 2025 follow-up calls Llama 3.x "still not Open Source by any stretch of the imagination" — not 3.3 by name, but the clauses above are those same restrictions. Llama 3.3 and the Gemma versions in that Appendix are open-weights, not open-source, and an inventory recording "open source: yes" against them holds a falsehood a procurement questionnaire will surface. Record the licence name and let the obligations follow.

The exit ramp: an inventory someone else can read

Holding this in an open BOM format rather than a governance platform is not ideology but the shape of the dependency. Evidence outlives the tool that produced it: an assessor may ask about a model you stopped serving eighteen months ago, and a platform you have left cannot answer. A CycloneDX document in git answers from a clone.

The same logic governs the reconciler: it reads two documented HTTP APIs and a documented on-disk layout, so the sweep is replaceable in an afternoon when a runtime changes. If a platform later does this better, the BOM imports into it — an inventory you can leave with is an inventory you own. The clause-to-artefact mapping for ISO/IEC 42001 argues the same from the certification side.

The long game

Runtimes will change. vLLM's internals moved between releases while this was written; Ollama's documentation is migrating; the Hub cache layout already has a fallback mode and will acquire more. Regimes will change too — the AI Act's obligations phase in, ISO/IEC 42001 will be revised, and whatever replaces the current framework guidance will still say "maintain an inventory" without saying how.

What survives is the shape: a declared set under version control, an observed set collected from the systems themselves, and a scheduled comparison producing a dated artefact whether or not anything is wrong. Swapping vLLM changes one collector; swapping regimes changes which properties the components carry. The loop does not move, and it is the only part of an AI governance programme that can tell you, on a specific Tuesday, which bytes answered a question.

§FAQ/Common questions

Frequently asked

How does maintaining an AI inventory support responsible governance?

It converts every other control from an assertion into something testable. Risk classification, evaluation evidence, licence compliance and log retention all attach to a specific model version; without an enumeration of what is actually running, each of those is a statement about a model you believe is deployed. NIST's AI RMF Playbook also notes that partial inventories may not deliver the value of a full one, which is why the enumeration has to be automated rather than surveyed.

Why can a served model name not be used as the inventory key?

Because it is an operator-chosen string. vLLM's --served-model-name sets the name used in the API and defaults to the --model argument, so the same name can front different weights across a redeploy and different names can front identical weights on two clusters. Key the inventory on a content identity — a repository revision plus per-file hashes — and keep the served name as a property of the component.

What does vLLM's /v1/models endpoint actually tell you?

Read from vLLM main on 17 September 2026, its ModelCard object carries id, object, created, owned_by, root, parent, max_model_len and permission. The id is the served name, root is the model path, and parent names the base model for a LoRA adapter entry. There is no digest, checksum or revision field, so the endpoint identifies a served label and a path, not the weights behind it.

Does emitting a CycloneDX ML-BOM satisfy the EU AI Act or ISO 42001?

No. No regulator requires an ML-BOM, and producing one discharges no obligation under the EU AI Act, ISO/IEC 42001 or the NIST AI RMF. Its value is practical: machine-learning-model is a defined component type in the CycloneDX schema, so the inventory lives in a format with existing validators and diff tooling, and stays portable if the tooling changes.

How often should the reconciliation run?

On every deploy as a gate, and on a schedule between deploys. The deploy-time run measures whether a change agrees with the declared set. Only the scheduled run catches drift that never went through the pipeline — a model pulled onto a node by hand, an adapter loaded at runtime, or a refs/main pointer that has moved since the last rollout.

ai governance best practicesai model inventoryhow does maintaining an ai inventory support responsible governanceai governance standardsai governance policynist ai rmf govern 1.6

Executive Briefing

Thirty minutes to clarify your infrastructure risk

Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.