Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

## [v0.2.5] - 2026-06-19

A module can now mark one of its own tables **read-only eligible** for a depending module — the producer half of the cross-module data contract. v0.2.0's design notes deferred this in favor of `pg_class` introspection; an explicit declaration is clearer (the producer's intent is in source, not inferred) and keeps the GRANT surface auditable, while preserving the same trust model: the producer marks a table readable, the **app owner** decides who reads it.

### Added
- **`ms.ExposeTable(name string)`** — a zero-runtime DECLARATION that marks a table in the module's `mod_<id>` schema as SELECT-eligible for a depending module (the producer side of `ms.DependsOn`'s `n.Table`). It surfaces in the manifest under a new top-level `exposes` block — `"exposes": { "tables": [...] }` — a flat, **sorted, de-duplicated** list of table names. The platform catalog issues `GRANT SELECT` against a depending module's DB role only after the **app owner** approves that dependency. v1 is **TABLES ONLY, read-only**. There is intentionally **no per-consumer `readableBy` allowlist**: in a marketplace the consumers are third parties, so a publisher-controlled reader list is the wrong trust model — the producer opts a table *in* to being readable, the app owner (the trust root) decides *who* reads. Repeated/feature-flagged declarations of the same name compose safely (set union); an empty or non-identifier-shaped name (`^[a-z][a-z0-9_]{0,62}$`, the Postgres NAMEDATALEN ceiling) panics at startup. The manifest always carries `exposes` (an empty `tables` array when the module exposes nothing).

## [v0.2.4] - 2026-06-17

