Skip to content

Extension points

Ospobox is a chassis. It schedules collection, stores evidence, runs scorers, evaluates rules, and dispatches signed webhooks, while knowing nothing about what any of those mean. Every judgement it declines to make is a plugin against one of four interfaces.

A plugin is a Python package with an ospobox.extensions entry point. Nothing in this repository changes when you add one.

# your package's pyproject.toml
[project.entry-points."ospobox.extensions"]
my_plugin = "my_plugin.hooks"

The in-tree plugins in src/ospobox/extensions/builtin/ load through exactly this registry, which makes them worked examples.

Subjects

Everything the control plane reasons about is a subject: a kind and an opaque identifier.

Subject(kind="repo", identifier="https://codeberg.org/forgejo/forgejo")
Subject(kind="purl", identifier="pkg:pypi/requests@2.31.0")

The canonical string form is "{kind}:{identifier}". Ospobox does not interpret identifiers: a repository URL or a package URL means whatever the plugins producing and consuming it agree it means.

Subjects of kind purl are the one exception. They are parsed and normalised on construction through packageurl-python, the reference implementation of the specification. A malformed purl is rejected where it enters, and pkg:PyPI/Requests@2.31.0 and pkg:pypi/requests@2.31.0 become one subject rather than two sets of advisories about one package.

1. Evidence sources

A source produces facts about a subject.

import pluggy
from ospobox.extensions import Evidence, Subject

hookimpl = pluggy.HookimplMarker("ospobox.extensions")


class MySource:
    name = "my-source"
    evidence_types = ("my.fact",)
    subject_kinds = ("repo",)

    async def collect(self, subject: Subject, options):
        yield Evidence(
            evidence_type="my.fact",
            payload={"anything": "json-serialisable"},
        )


@hookimpl
def ospobox_evidence_sources():
    return [MySource()]

Ospobox never interprets payload. It stores it, hashes it, and hands it back to whichever scorer or rule asks for that evidence type. A record may name its own subject, so an SBOM ingester reading one document can record facts about many packages.

The store is append-only and content-addressed. Collecting an unchanged fact again adds nothing; a changed fact is a new row, and the history is the sequence of rows. That means a source can be run as often as you like.

Worked example: extensions/builtin/forge_source.py.

2. Scorers

A scorer turns a subject and its evidence into a score with its reasoning attached.

from ospobox.extensions import ScoreBreakdown, ScoreComponent


class MyScorer:
    name = "my-scorer"
    subject_kinds = ("purl",)

    async def score(self, ctx):
        advisories = [e for e in ctx.evidence if e.evidence_type == "advisory"]
        return ScoreBreakdown(
            scorer=self.name,
            total=100.0 - min(len(advisories) * 10, 100),
            components=[
                ScoreComponent(
                    name="known-advisories",
                    score=100.0 - min(len(advisories) * 10, 100),
                    weight=1.0,
                    explanation=f"{len(advisories)} advisories on file",
                )
            ],
        )

Every component must explain itself. explanation is rendered verbatim, so a scorer that reasons about something Ospobox has never heard of still gets its reasoning in front of the user. That single requirement is the whole contract.

ctx carries the subject, its evidence, a database session and the anchor time. The session is there because the in-tree scorer reads the activity tables directly.

Worked example: extensions/builtin/composite_scorer.py, the five-factor activity composite, which is naive.

3. Rule evaluators

An evaluator decides which alerts a repository's state warrants.

class MyRule:
    name = "my-rule"

    def evaluate(self, ctx):
        if ctx.open_prs > 100:
            return [Alert(...)]
        return []

ctx carries the repository, the score breakdown, the baseline score, open PR and issue counts, week-over-week deltas, the alert config and the anchor time.

Alerts are the dispatch currency: whatever an evaluator returns is deduplicated, persisted, and carried to subscribers over the signed webhook bus. An actuator depends on nothing else.

Worked example: extensions/builtin/rules.py.

4. Report sections

A section renders one part of a report from a scope resolved once, before any section runs.

class BudgetSection:
    name = "budget"
    title = "Budget"
    audiences = ("finance",)

    async def render(self, ctx):
        return f"## Budget\n\n{len(ctx.repo_ids)} repositories in scope.\n"

ctx carries the session, the account, the organisations and repository ids in scope, and the period. Every section sees the same scope, so two of them in one report cannot disagree about what they covered.

audiences is what a composition selects on when it assembles a view for a role. The four that ship (engineering, OSPO, security and risk, finance) are named lists of section names, so adding a role means adding an entry.

Worked example: extensions/builtin/sections.py, and portfolio_sections.py for the ones reading the portfolio.

The plugins that ship in-tree

Plugin Kind What it does
forge evidence source Repository metadata through the platform adapters
cyclonedx evidence source Components from a CycloneDX SBOM, keyed by purl
osv evidence source Advisories from OSV.dev for a package
vulnerablecode evidence source Advisories from AboutCode's VulnerableCode, where an instance is configured
activity-composite scorer The five-factor activity score
dependency-exposure scorer Advisories against a repository's declared components
health-score, inactivity, activity-drop, backlog rule evaluators The six alert rules
summary, activity, contributors, releases, health, pull_requests, issues report sections The activity report
portfolio, continuity, cost, ai-contribution report sections Participation, continuity risk, cost and agent share

The supply-chain plugins are naive: one OSV query per package, without reachability analysis or caching. They demonstrate that a non-forge evidence type needs no chassis change, and they are meant to be replaced.

Where this is going

The interfaces exist because the hard parts of software-health assessment are not chassis work, and should not have to be built inside one.

Research

Under the Software Health Control Plane proposal, each layer is owned by whoever is best placed to build it. Supply-chain connectors and a verifiable evidence graph. A seven-dimension explainable health model. Policy-as-code verdicts, and actuators that hold or roll back deployments.

All four land as plugins against the interfaces on this page. That is the point of writing them down now: a partner can read the contract, check that it does not constrain their design, and build against something running. See Architecture.

If you are building a plugin today, nothing above changes what you write. The interfaces are small on purpose, and a small interface is what makes it credible that they will not move under you.

What the chassis will not do for you

  • It does not model your domain. Payload shapes are yours.
  • It does not resolve package identifiers, fetch anything on your behalf, or reconcile evidence from different sources.
  • It does not interpret scores. A total is a number between 0 and 100 and the components explain themselves.

The test of the boundary: deleting extensions/builtin/ leaves a chassis that still starts, serves and dispatches, with nothing to collect and nothing to score. If removing a plugin breaks the chassis, that is a bug in the chassis.