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
- No exported descriptor. The plugin hands the host
Fields()— a hand-written list ofFieldvalues — not a serialised definition and not reflected struct tags. Reflection exists only innxtest, to prove the two agree. - 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. - Persistence and versioning are the plugin's.
Putvalidates the whole patch and writes every row in one transaction;Updatere-reads and rebuilds all-or-nothing. The host never learns a table name. - 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,
GetAllanswers from the transaction's own view inside it, and the next read after commit picks the values up.
What the host may assume
GetAll()returns every declared key, always — a missing row is a default, never an error.Put()either accepts the whole patch or refuses it; partial saves don't exist.- A plugin that failed
Inithas no tab and no panel. The host shows nothing rather than an empty form. - Labels are the host's to translate:
plugin_<id>_namefor the tab,Field.Labelas the fallback for a field the host hasn't translated yet.
Rendered from plugins/contract/settings-contract.md · OKF bundle · regenerate with npm run render