v0.8.5: Extension composability — slots, contributes, lib.require relaxation
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 5s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m42s
CI/CD / test-sqlite (pull_request) Successful in 2m58s
CI/CD / build-and-deploy (pull_request) Successful in 1m33s

Manifest declarations for slots (host surfaces declare injection points)
and contributes (extensions declare UI contributions). lib.require()
relaxed from library-only to any package with exports. Admin slots
aggregation endpoint. SDK renderAll() helper. 7 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 11:14:04 +00:00
parent 3c403dd884
commit 68713bf539
14 changed files with 586 additions and 26 deletions

View File

@@ -2,6 +2,68 @@
All notable changes to Armature are documented here.
## v0.8.5 — Extension Composability
Extensions can now compose with each other through declared slots, UI
contributions, and cross-package function calls. This is the last kernel
feature before 1.0 — the platform now supports the "extensions extending
extensions" pattern.
**Manifest declarations**
- Surfaces declare named `slots` in their manifest (e.g., `toolbar-actions`,
`note-footer`) with context documentation for extension authors.
- Extensions declare `contributes` entries targeting `{host}:{slot}` names
(e.g., `notes:toolbar-actions`). Coupling is soft — install order doesn't
matter.
- The kernel validates slot/contribution conventions at install time.
**Backend: `lib.require()` relaxation**
- `lib.require()` now works with any package that declares `exports`, not
just `type: "library"` packages. A full package (with surfaces, settings,
UI) can export callable functions for cross-package use.
- The existing `depends` and permission model is unchanged — the called
function runs with the target package's permissions.
**Admin slots endpoint**
- `GET /api/v1/admin/slots` returns an aggregated map of all declared slots
across installed packages with their contributors.
- Uninstalling a package that declares slots warns about orphaned
contributions in other packages.
**SDK additions**
- `sw.slots.renderAll(name, context)` — convenience helper for host surfaces
to render all components in a slot with error isolation.
- `sw.slots.declare(name, description)` — runtime slot declaration for
discoverability and debugging.
**Documentation**
- `PACKAGE-FORMAT.md` — added `slots`, `contributes`, `depends` field docs.
- `EXTENSION-GUIDE.md` — new "Extension Composability" section with slot
naming conventions, contribution patterns, and cross-package call examples.
- `STARLARK-REFERENCE.md` — updated `lib` module to reflect exports-based
calling (not library-type-only).
**Modified files:**
- `server/sandbox/lib_module.go` — type check → exports check
- `server/handlers/extensions.go` — composability field validation
- `server/handlers/packages.go` — orphaned contribution warning
- `server/main.go` — admin slots route registration
- `src/js/sw/sdk/slots.js``renderAll()`, `declare()`, `declarations()`
- `docs/PACKAGE-FORMAT.md` — slots, contributes, depends
- `docs/EXTENSION-GUIDE.md` — composability section
- `docs/STARLARK-REFERENCE.md` — lib module update
- `docs/DESIGN-extension-composability.md` — status Draft → Implemented
**New files:**
- `server/handlers/admin_slots.go` — admin slot aggregation endpoint
## v0.8.4 — Documentation Refresh + Surface Sizing Fix
Eight versions of module additions (v0.7.5v0.8.3) shipped without a

View File

