Architecture
How the index is shaped, built and read, for contributors to the marketplace itself.
Table of contents
- The index document
- The build pipeline
- Local-first resolution
- Dependency kinds
- The guardian,
check:contracts
The index document
index.json is one self-describing document ("schema": 1) with build metadata (generated_at, generator, sources) and three collections.
{
"schema": 1,
"generated_at": "2026-10-06T18:01:58+00:00",
"generator": "splent_cli/1.26.1",
"sources": {
"orgs": ["splent-io"],
"repos": [],
"workspace": false,
"marketplace": "https://marketplace.splent.io"
},
"features": [ ... ],
"spls": [ ... ],
"collisions": [ ... ]
}
sources records what the build read. orgs and repos are the forge sources, workspace is true for a --local build, and marketplace is where the SPL lines came from (null with --local).
features
One entry per feature, keyed by id (org/repo) with a short name. Each entry is the full contract at the latest released tag plus what only the indexer can compute.
| Group | Fields |
|---|---|
| Identity | id, org, repo, short, version (latest tag), project_version, source ("github" for a contract read at a released tag on the forge, whatever the host, "workspace" with --local) |
| Presentation | description, archetype, category, tags, env |
| Contract | provides (routes, blueprints, models, services, hooks, signals, commands, commands_help, translations, docker), requires (features, features_manual and features_optional by name, feature_versions with the version specifier any of them carries, env_vars, signals), extensible, docker, refinement |
| Settings | config, the variables the feature declares under [tool.splent.config] (see below) |
| Tooling | cli_version, requires_python, dependencies |
| Computed | used_by (reverse dependencies across the whole index), pypi (published, latest, has_current), github (the forge repository’s url, description, pushed_at, stars and private, under that key whatever the forge) |
config lists the feature’s settings in the order its pyproject.toml declares them, one object per key.
"config": [
{
"key": "AUTH_NAV_LOGIN",
"default": "true",
"required": false,
"help": "\"Log in\" in the public header. A site where staff reach the panel by typing the URL turns it off."
},
{
"key": "AUTODEPLOY_SECRET",
"default": "",
"required": true,
"help": "Shared by the web container and the agent; every API call carries it."
}
]
| Field | Meaning |
|---|---|
key |
The variable, as the feature’s config.py reads it. |
default |
The declared value as text, the way product:env writes it into an env file. Booleans are true or false, numbers and strings are written plainly and a list is joined by commas. |
required |
true when the declared value is <SET>, so only the deployment can supply it. Its default is then empty. |
help |
The comment block right above the key, joined into one paragraph, or the comment after its value when there is no block. Empty when the key has neither. |
A table or a date under [tool.splent.config] never reaches the environment, so it is left out. config is the list to show as a feature’s settings. requires.env_vars is something else, the variables the feature’s code reads, which a product need not set when the feature declares a default for them. A consumer reads a missing config as an empty list.
provides.commands_help maps each command to its usage and a one line help, as feature:release writes it into the contract.
spls
One entry per product line the build can resolve, with name, description, the uvl pointer (mirror, doi, concept_doi, version, file) and the parsed model, so consumers never need a UVL parser.
| Field | Holds |
|---|---|
model.features |
Each feature with its presence (mandatory or optional), org, package and group membership |
model.alternative_groups |
owner, kind and members of each group |
model.constraints |
Requires-implications as [a, b] pairs (a => b) |
collisions
Computed across all indexed features. Each entry has a kind (route, service or model), the colliding item and the features that provide it. Two colliding features can coexist in an SPL only behind an alternative group, and the feature sheet and the configurator both show it.
The build pipeline
index.json is its only write.The published index lives in splent-io/splent_index. It holds registry.toml (the curated source list), a Forgejo Actions workflow (.forgejo/workflows/build-index.yml) and the index.json that workflow commits back. Only the recipe is written by hand.
| Step | What happens |
|---|---|
| Cadence | On a push to main (except the job’s own commit of index.json, so a build never retriggers itself), every 6 hours, and on demand through workflow_dispatch |
| Rebuild requests | feature:release, release:resume and spl:publish send that workflow_dispatch as their last step, so a new version reaches the index within a couple of minutes. It is best effort. It needs SPLENT_FORGE_TOKEN with write access to splent_index, and when the token is missing or the forge refuses, the command says so and finishes, because the release is already done. The 6-hour schedule is the safety net |
| Build | Installs splent_framework and splent_cli from git.splent.io, runs splent marketplace:index --registry registry.toml --output index.json and commits the result, which the repository serves raw |
| Auth | The contracts are read anonymously. The job blanks SPLENT_FORGE_TOKEN and GITHUB_TOKEN, because the token Forgejo injects is scoped to splent_index alone and would hide every other repository. No marketplace login is involved |
| SPL lines | Read from the marketplace’s public GET /api/v1/spls (the job sets SPLENT_MARKETPLACE_URL to https://marketplace.splent.io). Each line is indexed at the DOI it publishes, with its model read from .splent_cache/spls/ or downloaded from UVLHub. If the marketplace cannot be read, the build stops and the previous index stays. A model it cannot fetch is indexed without one and warned about |
The full option set (orgs, explicit repos, local mode) is at marketplace:index.
Local-first resolution
Every consumer falls back when a source is missing, but they rank the sources differently. The CLI is local-first, and so is a configurator installed without the marketplace feature. The web catalog takes the newer index, and a configurator installed next to it does the same.
| Consumer | Order |
|---|---|
CLI (feature:search / feature:info) |
Workspace cache (.splent_cache/marketplace/index.json) → SPLENT_INDEX_URL, fetched when there is no cache or with --refresh → live query of the forge |
| Marketplace web feature | In-memory TTL cache (60 seconds, SPLENT_INDEX_TTL_SECONDS) → the newer of the workspace cache and SPLENT_INDEX_URL (the published index by default), by generated_at → last stale copy → empty index |
| Configurator feature | MarketplaceService (soft dependency, so the web rule when the marketplace feature is installed) → workspace cache → SPLENT_INDEX_URL (caching what it fetches) |
For the CLI, and for a configurator without the marketplace feature, the workspace cache wins whenever it exists. A freshly built --local index may describe features and SPLs that are not published yet. Production containers have no workspace cache, so they read the published index.
The web catalog serves whichever copy was generated later. Ties go to the published copy, and a copy with no generated_at loses to one that has it. A developer who just ran marketplace:index sees their own build because it is newer, and a server whose copy is older follows the published one, with nothing to configure.
Dependency kinds
The contract distinguishes three kinds of feature dependency, inferred from how the code is written.
| Kind | Contract field | Inferred from | Enforced? |
|---|---|---|---|
| Hard | requires.features |
Imports of other features, bare service_proxy("XService") calls, and ForeignKey references to a table another feature owns, in the feature’s own code (files under tests/ are left out). Code that breaks without the provider |
Yes. feature:install resolves them recursively and feature:remove blocks removal |
| Soft | requires.features_optional |
What the code uses only when the product has it. service_proxy(...) or importlib.import_module(...) wrapped in a try: that tolerates the absence, connect_signal on another feature’s signal, and register_template_hook into another feature’s slot. Entries written by hand are kept across contract regeneration |
No, shown by the marketplace as “works better with” |
| Manual | requires.features_manual |
Declared by the author for real dependencies static analysis cannot see, such as a feature that stores data through another’s admin UI, or one that only gates its views with role_required and so needs auth. Kept across contract regeneration and merged into requires.features |
Yes, same as hard |
Call service_proxy bare when the feature needs the service, and wrap it in try/except when the feature only improves with it. The contract generator turns that choice into the marketplace’s dependency graph. The marketplace’s Elasticsearch upgrade and the configurator’s use of MarketplaceService are both soft dependencies.
The guardian, check:contracts
A dependency lives in two places, the contract (what feature:install and the marketplace see) and the SPL’s UVL constraints (what configuration validity sees). check:contracts checks that both agree. It fails on a contract dependency with no UVL constraint and warns on a UVL implication no contract backs. Run it in CI next to product:validate.