Skip to content
Back to skills

Using Lib Systemplane

ASecurity

Dual-mode skill for github.com/LerianStudio/lib-systemplane, Lerian's dual-backend (Postgres LISTEN/NOTIFY or MongoDB change streams) hot-reload runtime configuration plane. Sweep Mode dispatches 7 parallel explorers to detect DIY runtime-config wiring (env reload via SIGHUP, fsnotify/viper.WatchConfig, raw pgx LISTEN, hand-rolled change-stream watchers, manual tenant-scoping, hand-built admin CRUD UIs, v4 systemplane residue). Reference Mode catalogs the API by lifecycle (construct → registe...

  • 215 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 27, 2026
developmentgosqltestinggitapidatabasefrontendbackendsecurityperformance

Works with

  • claude code
  • cli
  • api

Security analysis

A100/100

Scanned May 27, 2026

npx -y skills add LerianStudio/ring --skill using-lib-systemplane --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Using Lib Systemplane?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Using Lib Systemplane
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/lerianstudio-using-lib-systemplane/badge)](https://www.skillsdirectory.com/skills/lerianstudio-using-lib-systemplane)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: ring:using-lib-systemplane
description: Dual-mode skill for github.com/LerianStudio/lib-systemplane, Lerian's dual-backend (Postgres LISTEN/NOTIFY or MongoDB change streams) hot-reload runtime configuration plane. Sweep Mode dispatches 7 parallel explorers to detect DIY runtime-config wiring (env reload via SIGHUP, fsnotify/viper.WatchConfig, raw pgx LISTEN, hand-rolled change-stream watchers, manual tenant-scoping, hand-built admin CRUD UIs, v4 systemplane residue). Reference Mode catalogs the API by lifecycle (construct → register → start → read/write/subscribe → close), including tenant-scoped overrides, the Fiber admin surface, redaction policies, and the test harness. For end-to-end migration use ring:dev-systemplane-migration. Skip for non-Go or frontend code.
---

# ring:using-lib-systemplane

## When to use
Sweep mode:
- "Sweep the codebase for lib-systemplane opportunities"
- "Find where we hot-reload config DIY (SIGHUP, fsnotify, viper.WatchConfig)"
- "Audit this service for lib-systemplane adoption"
- "Find raw pgx LISTEN / Mongo change-stream watchers wired against config tables"
- "Detect v4 systemplane residue (Supervisor, BundleFactory, SYSTEMPLANE_* env vars)"

Reference mode:
- "What does lib-systemplane provide?"
- "How do I construct the client for Postgres / MongoDB?"
- "Show me Register vs RegisterTenantScoped"
- "Which read accessor should I use for a duration / int / bool?"
- "How do OnChange and OnTenantChange differ?"
- "How do I mount the admin HTTP surface safely?"
- "What does the test harness look like?"

## Skip when
- Working on non-Go services
- Working on frontend code
- Target codebase has zero hot-reloadable runtime knobs (everything is static env-var-at-startup config — DSNs, TLS material, listen addresses, secrets stay outside the plane)
- Task is documentation-only or non-code

## Related
**Migration partner:** [[ring:dev-systemplane-migration]] — end-to-end 11-gate migration cycle. This skill is the **adoption/reference** counterpart; the migration skill is the **transformation pipeline**.
**Similar:** [[ring:using-lib-commons]], [[ring:using-lib-observability]], [[ring:using-runtime]], [[ring:using-assert]]

---

## Mode Selection

| Request Shape | Mode |
|---|---|
| "Sweep / audit / find DIY runtime config / migrate to lib-systemplane" | **Sweep** |
| "What does lib-systemplane provide for X?" | **Reference** |
| "How do I initialize / register / subscribe?" | **Reference** |
| "Replace our fsnotify + SIGHUP plumbing with lib-systemplane" | **Sweep** |
| "Wire admin routes onto our Fiber app" | **Reference** |

---

## Module Facts (lock-checked)

- **Module path:** `github.com/LerianStudio/lib-systemplane`
- **Go version:** 1.26.3+
- **Tenant context:** `github.com/LerianStudio/lib-commons/v5 v5.0.2` (via `tenant-manager/core`)
- **Observability:** `github.com/LerianStudio/lib-observability v1.0.0` (`log.Logger`, `tracing.Telemetry`, `runtime.RecoverAndLog`)
- **Dual backend:** Postgres 13+ (LISTEN/NOTIFY) **or** MongoDB 4.4+ (change streams; polling fallback for standalone deployments)
- **License:** Elastic 2.0
- **Scope:** runtime-mutable knobs only — never bootstrap-only material (DSNs, TLS, listen addresses, secrets)

---

# SWEEP MODE

Orchestrate a 4-phase sweep. Each phase has a hard gate — do not proceed until the current phase produces its artifact.

```
Phase 1: Version Reconnaissance     → systemplane-version-report.json
Phase 2: CHANGELOG Delta Analysis   → systemplane-delta-report.json
Phase 3: Multi-Angle DIY Sweep      → 7 × systemplane-sweep-{N}-{angle}.json
Phase 4: Consolidated Report        → systemplane-sweep-report.md + tasks.json
```

## Phase 1: Version Reconnaissance

1. Read `go.mod` — search for `github.com/LerianStudio/lib-systemplane` and any v4-era `github.com/LerianStudio/lib-commons/v[34]/commons/systemplane` imports
2. WebFetch `https://api.github.com/repos/LerianStudio/lib-systemplane/releases/latest` — extract `tag_name`
3. Classify drift: `not-adopted` / `up-to-date` / `minor-drift` / `moderate-drift` / `major-upgrade` / `v4-residue`
4. If any `v4/commons/systemplane` or `Supervisor`/`BundleFactory` import survives → flag `v4-residue: true`
5. Emit `/tmp/systemplane-version-report.json`:
   `{adopted, pinned_version, latest_version, drift_classification, v4_residue, module_path}`

## Phase 2: CHANGELOG Delta Analysis

1. WebFetch `https://raw.githubusercontent.com/LerianStudio/lib-systemplane/main/CHANGELOG.md`
2. Extract entries between pinned_version (exclusive) and latest_version (inclusive). If not yet adopted, summarize the whole CHANGELOG.
3. Classify each entry: `new-api` / `breaking-change` / `tenant-feature` / `admin-feature` / `security-fix` / `performance` / `bugfix`
4. Cross-reference `MIGRATION_TENANT_SCOPED.md` for two-phase rolling-deploy implications when adopting tenant overrides
5. Emit `/tmp/systemplane-delta-report.json` with classified entries

## Phase 3: Multi-Angle DIY Sweep

### ⛔ STOP-CHECK BEFORE DISPATCH

Before emitting any Task call, count the explorers you intend to launch in this turn.
- Count MUST equal 7.
- If count < 7 → STOP. Do not partial-dispatch. Reconcile against the 7 angles below and try again.
- The 7 angles are the canonical sweep. No substitutions, no omissions.

### ⛔ MUST NOT trickle-dispatch

All 7 explorers leave in the SAME TURN, before reading any explorer output.

Forbidden sequences:
- Dispatch explorer 1 → read result → dispatch explorer 2
- Dispatch a subset → wait → dispatch the rest
- Dispatch follow-up explorers conditioned on partial output
- Loop sequentially over the angle list

If you find yourself about to dispatch an explorer in a turn AFTER any explorer has already returned a result → STOP. You violated parallel dispatch. Report the violation and mark the phase INCOMPLETE rather than completing the trickle.

### Self-verify after dispatch

After the dispatch turn, verify all 7 Task calls were emitted in that single turn. If fewer than 7 went out, the phase did NOT execute correctly. Mark INCOMPLETE and surface the dispatch failure — do NOT silently continue with a partial pool.

### Parallel dispatch — atomic batch

Emit all 7 Task calls in a SINGLE TURN, as one atomic batch.

**If your runtime exposes a `multi_tool_use.parallel` wrapper**, use it to dispatch the complete pool in one wrapped invocation. This is the canonical fan-out mechanism on OpenAI-style tool envelopes and on certain Anthropic SDK consumers — naming it explicitly activates parallel emission on runtimes where trickle-dispatch is the default behavior.

**If your runtime emits parallel tool_use blocks natively** (Claude Code with Claude models), `multi_tool_use.parallel` may not be needed — but naming it is harmless and serves as an enforcement anchor.

The STOP-CHECK, anti-trickle, and self-verify guards above remain binding regardless of which mechanism your runtime uses.

Dispatch all 7 explorer angles **in a single parallel batch**. Wait for all before Phase 4.

**Per-explorer dispatch** (`subagent_type: ring:codebase-explorer`):

```
## Target
<absolute path to target repo root>

## Your Angle
<angle number + name>

## Severity Calibration / DIY Patterns / Replacement / Migration Complexity / Version Context
<verbatim from the angle spec below>

## Output
Write findings to: /tmp/systemplane-sweep-{N}-{angle-slug}.json
Schema: { angle_number, angle_name, severity, migration_complexity,
  findings: [{file, line, diy_pattern, replacement, evidence_snippet, notes}],
  summary, requires_major_upgrade }
If no findings: write file with empty findings array and summary
"No DIY patterns detected for this angle".
```

### Angle 1 — SIGHUP / fsnotify .env reload (CRITICAL)

**DIY patterns to grep:**
- `signal.Notify(.*syscall.SIGHUP` paired with re-reading `.env`, YAML, or `os.Getenv` post-startup
- `fsnotify.NewWatcher()` watching config files
- Goroutines that `os.Open` a config file on a `time.Ticker`
- Any code path that re-loads env vars after `main()` has started

**Replacement:** `systemplane.NewPostgres` / `NewMongoDB` + `Register` + `Start` + `OnChange`. Per-key subscriptions replace the global reload pulse.

**Severity rationale:** SIGHUP/fsnotify reloads are racy by definition (no per-key fan-out, no validator, no audit trail). Hot-reload runtime config without observability is a class of silent misbehavior.

### Angle 2 — viper.WatchConfig / envconfig.Watch (HIGH)

**DIY patterns to grep:**
- `viper.WatchConfig()` / `viper.OnConfigChange(`
- `envconfig.Watch` / `kelseyhightower/envconfig` reload helpers
- Hand-rolled `time.Ticker` polling a settings table

**Replacement:** Same as Angle 1. Viper's file-watching does not bind keys to validators or redaction policies; lib-systemplane does both at `Register`.

### Angle 3 — Raw pgx LISTEN for config tables (CRITICAL)

**DIY patterns to grep:**
- `LISTEN ` SQL statements in code targeting a config / settings / feature-flag table
- `conn.WaitForNotification(` consumers on settings channels
- `pgx.Connect` long-lived connections used solely for config notifications
- Custom `NOTIFY` triggers on a runtime-config table without debounce or write-through cache

**Replacement:** `systemplane.NewPostgres(db, listenDSN, opts...)`. The library owns the LISTEN connection lifecycle, debounces per `(namespace, key, tenantID)` via `WithDebounce` (default 100ms), and provides a write-through cache so the in-process Get is consistent with the changefeed echo.

**Severity rationale:** Hand-rolled LISTEN paths typically miss the panic-recovery and reconnect-with-backoff machinery `internal/postgres` provides — silent goroutine death under load.

### Angle 4 — Hand-rolled MongoDB change-stream / polling watchers (CRITICAL)

**DIY patterns to grep:**
- `coll.Watch(` against a config collection
- `mongo.ChangeStream` consumers without panic recovery
- Polling loops (`time.Ticker` + `Find`) over a settings collection
- Manual `resumeAfter` token persistence for config streams

**Replacement:** `systemplane.NewMongoDB(client, "db", opts...)`. Pass `WithPollInterval(...)` for standalone Mongo (no replica set); otherwise change-streams are used automatically. The library wraps the subscribe goroutine with `runtime.RecoverAndLog`.

### Angle 5 — Manual tenant-scoping in config reads (HIGH)

**DIY patterns to grep:**
- Custom `(tenantID, key) → value` map indexed off a global config struct
- Calls to `core.GetTenantIDContext(ctx)` followed by manual cascade to a global value or default
- Tenant-specific config columns / Mongo fields read directly without validator or redaction
- Missing tenant-ID validation when reading config (no `core.IsValidTenantID` check)

**Replacement:** `RegisterTenantScoped(...)` + `GetForTenant(ctx, ns, key)` (or the typed `GetStringForTenant` / `GetIntForTenant` / `GetBoolForTenant` / `GetFloat64ForTenant` / `GetDurationForTenant`). The library's `extractTenantID` is **fail-closed** — `ErrMissingTenantContext` / `ErrInvalidTenantID` is returned rather than silently falling back to a shared global.

**Severity rationale:** Silent fallback from a missing tenant to a shared global is the cross-tenant-leak vector. The library's fail-closed contract makes the bug a loud error instead of a quiet data leak.

### Angle 6 — Hand-built HTTP admin UI for config CRUD (MEDIUM)

**DIY patterns to grep:**
- Custom Fiber/Gin/chi handlers for `GET/PUT/DELETE` on a settings table or collection
- Authorization middleware bolted onto a config admin route without a default-deny path
- Routes echoing config values without applying a redaction policy
- Handcrafted tenant-list endpoints (`GET /system/:ns/:key/tenants`)

**Replacement:** `admin.Mount(router, client, admin.WithPathPrefix(...), admin.WithAuthorizer(...), admin.WithTenantAuthorizer(...), admin.WithActorExtractor(...))`. The library serves the full six-route surface (list namespace, get/put one, list tenants for key, put/delete tenant override) with default-deny authorization and per-key redaction.

**Severity rationale:** Default-deny is the safe-by-default property. Hand-built admin routes routinely ship with weaker authorization than the lib-commons-backed reference implementation.

### Angle 7 — v4 systemplane residue (CRITICAL)

**DIY patterns to grep:**
- `github.com/LerianStudio/lib-commons/v[34]/commons/systemplane` imports
- `Supervisor`, `BundleFactory`, `ApplyBehavior` types or methods
- `SYSTEMPLANE_*` environment variables (the v4-era runtime knobs)
- Sub-packages from the v4 layout: `domain/`, `ports/`, `registry/`, `service/`, `bootstrap/` under any `systemplane/` tree
- `lib-commons/v5/commons/systemplane` imports (the v5 package was **extracted** to the standalone `lib-systemplane` module; `lib-commons/v5/commons/systemplane` is the pre-extraction location and signals an out-of-date pin)

**Replacement:** Switch to the standalone module `github.com/LerianStudio/lib-systemplane`. Delete the v4 sub-packages outright; v5 has no equivalent layers because the API surface is flat.

**Severity rationale:** v4 packages are **deleted** from current lib-commons; any surviving import will fail the build under a clean module cache. Surfacing this in the sweep prevents a CI surprise.

## Phase 4: Consolidated Report

Dispatch synthesizer (`subagent_type: ring:codebase-explorer`):

```
Read /tmp/systemplane-version-report.json, /tmp/systemplane-delta-report.json,
and /tmp/systemplane-sweep-*.json (7 files).

Emit:
1. /tmp/systemplane-sweep-report.md — aggregate findings by severity
2. /tmp/systemplane-sweep-tasks.json — one task per DIY-pattern cluster
   (same file/package = one task). Each task references the matching
   replacement API surface from Reference Mode.

MUST NOT invent findings.
MUST NOT omit explorer findings.
MUST NOT reclassify severity without justification.
```

Surface report path + task count to user; offer handoff to `ring:dev-systemplane-migration` for the gated implementation cycle, or to `ring:dev-cycle` for ad-hoc remediation.

---

# REFERENCE MODE

The API is small enough to inline. Sections follow the **lifecycle order** the client enforces at runtime: construct → register → start → read/write/subscribe → close.

## 1. Construction

### `NewPostgres(db *sql.DB, listenDSN string, opts ...Option) (*Client, error)`

Backs the client with Postgres LISTEN/NOTIFY.

- `db` is the read/write handle (returned by `sql.Open("pgx", dsn)`).
- `listenDSN` is a **separate** DSN passed to `pgx.Connect` for the long-lived LISTEN connection. `database/sql` does not expose its underlying DSN, so the caller supplies it explicitly. Typically equal to the DSN used to open `db`.
- Returns `store.ErrNilBackend` if `db == nil`.

```go
db, err := sql.Open("pgx", dsn)
if err != nil { return err }

client, err := systemplane.NewPostgres(db, dsn,
    systemplane.WithLogger(logger),
    systemplane.WithTelemetry(telemetry),
    systemplane.WithDebounce(150*time.Millisecond),
)
```

### `NewMongoDB(client *mongo.Client, database string, opts ...Option) (*Client, error)`

Backs the client with MongoDB change-streams (or polling if `WithPollInterval` is set).

- Change-streams require a **replica set**. For standalone MongoDB, pass `WithPollInterval(2*time.Second)` (or another positive duration).
- Returns `store.ErrNilBackend` if `client == nil`.

```go
mc, _ := mongo.Connect(options.Client().ApplyURI("mongodb://..."))
client, err := systemplane.NewMongoDB(mc, "app",
    systemplane.WithPollInterval(2*time.Second), // standalone Mongo
)
```

## 2. Options (construction-time)

| Option | Default | Purpose |
|---|---|---|
| `WithLogger(log.Logger)` | `log.NewNop()` | Structured logger (lib-observability). Nil is ignored. |
| `WithTelemetry(*tracing.Telemetry)` | nil | OpenTelemetry provider for spans/metrics. Nil is ignored. |
| `WithListenChannel(string)` | `"systemplane_changes"` | Postgres LISTEN channel name. Ignored by MongoDB. |
| `WithPollInterval(time.Duration)` | 0 (change-streams) | Enables MongoDB polling mode. Ignored by Postgres. |
| `WithDebounce(time.Duration)` | 100ms | Trailing-edge debounce per `(ns, key, tenantID)` tuple. Zero/negative disables debouncing. |
| `WithCollection(string)` | `"systemplane_entries"` | MongoDB collection. Ignored by Postgres. |
| `WithTable(string)` | `"systemplane_entries"` | Postgres table. Ignored by MongoDB. |
| `WithLazyTenantLoad(maxEntries int)` | eager mode | Switches tenant cache from eager hydration to a bounded LRU (`hashicorp/golang-lru/v2`). Non-positive `max` falls back to eager. |
| `WithTenantSchemaEnabled()` | false (phase-1 compat) | Drops the legacy `(namespace, key)` unique constraint and creates the composite `(namespace, key, tenant_id)` unique. Required before tenant writes succeed. See `MIGRATION_TENANT_SCOPED.md` §4 for rolling-deploy ordering. |

## 3. Key Registration (before `Start`)

### `Register(namespace, key string, defaultValue any, opts ...KeyOption) error`

Declares a globals-only key. **Must be called before `Start`** — returns `ErrRegisterAfterStart` otherwise.

- Reserved key name: `"tenants"` (collides with the admin tenant routes — `ErrValidation`).
- Reserved character: `U+001F` (Unit Separator) in namespace or key — `ErrValidation`.
- Duplicate `(namespace, key)` registration → `ErrDuplicateKey`.
- If `WithValidator` is supplied and rejects the default value at registration time → `ErrValidation` wrapping the validator's own error.

### `RegisterTenantScoped(namespace, key string, defaultValue any, opts ...KeyOption) error`

Declares a tenant-scoped key. Same global semantics as `Register` (Get / Set / List / OnChange still operate on the legacy global row), **plus** the key becomes eligible for per-tenant overrides via `SetForTenant` / `GetForTenant` / `DeleteForTenant` / `OnTenantChange`.

**Mutable-default caveat (locked in code comments):** the registered default is held by reference. A subscriber mutating a slice/map default mutates it for every tenant falling through to the default. Prefer value types or wrap in a defensive copy.

### `KeyOption` setters

| Option | Effect |
|---|---|
| `WithDescription(string)` | Human-readable description surfaced via `KeyDescription` and admin GET responses |
| `WithValidator(func(any) error)` | Runs against the default at registration AND against every `Set` / `SetForTenant` value |
| `WithRedaction(RedactPolicy)` | Renders value as raw / `"****"` / `"[REDACTED]"` in admin output and logs |

### Redaction policies

```go
const (
    RedactNone RedactPolicy = iota // raw value
    RedactMask                     // "****"
    RedactFull                     // "[REDACTED]"
)

func ApplyRedaction(value any, policy RedactPolicy) any
```

Any key holding credentials / tokens / secrets MUST use `RedactFull`. The admin handlers call `ApplyRedaction` per key on every GET, with the policy looked up via `client.KeyRedaction(ns, key)`.

## 4. Lifecycle: `Start` and `Close`

### `Start(ctx context.Context) error`

1. Seeds the cache with registered defaults under `cacheMu`.
2. Hydrates from the backing store via `store.List`; unregistered rows are logged and skipped.
3. Eagerly hydrates the tenant cache (skipped in lazy mode — first-touch populates the LRU).
4. Launches the subscribe goroutine wrapped in `runtime.RecoverAndLog`.

Idempotent: a second `Start` returns nil. Returns `ErrClosed` on a closed receiver.

### `Close() error`

- Idempotent (via `sync.Once`), safe on a nil receiver.
- Cancels the subscribe goroutine and waits up to `closeWaitTimeout` (10s) for it to exit.
- Calls `store.Close` on the backend adapter. Does **not** close the externally-owned `*sql.DB` / `*mongo.Client`.

## 5. Reads (nil-receiver safe)

All read accessors return zero values when the receiver is nil, the client is closed, or the key is unregistered.

### Untyped

- `Get(ns, key string) (any, bool)` — `(value, true)` on hit; falls through cache → default.
- `KeyStatus(ns, key) (registered, tenantScoped bool)` — used by admin to distinguish 404 vs 400.
- `KeyDescription(ns, key) string`
- `KeyRedaction(ns, key) RedactPolicy`
- `Logger() log.Logger` — never nil; subpackages (notably `admin`) reuse the client's logger.

### Typed (legacy / globals)

- `GetString(ns, key) string` — `""` on miss.
- `GetInt(ns, key) int` — accepts both `int` and `float64` (JSON numbers decode as float64).
- `GetBool(ns, key) bool`
- `GetFloat64(ns, key) float64`
- `GetDuration(ns, key) time.Duration` — accepts `time.Duration`, `time.ParseDuration`-compatible strings, or float64 nanoseconds.

### `List(namespace string) []ListEntry`

Returns all currently-cached entries in the namespace, sorted by key. `ListEntry{Key, Value, Description}`. Empty namespace → empty slice (never nil).

### Typed tenant reads

These mirror the legacy typed accessors but **surface errors** instead of silently returning zeros. Type mismatches return `ErrValidation` (configuration bug, not a runtime miss).

- `GetStringForTenant(ctx, ns, key) (string, error)`
- `GetIntForTenant(ctx, ns, key) (int, error)` — rejects non-integral float64 with `ErrValidation` (silent truncation would convert one bad config into another valid-looking config).
- `GetBoolForTenant(ctx, ns, key) (bool, error)`
- `GetFloat64ForTenant(ctx, ns, key) (float64, error)`
- `GetDurationForTenant(ctx, ns, key) (time.Duration, error)`

Underlying primitive:

```go
GetForTenant(ctx, ns, key) (value any, found bool, err error)
```

Resolution order: `tenantCache[tenantID][nk]` → legacy `cache[nk]` → registered default. `found` is `true` whenever a value is returned (including the default fallthrough — the "no tenant override yet" case is **not** an error).

## 6. Writes

### `Set(ctx, namespace, key string, value any, actor string) error`

Globals-only write. Runs the registered validator (if any), `json.Marshal`s the value, persists via `store.Set`, then write-through-caches the **JSON-canonicalized** value (so a subsequent `GetInt` returns a `float64` consistently with the changefeed echo).

Errors: `ErrClosed`, `ErrNotStarted`, `ErrUnknownKey`, `ErrValidation` (validator or non-serializable value), or any wrapped store error.

**Subscribers are NOT fired from `Set`.** The changefeed echo drives `OnChange`. This invariant prevents double-firing and keeps the semantic that `OnChange` observes *backend* state changes.

### `SetForTenant(ctx, namespace, key string, value any, actor string) error`

Tenant write. Validates tenant ID via `extractTenantID` (fail-closed: `ErrMissingTenantContext`, `ErrInvalidTenantID`), checks `requireTenantScoped` (`ErrUnknownKey`, `ErrTenantScopeNotRegistered`), runs validator, marshals, persists via `store.SetTenantValue`, write-through-caches.

Additional errors: `ErrTenantSchemaNotEnabled` (phase-1 backend rejecting tenant writes).

### `DeleteForTenant(ctx, namespace, key, actor string) error`

Removes a tenant override. **Idempotent at the backend** (delete-missing returns nil). On a no-op delete the backend emits no event, so `OnTenantChange` does **not** fire — tests relying on the callback must `SetForTenant` first.

### `ListTenantsForKey(namespace, key string) []string`

Returns the sorted, deduplicated set of tenant IDs with an override for `(ns, key)`. Never returns nil — every error path returns the shared `emptyTenantList` sentinel. Excludes the `_global` sentinel. Bounded by an internal 5s timeout.

## 7. Subscriptions

### `OnChange(namespace, key string, fn func(newValue any)) (unsubscribe func())`

Per-key callback fired by the changefeed echo for **global-row** writes (i.e. tenant_id = `_global`). Callbacks are invoked sequentially, each wrapped in `runtime.RecoverAndLog`. The returned unsubscribe is `sync.Once`-guarded; safe to call multiple times.

Nil receiver, nil `fn`, or unregistered key → no-op unsubscribe.

### `OnTenantChange(namespace, key string, fn func(ctx context.Context, ns, key, tenantID string, newValue any)) (unsubscribe func())`

Tenant-aware callback fired by the changefeed echo for **tenant-row** writes. The `ctx` argument is pre-scoped to `tenantID` via `core.ContextWithTenantID`, so subscribers can pass it straight into tenant-aware lib-commons facilities (DLQ, idempotency, webhook delivery) without re-propagating the tenant ID.

A single subscription observes every tenant — the `tenantID` argument distinguishes which override changed.

Invariant (AC8 in the source): `OnChange` fires **exclusively** for `store.SentinelGlobal` events, `OnTenantChange` fires **exclusively** for tenant events. The split is on the row's `tenant_id`, not on whether the key was registered as tenant-scoped.

```go
unsub := client.OnTenantChange("global", "fees.fail_closed_default",
    func(ctx context.Context, ns, key, tenantID string, newValue any) {
        // ctx already carries tenantID — safe for DLQ / webhook / idempotency
    })
defer unsub()
```

## 8. Admin HTTP Surface (`admin` subpackage)

Import path: `github.com/LerianStudio/lib-systemplane/admin`.

### `admin.Mount(router fiber.Router, c *systemplane.Client, opts ...MountOption)`

Routes registered (default prefix `/system`):

```
GET    /<prefix>/:namespace                          - list entries
GET    /<prefix>/:namespace/:key                     - get one entry
PUT    /<prefix>/:namespace/:key                     - write a global value
GET    /<prefix>/:namespace/:key/tenants             - list tenants with overrides
PUT    /<prefix>/:namespace/:key/tenants/:tenantID   - write a tenant override
DELETE /<prefix>/:namespace/:key/tenants/:tenantID   - remove a tenant override
```

Path-segment caps enforced at the edge: namespace ≤ 256 bytes, key ≤ 512 bytes (BadRequest on overflow). Nil `router` or nil `client` makes `Mount` a no-op.

### Mount options

| Option | Effect |
|---|---|
| `WithPathPrefix(string)` | Override the URL prefix (default `/system`). Empty value ignored. |
| `WithAuthorizer(func(*fiber.Ctx, action string) error)` | Authorizes **legacy global routes**. action is `"read"` or `"write"`. Non-nil error → 403 with body `"forbidden"`. Default: deny-all. |
| `WithTenantAuthorizer(func(*fiber.Ctx, action, tenantID string) error)` | Authorizes **tenant routes**. Default: deny-all. For the tenant-list route, `tenantID` is empty. |
| `WithActorExtractor(func(*fiber.Ctx) string)` | Extracts the actor string passed into `client.Set` / `SetForTenant`. Default returns `""`. |

**Critical contract — default-deny escalation:** the two authorizers are **independent**. Configuring only `WithAuthorizer` does **NOT** implicitly grant access to tenant routes (and vice-versa). This prevents a silent privilege escalation: a service that pre-dates tenant support and configures only `WithAuthorizer` cannot accept tenant writes it was never authorized to handle.

**Wire-redaction contract:** authorizer error strings are NOT echoed on the wire (the body is the fixed string `"forbidden"`). The original error is logged at Debug level.

## 9. Errors (sentinel set)

All exported as package-level vars; check via `errors.Is`.

| Sentinel | When |
|---|---|
| `ErrClosed` | Receiver is nil or `Close` has run |
| `ErrNotStarted` | Read/write before `Start` |
| `ErrRegisterAfterStart` | `Register` / `RegisterTenantScoped` after `Start` |
| `ErrUnknownKey` | Unregistered `(namespace, key)` |
| `ErrValidation` | Validator rejected the value, non-JSON-serializable value, or typed accessor mismatch |
| `ErrDuplicateKey` | Re-registration of the same `(namespace, key)` |
| `ErrMissingTenantContext` | Tenant-scoped op called without a tenant ID in ctx |
| `ErrInvalidTenantID` | Tenant ID fails `core.IsValidTenantID` or equals `_global` sentinel |
| `ErrTenantScopeNotRegistered` | Tenant-scoped op against a key registered via `Register` |
| `ErrTenantSchemaNotEnabled` | Tenant write against a phase-1 backend (re-exported from `internal/store`) |

## 10. Test Harness (`NewForTesting`)

Build-tag gated: compiled only under `-tags=unit` or `-tags=integration`. **Excluded from production binaries.**

```go
//go:build unit || integration

client, err := systemplane.NewForTesting(myTestStore,
    systemplane.WithDebounce(0), // already the default for testing
)
```

- `TestStore` mirrors the internal `store.Store` interface with public `TestEntry` / `TestEvent` types.
- Debouncing is **disabled** by default for deterministic tests (override via `WithDebounce`).
- API stability is **not** promised — the harness is intentionally undocumented in README/API docs.

For contract testing against a real backend, see `systemplanetest` in the library repo (separate package, mirrors lib-commons conventions).

## 11. Composition With Adjacent Libraries

| Library | Touchpoint |
|---|---|
| `lib-commons/v5/commons/tenant-manager/core` | `ContextWithTenantID`, `GetTenantIDContext`, `IsValidTenantID` — the only allowed tenant-ID extraction path. `OnTenantChange` pre-scopes ctx via `ContextWithTenantID`. |
| `lib-observability/log` | `WithLogger` accepts a `log.Logger`. Logger is reused by `admin` via `client.Logger()` — no parallel admin logger option exists. |
| `lib-observability/tracing` | `WithTelemetry(*tracing.Telemetry)` enables spans on Start / Set / GetForTenant / SetForTenant / DeleteForTenant / ListTenantsForKey. |
| `lib-observability/runtime` | `runtime.RecoverAndLog` wraps the subscribe goroutine **and** every OnChange / OnTenantChange callback dispatch. |
| `lib-commons/v5/commons/net/http` | The admin package uses `commonshttp.RespondError` for uniform error responses. |

## 12. Scope Reminder (locked)

Systemplane is for **runtime-mutable knobs only**. Bootstrap-only configuration (DB DSNs, secrets, TLS material, telemetry endpoints, server identity, listen addresses) belongs in environment variables or a secret manager — **never** in the systemplane plane. Anything requiring resource teardown to apply (reopening a DB pool, rotating a TLS cert) violates the hot-reload contract by definition.

## 13. Cross-references

- [[ring:dev-systemplane-migration]] — gated end-to-end migration cycle (stack detection → v5 upgrade → register → subscribe → admin mount → tests → review). Use after this skill identifies adoption opportunities.
- [[ring:using-lib-commons]] — tenant-manager/core, idempotency, DLQ, webhook delivery, and the broader v5 surface that composes with systemplane.
- [[ring:using-lib-observability]] — `log.Logger`, `tracing.Telemetry`, `runtime.RecoverAndLog` — the three injected by `WithLogger` / `WithTelemetry`.
- [[ring:using-runtime]] — panic-observability trident used internally for the subscribe goroutine and callback shields. Match the same policy elsewhere in your service.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.