M1-46: Instance settings, branding and discovery¶
Status: ready | Depends on: M1-36 | Milestone: M1
Goal¶
After this ticket the instance module serves the public discovery document, the instance view
for every signed-in principal, the documentation of every problem type, and lets owners change
instance settings and branding with step-up and audit. Settings are typed and validated in one
code registry; the accent color is checked against keys generated from the design tokens, so no
free color value or emoji can enter the product (ADR 0044, R-PR-08), and the same generated keys
serve space colors, space icons and forum tag colors in later tickets. Every change writes
instance.updated with exactly the changed keys.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 7 rows M1-15, M1-17, M1-36, M1-37, M1-46, M1-47, M1-52, M3-10; "Prerequisites from the core contract" (rowinstance.settings.read).docs/spec/01-product.md: "Languages" (9), "Branding per instance" (12).docs/spec/03-data-model.md: 4.1 tablesinstance,instance_settings; "Principles" items 10 (optimistic lock) and 13 (nonull).docs/spec/05-actions.md: 4.1 rowinstance.discovery.read; 4.2 rowsinstance.read,instance.settings.read,instance.settings.update,instance.branding.update; 2.6 (no scope forinstance.read); 6 rowinstance.updated.docs/spec/06-api.md: "Basic rules" (2), "Authentication per principal kind" (3, anonymous operations), "Errors" (5), "Concurrency" (8, items 1 to 3: branding ETag derived from several setting versions; settings with optionalIf-Match), "Versioning, deprecation, compatibility" (13, item 1: discovery reportsapi_versions,spec_version,features), 16 "Endpoints" groups Discovery and Admin.docs/spec/08-identity.md: "Initial installation" (4: discovery answers withsetup_requiredbefore setup).docs/spec/16-operations.md: "Configuration" (5.1 item 6: settings are not process configuration).docs/spec/18-release.md: "Compatibility between app and server" (9, item 2: discovery deliversclient.min_version.<platform>andclient.recommended_version.<platform>).docs/spec/19-security.md: SEC-IN-4 (control and bidi characters in display texts).docs/operations/configuration.md: 6 "Instance settings"; sections "03 Data model", "12 Files", "18 Release" of section 4 for the limit and version keys.docs/spec/02-architecture.md: 4.2, 4.3 (rowinstance), 4.4 (instancedepends on nothing), 4.5 "Cross-cutting ports", 5 "Layers within a module", 6 "Code layout" (paragraph on name-bound places), 7.2 "go-arch-lint", 7.3.- ADRs:
docs/adr/0044-working-name-and-branding-as-configuration.md,docs/adr/0030-design-tokens-as-one-source-with-a-generator.md,docs/adr/0013-spec-first-openapi-31-and-asyncapi-30-normative.md,docs/adr/0015-error-format-rfc-9457-idempotency-pagination.md. design/build/tokens.resolved.json(contexts.theme.lightand.darkkeyscolor.category.<key>.*;icons.space-icons),design/build/README.md.docs/api/openapi.yaml: operationsgetDiscovery,getInstance,listInstanceSettings,putInstanceSetting,updateBranding,getProblemType; schemasDiscovery,GatewayInfo,ClientVersionRequirement,AuthProviderInfo,PublicBranding,Branding,BrandingUpdate,InstanceInfo,InstanceLimits,EvaluatedFlag,InstanceSetting,InstanceSettingPut,InstanceSettingList,ProblemType,Problem,FieldError,InstanceUpdatedPayload; parametersKeyPath,CodePath,IfMatchRequired,IfMatchOptional.docs/api/asyncapi.yamlinfo.- Code of earlier tickets:
internal/instance(M1-15 instance row andCounters, M1-32 keys),internal/identity(M1-26 display-name check, M1-36 identity providers), M1-17 generator ofapi/routesandapi/operations.yaml, M1-18 problem catalogue ininternal/platform/httpserver, M1-07 configuration schema, M1-05architecture/modules.yamlandtools/archgen. - Tables:
instance,instance_settings. Action ids:instance.discovery.read,instance.read,instance.settings.read,instance.settings.update,instance.branding.update.
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behavior gets a test that is red before the code exists (plan section 3).
- No en dash or em dash in any text you write; white label (people Alex, Sam, Robin, domains
under example.com); configuration only through named keys from
docs/operations/configuration.md. - 01 12 and ADR 0044:
branding.accent_coloris a key of the category colorsspruce,loch,heather,rowan,amber,gorse,moss; a hex value, a CSS color or an emoji is rejected. The server checks against keys fromdesign/build/tokens.resolved.json. - 03
instance_settings: typed key-value pairs, value checked against the key's schema,versionas optimistic lock; keys and defaults as in configuration.md section 6. - 05 4.2: reading all settings is owner only; changing settings and branding is owner only with step-up and audit (allow and deny).
- 06 8: branding changes need
If-Match(428without,412 version_conflictwithcurrenton mismatch); the branding ETag is derived from the versions of its setting keys; settings take an optionalIf-Match. - 05 6 and 16 6.2:
instance.updatednames only the changed keys, never values. - 08 4: discovery answers before setup with
setup_required: true. - 06 13 item 1 and 18 9 item 2: discovery reports
api_versions,spec_version,featuresand per platform the minimum and recommended app version. - 06 5:
GET /api/v1/problems/{code}documents every known problem code. - 19 SEC-IN-4:
branding.product_namewith control or bidi control characters is rejected.
Scope¶
Paths are relative to services/server/ unless they start with docs/, design/ or Makefile.
- Specification (in place from planning, spec first; change it only where the
implementation proves it wrong):
docs/api/openapi.yaml(Discovery.setup_requiredrequired,ClientVersionRequirement.recommended_version,InstanceSettingwithupdated_atoptional and version0for defaults,FieldError.pointerwith#path/<name>,x-problem-codeswithsetup_required);docs/api/asyncapi.yamlinfo.x-protocol-versions: [1](04 6.1); 06 5 item 3; 03instance_settingsnote (version0) andbranding.product_name_max_chars; 02 4.3 (instancedeclaresLoginOptionsSource, implemented byidentity), 4.5 and 7.2 (kernel/designkeysin kernel-core), 6 (the list of places with the product name, by kind); ADR 0044 (accent list). To do here:docs/DECISIONS.mdthe rulings under Design notes, in the file's existing format. - Generated design keys
internal/kernel/designkeys:- generator
tools/designkeysgen(Gomain, beside M1-05'stools/archgen; follow its location convention), readingdesign/build/tokens.resolved.jsonand writinginternal/kernel/designkeys/designkeys_gen.go; internal/kernel/designkeys/doc.gowith the//go:generateline, somake codegen-checkregenerates and diffs it (verify that the target runsgo generatefor this package; if it uses an explicit list, add the package);internal/kernel/designkeys/accent.go(hand written, see Design notes);- register the package as kernel-core in the module register (where M1-05 lists
kernel/ids,kernel/errs,kernel/clock), so domains of all modules may use it, and regenerate.go-arch-lint.yml.
- generator
- Instance domain
internal/instance/domain:settings.go(registry),branding.go,discovery.go; pure functions only. - Display-text check.
branding.product_nameusesinternal/kernel/textcheck(M1-26, kernel-core, 02 4.5) and the lengthbranding.product_name_max_chars(03 9). Do not write a second copy. - Port
internal/instance: interfaceSettings(Branding(ctx) (Branding, error),LocaleDefault(ctx) (string, error)), the DTOsBranding,LoginOptions,ProviderInfo, the exported funcSupportedLocales() []string, and the declared source portLoginOptionsSource(Design notes). - Application
internal/instance/app:GetDiscovery,GetInstanceInfo,ListSettings,PutSetting,UpdateBranding,GetProblemType. - Adapter postgres
internal/instance/adapters/postgres: migrationmigrations/<next>_instance_settings.sql(table exactly as 03 4.1; renumber at merge if a parallel ticket took the number),queries/settings.sql, sqlc output. - Adapter httpapi
internal/instance/adapters/httpapi: strict-server methodsGetDiscovery,GetInstance,ListInstanceSettings,PutInstanceSetting,UpdateBranding,GetProblemType. - Identity side of the source port:
internal/identity/adapters/loginoptionsimplementinginstance.LoginOptionsSourceover the identity provider store of M1-36 and the identity configuration valueauth.local_password_enabled; wired ininternal/wire. - Generated API constants: extend the M1-17 generator of
api/routeswithSpecVersion string(frominfo.versionofdocs/api/openapi.yaml) andGatewayProtocolVersions []int(frominfo.x-protocol-versionsofdocs/api/asyncapi.yaml). If M1-17 already exposes the spec version, reuse it. - Configuration schema (M1-07): make sure these keys exist with the defaults of their
chapter tables, and hand them to
instanceas one typed value frominternal/wire:message.max_content_bytes,message.max_attachments,file.max_upload_bytes,thread.title_max_chars,voice.max_duration,client.min_version.ios,client.min_version.android,client.min_version.macos,client.min_version.windows,client.recommended_version.ios,client.recommended_version.android,client.recommended_version.macos,client.recommended_version.windows. Validate the version keys as SemVer withoutvwhen set. Regeneratedocs/operations/configuration.md(make config-reference). - Enable list
api/operations.yaml(P-04): the six operations. 12a. Language of new people (PLAN row M1-46):createUser(M1-31) storeslocale.defaultasusers.localewhen the request has nolocale; M1-31 used the 03 column default until now. Read the setting through theSettingsport (identity may useinstance, 02 "Allowed dependencies"); testTestCreateUser_UsesLocaleDefault(settingen, request withoutlocale, storeden). - Documentation:
internal/instance/README.md(settings registry, branding version, discovery sources, source port),internal/kernelREADME or package docs,services/server/README.md("Name-bound locations": the default product name constant),services/server/CHANGELOG.mdunderUnreleased. - Places with the product name (02 "6. Code layout", ADR 0044 Confirmation): a test
tools/ci/tests/test_product_name_places.pysearches tracked code and configuration (notdocs/) for the working name, case-insensitively, and allows it only at the kinds 02 6 lists (module path, binary andcmd/bothy,BOTHY_variables, image names and container paths, database roles, telemetry, alert and SLO prefixes, the token prefix with its gitleaks rule,DefaultProductName); every other hit fails with file and line. Add it to the existingtools/citest run.
Out of scope¶
- Feature flags, their evaluation and the
featureslist ofgetInstance: M1-47 (this ticket returnsfeatures: []). - Logo upload, download and deletion (
uploadBrandingLogo,getBrandingLogo,deleteBrandingLogo): M3-10. This ticket never writesbranding.logo_file_id. - JWKS and webhook keys (
getJwksM1-32,getWebhookKeysM2). - The setup lock (
503 setup_requiredfor every other route): M1-37. - Space colors and icons (M1-52) and forum tag colors (M1-53) use the generated keys but are not built here.
- The setup lock itself and
bothy setup: M1-37 (the problem codesetup_requiredis in the specification already).
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-46-instance-settings. - Conventional Commits with module scope (for example
feat(instance): discovery and branding) and DCO sign-off (git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above except
services/server/CHANGELOG.mdanddocs/DECISIONS.md. - Follow the host rules supplied by the launcher.
- Stop only processes you started, by PID. Temporary directories only via
mktemp -d; remove them by their literal path at the end. PostgreSQL runs only as the throwaway container ofpgteston a random port bound to127.0.0.1. - Never read or print secrets. No network calls except package downloads for pinned tools.
Design notes¶
Design keys. The generator reads contexts.theme.light and contexts.theme.dark, collects
the second path segment of every key color.category.<key>.<role>, and fails if the two themes
give different sets. It reads icons.space-icons as the icon list. Output (sorted, no
duplicates):
func CategoryColors() []string // copy of the generated list
func IsCategoryColor(key string) bool
func SpaceIcons() []string
func IsSpaceIcon(key string) bool
func AccentColors() []string // hand written in accent.go
func IsAccentColor(key string) bool
accent.go holds the accent list as an explicit constant of the seven keys of ADR 0044 and 01
12 (spruce, loch, heather, rowan, amber, gorse, moss), not derived from the tokens:
granite is the neutral palette of all surfaces, and a new category color becomes an accent only
after the contrast gate (ADR 0044). Tests assert that every accent is a generated category color
and that the constant equals the list in 01 12. Keys are case sensitive;
Spruce, spruce and #5865f2 are not keys.
Settings registry (domain/settings.go). One entry per key of configuration.md section 6:
| Key | JSON type | Default | Route | Validation |
|---|---|---|---|---|
branding.product_name |
string | DefaultProductName |
branding | trimmed length 1 to branding.product_name_max_chars, display-text check (SEC-IN-4) |
branding.accent_color |
string | spruce |
branding | designkeys.IsAccentColor |
branding.logo_file_id |
string (UUID) | absent | branding, written only by M3-10 | UUIDv7 |
locale.default |
string | de |
settings | one of SupportedLocales() = de, en (01 9) |
DefaultProductName = "Bothy" is the one constant with the working name in this module (ADR
0044 counts branding.* among the name-bound places). SupportedLocales() lives in the port so
that identity can validate users.locale against the same list; if M1-31 holds its own list,
point it to this function in the same change. A test parses the table in configuration.md
section 6 and fails if keys or defaults differ from the registry.
Reading a setting. The stored row wins; a key without a row has its registry default and
version = 0. listInstanceSettings returns every registered key in key order: rows with
version, updated_at, updated_by; defaults with version = 0 and without updated_at and
updated_by. Rows whose key is not registered are not listed (they cannot exist: the writer
only accepts registered keys).
putInstanceSetting (owner, step-up, audit, IfMatchOptional): unknown key: 404
not_found. Key with route branding: 400 validation_failed with errors[0] = {pointer:
"#path/key", code: "use_branding_endpoint"}. Invalid value: 400 validation_failed with
pointer /value. If-Match present and different from the current version (0 for a default):
412 version_conflict with current. Same value as stored: no write, no event, 200 with the
current setting. Otherwise insert with version = 1 or update with version + 1,
updated_by = actor, updated_at = t; append instance.updated with changed_keys = [key];
audit allow with details = {key} and no value, so that one rule covers every future key,
including keys whose values are personal. Response 200 with ETag: "<version>".
Branding version. Branding.version and its ETag are the sum of the versions of
branding.product_name, branding.accent_color and branding.logo_file_id (absent row counts
0). Every write raises at least one of them by one, so the sum rises strictly and works as an
optimistic lock (06 8 item 1).
updateBranding (owner, step-up, audit, IfMatchRequired): body BrandingUpdate. PUT
replaces both fields: an absent accent_color means the default spruce, written as a value
(not by deleting the row, which would lower the version sum). Missing If-Match: 428
precondition_required; mismatch: 412 version_conflict with current = the Branding
object. Only keys whose value changes are written and listed in changed_keys; no change at
all: no write, no event, 200. Audit allow with details = {changed_keys}. Errors: pointer
/product_name code invalid_text or required; pointer /accent_color code
unknown_color_key.
getInstance (instance.read, any active principal including agent and app tokens,
ETag): InstanceInfo with instance_id, branding (from settings), locale_default,
spec_version (routes.SpecVersion), features: [] (M1-47 fills it), limits from the typed
configuration value (voice.max_duration in milliseconds). The ETag is a strong hash of the
canonical response body (SHA-256, base64url, first 22 characters), because the body differs per
caller once flags exist.
getDiscovery (instance.discovery.read, public, works before setup):
| Field | Source |
|---|---|
instance_id |
instance.instance_id |
setup_required |
instance.setup_completed_at IS NULL |
api_versions |
["v1"] |
spec_version |
routes.SpecVersion |
features |
[] until a server capability beyond the base contract exists; the list is a constant in domain/discovery.go with a comment on how to add one |
gateway |
{url: "/api/v1/gateway", protocol_versions: routes.GatewayProtocolVersions} |
branding |
PublicBranding{product_name, accent_color, logo_url}; logo_url = "/api/v1/instance/branding/logo" only when branding.logo_file_id is set |
auth_providers |
LoginOptionsSource: enabled providers, ordered by name |
local_password_enabled |
LoginOptionsSource: auth.local_password_enabled and an enabled provider of kind local |
jwks_uri, webhook_keys_uri |
/api/v1/instance/jwks, /api/v1/instance/webhook-keys |
min_client_versions |
one entry per platform with client.min_version.<p> or client.recommended_version.<p> set, in the order ios, android, macos, windows; array always present |
Arrays are always sent (possibly empty); optional scalars are omitted, never null.
Source port (declared in internal/instance, implemented in identity):
// LoginOptionsSource tells discovery which sign-in ways exist. Declared here because
// instance may not import identity (02 4.4); identity implements it (02 4.1 item 4).
type LoginOptionsSource interface {
LoginOptions(ctx context.Context) (LoginOptions, error)
}
type LoginOptions struct { Providers []ProviderInfo; LocalPasswordEnabled bool }
type ProviderInfo struct { ID ids.ID; Kind string; Name string } // kind: oidc, trusted_header, local
getProblemType (instance.discovery.read, public): look up code in the problem
catalogue that internal/platform/httpserver uses for problem bodies (M1-18). Its source is the
machine-readable problem list x-problem-codes in docs/api/openapi.yaml that also generates
the table in 06 5 (R-API-23, checked by M1-03). Map to ProblemType{code, status, title,
description, client_action}; unknown code: 404 not_found. If the catalogue in code lacks
description or client_action, extend its generator; never add a second hand-written list.
Events. instance.updated: resource = {kind: instance, id: instance_id} (no version),
empty scope, payload {changed_keys} sorted.
Rulings for docs/DECISIONS.md:
- Branding keys change only through
updateBranding; the settings route rejects them. Reason: one action per write path and one audit action per change. - Branding version is the sum of the three branding row versions.
- A setting with its default has
version = 0and noupdated_at. - Accent keys are the explicit list of ADR 0044, held in
kernel/designkeys. kernel/designkeysis a kernel-core package (02 4.5). Reason:chat(M1-52, M1-53) needs the same keys and may not importinstance(02 4.4).local_password_enabledin discovery means configuration allows it and alocalprovider is enabled.
Tests (write them first)¶
tools/designkeysgen: golden test on a small fixture token file; a fixture where light and dark differ fails; a hand edit ofdesignkeys_gen.gomakesmake codegen-checkfail (run it in the test of the Makefile target or document the manual proof in the report).internal/kernel/designkeys:TestAccentColorsMatchADR0044,TestIsAccentColorRejects(#5865f2,rgb(0,0,0),Spruce,granite, an emoji such as U+1F535, empty string),TestSpaceIconsFromTokens.internal/instance/domain:TestSettingsRegistryMatchesConfigurationDoc(parses configuration.md section 6),TestValidateProductName(empty, spaces only, U+202E, U+0007, valid),TestValidateLocale,TestBrandingVersionSum,TestDiscoveryMinClientVersions.- Integration against PostgreSQL 18 with the real HTTP chain, real authentication and step-up
(identity helpers of M1-34 and M1-35), real audit and event log:
TestDiscovery_BeforeAndAfterSetup(setup_requiredtrue, then false after settingsetup_completed_at),TestDiscovery_Anonymous,TestDiscovery_SpecVersionMatchesOpenAPI,TestDiscovery_ProvidersFromIdentity(disabled provider absent),TestDiscovery_NoLogoUrl.TestGetInstance_AnyPrincipal(person, agent token), limits equal configuration,featuresis an empty array, ETag stable for the same body.TestListSettings_OwnerOnly(Sam gets403 forbiddenwithreasonowner_only), defaults withversion = 0.TestPutSetting_LocaleDefault(eventinstance.updatedwith["locale.default"], audit, ETag),TestPutSetting_UnknownKey404,TestPutSetting_BrandingKeyRejected,TestPutSetting_IfMatchConflict412,TestPutSetting_SameValueNoEvent,TestPutSetting_StepUpRequired.TestUpdateBranding_HexAndEmojiRejected,TestUpdateBranding_PaletteKeyAccepted,TestUpdateBranding_OnlyChangedKeysInEvent,TestUpdateBranding_IfMatchRequired428,TestUpdateBranding_VersionConflict412,TestUpdateBranding_AbsentAccentResetsDefault,TestUpdateBranding_NonOwner403,TestUpdateBranding_AuditOnAllowAndDeny.TestGetProblemType_KnownAndUnknown.
- Every response passes the response validation of the test build.
Commands that must pass:
cd services/server && go test -race ./internal/instance/... ./internal/kernel/... ./internal/identity/... ./tools/...
make codegen-check
make config-doc-check
make api-lint
make arch
make check
Acceptance criteria¶
- Hex values, CSS colors and emoji are rejected as accent; the seven palette keys are
accepted; the generated key list is current (
make codegen-check). - Discovery reports
setup_required,api_versions,spec_version,features, gateway protocol versions and minimum and recommended versions per platform; it works without sign-in and before setup. -
instance.updatednames only changed keys; a no-op writes no event. - Settings and branding changes need an owner with fresh step-up and are audited on allow
and deny;
listInstanceSettingsis owner only. - Branding needs
If-Match; settings accept it optionally; conflicts answer412withcurrent. - Every known problem code is served by
getProblemTypefrom the one catalogue. - The spec changes of Scope item 1 are in the same change, and
make checkis green.
Definition of done¶
Code, tests and documentation are complete: module README updated, doc comments on every public
symbol, an ADR if you took an architecture decision beyond this brief, CHANGELOG entry under
Unreleased in services/server/CHANGELOG.md. make check is green. An independent review and
the red team (docs/quality/red-team-gate.md) have run over the diff with their verdicts
recorded in the report. Merge locally into the base branch only if both are green; then remove
the worktree with git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red run of each
new test before the code), what is not proven, and follow-ups that could not be done in scope
with the reason. Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.