Skip to content

Models

Core domain models for the Imbi ecosystem.

Overview

The models module provides Pydantic models for all core domain entities (projects, organizations, teams) and the blueprint system for dynamic schema extension.

Model Categories

Domain Models

  • Organization: Top-level organizational units
  • Team: Groups within organizations
  • Environment: Deployment environments (production, staging, etc.)
  • ProjectType: Project categorization and templates
  • Project: Services and applications
  • TagFormat: A named (label) regular-expression (pattern) policy for release/deploy tags. Both Organization and ProjectType carry a tag_formats: list[TagFormat] field (see Release/Deploy Tag Formats).

Software-Composition Models

  • Component: Third-party package identity (purl with version stripped, e.g. pkg:npm/express)
  • ComponentRelease: A specific version of a Component (Component-[:HAS_RELEASE]->ComponentRelease); attached to a project Release via [:USES_COMPONENT_RELEASE]
  • ComponentIdentifier: Globally unique (kind, value) pair (purl / cpe / bom-ref / swid) linked to a Component via [:IDENTIFIED_BY]

Collaboration Models

  • Document: A free-form, taggable markdown document attached to exactly one owning vertex via [:ATTACHED_TO] — a Project, a ProjectType, or a User (the User vertex is defined by imbi-api, so only the project and project-type edges are typed on the model).
  • DocumentTemplate: Reusable starter content for a Document. type declares which attachment contexts may use the template: project, user, project_type, or global (every context); project_type_slugs optionally narrows project types further.
  • CommentThread: A thread of comments anchored to a Document via [:ON_DOCUMENT]. kind is page (whole-document) or inline (text-anchored); inline anchors are flattened into the anchor_quote / anchor_prefix / anchor_suffix / anchor_start scalar properties.
  • Comment: A single comment within a CommentThread (Comment-[:IN_THREAD]->CommentThread); carries mentions and acknowledged_by email lists.

Blueprint Models

  • Blueprint: Dynamic schema definitions
  • BlueprintAssignment: Blueprint-to-entity relationships

Analytics Models

These are not graph nodes — they are typed rows published to Apache Iggy via iggy.publish and written to ClickHouse by the sink. They are provider-agnostic so any version-control plugin (GitHub, GitLab, …) can reuse them. - CommitRecord: A VCS commit, written to the commits table (ReplacingMergeTree keyed by (project_id, sha)) - TagRecord: A VCS tag, written to the tags table (ReplacingMergeTree keyed by (project_id, name)) - ReleaseComponentRecord: One SBoM dependency of a release, written to the release_components table - ReleaseComponentBatch: The record publishing one release's component snapshot, written to release_component_batches after every fact row of that batch

Basic Usage

from imbi.common import graph, models

# Create an organization
org = models.Organization(
    name="My Company",
    slug="my-company",
    description="Our organization"
)
await db.create(org)

# Create a team linked to an organization
team = models.Team(
    name="Platform Team",
    slug="platform-team",
    description="Infrastructure and platform",
    organization=org
)
await db.create(team)

Release/Deploy Tag Formats

The tags accepted when cutting a release or promoting a deployment are governed by a list of TagFormat policies. A tag is accepted when it matches any configured format (full-string regex match).

Resolution is hierarchical, project-type overriding organization:

  1. If the project's type(s) configure tag_formats, those apply.
  2. Otherwise the organization's tag_formats apply.
  3. If neither configures any, no restriction is imposed (any tag is accepted). Seed models.SEMVER_TAG_FORMAT to require semver.
from imbi.common import models, versioning

org = models.Organization(
    name="My Company",
    slug="my-company",
    tag_formats=[models.SEMVER_TAG_FORMAT],
)

patterns = [fmt.pattern for fmt in org.tag_formats]
versioning.matches_tag_formats("v1.2.3", patterns)  # True
versioning.matches_tag_formats("nightly", patterns)  # False

matches_tag_formats

matches_tag_formats(
    tag: str, patterns: Sequence[str]
) -> bool

Return True when tag satisfies the configured tag formats.

patterns is the resolved list of regular-expression patterns for the project (see imbi.common.models.TagFormat). Each pattern is matched against the whole tag with :func:re.fullmatch, so a pattern need not anchor itself with ^/$.

An empty patterns sequence means "no configured policy" and matches any tag -- callers that need a stricter default should seed a format (e.g. :data:SEMVER_TAG_PATTERN) rather than relying on this.

Invalid patterns are rejected at write time (TagFormat validates them), so a bad pattern here is treated as a non-match rather than raising.

