15. Plugin Center
Management repository: vastsa/pi-plugin-center Distribution repository: vastsa/pi-desktop-plugins Decision record: ADR 0102 Client contract: 07-plugin-marketplace.md
This document defines the distribution system that replaces the single-repository marketplace. It is the normative plan for the center service; the marketplace document remains the normative contract for what the desktop client consumes.
1. Positioning
The plugin center is the publishing side of plugin distribution. It answers three questions the desktop client cannot answer for itself:
- Does this publisher actually control the repository they claim to publish from?
- What source produced these bytes, and can that be shown to a user before install?
- Is this version fit to publish, and who decided that?
The client remains responsible for validation, permission review, install, isolation, and runtime. The center never gains the ability to run code on a user's machine that the client would not otherwise accept.
2. Ownership model
| Asset | Owner | Location |
|---|---|---|
| Plugin source | Publisher | Publisher's own GitHub repository |
| Build inputs (commit, tree, path) | Center (pinned copy) | Center database + snapshot store |
| Registry and review evidence | Center | vastsa/pi-plugin-center |
.piplug artifact | Center (published) | vastsa/pi-desktop-plugins/packages/ |
| Catalog | Center (generated) | vastsa/pi-desktop-plugins/catalog.json |
| Distribution mirror | Center | CNB Git mirror of the distribution repository |
| Install decision | User | Desktop permission review |
Plugin source is never copied into a PI-Desktop-owned Git repository. The center retains an immutable snapshot of the pinned commit for build and review evidence; that snapshot is storage, not a Git mirror, and is not published.
3. Repositories and artifact hosting
The reference design used S3/R2 with a CDN. This system splits management from distribution and uses the Git hosting that already exists (ADR 0102 §2).
3.1 Layout
vastsa/pi-plugin-center # management
├─ registry/<pluginId>.json # published projection, one per plugin
├─ schema/ # submission, registry, catalog, review
├─ scripts/ # catalog generation and verification
└─ api/ db/ # service contract and migrations
vastsa/pi-desktop-plugins # distribution, unchanged addresses
├─ catalog.json # generated by the center
├─ packages/<pluginId>-<version>.piplug # published by the center
└─ plugins/ # first-party sources, legacyThe center never writes plugin source into either repository. plugins/ holds the project's own demo plugins and predates publisher-owned sources; a third-party plugin adds a package and a catalog entry and nothing else.
3.2 Artifact URL
https://raw.githubusercontent.com/vastsa/pi-desktop-plugins/main/packages/<pluginId>-<version>.piplugCatalog entries keep a relative url of packages/<pluginId>-<version>.piplug, so it resolves against whichever host served the catalog:
GitHub raw.githubusercontent.com/vastsa/pi-desktop-plugins/main/…
CNB cnb.cool/aixk/pi-desktop-plugins/-/git/raw/main/…Because the mirror is a Git mirror, packages and catalog move together and stay byte-identical. A source switch mid-session therefore cannot cross providers or invalidate a checksum, and no artifactBaseUrl is required. The client still supports a declared base for mirrors and enterprise sources that need one.
3.3 What replaces WORM
Object-lock semantics are not available, so integrity rests on three independent records:
- The artifact SHA-256 is written to the center database inside the publish transaction and never updated.
- The bytes and that digest are committed to the distribution repository in the same commit, so Git history holds both and is append-only.
- The client verifies the downloaded bytes against the catalog digest before extracting anything.
Changing what a published version resolves to therefore requires rewriting the distribution repository's history rather than replacing a file, which branch protection is expected to prevent and which is visible to anyone who has cloned. This is tamper-evident, not tamper-proof, and is the accepted cost of dropping object storage.
3.4 Retention
Every published package stays in packages/ while its version is offered. A withdrawn version keeps its catalog entry and its reason, and its package may be deleted from packages/ after the incident window so an already-warned user is not silently re-served the withdrawn bytes.
Deleting a file does not reclaim history. Distribution repository growth is monotonic and is the accepted cost of committing artifacts (ADR 0102).
4. Service architecture
┌──────────────────────────────┐
│ Next.js on Vercel │
│ SEO pages · publisher console│
└──────────────┬───────────────┘
│ HTTPS
┌──────────────▼───────────────┐
│ Center API │
│ auth · ownership · submission│
└──────┬───────────────┬───────┘
│ │
┌──────────▼─────┐ ┌─────▼──────────┐
│ PostgreSQL │ │ Redis / queue │
│ source of truth│ │ tasks + leases │
└────────────────┘ └─────┬──────────┘
│
┌────────▼─────────┐
│ Worker │
└──┬────────────┬──┘
│ │
┌───────────▼──┐ ┌─────▼─────────┐
│ Isolated │ │ AI review │
│ runner │ │ primary+critic│
└───────────┬──┘ └─────┬─────────┘
│ │
┌─────▼────────────▼─────┐
│ Policy evaluator │
│ the only publish gate │
└───────────┬────────────┘
│
┌───────────▼────────────┐
│ Release publisher │
│ GH Releases + CNB + Git│
└────────────────────────┘4.1 Responsibilities
| Component | Owns | Must not |
|---|---|---|
| Next.js | Pages, forms, SEO, status display | Touch the database, hold keys, decide publication |
| Center API | Identity, ownership, submissions, queries | Execute plugin code, build, publish directly |
| PostgreSQL | Accounts, links, releases, reviews, audit | Act as a queue or a blob store |
| Redis | Queue, leases, idempotency locks | Be the source of truth |
| Worker | Orchestrate snapshot, build, scan, review, evaluate | Skip the state machine or overwrite an attempt |
| Isolated runner | Install dependencies, build, test, scan | Reach production secrets, the host, or the network outside its allowlist |
| Release publisher | Upload assets, mirror, regenerate catalog | Publish a release the evaluator has not approved |
The API process never executes publisher-supplied code. Everything that runs untrusted input runs in the isolated runner.
5. Identity and ownership
A single GitHub App provides both login and repository access.
- OAuth (PKCE + state, short-lived user token) proves who is submitting.
- Installation proves which repositories that account has granted, and to what scope.
- Webhooks deliver installation, repository, push, release, and membership changes so a revoked installation stops further submissions immediately.
Requested permissions are read-only: repository metadata, repository contents, and the commit/tag/release reads a pin requires. No write scopes, no secrets, no organization administration. Adding a scope requires an ADR.
Ownership verification runs before a submission is queued, never after:
- The session's GitHub account matches the request identity.
- The installation still exists and still covers the target repository.
- The account holds
admin,maintain, or an explicitly allowed role. - The submitted canonical URL matches the owner/name GitHub returns.
- The account, installation, and repository link is persisted with the check time and a hash of the permission evidence.
Failures return SOURCE_OWNERSHIP_DENIED or SOURCE_OWNERSHIP_UNAVAILABLE. Being logged in is never sufficient.
Personal access tokens are not a supported publishing credential. The GitHub App private key lives only in the secret manager; installation tokens are exchanged on demand and cached no longer than their lifetime.
6. Submission state machine
submitted
→ ownership_verified
→ source_pinned
→ scanning
→ building
→ ai_review
→ policy_evaluated
→ approved
→ published
any intermediate state → needs_info | changes_requested | blocked | build_failed | canceled
published → yankedRules:
- Only the server advances state, using conditional updates guarded by a row version, so concurrent workers cannot both advance the same release.
published,canceled, andyankedare terminal for that release. A retry creates a new attempt; it never revives a terminal state.- Every retry creates a new
review_attemptand keeps the prior attempt's inputs, outputs, and hashes. - Queue redelivery is idempotent by submission key; a crashed worker's lease is recoverable without republishing.
- An incomplete deterministic gate cannot be marked passed by an AI report.
7. Source pinning
Accepted input:
{
"pluginId": "acme.todo",
"repository": "https://github.com/acme/pi-plugin-todo",
"path": ".",
"ref": "refs/tags/v1.2.0",
"channel": "stable",
"version": "1.2.0",
"artifact": {
"mode": "publisher-release",
"assetUrl": "https://github.com/acme/pi-plugin-todo/releases/download/v1.2.0/acme.todo-1.2.0.piplug",
"sha256": "…"
},
"idempotencyKey": "…"
}- Only canonical HTTPS GitHub URLs. No query, fragment, credentials, or non-GitHub host.
refis a full 40-hex commit SHA orrefs/tags/<tag>. Annotated tags resolve through to their commit.- The worker re-resolves the commit, tree, and archive itself. Client-supplied hashes are inputs to compare against, never trusted values.
- The snapshot records repository, submitted ref, resolved commit, tree SHA, archive SHA-256, and fetch time.
- Unpacking rejects absolute paths,
..traversal, symlinks, hard links, device files, and duplicate paths, and bounds total size, per-file size, file count, and directory depth.
7.1 Artifact modes
| Mode | Publisher provides | Center does |
|---|---|---|
publisher-release | A .piplug attached to their own release | Verify bytes against the submitted digest, verify the package's manifest.json matches the pinned source, re-host |
center-build | Nothing beyond the pin | Build in the isolated runner from the pinned commit, then re-host |
publisher-release is the default because it keeps toolchain responsibility with the publisher. In both modes the center re-hosts, and in both modes the packaged manifest identity must agree with the pinned source and the submitted version.
8. Review and the publish gate
Each release runs two independent AI passes over the same evidence bundle: primary checks the contract, behavior, permissions, data practices, and supply chain; critic receives the same inputs and looks for what primary missed or over-claimed. critic never receives primary's output as fact. Each attempt records model, prompt version, and input hash.
The review skill treats source, manifests, and logs as untrusted data and does not execute instructions found in them. It has no network, no environment variables, and no key access. Insufficient evidence produces needs_info, never a guess. Its output is schema-valid JSON only.
The policy evaluator is the only component that can produce approved. It recomputes, from the database rather than the report:
- The release tuple matches the recorded submission.
- Snapshot, artifact, SBOM, and provenance hashes match.
- Every required deterministic gate passed.
- Both
primaryandcriticattempts exist and are independent. - No unresolved high or critical finding, permission escalation, or data-practice change.
- Signature, catalog key, builder version, and policy version are valid.
- This version is still the unique, unrevoked publish candidate.
deterministicGates, publishable, and approved in a model report are advisory text and are ignored. Evaluator failure, missing evidence, or a dependency outage yields needs_info or build_failed — never published.
9. Publication order
A publish is only durable if it happens in this order:
- Commit the database transaction that marks the release published and writes the artifact digest.
- Emit the outbox event.
- Write the registry entry in
pi-plugin-centerand regenerate the catalog from it. - Commit the package and the regenerated
catalog.jsontopi-desktop-pluginsin one commit. - Let the CNB Git mirror replicate that commit.
Step 4 is a single commit on purpose: a client that fetches the catalog between two commits would otherwise see an entry whose package is not there yet. Any failed step leaves the previous catalog in place, so there is no state in which the database has published a version but a client can fetch a catalog pointing at a missing package.
10. Public API
GET /v2/catalog.json
GET /v2/plugins
GET /v2/plugins/{pluginId}
GET /v2/plugins/{pluginId}/versions
GET /v2/plugins/{pluginId}/versions/{version}
GET /v2/publishers/{publisherId}
GET /v2/categories
GET /v2/healthzPublisher-facing:
GET /v2/auth/github/start
GET /v2/auth/github/callback
GET /v2/me
POST /v2/auth/logout
GET /v2/github/installations
POST /v2/github/installations/link
POST /v2/plugins/{pluginId}/submissions
GET /v2/submissions/{submissionId}
POST /v2/submissions/{submissionId}/cancelInternal, service identity only:
POST /internal/workflows/plugin-scan
POST /internal/policy-evaluations/{releaseId}
POST /internal/catalog-publications
POST /v2/webhooks/githubPublic responses contain only published data and carry a schema version. Mutations require a session, CSRF and Origin checks, an idempotency key, and an audit event. Internal routes reject browser sessions.
The desktop client reads the generated catalog and calls one download route. The official plugin channel resolves each install or update through POST /api/v1/download/resolve, sending the plugin id, the version when the user picked one, and a stable device identifier (deviceId), so the platform can de-duplicate a download and rate-limit per device instead of per source address. The byte sibling of that route, GET /download/{id}/{version}, is the marketplace page and console path, not the client's. Browse and update checking still need nothing but static files, and an install whose resolve call cannot reach the platform falls back to the catalog's own package URL, so the API can be unavailable without making published plugins unreachable. The client-side contract — channels, mirrors, digest verification, and how the identifier is derived — is in 07-plugin-marketplace.md.
A running install reports its own progress on plugin.installProgress — the phase, the mirror being tried, and the bytes received — and market.cancelInstall cancels an install that is still running, where only the download is interruptible. Both are client-side contract: the platform's resolve call is unchanged and still sees one request per install or update.
11. Trust tiers
| Tier | Meaning | Who can set it |
|---|---|---|
verified | Center-reviewed publisher with a confirmed identity | Center operators |
community | Ownership-verified publisher, automated review passed | Policy evaluator |
unknown | Custom or enterprise source the center did not review | Client default |
A publisher cannot assert any tier. Catalog generation writes the tier from the database; a submitted value in plugin metadata is dropped. The client renders anything it cannot attribute to the center as unknown and never upgrades a tier based on catalog text alone.
12. Migration
| Phase | State | Client behavior |
|---|---|---|
| 1 | catalog.json is hand-maintained at schemaVersion 1 | Reads v1, as today |
| 2 | The center takes over generating the same file, still v1 | No change |
| 3 | Generated catalog declares schemaVersion 2 | v2 fields render when present |
| 4 | Center-published plugins join the first-party ones | Provenance and trust shown per entry |
The catalog URL never changes, so no phase requires a client release, a settings migration, or any user action. A client that predates v2 keeps working because every v2 field is additive and optional.
13. Delivery phases
Phase 0 — contracts (this change)
- ADR 0102, this document, and the catalog v2 contract in the marketplace spec.
- JSON Schemas for registry entry, catalog, submission, and review report.
- Center repository scaffolding: schemas, catalog builder, verification scripts, publish workflow, API contract, database migrations.
- Client: catalog v2 parsing, download host allowlist, yank enforcement,
minPiDesktopenforcement, provenance capture and display,pi-plugin publish.
Acceptance: the client installs from a v2 catalog served by the distribution repository, and refuses a package from a host outside the allowlist.
Phase 1 — identity and pinning
GitHub App OAuth, installation linkage, ownership verification, webhooks, source snapshot. Acceptance: a user cannot submit a repository they do not control, and every accepted input is pinned to a commit.
Phase 2 — build and review
Isolated runner, deterministic packaging, SBOM and provenance, two-pass AI review, schema validation. Acceptance: the same input reproduces the same artifact digest, and no model output can fake a gate.
Phase 3 — policy, signing, catalog generations
Policy evaluator, signing keys, catalog generation and rollback, client signature verification. Acceptance: only an approved release reaches a published catalog, and a generation can be rolled back.
Phase 4 — publisher console and SEO
Submission history, version status, evidence summaries, detail pages, sitemap, hreflang. Acceptance: public pages show only published data.
Phase 5 — resilience
Backups and restore drills, runner escape drills, token revocation, catalog rollback, rate limits, alerting.
14. Explicitly not doing
- Copying publisher source into a PI-Desktop repository.
- Personal access tokens as a publishing credential.
- Executing plugin code, installing dependencies, or running tests inside the API process.
- Letting the frontend, an ordinary admin route, or a model output set
published. - Human review as the main path. People handle incidents, appeals, and policy exceptions.
- Object storage, a separately operated CDN, in-app payments, or remote code patching.
15. Acceptance for "the center is live"
- Ownership verification passes integration tests against a real installation.
- API, worker, runner, database, queue, and secret manager are production instances.
- Two-pass review and a trusted policy evaluator are enabled.
- Builds are reproducible and artifacts verifiable from provenance.
- The catalog has generations, signatures, and a rollback path.
- Public pages expose only approved, published versions, and a yank propagates.
- Audit, metrics, backup, restore, and a security drill are complete.
Until then, any local fixture, static sample, or manual step is development validation and must not be described as a publishing capability.