type
Contract
title
Plugin settings contract
description
How a plugin declares and persists its settings, and how the host renders and saves them without knowing the plugin.
tags
plugin, settings, contract, nx-common
status
stable
stale_after
Fri Apr 30 2027 02:00:00 GMT+0200 (Central European Summer Time)
generated
day-to-day-task/deepseek-v4.1-flash · Fri Oct 30 2026 12:25:00 GMT+0100 (Central European Standard Time)
verified
process:live-acceptance-run · Fri Oct 30 2026 12:25:00 GMT+0100 (Central European Standard Time)
sources
repo → https://github.com/nextrum-sy/nx-common

The plugin settings contract

nx-common defines the contract, the plugin implements it, the host draws the screen. No descriptor, no reflection in either direction: the plugin's Settings struct is the schema, and its field values are the defaults.

flowchart TD subgraph host["Host — simple-lms, erp"] tabs["settings page<br/>one tab per enabled plugin"] save["save<br/>validate → Put → Update → audit<br/>one transaction"] enabled["enabled = registered AND initialised"] end subgraph contract["nx-common/settings — the contract"] iface["type Settings interface<br/>Fields() · GetAll() · Put() · Update()"] field["Field{Key, Label, Type, Group, Choices, Critical}<br/>kinds: string · bool · int · list · enum"] schema["ValidateSchema at Register<br/>APIVersion 2 · hard fail"] drift["nxtest drift rule<br/>Fields() == struct fields == Put keys"] end subgraph plugin["Plugin"] struct["Settings struct<br/>field values ARE the defaults"] rows[("re_settings<br/>key / value rows")] cfg["Config() → service.Config"] end tabs -->|"render from Fields() + GetAll()"| iface save -->|"validate whole patch, write in one tx"| iface iface --> struct iface --> field schema --> iface drift --> field struct --> rows struct --> cfg enabled --> tabs rows -->|"no row = default in memory<br/>never seeded at boot"| struct class tabs,save,enabled host class iface,field,schema,drift contract class struct,rows,cfg plugin classDef host fill:#15314a,stroke:#2f5d84,color:#e6edf7 classDef plugin fill:#163a2f,stroke:#2f7d5b,color:#e6edf7 classDef contract fill:#2a1f3a,stroke:#6b4f8a,color:#e9dcf5

The four rules

  1. No exported descriptor. The plugin hands the host Fields() — a hand-written list of Field values — not a serialised definition and not reflected struct tags. Reflection exists only in nxtest, to prove the two agree.
  2. Defaults live in the struct. Settings{LockEnforced: true} is both the default and the value. First boot writes no rows; a row appears only when someone saves. Reading is defaults-overlaid-with-rows.
  3. Persistence and versioning are the plugin's. Put validates the whole patch and writes every row in one transaction; Update re-reads and rebuilds all-or-nothing. The host never learns a table name.
  4. Memory never runs ahead of the database. A write inside a caller's transaction is not published until that transaction commits — the cache is marked stale, GetAll answers from the transaction's own view inside it, and the next read after commit picks the values up.

What the host may assume

Rendered from plugins/contract/settings-contract.md · OKF bundle · regenerate with npm run render