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. Everything domain-shaped is a plugin against one of three 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 three 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 parse identifiers: a package URL means whatever the plugins that produce and consume it agree it means.

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 deliberately 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.

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
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 five alert rules

The supply-chain plugins are deliberately naive: one OSV query per package, no reachability analysis, no 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 three 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.