Source code in libraries/common/src/imbi/common/versioning.py
def matches_tag_formats(
    tag: str,
    patterns: typing.Sequence[str],
) -> bool:
    """Return ``True`` when *tag* satisfies the configured tag formats.

    *patterns* is the resolved list of regular-expression patterns for
    the project (see ``imbi.common.models.TagFormat``).  Each pattern is
    matched against the whole *tag* with :func:`re.fullmatch`, so a
    pattern need not anchor itself with ``^``/``$``.

    An **empty** *patterns* sequence means "no configured policy" and
    matches any tag -- callers that need a stricter default should seed a
    format (e.g. :data:`SEMVER_TAG_PATTERN`) rather than relying on this.

    Invalid patterns are rejected at write time
    (``TagFormat`` validates them), so a bad pattern here is treated as a
    non-match rather than raising.
    """
    if not patterns:
        return True
    for pattern in patterns:
        try:
            if re.fullmatch(pattern, tag):
                return True
        except re.error:
            continue
    return False

API Reference

Base Classes

GraphModel

Bases: BaseModel

Minimal base for any model stored as a graph vertex.

Provides identity (id), timestamps, and extra='ignore' so AGE metadata is silently dropped. Subclass Node when you also need name/slug.

Node

Bases: GraphModel

Graph node with business identity fields.

The icon attribute can either be a URL or a CSS class name.

Domain Models

Organization

Bases: Node

Team

Bases: Node

Environment

Bases: Node

ProjectType

Bases: Node

validate_deployable_releasable_exclusive

validate_deployable_releasable_exclusive() -> typing.Self

Reject a type marked both deployable and releasable.

Source code in libraries/common/src/imbi/common/models.py
@pydantic.model_validator(mode='after')
def validate_deployable_releasable_exclusive(self) -> typing.Self:
    """Reject a type marked both deployable and releasable."""
    if self.deployable and self.releasable:
        raise ValueError(
            'deployable and releasable are mutually exclusive'
        )
    return self

Project

Bases: Node

TagFormat

Bases: BaseModel

A named release/deploy tag-format policy.

label is the human-facing name shown in the UI (e.g. Semver or CalVer); pattern is a regular expression a tag must match. A tag is accepted when it matches any configured TagFormat -- see imbi.common.versioning.matches_tag_formats.

Patterns are matched with :func:re.fullmatch and validated as compilable at assignment time so an invalid expression is rejected at the API boundary rather than at deploy/release time.

MCPServer

Bases: Node

An external MCP server reachable over streamable HTTP.

The *_encrypted fields store the ciphertext (Fernet via :mod:imbi.common.auth.encryption, keyed off IMBI_CONFIG_ENCRYPTION_KEY). Plaintext secrets must never be assigned to these fields; encryption and decryption happen in the repository/consumer layer, never on the model.

Software-Composition Models

Component

Bases: GraphModel

A piece of third-party software that may appear as a dependency of a project Release.

Identity is the package URL with version stripped — e.g. pkg:npm/express for any version of express. Versions are captured as ComponentRelease nodes linked via HAS_RELEASE.

A component may be marked deprecated or forbidden to steer projects off of it wholesale — every version inherits the mark. status is the flag; clearing it removes all three status_* properties, mirroring the blocked_* triple on Release. The why lives in the notes on the affected versions rather than in a reason property, per the report designs.

ComponentRelease

Bases: GraphModel

A specific version of a Component.

Per-component uniqueness of version is enforced at the application layer via MERGE on (Component)-[:HAS_RELEASE]->(ComponentRelease {version: ...}); no graph-wide UNIQUE index is possible because two components may legitimately ship the same version string.

status marks this one version deprecated or forbidden. The status a report shows is the strictest of this mark and the owning component's — see :func:effective_component_status.

ComponentIdentifier

Bases: GraphModel

A unique identifier for a software Component.

Versioned identifier kinds (purl with @version, CPE with a version segment) are normalized to their version-agnostic form before persistence so a single ComponentIdentifier resolves one Component regardless of release. (kind, value) is globally unique.

Collaboration Models

CommentThread

Bases: GraphModel

A thread of comments anchored to a project Document.

kind is 'page' for a whole-document discussion or 'inline' for a comment tied to a span of the document's text. The inline anchor is FLATTENED into the four anchor_* scalar properties (rather than a nested model) so the stored agtype stays a plain map. Page-level threads leave the anchor fields at their defaults.

Comment

Bases: GraphModel

A single comment within a CommentThread.

mentions and acknowledged_by hold email addresses and round-trip through AGE as agtype arrays. body is markdown text and is embedded so semantic search can surface comments alongside other corpus content.

Blueprint Models

Blueprint

Bases: Node

generate_and_validate_slug

generate_and_validate_slug() -> typing.Self

