This document describes the full plugin manifest schema that lands in Phase 4-Foundation. It is the engineer-facing reference for plugin authors, plugin consumers, and anyone hooking into the engine's plugin lifecycle (install / uninstall / upgrade / share).
Disambiguation. This is the Phase-4-Foundation FULL plugin manifest. See
MODULE-MANIFEST.mdfor the Phase-2b WASM module bundle schema (SANDBOX runtime). The two manifests serve different purposes:
- Plugin manifest (this doc) — describes a shareable subgraph of operation Nodes that the engine evaluator walks. The plugin itself runs as code-as-graph; the manifest is the trust envelope.
- Module manifest (
MODULE-MANIFEST.md) — describes one or more WASM modules consumed by the SANDBOX primitive at evaluation time. Sandboxed compute, not a shareable application surface.Cross-reference: a plugin may contain SANDBOX nodes whose host-fn invocations point to module-manifest-declared WASM modules. The plugin manifest's
requiresfield would namehost:sandbox:execcap in that case; the module manifest still lives separately.
A plugin is a content-addressed subgraph (per CLAUDE.md baked-in #18). Without a manifest, two questions are unanswered:
- What capabilities does this plugin need to function? (e.g., "read user's notes", "write to a sandbox label", "use the time host-fn"). The user must consent to the cap envelope at install — they shouldn't be asked on every action.
- What can this plugin delegate to other plugins? A plugin that holds a cap can re-issue UCANs to other plugins inside its own walk. Without policy, an installed plugin could hand any cap it holds to any other plugin. The user's consent at install must bound this.
The manifest answers both as two halves: requires (caps the plugin needs) and shares (delegation policy). Both halves are signed by the plugin author so they cannot drift post-install.
Phase-4-Foundation ratifies a four-distinct-identity-concepts model (CLAUDE.md baked-in #18 "Implementation refinements ratified 2026-05-11" block, retensed post-R1-triage per D-4F-12). NONE of these are conflated in the engine implementation; each lives at a different seam:
| # | Identity | Role | Surface |
|---|---|---|---|
| 1 | Content-CID | What the plugin IS — canonical bytes of its subgraph + manifest, content-addressed | benten-core::Cid; the engine's content-addressing primitive |
| 2 | Peer-DID signature on original content | Provenance — who originally authored or shared the content | benten-id peer-DID + Ed25519 signature; benten-id RotationLog handles peer-DID rotation/revocation |
| 3 | Plugin-DID minted at install | UCAN audience AND constrained issuer within manifest envelope. NOT an attested sub-identity of user-DID. Per D-4F-16: did:key:... shape, fresh Ed25519 keypair minted by the caller (admin UI / installer) via OsRng + inserted into PluginDidStore BEFORE install_plugin runs (caller-mint-first contract — see §3 below). The engine NEVER mints plugin-DIDs internally |
benten_id::plugin_did::mint(rng) called caller-side; persisted in engine's PluginDidStore (in-memory at v1); one keypair per install |
| 4 | User-DID | Trust anchor + signs install records + issues UCAN caps with audience=plugin-DID |
benten-id user-DID; root of every cap chain per CLAUDE.md #18 Layer 1 |
Cross-plugin/schema references use content-CID, not author-DID. accepts_content: [hash, ...] rather than accepts_author: [did, ...]. Schema authors can rotate keys without breaking downstream references — the references are CID-keyed.
Plugin-DID is a UCAN audience AND constrained issuer. Plugin-DID can issue UCAN delegations to OTHER plugins WITHIN its manifest shares policy. Chain validator at crates/benten-caps/src/manifest_envelope_chain_validation.rs (G24-D-FP-2) enforces. Plugin-DID has no inherent authority — its issuance is bounded by what the source plugin's manifest allows + the chain must still trace back to a user-DID-issued root grant.
The manifest is a content-addressed DAG-CBOR document. At a minimum it contains:
PluginManifest {
// Identity
plugin_name: text, // Human-readable name (not unique; CID is the unique handle)
content_cid: Cid, // CID of the plugin's subgraph
peer_did: Did, // peer-DID of original author
peer_signature: Sig, // peer-DID signature over (content_cid + manifest_body)
// Capability envelope
requires: [CapRequirement], // Caps the plugin needs to function
shares: SharesPolicy, // Delegation policy
// Renderer + composition
renderer_config: RendererConfig?, // Optional: output-format + renderer-backend hints
composes_plugins: [Cid]?, // Optional: meta-plugin composition (CID-keyed)
// Cross-references
accepts_content: [Cid]?, // Content-CIDs this plugin can consume (e.g., schema CIDs)
requires_schema_authors: [Did]?, // Trust-list of peer-DIDs for schemas this plugin reads
requires_plugin_authors: [Did]?, // Trust-list of peer-DIDs for plugins this plugin composes
}
At install time, the user-DID signs an InstallRecord referencing this manifest:
InstallRecord {
manifest_cid: Cid,
consenting_user_did: Did,
user_signature: Sig, // user-DID signature over (manifest_cid + timestamp + nonce)
timestamp: Hlc,
nonce: Bytes,
granted_caps: [CapGrant], // UCAN delegations from user-DID to plugin-DID
}
The install record (NOT the manifest) carries the user's consent. The manifest is what the plugin author published; the install record is what the user agreed to.
A CapRequirement names a typed scope the plugin needs to call upon. Phase-4-Foundation manifest_scope.rs maps to:
requires:<plugin_did>:<requirement_path>— engine cap-scope shape (canonical; see §6 scope grammar)- typed-by-domain (e.g.,
store:notes:read/host:time:now/host:sandbox:exec)
If a plugin's requires include host:sandbox:exec AND the installing peer is a thin-compute-surface (browser / edge per CLAUDE.md #17), install fails with E_PLUGIN_HETEROGENEITY_INCOMPATIBLE (heterogeneity contract).
SharesPolicy is the delegation envelope:
SharesPolicy {
default: SharesPolicyDefault, // "none" | "any" | "matching"
rules: [SharesRule]?, // Per-cap or per-plugin-target rules
}
SharesRule {
cap_pattern: text, // e.g., "store:notes:read"
target: SharesTarget, // "any" | { plugin_did: Did } | { plugin_author: Did }
}
Conservative default for v0 plugins: shares: { default: none }. Phase 4 ecosystem broadens cautiously.
Plugin-DID identity at install is bound by a caller-mint-first contract: the engine NEVER mints plugin-DID keypairs internally. The caller protocol is:
- Caller mints a
PluginDidHandleviabenten_id::plugin_did::mint(rng)— generates a fresh Ed25519 keypair; thedid:key:encoding is structurally derived from the public key (one-way; caller cannot synthesize a keypair matching an arbitrary chosen DID string). - Caller inserts the handle into the engine's
PluginDidStoreviaplugin_did_store.insert(handle)— establishes the DID as a known audience handle BEFORE install runs. - User signs the
InstallRecordbindinghandle.did()into the signed payload (alongsidemanifest_cid+consenting_user_did+nonce). - Caller passes
expected_plugin_did = &handle.did()throughInstallContext::expected_plugin_didintoplugin_lifecycle::install_plugin. Step 8 of install_plugin asserts BOTHinstall_record.plugin_did == *ctx.expected_plugin_did(rejects record-substitution; surfacesE_PLUGIN_INSTALL_RECORD_PLUGIN_DID_MISMATCH) ANDplugin_did_store.get(expected_plugin_did).is_some()(rejects orphan-handle path; surfacesE_PLUGIN_DID_HANDLE_NOT_PRE_INSERTED).
The contract surface: crates/benten-platform-foundation/src/plugin_lifecycle.rs::InstallParams documents the full 4-step caller protocol; crates/benten-platform-foundation/src/plugin_lifecycle.rs::install_plugin Step 8 carries the structural enforcement. Admin UI integrators consume this protocol per docs/ADMIN-UI.md §3.1 consent flow.
Why caller-mint-first. The Ed25519-derives-DID-from-public-key property makes the contract structurally adversary-resistant: even if a caller passes expected_plugin_did = attacker_chosen_string, Step 8 enforces that the signed install_record.plugin_did byte-equals expected_plugin_did AND that a handle for that DID is already in the store — to substitute identities, an attacker must (a) tamper the record (caught at Step 3 UserSignatureInvalid via signing-payload plugin_did_bytes binding) OR (b) mint a keypair whose did:key: encoding matches the chosen string (computationally infeasible).
Private-namespace data residency is automatic: any cap-scope of shape private:<plugin_did>:* is implicitly shares: none regardless of declared policy. Enforcement composes across two surfaces: (a) the crates/benten-caps/src/plugin_delegation.rs::is_private_namespace_cap shape predicate that classifies any cap-scope starting with private:<plugin_did>: as a private-namespace cap and refuses cross-plugin delegation (Phase-4-Foundation G24-D wiring; companion test at crates/benten-caps/tests/private_namespace_scope_admits_only_plugin_did_actor.rs); (b) the crates/benten-caps/src/manifest_envelope_chain_validation.rs::validate_chain_with_manifest_envelope per-step audit that rejects any UCAN delegation chain where a non-root step (idx > 0) targets a private-namespace cap pattern. The SharesPolicy::validate entry point at crates/benten-platform-foundation/src/plugin_manifest.rs::SharesPolicy::validate ensures install-time manifests with private-namespace requires never get widened by the manifest-envelope itself. (Earlier doc drafts cited a single private_namespace_policy.rs file; closure cluster lives at the two named surfaces above — historical phantom-destination retensed at R6-FP-2 doc-retense.)
- Receiver peer receives plugin bytes (out-of-band content-addressed-share over Atriums in Phase 4-Foundation; decentralized registry → Phase 4-Meta per post-R1-triage ratification #3).
- Receiver verifies: (a) bytes hash to declared content-CID; (b) peer-DID signature on content; (c) peer-DID is in user's
requires_plugin_authorstrust-list (else first-install prompt). - Caller mints fresh plugin-DID keypair via
benten_id::plugin_did::mint(rng)+ inserts the handle intoPluginDidStoreper the caller-mint-first contract above (§3 Plugin-DID minting protocol). The engine NEVER mints plugin-DIDs internally; install_plugin Step 8 asserts the pre-inserted handle is present. - Admin UI surfaces manifest review screen (per
docs/ADMIN-UI.md): plain English requires/shares, per-cap-grant decline option. - On consent, user-DID signs
InstallRecord+ issues UCAN delegations from user-DID to plugin-DID for granted caps. - Install record persisted to
ManifestStore. Two backends ship at Phase-4-Meta-Core G-CORE-7 (perdocs/future/phase-4-backlog.md §6.4): (a) the in-memoryHashMap-backedManifestStorefor browser-wasm32 / scoped-test use (crates/benten-platform-foundation/src/manifest_store.rs); (b) the durableRedbManifestStore(native-only; gated#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]) whose persisted records survive a process / handle restart — opening aRedbManifestStoreat the same path reloads + re-verifies the previously-installed records via the same canonical-bytes DAG-CBORInstallRecordencoding, extending the verify-on-every-load defense across the restart boundary. - Plugin enters user's "plugin library" subgraph (per D-4F-14 — content-addressed; cheap; holds all installed versions + forks).
- Active reference (per ratification #2 — Loro Map per-device-keyed CURRENT) updates to point at this plugin-version.
Meta-plugin composition cycle detection runs at install (per post-R1-triage Q2 ratification): rejects with E_PLUGIN_META_COMPOSITION_CYCLE_REJECTED if the install would create a cycle in the composes_plugins graph.
The G24-D-FP-1 uninstall_plugin seam (forthcoming in crates/benten-platform-foundation):
- Enumerate all user-DID-issued grants WHERE
audience=plugin-DID; revoke each. - Cascade plugin-DID's own downstream UCAN delegations (enumerate grants issued BY plugin-DID; revoke each).
- Terminate all live subscriptions whose subscriber DID was this plugin.
- Delete private namespace data (
private:<plugin_did>:*rows). - Remove from manifest store.
- Remove library entry (update plugin library subgraph + active reference per ratification #2).
- Emit
PluginUninstalledchange-event.
upgrade(plugin_did, new_content_cid) (per post-R1-triage ratification #8 cap-change-triggered fresh consent):
v1 ships the consent half only:
- (SHIPPED at G24-D) Verify peer-DID signature on new content matches the previously-installed peer-DID (peer-DID change at upgrade = re-install, user re-consents per T10-upgrade). Surface:
crates/benten-platform-foundation/src/module_ecosystem.rs::decide_upgrade_consent. - (SHIPPED at G24-D) Verify DAG-shaped version chain monotonicity: new CID is a descendant of installed in the DAG (per D-4F-14 anchor + Version Node DAG extension). Older-version "upgrades" rejected via
crates/benten-core/src/version_chain.rs::DagVersionChainparent→child wiring (consumed bycrates/benten-platform-foundation/src/plugin_library.rs::PluginLibrary) + reject-downgrade check at the upgrade entry point.
Phase-4-Meta-Core G-CORE-7 — atomicity + caps-grew gate (SHIPPED):
- (SHIPPED at G-CORE-7 per
docs/future/phase-4-backlog.md §4.41) Cap-change consent rule (ratification #8):- Silent within-lineage upgrade if
requiresis a strict subset of installed manifest; - Full re-consent if
requiresGREW (any cap added or scope widened) —install_pluginconsultsmodule_ecosystem::decide_upgrade_consentagainstparams.prior_installed_cid's resolved manifest; a within-lineage upgrade whoserequiresGREW relative to the prior manifest is REJECTED withE_PLUGIN_INSTALL_CONSENT_REQUIREDunless the install record'sgranted_caps_bytescovers every scope in the new manifest'srequires. Initial installs (prior_installed_cid = None) skip this check — the manifest_cid binding in the user-signed install record is itself the consent for the initial cap set. - Cross-fork merge = user-initiated through same consent flow (no separate cross-fork merge surface).
- Silent within-lineage upgrade if
- (SHIPPED at G-CORE-7 per
docs/future/phase-4-backlog.md §4.21+ §4.35) Atomic install: Steps 9 (cap-cascade mint) → 10 (provision namespace) → 11 (library insert + active-ref) are all-or-nothing. Step 9's mint-loop tracks every minted grant CID; a mid-loop mint failure unwinds the already-minted grants viaCapMinter::revoke_root_grantbefore propagating the error (§4.35 cap-cascade atomicity). A Step-10 provision failure unwinds Step-9 grants; a Step-11 set-active failure unwinds Step-10 namespace provision AND Step-9 grants AND the just-inserted library entry, leaving zero observable residue (§4.21 Steps 9/10/11 rollback).CapMinter::revoke_root_grant+PrivateNamespaceProvisioner::deprovision_private_namespaceship as additive trait methods with no-op defaults so existing adapters compile against an additive signature.
No manifest-schema-version check (per D-4F-13 — CID covers shape; pull-not-push obviates schema-version field).
Direct content-addressed-share over Atriums (Phase 4-Foundation v0 scope per ratification #3). Receiver verifies content-CID + peer-DID signature; install flow above runs. Decentralized self-discovered registry is Phase 4-Meta scope.
Per D-4F-14, version chains extend the Phase-1 Anchor + Version Node + CURRENT pointer pattern to DAG-shape (branches + forks):
anchor
│
v1
│
v2-mainline
/ \
v3 v2.5-fork
\ │
\ v3-fork
\ /
v4-merge (user-initiated cross-fork merge per ratification #8)
CURRENT pointer can point at any reachable branch tip. Per-device-local CURRENT (ratification #2 — Loro Map per-device-keyed); user "switches active version" = updates the local CURRENT pointer; sync surface presents per-device-keyed map.
The version_chain.rs extension is in crates/benten-core. New primitive mints? NO — uses existing 12-primitive vocabulary (LABEL_NEXT_VERSION + LABEL_CURRENT edge labels at crates/benten-core/src/version.rs; no new edge-label kinds minted per cag-r1-4; DAG-shape supplied structurally via crates/benten-core/src/version_chain.rs::DagVersionChain parent→child wiring consumed by crates/benten-platform-foundation/src/plugin_library.rs::PluginLibrary — multiple children of one parent express forks without a FORK_OF constant).
Canonical scope-string shapes (per plugin-arch-r1-10 + sec-4f-r1-7):
private:<plugin_did>:*— private namespace;shares: nonealways; cap-policy rejects cross-plugin delegationrequires:<plugin_did>:<requirement_path>— cap-scope derived from manifestrequireshalfshares:<plugin_did>:<share_path>— cap-scope derived from manifestshareshalf
crates/benten-caps/src/manifest_scope.rs (G27-D) implements the mapping. NoAuthBackend defaults permit even for plugin-issued scopes (matches CLAUDE.md baked-in #7 pluggable-policy default; explicit doc-comment in code).
GrantBackedPolicy::derive_write_scope (G27-B) and GrantBackedPolicy::check_read (G22-FP-3 with ctx.actor_cid consultation) both consult manifest scope.
RendererConfig disambiguates manifest envelope (CAPS) vs renderer-config (rendering targets / UI hosting / bundle layout) per plugin-arch-r1-15:
RendererConfig {
output_format: OutputFormat, // "html_json" | "plaintext" | (others)
renderer_backends: [RendererBackend]?,
hosting_target: HostingTarget?, // "browser_wasm32" | "tauri_embedded_webview"
bundle_size_budget_kb: u32?,
}
Optional because not every plugin renders user-facing output (a SUBSCRIBE-only plugin might have no rendering). Admin UI v0 fills this with output_format: html_json, renderer_backends: [browser_wasm32, tauri_embedded_webview].
Per CLAUDE.md baked-in #18:
- Layer 1 user-as-root. Every cap chain traces back to a user-issued root grant. No plugin gets capability without user consent somewhere in the chain.
- Layer 2 install-time manifest envelope. User reviews
requires+sharesat install; consents to envelope; both halves signed by plugin author so can't drift post-install. - Layer 3 runtime delegation within envelope. Plugins delegate UCANs to each other freely if the request fits source plugin's manifest
shares.CapabilityPolicyvalidates chain at access-time: chain traces to user-root + each delegation step fits source plugin's policy + requested cap within attenuation envelope.
ErrorCodes (Phase 4-Foundation mints): E_PLUGIN_MANIFEST_INVALID / E_PLUGIN_INSTALL_RECORD_USER_SIGNATURE_INVALID / E_PLUGIN_CONTENT_PEER_SIGNATURE_INVALID / E_PLUGIN_CONTENT_PEER_KEY_ROTATED / E_PLUGIN_AUTHOR_NOT_TRUSTED / E_PLUGIN_INSTALL_CONSENT_REQUIRED / E_PLUGIN_DELEGATION_OUTSIDE_MANIFEST_ENVELOPE / E_PLUGIN_PRIVATE_NAMESPACE_DELEGATION_FORBIDDEN / E_PLUGIN_CONTENT_CID_MISMATCH / E_PLUGIN_NEW_VERSION_AVAILABLE / E_PLUGIN_HETEROGENEITY_INCOMPATIBLE / E_PLUGIN_META_COMPOSITION_CYCLE_REJECTED. See ERROR-CATALOG.md for the full catalogue.
- CLAUDE.md baked-in #18 — three-layer consent model (canonical)
- CLAUDE.md baked-in #19 — engine-level extensions are Rust crates (distinct trust model; plugins are NOT engine extensions)
ARCHITECTURE.md§"Plugins and engine extensions" — workspace shape + crate boundariesSECURITY-POSTURE.md"Plugin trust model" — security narrativeMODULE-MANIFEST.md— Phase-2b WASM module manifest (distinct surface)SCHEMA-DRIVEN-RENDERING.md— schema vocabulary; plugin manifests reference schema CIDsADMIN-UI.md— admin UI v0; first plugin instance + manifest review UX
(Phase-4-Foundation companion doc lands at G24-D canary per feedback_post_fix_doc_coupling_preflight.md §3.5b HARDENED + meth-r1-7 companion-with-canary discipline.)