The usage-meter transport moves from an AWS Lambda invoke to a dispatch-HTTP POST, exactly mirroring `ms.Emit`. The public metering API (`ms.Meter` declaration, `ms.Record` emit-by-name, the v1 envelope with no kind on the wire, the reserved-namespace guards) is unchanged — only how a recorded event reaches the platform changes.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ Keys auto-prefixed: developer writes `"views:123"`, Redis stores `"app_abc123:mo

- [x] `ms.Describe()` — module description for agent discovery
- [x] `ms.DependsOn()` — dependency declaration with auto-detected required/optional
- [x] `ms.ExposeTable()` — mark a table read-only (SELECT) eligible for depending modules; app owner approves who reads (no producer allowlist)
- [x] `ms.Resolve[T]()` — typed runtime lookup for optional deps (stub pending cross-module wiring)
- [x] `ms.MCPTool()` — agent-callable tool with JSON Schema derivation
- [x] `ms.MCPResource()` — agent-readable resource
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.4
0.2.5
29 changes: 29 additions & 0 deletions internal/core/expose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package core

// ExposeTable marks a table in this module's `mod_<id>` schema as eligible to
// be read (SELECT) by a depending module. It is a pure DECLARATION — no
// runtime, no return value — recorded in the manifest under
// `exposes.tables` so the platform catalog can issue GRANT SELECT after the
// app owner approves a dependency.
//
// v1 is TABLES ONLY, read-only. The producer marks a table READABLE; it does
// NOT name WHO reads it. The app owner — the trust root — decides which
// installed modules may read by approving their declared dependency. There is
// intentionally no per-consumer allowlist here: a marketplace's consumers are
// third parties, so a publisher-controlled reader list is the wrong trust
// model.
//
// Panics on an empty or otherwise invalid table identifier (lowercase, leading
// letter, [a-z0-9_], <=63 chars). Call from startup code, not a request
// handler.
//
// ms.ExposeTable("orders")
func (m *Module) ExposeTable(name string) {
m.registry.AddExposedTable(name)
}

// ExposeTable declares an exposed table on the default Module created by
// Init(). Panics before Init — matches Platform/Public/Internal/Emits.
func ExposeTable(name string) {
mustDefault("ExposeTable").ExposeTable(name)
}
71 changes: 71 additions & 0 deletions internal/core/expose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package core

import (
"encoding/json"
"slices"
"testing"

"github.com/mirrorstack-ai/app-module-sdk/system"
)

func TestExposeTable_RecordsInRegistry(t *testing.T) {
t.Parallel()

m, _ := New(Config{ID: "media"})
m.ExposeTable("orders")
m.ExposeTable("invoices")
m.ExposeTable("orders") // dup, dropped

got := m.registry.ExposedTables()
if !slices.Equal(got, []string{"invoices", "orders"}) {
t.Errorf("ExposedTables() = %v, want sorted [invoices orders]", got)
}
}

func TestExposeTable_PanicsOnInvalidName(t *testing.T) {
t.Parallel()

m, _ := New(Config{ID: "media"})
assertPanics(t, "expected panic on invalid exposed table name", func() {
m.ExposeTable("")
})
}

func TestExposeTable_AppearsInManifestExposes(t *testing.T) {
m := newTestModuleWithSecret(t, "media")

m.ExposeTable("orders")
m.ExposeTable("invoices")

rec := doRequestWithSecret(t, m.Router(), "GET", "/__mirrorstack/platform/manifest", "secret")
var got system.ManifestPayload
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode manifest: %v", err)
}

if !slices.Equal(got.Exposes.Tables, []string{"invoices", "orders"}) {
t.Errorf("exposes.tables = %v, want sorted [invoices orders]", got.Exposes.Tables)
}
}

func TestExposeTable_TopLevelFacade(t *testing.T) {
resetDefault(t)
if err := Init(Config{ID: "media", Name: "Media"}); err != nil {
t.Fatalf("Init: %v", err)
}

ExposeTable("orders")
ExposeTable("invoices")

got := DefaultModule().registry.ExposedTables()
if !slices.Equal(got, []string{"invoices", "orders"}) {
t.Errorf("package-level ExposeTable -> ExposedTables() = %v, want sorted [invoices orders]", got)
}
}

func TestExposeTable_TopLevelPanicsBeforeInit(t *testing.T) {
resetDefault(t)
assertPanics(t, "expected panic for top-level ExposeTable before Init", func() {
ExposeTable("orders")
})
}
55 changes: 55 additions & 0 deletions internal/registry/exposure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package registry

import (
"fmt"
"regexp"
"slices"
)

// exposedTableNamePattern: Postgres-safe identifier. Lowercase, starts with a
// letter, only [a-z0-9_], up to 63 chars (the Postgres NAMEDATALEN ceiling).
// An exposed table lives under the module's `mod_<id>` schema; the platform
// composes the fully-qualified name itself when it issues GRANT SELECT.
var exposedTableNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)

// AddExposedTable records a table NAME as eligible for SELECT by a depending
// module. v1 is TABLES ONLY, read-only — the producer marks a relation
// readable; it does NOT name WHO reads it. The app owner (not the producer)
// decides which installed modules may read by approving a dependency. There is
// intentionally no per-consumer allowlist on the exposure itself.
//
// Name must match exposedTableNamePattern; validation panics, like the rest of
// the registry — an invalid declaration is a programmer error caught at module
// init, not a runtime input.
//
// Dedup: declaring the same name twice is a no-op (set union). ExposedTables
// returns the de-duplicated set sorted, so repeated/feature-flagged
// declarations compose safely and the manifest output is deterministic.
func (r *Registry) AddExposedTable(name string) {
if !exposedTableNamePattern.MatchString(name) {
panic(fmt.Sprintf(
"mirrorstack/registry: ExposeTable(%q) name must be lowercase, start with a letter, only [a-z0-9_], <=63 chars",
name,
))
}
r.mu.Lock()
defer r.mu.Unlock()
if slices.Contains(r.exposedTables, name) {
return
}
r.exposedTables = append(r.exposedTables, name)
}

// ExposedTables returns a non-nil, SORTED, de-duplicated copy of all exposed
// table names. Sorting makes the manifest output deterministic (stable for
// prompt-cache and manifest-diffing) regardless of declaration order.
func (r *Registry) ExposedTables() []string {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.exposedTables) == 0 {
return []string{}
}
out := slices.Clone(r.exposedTables)
slices.Sort(out)
return out
}
123 changes: 123 additions & 0 deletions internal/registry/exposure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package registry

import (
"slices"
"testing"
)

func TestAddExposedTable_Records(t *testing.T) {
t.Parallel()

r := New()
r.AddExposedTable("orders")
r.AddExposedTable("invoices")

got := r.ExposedTables()
if !slices.Equal(got, []string{"invoices", "orders"}) {
t.Errorf("ExposedTables() = %v, want sorted [invoices orders]", got)
}
}

func TestExposedTables_UnionDedup(t *testing.T) {
t.Parallel()

// Re-declaring the same name (e.g. behind a feature flag) is a no-op:
// the set union keeps each name once.
r := New()
r.AddExposedTable("orders")
r.AddExposedTable("orders")
r.AddExposedTable("invoices")

got := r.ExposedTables()
if len(got) != 2 {
t.Errorf("ExposedTables() = %v, want 2 distinct names", got)
}
}

func TestExposedTables_SortedDeterministic(t *testing.T) {
t.Parallel()

// Declaration order must NOT affect output order — the manifest must be
// stable for prompt-cache / manifest-diffing. Both orderings sort the same.
a := New()
a.AddExposedTable("zebra")
a.AddExposedTable("apple")
a.AddExposedTable("mango")

b := New()
b.AddExposedTable("mango")
b.AddExposedTable("zebra")
b.AddExposedTable("apple")

want := []string{"apple", "mango", "zebra"}
if got := a.ExposedTables(); !slices.Equal(got, want) {
t.Errorf("a.ExposedTables() = %v, want %v", got, want)
}
if got := b.ExposedTables(); !slices.Equal(got, want) {
t.Errorf("b.ExposedTables() = %v, want %v (order-independent)", got, want)
}
}

func TestExposedTables_EmptyReturnsNonNil(t *testing.T) {
t.Parallel()
if got := New().ExposedTables(); got == nil {
t.Error("empty ExposedTables() returned nil, want []string{}")
}
}

func TestExposedTables_ReturnsCopy(t *testing.T) {
t.Parallel()

r := New()
r.AddExposedTable("orders")

first := r.ExposedTables()
first[0] = "tampered"

if second := r.ExposedTables(); second[0] != "orders" {
t.Errorf("ExposedTables() returned shared backing slice: caller mutation leaked, got %v", second)
}
}

func TestAddExposedTable_PanicsOnInvalidName(t *testing.T) {
t.Parallel()

bad := []string{
"", // empty
"Orders", // uppercase
"1orders", // leading digit
"my orders", // whitespace
"orders-table", // hyphen (not a Postgres-safe bare identifier)
"mod.orders", // dot / schema-qualified
"../etc", // path traversal shape
}
for _, name := range bad {
name := name
t.Run(name, func(t *testing.T) {
t.Parallel()
defer func() {
if rec := recover(); rec == nil {
t.Errorf("AddExposedTable(%q) did not panic, want panic on invalid identifier", name)
}
}()
New().AddExposedTable(name)
})
}
}

func TestAddExposedTable_AcceptsValidNames(t *testing.T) {
t.Parallel()

for _, name := range []string{"orders", "order_items", "o", "a1", "user_2fa_tokens"} {
name := name
t.Run(name, func(t *testing.T) {
t.Parallel()
defer func() {
if rec := recover(); rec != nil {
t.Errorf("AddExposedTable(%q) panicked, want accept: %v", name, rec)
}
}()
New().AddExposedTable(name)
})
}
}
1 change: 1 addition & 0 deletions internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ type Registry struct {
tasks []Task
permissions []Permission
metrics []MetricDecl
exposedTables []string
description string
dependencies []Dependency
outboundContributions []OutboundContribution
Expand Down
15 changes: 15 additions & 0 deletions mirrorstack.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,21 @@ func Resolve[T any](id string) (T, bool) { return core.Resolve[T](id) }
// after app-owner approval. Pair with ms.DependsOn(host). See core.ContributesTo.
func ContributesTo(host, slot string, payload any) { core.ContributesTo(host, slot, payload) }

// ExposeTable marks a table in this module's schema as read-only
// SELECT-eligible for a depending module — the producer side of DependsOn's
// n.Table. It is a zero-runtime DECLARATION that lands in the manifest under
// exposes.tables; the platform issues GRANT SELECT after the app owner
// approves a dependency.
//
// The producer only opts a table IN to being readable — it does NOT decide
// WHO reads it. The app owner (not the producer) is the trust root and
// chooses which installed modules may read by approving their dependency.
// There is intentionally NO consumer allowlist: in a marketplace the
// consumers are third parties, so a publisher-controlled reader list is the
// wrong model. v1 is TABLES ONLY, read-only (SELECT). Panics on an empty or
// invalid table identifier. Call from startup code.
func ExposeTable(name string) { core.ExposeTable(name) }

// --- UI surface ---

// ModuleUI is the module's declared UI surface. Pass to ms.RegisterUI.
Expand Down
17 changes: 17 additions & 0 deletions system/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ type ManifestPayload struct {
Versions map[string]MigrationVersions `json:"versions"`
Routes map[registry.Scope][]registry.Route `json:"routes"`
Events ManifestEvents `json:"events"`
// Exposes lists the tables this module marks readable (SELECT-eligible)
// by a depending module (ms.ExposeTable). The platform catalog issues
// GRANT SELECT against the depending module's DB role after the app
// owner approves the dependency. Always present; tables is an empty
// array when nothing is exposed.
Exposes ManifestExposes `json:"exposes"`
Schedules []registry.Schedule `json:"schedules"`
Tasks []registry.Task `json:"tasks"`
Permissions []registry.Permission `json:"permissions"`
Expand Down Expand Up @@ -94,6 +100,16 @@ type ManifestEvents struct {
Subscribes map[string]string `json:"subscribes"`
}

// ManifestExposes declares the read-only (SELECT) surface this module opens to
// depending modules. Tables is a flat, sorted, de-duplicated list of table
// NAMES (ms.ExposeTable) in the module's `mod_<id>` schema. There is no
// per-consumer "readableBy" list: the producer only marks a table
// SELECT-eligible; the app owner decides WHO reads it by approving a
// dependency. v1 is TABLES ONLY.
type ManifestExposes struct {
Tables []string `json:"tables"`
}

// buildManifestMCP projects the registry's MCP declarations into wire-safe
// entries (Handler stripped). Uses the shared toolEntries/resourceEntries
// helpers from mcp.go so list endpoints and manifest stay in lockstep.
Expand Down Expand Up @@ -154,6 +170,7 @@ func ManifestHandler(id, slug, name, icon string, tags []string, sqlFS fs.FS, ve
Versions: versions,
Routes: reg.Routes(),
Events: ManifestEvents{Emits: reg.Emits(), Subscribes: reg.Subscribes()},
Exposes: ManifestExposes{Tables: reg.ExposedTables()},
Schedules: reg.Schedules(),
Tasks: reg.Tasks(),
Permissions: reg.Permissions(),
Expand Down
Loading
Loading