@@ -1,6 +1,6 @@
# Armature — Roadmap
## Current: v0.8.5 — Extension Composability
## Current: v0.8.5 — Extension Composability (Kernel Complete)
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
sandbox, storage, realtime, and ops are kernel primitives. Everything else
@@ -444,7 +444,7 @@ sizing fix: `.surface-inner` converted to flex column layout so the shell
topbar and surface container share vertical space correctly — eliminates
44px scroll cutoff on all surfaces.
**v0.8.5 — Extension Composability**
**v0.8.5 — Extension Composability**
Design doc: `docs/DESIGN-extension-composability.md`
@@ -453,14 +453,16 @@ and `contributes` (extensions declare UI contributions to other packages'
slots). `lib.require()` relaxed from library-only to any package with
`exports` — enables full packages (with surfaces, settings, UI) to also
expose callable backend functions. `sw.slots.renderAll()` SDK helper.
Admin slot aggregation endpoint.
Admin slot aggregation endpoint. Uninstall orphan warnings.
The composability primitive that enables: LLM tool registration, UI
contribution (toolbar buttons, image actions), and cross-extension
function calls. No new tables, migrations, or permissions.
Modified: `sandbox/lib_module.go` (~1 line), `handlers/extensions.go`,
`src/js/sw/sdk/slots.js`. New: `handlers/admin_slots.go`.
Modified: `sandbox/lib_module.go`, `handlers/extensions.go`,
`handlers/packages.go`, `src/js/sw/sdk/slots.js`, `server/main.go`.
New: `handlers/admin_slots.go`. Docs: PACKAGE-FORMAT, EXTENSION-GUIDE,
STARLARK-REFERENCE updated.
---

View File

@@ -1 +1 @@
0.8.4
0.8.5

View File

@@ -1,7 +1,7 @@
# DESIGN — Extension Composability
**Version:** v0.8.4
**Status:** Draft
**Version:** v0.8.5
**Status:** Implemented
**Author:** Jeff / Claude session 2026-04-02
---

View File

@@ -77,7 +77,10 @@ Every package has a `manifest.json` at its root. Example for a surface:
| `api_schema` | no | OpenAPI documentation for extension API routes (see below) |
| `db_tables` | no | Table definitions (see below) |
| `settings` | no | User-configurable settings schema |
| `exports` | libraries | Functions exported for other packages |
| `exports` | no | Functions exported for cross-package calls via `lib.require()` |
| `depends` | no | Array of package IDs this package depends on |
| `slots` | no | Named UI injection points for host surfaces |
| `contributes` | no | Slot contributions into other surfaces |
| `hooks` | no | Event bus subscriptions |
| `config_section` | no | Settings/Admin panel injection (see below) |
| `capabilities` | no | Environment requirements (see below) |
@@ -194,6 +197,117 @@ sandbox permission model.
In Starlark, check permissions inline via `req["permissions"]` or call
`permissions.check(user_id, "image-gen.use")`.
## Extension Composability
Extensions compose with each other through three mechanisms: manifest-declared
**slots** (UI injection points), **contributions** (UI components injected into
those slots), and cross-package **function calls** via `lib.require()`.
### Slots — Host Surfaces Declare Injection Points
A surface declares named slots in its manifest where other extensions can
inject UI components:
```json
{
"id": "notes",
"type": "surface",
"slots": {
"toolbar-actions": {
"description": "Toolbar action buttons",
"context": {
"noteId": "string",
"getContent": "function — returns note body",
"setContent": "function — replaces note body"
}
}
}
}
```
The surface renders slot contents using the SDK helper:
```javascript
html`<div class="toolbar">
${sw.slots.renderAll('notes:toolbar-actions', {
noteId: note.id,
getContent: () => editor.getValue(),
setContent: (text) => editor.setValue(text),
})}
</div>`
```
### Contributions — Extensions Inject UI
An extension declares which slots it contributes to:
```json
{
"id": "note-dictate",
"type": "extension",
"contributes": {
"notes:toolbar-actions": {
"label": "Dictate",
"icon": "🎤",
"description": "Voice-to-text dictation"
}
}
}
```
In its JavaScript, it registers the component:
```javascript
sw.slots.register('notes:toolbar-actions', {
id: 'note-dictate',
priority: 200,
component: ({ noteId, setContent, getContent }) => {
// ... component implementation
return html`<button class="sw-btn sw-btn--ghost">🎤</button>`;
}
});
```
Contributions are soft-coupled — install order doesn't matter. The admin
can view all slots and contributors at **Admin > Packages** or via
`GET /api/v1/admin/slots`.
### Cross-Package Function Calls
Any package that declares `exports` in its manifest can be called by other
packages via `lib.require()`. The caller declares the dependency:
```json
{
"id": "note-ai",
"depends": ["llm-bridge"],
"permissions": ["api.http"]
}
```
In Starlark:
```python
llm = lib.require("llm-bridge")
result = llm.complete([{"role": "user", "content": prompt}])
```
The called function runs with the *target* package's permissions, not the
caller's. This is the same security model as library packages.
### Slot Naming Convention
Slot names follow `{host-package-id}:{slot-name}`. The colon separates the
namespace from the slot. Standard slots for first-party packages:
| Slot | Host | Use Case |
|------|------|----------|
| `notes:toolbar-actions` | notes | Dictation, AI tools, formatting |
| `notes:note-footer` | notes | Related items, AI summary |
| `chat:composer-tools` | chat | Image gen, file attach |
| `chat:message-actions` | chat | Reactions, translate, bookmark |
| `chat:image-actions` | chat | Regen, edit, upscale |
## Starlark Sandbox API
Starlark scripts run server-side with a 1M operation budget and no

