From ring-dev-team
Audits Go services for hot-reload runtime-config opportunities with lib-systemplane (Postgres LISTEN/NOTIFY or MongoDB change streams). Sweep mode detects DIY patterns; reference mode catalogs client lifecycle.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ring-dev-team:using-lib-systemplaneThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Sweep mode:
Sweep mode:
Reference mode:
Migration partner: ring:migrating-to-lib-systemplane — 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]]
| 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 |
github.com/LerianStudio/lib-systemplanegithub.com/LerianStudio/lib-commons/v5 v5.0.2 (via tenant-manager/core)github.com/LerianStudio/lib-observability v1.1.0 (log.Logger, tracing.Telemetry, runtime.RecoverAndLog)systemplane.SchemaSQL() + systemplane.DefaultSeedSQL() public artifacts. Runtime DDL hook (runSchema) was removed in v1.6.0. Consumers vendor the artifacts into their own SQL migration pipeline via the make systemplane-ddl generator pattern — see ring:migrating-to-lib-systemplane Gate 3.5 and multi-tenant.md §27 "Cold-tenant resolution"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
go.mod — search for github.com/LerianStudio/lib-systemplane and any v4-era github.com/LerianStudio/lib-commons/v[34]/commons/systemplane importshttps://api.github.com/repos/LerianStudio/lib-systemplane/releases/latest — extract tag_namenot-adopted / up-to-date / minor-drift / moderate-drift / major-upgrade / v4-residuev4/commons/systemplane or Supervisor/BundleFactory import survives → flag v4-residue: true/tmp/systemplane-version-report.json:
{adopted, pinned_version, latest_version, drift_classification, v4_residue, module_path}https://raw.githubusercontent.com/LerianStudio/lib-systemplane/main/CHANGELOG.mdnew-api / breaking-change / tenant-feature / admin-feature / security-fix / performance / bugfixMIGRATION_TENANT_SCOPED.md for two-phase rolling-deploy implications when adopting tenant overrides/tmp/systemplane-delta-report.json with classified entriesBefore emitting any Task call, count the explorers you intend to launch in this turn.
All 7 explorers leave in the SAME TURN, before reading any explorer output.
Forbidden sequences:
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.
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.
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".
DIY patterns to grep:
signal.Notify(.*syscall.SIGHUP paired with re-reading .env, YAML, or os.Getenv post-startupfsnotify.NewWatcher() watching config filesos.Open a config file on a time.Tickermain() has startedReplacement: 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.
DIY patterns to grep:
viper.WatchConfig() / viper.OnConfigChange(envconfig.Watch / kelseyhightower/envconfig reload helperstime.Ticker polling a settings tableReplacement: Same as Angle 1. Viper's file-watching does not bind keys to validators or redaction policies; lib-systemplane does both at Register.
DIY patterns to grep:
LISTEN SQL statements in code targeting a config / settings / feature-flag tableconn.WaitForNotification( consumers on settings channelspgx.Connect long-lived connections used solely for config notificationsNOTIFY triggers on a runtime-config table without debounce or write-through cacheReplacement: 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.
DIY patterns to grep:
coll.Watch( against a config collectionmongo.ChangeStream consumers without panic recoverytime.Ticker + Find) over a settings collectionresumeAfter token persistence for config streamsReplacement: 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.
DIY patterns to grep:
(tenantID, key) → value map indexed off a global config structcore.GetTenantIDContext(ctx) followed by manual cascade to a global value or defaultcore.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.
DIY patterns to grep:
GET/PUT/DELETE on a settings table or collectionGET /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.
DIY patterns to grep:
github.com/LerianStudio/lib-commons/v[34]/commons/systemplane importsSupervisor, BundleFactory, ApplyBehavior types or methodsSYSTEMPLANE_* environment variables (the v4-era runtime knobs)domain/, ports/, registry/, service/, bootstrap/ under any systemplane/ treelib-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)systemplane.SchemaSQL() called at boot, runSchema-style hooks, CREATE TABLE systemplane_entries executed outside migrations/, missing cmd/generate-systemplane-ddl/ + migrations/systemplane_ddl_manifest.json, missing make systemplane-ddl / check-systemplane-ddl-drift Make targetsReplacement:
github.com/LerianStudio/lib-systemplane; delete the v4 sub-packages outright (v5 has no equivalent layers — the API surface is flat).make systemplane-ddl generator (canonical scaffold: go-boilerplate-ddd once its systemplane-ddl PR lands; reference implementation in plugin-br-bank-transfer at cmd/generate-systemplane-ddl/, migrations/000011_systemplane_schema.{up,down}.sql → 000013_systemplane_project_seed.{up,down}.sql, migrations/systemplane_ddl_manifest.json, and the bootstrap seam SystemplaneSeedEntries() ([]SystemplaneSeedEntry, error) at internal/bootstrap/systemplane_ddl_gen.go).Severity rationale: v4 packages and the runtime DDL hook are deleted from current lib-systemplane; any surviving call site will fail the build (v4) or fail at boot (runtime DDL under a least-privilege tenant-manager role). Surfacing both in the sweep prevents a CI/runtime surprise.
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:migrating-to-lib-systemplane for the gated implementation cycle, or to ring:running-dev-cycle for ad-hoc remediation.
The API is small enough to inline. Sections follow the lifecycle order the client enforces at runtime: construct → register → start → read/write/subscribe → close.
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.store.ErrNilBackend if db == nil.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).
WithPollInterval(2*time.Second) (or another positive duration).store.ErrNilBackend if client == nil.mc, _ := mongo.Connect(options.Client().ApplyURI("mongodb://..."))
client, err := systemplane.NewMongoDB(mc, "app",
systemplane.WithPollInterval(2*time.Second), // standalone Mongo
)
| 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. |
Start)Register(namespace, key string, defaultValue any, opts ...KeyOption) errorDeclares a globals-only key. Must be called before Start — returns ErrRegisterAfterStart otherwise.
"tenants" (collides with the admin tenant routes — ErrValidation).U+001F (Unit Separator) in namespace or key — ErrValidation.(namespace, key) registration → ErrDuplicateKey.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) errorDeclares 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 |
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).
Start and CloseStart(ctx context.Context) errorcacheMu.store.List; unregistered rows are logged and skipped.runtime.RecoverAndLog.Idempotent: a second Start returns nil. Returns ErrClosed on a closed receiver.
Close() errorsync.Once), safe on a nil receiver.closeWaitTimeout (10s) for it to exit.store.Close on the backend adapter. Does not close the externally-owned *sql.DB / *mongo.Client.All read accessors return zero values when the receiver is nil, the client is closed, or the key is unregistered.
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) stringKeyRedaction(ns, key) RedactPolicyLogger() log.Logger — never nil; subpackages (notably admin) reuse the client's logger.GetString(ns, key) string — "" on miss.GetInt(ns, key) int — accepts both int and float64 (JSON numbers decode as float64).GetBool(ns, key) boolGetFloat64(ns, key) float64GetDuration(ns, key) time.Duration — accepts time.Duration, time.ParseDuration-compatible strings, or float64 nanoseconds.List(namespace string) []ListEntryReturns all currently-cached entries in the namespace, sorted by key. ListEntry{Key, Value, Description}. Empty namespace → empty slice (never nil).
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:
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).
Set(ctx, namespace, key string, value any, actor string) errorGlobals-only write. Runs the registered validator (if any), json.Marshals 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) errorTenant 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) errorRemoves 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) []stringReturns 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.
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.
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()
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.
| 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.
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) |
NewForTesting)Build-tag gated: compiled only under -tags=unit or -tags=integration. Excluded from production binaries.
//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.WithDebounce).For contract testing against a real backend, see systemplanetest in the library repo (separate package, mirrors lib-commons conventions).
| 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. |
SchemaSQL / DefaultSeedSQL) — migration-onlylib-systemplane v1.6.0+ publishes the DDL it needs as two public functions and removes the runtime runSchema hook. Consumers MUST fold the artifacts into their own SQL migration pipeline; provisioning at boot is FORBIDDEN.
| Artifact | Returns | Folds into |
|---|---|---|
systemplane.SchemaSQL() string | Byte-faithful DDL for systemplane_entries table + systemplane_notify_v3 function + 2 triggers (fully idempotent: CREATE TABLE IF NOT EXISTS / CREATE OR REPLACE / DROP TRIGGER IF EXISTS). Channel systemplane_changes, table systemplane_entries are fixed (no placeholders). | migrations/NNN_systemplane_schema.up.sql (down = drop triggers + function, never the table) |
systemplane.DefaultSeedSQL() string | INSERT ... ON CONFLICT (namespace, key) DO NOTHING over the universal default keys (the lib's own runtime knobs). | migrations/NNN+1_systemplane_default_seed.up.sql (down = value-guarded DELETE of the universal keys) |
Canonical scaffold: make systemplane-ddl (generator under cmd/generate-systemplane-ddl/) vendors both artifacts into append-only migrations + a migrations/systemplane_ddl_manifest.json, then emits a third project-seed migration derived from the service's own registrations via a bootstrap seam returning []SystemplaneSeedEntry. The full pattern (seam contract, manifest, drift guard, MT/ST symmetry) is normative in multi-tenant.md §27 "Cold-tenant resolution" and operational in ring:migrating-to-lib-systemplane Gate 3.5. Reference implementation: plugin-br-bank-transfer (cmd/generate-systemplane-ddl/, internal/bootstrap/systemplane_ddl_gen.go, migrations/000011..000013_systemplane_*).
Invariants (locked):
SchemaSQL() or DefaultSeedSQL() at boot. Runtime DDL is a CRITICAL deviation — least-privilege tenant-manager roles cannot execute it.migrations/NNN_systemplane_*.sql. The generator is the only writer; check-systemplane-ddl-drift enforces this.SystemplaneSeedEntries() ([]SystemplaneSeedEntry, error) or equivalent) across services. The generator depends on it; ad-hoc per-project shapes break the scaffold contract.migrate-up or multi-tenant tenant-manager provisioning — same .sql files).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.
ring:migrating-to-lib-systemplane — gated end-to-end migration cycle (stack detection → v5 upgrade → register → subscribe → admin mount → tests → review). Use after this skill identifies adoption opportunities.log.Logger, tracing.Telemetry, runtime.RecoverAndLog — the three injected by WithLogger / WithTelemetry.npx claudepluginhub p/lerianstudio-ring-dev-team-dev-teamMigrates Lerian Go services from static .env/YAML config to lib-systemplane hot-reloadable runtime config client. Orchestrates an 11-gate migration cycle.
Plans zero-downtime data and system migrations with rollback procedures, validation checks, and execution timeline. Useful for database restructures, provider switches, and infrastructure moves.
Enforces Supabase project governance: shared RLS policy templates, naming conventions, CI migration reviews, cost alerts, security audits for common misconfigurations.