Generate slug from name if not provided and validate it.

Source code in libraries/common/src/imbi/common/models.py
@pydantic.model_validator(mode='after')
def generate_and_validate_slug(self) -> typing.Self:
    """Generate slug from name if not provided and validate it."""
    if self.slug is None:
        self.slug = slugify.slugify(self.name)
    else:
        self.slug = self.slug.lower()

    # Validate slug format
    if not self.slug:
        raise ValueError('Slug cannot be empty')
    if not all(c.islower() or c.isdigit() or c == '-' for c in self.slug):
        raise ValueError(
            'Slug must contain only lowercase letters, '
            'numbers, and hyphens'
        )
    return self

validate_kind_fields

validate_kind_fields() -> typing.Self

Validate kind-specific required fields.

Source code in libraries/common/src/imbi/common/models.py
@pydantic.model_validator(mode='after')
def validate_kind_fields(self) -> typing.Self:
    """Validate kind-specific required fields."""
    if self.kind == 'node':
        if not self.type:
            raise ValueError('type is required for node blueprints')
        invalid = [
            f
            for f in ('source', 'target', 'edge')
            if getattr(self, f) is not None
        ]
        if invalid:
            raise ValueError(
                f'{", ".join(invalid)} must be None for node blueprints'
            )
    else:
        if self.type is not None:
            raise ValueError(
                'type must be None for relationship blueprints'
            )
        missing = [
            f for f in ('source', 'target', 'edge') if not getattr(self, f)
        ]
        if missing:
            raise ValueError(
                f'{", ".join(missing)} required for '
                f'relationship blueprints'
            )
    return self

BlueprintAssignment

Bases: BaseModel

BlueprintEdge

Bases: NamedTuple

Analytics Models

CommitRecord

Bases: BaseModel

A VCS commit recorded in the ClickHouse commits table.

Generic across version-control providers — a GitHub, GitLab, or Bitbucket plugin maps its API response onto these fields and publishes them with :func:imbi.common.iggy.publish to the commits stream, which the ClickHouse sink drains into the table. The table is a ReplacingMergeTree keyed by (project_id, sha), so re-syncing an overlapping commit range collapses duplicates on merge.

TagRecord

Bases: BaseModel

A VCS tag recorded in the ClickHouse tags table.

Mirrors :class:CommitRecord's role for tags. The table is a ReplacingMergeTree keyed by (project_id, name); annotated-tag metadata (message, tagger_*, tagged_at) is populated when the provider exposes it and left at its default otherwise.

sha is always the commit the tag resolves to. Providers that expose annotated tags as their own objects (git, hence GitHub) must peel the tag before recording it: consumers join this column against commits.sha and match it against deployment committishes, so a tag object hash silently matches nothing. A tag that cannot be peeled to a commit must be skipped rather than recorded against the unresolved hash -- no row is better than one nothing can join to.

ReleaseComponentRecord

Bases: BaseModel

One component a release depends on, per its SBoM.

Rows land in the ClickHouse release_components table. Component and release identity live in the graph; this table holds only the usage fact joining them, which is what the component reports aggregate over.

Only fields immutable for the life of the release are carried here. Team, project type, and environment are deliberately absent: they are mutable, and an append-only table would serve stale values for them. project_id is the exception -- it does not change for a release, and it is what lets the authorization checks scope to an organization without enumerating its releases.

A row is invisible until :class:ReleaseComponentBatch publishes its batch_id. Neither recorded_at nor batch_id has a default: both belong to the batch, not the row, and a per-row default would split one snapshot across several.

ReleaseComponentBatch

Bases: BaseModel

The record that publishes one release's component snapshot.

Rows land in the ClickHouse release_component_batches table and are written after every :class:ReleaseComponentRecord of the same batch_id. A batch is what readers resolve a release to:

.. code-block:: sql

SELECT release_id,
       argMax(batch_id, (source = 'ingest', recorded_at)) AS batch_id
  FROM imbi.release_component_batches
 WHERE release_id IN {release_ids}
 GROUP BY release_id

Fact rows are then matched on (release_id, batch_id). Rows whose batch was never published are inert, so an interrupted write leaves readers on the previous complete snapshot.

An ingest that found no components still publishes a batch, with component_count 0. Without one the previous batch would stay current and the release would keep reporting components it dropped.

component_count is the rows actually written and parsed_count the components the SBoM held. They differ when the graph upsert dropped some component, which is deliberately non-fatal -- recording both is what makes an incomplete snapshot detectable rather than silently authoritative.

source leads the resolver key. A backfill row can never outrank an ingest for the same release, whatever order they land in, so backfilling alongside live writes needs no check-then-publish race.