View File

@@ -68,10 +68,66 @@ Only `manifest.json` is required. All other directories are optional and include
| `db_tables` | Table definitions with columns and indexes |
| `settings` | User-configurable settings with type, label, description, default |
| `hooks` | Event bus subscription patterns |
| `exports` | Functions exported by library packages |
| `exports` | Functions exported for cross-package calls via `lib.require()` |
| `depends` | Array of package IDs this package depends on |
| `slots` | Named UI injection points (host surfaces declare these) |
| `contributes` | Slot contributions this package injects into other surfaces |
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
| `schema_version` | Integer for additive schema migrations |
## Composability: Slots and Contributions
Packages compose with each other through named UI injection points (**slots**) and **contributions**.
### Declaring Slots (Host Surfaces)
Surfaces declare slots where other extensions can inject UI:
```json
{
"slots": {
"toolbar-actions": {
"description": "Toolbar action buttons",
"context": {
"noteId": "string — current note ID",
"getContent": "function — returns note body text"
}
}
}
}
```
Slot names are namespaced at runtime as `{package-id}:{slot-name}` (e.g., `notes:toolbar-actions`). The `context` field documents what data the slot provides — it is not enforced at runtime.
### Contributing to Slots
Extensions declare which slots they inject into:
```json
{
"contributes": {
"notes:toolbar-actions": {
"label": "Dictate",
"icon": "🎤",
"description": "Voice-to-text dictation"
}
}
}
```
Contributing extensions can be installed before or after their host surface — the coupling is soft. The admin can view all slots and their contributors at `GET /api/v1/admin/slots`.
### Cross-Package Function Calls
Any package that declares `exports` can be called via `lib.require()`, not just library-type packages. The caller must declare the target in its `depends` array:
```json
{
"depends": ["image-gen"],
"permissions": ["api.http"]
}
```
## Package Lifecycle
1. **Install**: Upload a `.pkg` file via Admin > Packages or `POST /api/v1/admin/packages/install`. The kernel extracts the archive, creates database tables, and registers routes.

View File

@@ -50,7 +50,7 @@ is detected by the kernel. Detected capabilities: `pgvector`, `workspace`,
### lib
Load exported functions from library packages.
Load exported functions from other packages.
```python
helpers = lib.require("my-utils")
@@ -58,11 +58,17 @@ result = helpers.format_date("2026-01-15")
```
Requirements:
- The library must be declared in your package manifest's `dependencies`.
- The library must be type `library`, status `active`, tier `starlark`.
- The target package must be declared in your manifest's `depends` array.
- The target package must declare `exports` in its manifest.
- The target package must be status `active`, tier `starlark`.
- Any package type (`library`, `extension`, `full`) can be called as long
as it declares exports. This enables full packages to expose callable
functions alongside their UI and API routes.
- Circular dependencies are detected and rejected.
- Results are cached per execution (calling `require` twice returns the
same object).
- The called function runs with the *target* package's permissions, not
the caller's.
### permissions

View File

@@ -0,0 +1,96 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// slotInfo describes a declared slot and its contributors.
type slotInfo struct {
Host string `json:"host"`
Description string `json:"description"`
Context map[string]any `json:"context,omitempty"`
Contributors []slotContributor `json:"contributors"`
}
// slotContributor describes one package's contribution to a slot.
type slotContributor struct {
Package string `json:"package"`
Label string `json:"label,omitempty"`
Icon string `json:"icon,omitempty"`
Description string `json:"description,omitempty"`
}
// ListSlots returns an aggregated map of all declared slots across installed
// packages together with their contributors. Built on-the-fly from manifests.
// GET /api/v1/admin/slots
func (h *PackageHandler) ListSlots(c *gin.Context) {
pkgs, err := h.stores.Packages.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
return
}
slots := map[string]*slotInfo{}
// Pass 1: collect declared slots from host surfaces
for _, pkg := range pkgs {
slotsRaw, ok := pkg.Manifest["slots"].(map[string]any)
if !ok {
continue
}
for name, v := range slotsRaw {
fullName := pkg.ID + ":" + name
entry, ok := v.(map[string]any)
if !ok {
continue
}
desc, _ := entry["description"].(string)
var ctx map[string]any
if c, ok := entry["context"].(map[string]any); ok {
ctx = c
}
slots[fullName] = &slotInfo{
Host: pkg.ID,
Description: desc,
Context: ctx,
Contributors: []slotContributor{},
}
}
}
// Pass 2: collect contributions
for _, pkg := range pkgs {
if !pkg.Enabled {
continue
}
contribs, ok := pkg.Manifest["contributes"].(map[string]any)
if !ok {
continue
}
for slotKey, v := range contribs {
// Ensure the slot entry exists (contributor can arrive before host)
if _, exists := slots[slotKey]; !exists {
host := slotKey
if idx := strings.Index(slotKey, ":"); idx >= 0 {
host = slotKey[:idx]
}
slots[slotKey] = &slotInfo{
Host: host,
Contributors: []slotContributor{},
}
}
contrib := slotContributor{Package: pkg.ID}
if m, ok := v.(map[string]any); ok {
contrib.Label, _ = m["label"].(string)
contrib.Icon, _ = m["icon"].(string)
contrib.Description, _ = m["description"].(string)
}
slots[slotKey].Contributors = append(slots[slotKey].Contributors, contrib)
}
}
c.JSON(http.StatusOK, gin.H{"data": slots})
}

View File

@@ -0,0 +1,97 @@
package handlers
import (
"testing"
)
func TestValidateComposabilityFields_ValidSlots(t *testing.T) {
m := map[string]any{
"slots": map[string]any{
"toolbar-actions": map[string]any{
"description": "Toolbar action buttons",
"context": map[string]any{
"noteId": "string",
},
},
},
}
if err := ValidateComposabilityFields(m); err != "" {
t.Errorf("expected valid, got: %s", err)
}
}
func TestValidateComposabilityFields_SlotMissingDescription(t *testing.T) {
m := map[string]any{
"slots": map[string]any{
"toolbar": map[string]any{
"context": map[string]any{},
},
},
}
if err := ValidateComposabilityFields(m); err == "" {
t.Error("expected error for slot missing description")
}
}
func TestValidateComposabilityFields_SlotsNotObject(t *testing.T) {
m := map[string]any{
"slots": "bad",
}
if err := ValidateComposabilityFields(m); err == "" {
t.Error("expected error for non-object slots")
}
}
func TestValidateComposabilityFields_ValidContributes(t *testing.T) {
m := map[string]any{
"contributes": map[string]any{
"notes:toolbar-actions": map[string]any{
"label": "Dictate",
"icon": "🎤",
},
},
}
if err := ValidateComposabilityFields(m); err != "" {
t.Errorf("expected valid, got: %s", err)
}
}
func TestValidateComposabilityFields_ContributesMissingColon(t *testing.T) {
m := map[string]any{
"contributes": map[string]any{
"toolbar-actions": map[string]any{
"label": "Bad",
},
},
}
if err := ValidateComposabilityFields(m); err == "" {
t.Error("expected error for contributes key without colon")
}
}
func TestValidateComposabilityFields_Empty(t *testing.T) {
m := map[string]any{
"id": "test",
}
if err := ValidateComposabilityFields(m); err != "" {
t.Errorf("expected valid for manifest without slots/contributes, got: %s", err)
}
}
func TestValidateComposabilityFields_BothValid(t *testing.T) {
m := map[string]any{
"slots": map[string]any{
"my-slot": map[string]any{
"description": "A slot",
},
},
"contributes": map[string]any{
"other:their-slot": map[string]any{
"label": "Hello",
},
},
}
if err := ValidateComposabilityFields(m); err != "" {
t.Errorf("expected valid, got: %s", err)
}
}

View File

@@ -37,6 +37,44 @@ var validTiers = map[string]bool{
models.ExtTierSidecar: true,
}
// ValidateComposabilityFields checks that manifest slots and contributes
// fields follow the expected conventions. Slots values must have a
// "description" string. Contributes keys must follow "pkg:slot" naming.
// Returns nil if valid or missing; returns an error string otherwise.
func ValidateComposabilityFields(manifest map[string]any) string {
// Validate slots: map of name → {description, ...}
if slotsRaw, ok := manifest["slots"]; ok {
slots, ok := slotsRaw.(map[string]any)
if !ok {
return "slots must be an object"
}
for name, v := range slots {
entry, ok := v.(map[string]any)
if !ok {
return "slots." + name + " must be an object"
}
if _, ok := entry["description"]; !ok {
return "slots." + name + " must have a description"
}
}
}
// Validate contributes: map of "pkg:slot" → {label, ...}
if contribRaw, ok := manifest["contributes"]; ok {
contribs, ok := contribRaw.(map[string]any)
if !ok {
return "contributes must be an object"
}
for key := range contribs {
if !strings.Contains(key, ":") {
return "contributes key " + key + " must follow pkg:slot convention"
}
}
}
return ""
}
// ── User endpoints ──────────────────────────────
// ListUserExtensions returns all enabled extensions for the current user,
@@ -208,6 +246,12 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
manifestMap = map[string]any{}
}
// Validate composability fields (slots / contributes)
if errMsg := ValidateComposabilityFields(manifestMap); errMsg != "" {
c.JSON(400, gin.H{"error": "manifest: " + errMsg})
return
}
pkg := &store.PackageRegistration{
ID: body.ExtID,
Title: body.Name,

View File

@@ -212,10 +212,48 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
resp := gin.H{"id": id, "deleted": true}
// Warn about orphaned slot contributions
if orphaned := findOrphanedContributors(c, h.stores, id); len(orphaned) > 0 {
resp["warning"] = "slot contributions orphaned in: " + strings.Join(orphaned, ", ")
}
c.JSON(http.StatusOK, resp)
h.broadcastPackageChanged("deleted", id)
}
// findOrphanedContributors returns package IDs that declare contributes
// targeting slots owned by the given package (e.g. "notes:toolbar-actions"
// targets the "notes" package). This is a warning, not a blocker.
func findOrphanedContributors(c *gin.Context, stores store.Stores, deletedPkgID string) []string {
allPkgs, err := stores.Packages.List(c.Request.Context())
if err != nil {
return nil
}
prefix := deletedPkgID + ":"
seen := map[string]bool{}
for _, p := range allPkgs {
if p.ID == deletedPkgID || !p.Enabled {
continue
}
contribs, ok := p.Manifest["contributes"].(map[string]any)
if !ok {
continue
}
for key := range contribs {
if strings.HasPrefix(key, prefix) {
seen[p.ID] = true
}
}
}
result := make([]string, 0, len(seen))
for id := range seen {
result = append(result, id)
}
return result
}
// InstallPackage uploads and installs a .pkg/.surface archive.
// POST /api/v1/admin/packages/install
//
@@ -466,6 +504,13 @@ func (h *PackageHandler) parseAndValidateArchive(c *gin.Context, tmpPath string)
return nil, nil, nil, err
}
// Validate composability fields (slots / contributes)
if errMsg := ValidateComposabilityFields(manifest); errMsg != "" {
zr.Close()
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest: " + errMsg})
return nil, nil, nil, fmt.Errorf("invalid composability fields")
}
// Signing hook (reserved)
if os.Getenv("PACKAGE_VERIFY_SIGNATURES") == "true" {
if mInfo.Signature != "" {

View File

@@ -859,6 +859,7 @@ func main() {
admin.POST("/packages/:id/update", pkgAdm.UpdatePackage)
admin.GET("/packages/:id/export", pkgAdm.ExportPackage)
admin.GET("/dependencies", pkgAdm.ListAllDependencies)
admin.GET("/slots", pkgAdm.ListSlots)
// Workflow package export
wfPkgH := handlers.NewWorkflowPackageHandler(stores)

View File

@@ -79,19 +79,21 @@ func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *Ru
return nil, fmt.Errorf("lib.require: package %q does not declare dependency on %q", consumerPkgID, libraryID)
}
// 4. Load library's PackageRegistration
// 4. Load target PackageRegistration
libPkg, err := r.stores.Packages.Get(ctx, libraryID)
if err != nil {
return nil, fmt.Errorf("lib.require: library %q not found: %w", libraryID, err)
return nil, fmt.Errorf("lib.require: package %q not found: %w", libraryID, err)
}
if libPkg.Type != "library" {
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
// Any package with exports is callable — not just type "library"
exports := extractExports(libPkg.Manifest)
if len(exports) == 0 {
return nil, fmt.Errorf("lib.require: package %q declares no exports", libraryID)
}
if libPkg.Status != models.PackageStatusActive {
return nil, fmt.Errorf("lib.require: library %q is %s, not active", libraryID, libPkg.Status)
return nil, fmt.Errorf("lib.require: package %q is %s, not active", libraryID, libPkg.Status)
}
if libPkg.Tier != models.ExtTierStarlark {
return nil, fmt.Errorf("lib.require: library %q is tier %s, not starlark", libraryID, libPkg.Tier)
return nil, fmt.Errorf("lib.require: package %q is tier %s, not starlark", libraryID, libPkg.Tier)
}
// 5. Build library's module set (library's own permissions)
@@ -113,12 +115,7 @@ func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *Ru
return nil, fmt.Errorf("lib.require: error executing library %q: %w", libraryID, err)
}
// 8. Extract exports from manifest
exports := extractExports(libPkg.Manifest)
if len(exports) == 0 {
return nil, fmt.Errorf("lib.require: library %q declares no exports", libraryID)
}
// 8. Build exported members (exports already extracted above)
members := make(starlark.StringDict, len(exports))
for _, name := range exports {
val, ok := result.Globals[name]

View File

@@ -15,6 +15,8 @@
export function createSlots(emitFn) {
/** @type {Map<string, Array<{id:string, component:Function, priority:number}>>} */
const _registry = new Map();
/** @type {Map<string, string>|undefined} */
let _declarations;
function _sort(arr) {
arr.sort((a, b) => a.priority - b.priority);
@@ -73,5 +75,43 @@ export function createSlots(emitFn) {
names() {
return [..._registry.keys()];
},
/**
* Render all components registered in a slot.
* Each component is called with the context object. Errors are
* caught so one broken contributor doesn't break the host surface.
* @param {string} name — slot name
* @param {object} context — props passed to each component
* @returns {Array} — array of rendered vnodes (nulls filtered)
*/
renderAll(name, context = {}) {
return this.get(name).map(e => {
try {
return e.component(context);
} catch (err) {
console.error(`[sw.slots] Error rendering "${e.id}" in slot "${name}":`, err);
return null;
}
}).filter(Boolean);
},
/**
* Declare a slot for discoverability. Runtime-only metadata —
* does not affect registration or rendering.
* @param {string} name — slot name
* @param {string} description — human-readable purpose
*/
declare(name, description) {
if (!_declarations) _declarations = new Map();
_declarations.set(name, description);
},
/**
* List declared slot metadata (from declare()).
* @returns {Map<string, string>}
*/
declarations() {
return _declarations ? new Map(_declarations) : new Map();
},
};
}