Skip to main content

App Customization Framework — the Gentian Customization Ladder

Companion to: app-catalog.md, app-catalog.md §12 and §17.3, multi-tenancy.md, gentian-apps/docs/app-profile-guide.md


0. Problem statement​

Gentian has customization mechanisms — extraValues, Tenant.spec.apps[].config, profile annotations, composition.yaml, the gentian-sidecar-git-modules addon sync, the ocb Odoo fork — and this framework puts an ordering over them. It tells a human or an agent:

  • Which mechanism is the cheapest one that can express this change?
  • Which mechanisms does this particular app actually support?
  • Who pays when the app is upgraded, and how do we ever get back down?

Without an ordering every ERP/ITSM platform hits the same failure mode: customizations land at whatever rung the author happened to know, upgrade cost compounds silently, and the platform ossifies. ServiceNow calls this the "customization conundrum"; SAP's answer is "Clean Core". This is the same discipline, expressed in a Debian/Fedora idiom, and machine-readable enough that an agent can follow it without judgement.

This document defines:

  1. A seven-rung ladder (§2) ordered by how much of the app's own artifact Gentian ends up owning.
  2. A second, independent axis — scope (§3): tenant / profile / platform blast radius.
  3. A per-app capability declaration — ComponentProfile.spec.customization (§4) — so the ladder is app-specific: which rungs are reachable at all, and by which mechanism.
  4. A customization record — DEP-3-inspired manifest (§5) — so every rung ≥ L2 is tracked, owned, dated, and has exit criteria.
  5. A deterministic decision procedure for agents (§6).
  6. Best practices, templated into gentian-apps/apps/_template (§7) so first-party apps are born customizable at L1–L3 instead of forcing everyone to L5.
  7. Governance, CI gates, and a customization-debt report (§8).

Research that informed the design is in §10.


1. Design principles​

#PrincipleOrigin
P1Lowest viable rung, narrowest viable scope. Two independent minimizations, always both.SAP Clean Core; ServiceNow OOTB-first
P2The app declares its own ladder. Rung availability is a property of the app, published in its ComponentProfile. Generic advice is useless; "Odoo supports L3 via addons, Collabora does not" is actionable.Eclipse declared extension points
P3Upstream first. Any rung ≥ L4 carries an obligation to attempt the change upstream and to record the outcome. Carrying a downstream delta is a debt with a due date, not a decision.Fedora "Upstream First"; Debian DEP-3 Forwarded:
P4Every customization is a tracked artifact in git. No rung of this ladder terminates in a live cluster. The existing absolute prohibition on cluster hotfixes is Rung X (§2.8).gentian-apps app-profile-guide.md
P5Descend over time. Rungs are not permanent homes. Each record names exit criteria and a review date; the platform reports on aging debt.Debian patch series shrink as patches land upstream
P6Configuration layers, it does not fork. Drop-in precedence is fixed and documented (image → chart → profile → tenant), like /usr → /run → /etc.systemd drop-in precedence
P7Extension APIs are versioned contracts. An app that offers L3 owes plugin authors a stability policy, a deprecation window, and a "proposed API" lane for the unstable parts.VS Code proposed API; Eclipse API freeze
P8Customization inherits the trust model. A customization can never raise its target's trustTier or bypass Kyverno, MAC waivers, licensing, or tenant isolation.existing catalog tiers, MAC waivers
P9An app declares needs, never endpoints. A profile says that it sends mail, uses a database, needs object storage — never where those are or what credentials reach them. Hosts, ports, users and passwords arrive through valueMapping, as references the app does not resolve.this section

1.1 Declaring a need — P9 in practice​

Two fields, and neither names the cluster:

spec:
requires:
services:
mail:
smtp: {} # the need. It carries nothing.
package:
valueMapping:
smtp:
hostKey: mail.smtp.host # what THIS chart calls these values
portKey: mail.smtp.port
userKey: mail.smtp.name
passwordKey: mail.smtp.password

The requirement is empty on purpose: the mechanism a server accepts and the port it listens on belong to the platform, and an app asserting 587, plain asserts something it cannot verify and would be wrong about the moment the cluster changed.

valueMapping is not cluster knowledge either — it is the app describing its own chart. Nextcloud calls it nextcloud.mail.smtp.host, OpenProject environment.OPENPROJECT_SMTP__ADDRESS. Only the packager knows that, and it is the whole of what they must supply. The values then reach the chart as secretKeyRef entries into a Secret the app never names, so a cluster can move from in-cluster Postfix to a relay and no profile changes. Mail is optional: an SMTP value the cluster has not got (a relay without credentials) is left unset rather than blocking the install, so the chart's own default must mean "no mail", and the value arrives on the next sync once it exists.

The same shape covers database, cache, s3, identity and imap.

A bucket nobody may rewrite is said separately too. An app that keeps records which must not change -- an audit archive -- asks for Object Lock:

spec:
requires:
services:
storage:
s3: {objectLock: true}

The platform makes the bucket with S3 Object Lock, which cannot be switched off again, and lets the app read and set the bucket's default retention (mode and period) through the S3 API. It never gets the right to bypass a retention. Uninstalling with purge removes the app's key and keeps the bucket for as long as it holds locked objects.

Opening mailboxes with the sign-in token is said separately. imap: {} gives the app the mail server's address and nothing more. An app that signs people in to their mailboxes with their own token (XOAUTH2) declares it:

spec:
requires:
services:
identity:
oidc: {clientId: gentian-webmail} # the client the grant is given to
mail:
imap:
tokenSignIn: true

The app's client is given the optional scope mailbox. The app asks for it when it signs a person in for mail (scope=openid email mailbox), and that token -- no other -- is accepted by the mail server, for that person's mailbox only. Without the declaration Keycloak refuses the scope. It is served where the cluster runs its own mail server and the tenant has mailboxes on it; elsewhere it is accepted and does nothing, so an app keeps a fallback (an app password) for those clusters. Who checks what: security/design.md §2.16.

Naming the service a token is for. A Keycloak access token names the client it was issued to in azp, and nothing in aud unless a mapper puts it there. An app that hands its tokens to a service which checks for its own name declares that name:

metadata:
name: example
spec:
requires:
services:
identity:
oidc:
clientId: example
audiences: [example-api]

Each name goes into the client's access tokens and the introspection answer, not its ID tokens, and comes off when the profile stops naming it. It is a grant -- the service accepts every token that names it -- so a name is the profile's own: its name, a dash and a label. None may begin with gentian, name a kernel service or a client Keycloak makes in every realm, or be another profile's name; the operator holds an install that would (SignInClientRefused).

Every public client (accessType: PUBLIC, the default) must use PKCE with S256; the realm refuses a request without a code challenge.

An extension's client. An extension that signs people in itself declares its client the same way, under extensions[].kernelRequirements.identity.oidc, and the operator renders it beside the app's own: redirect addresses (or the profile's gentianos.io/oidc-default-redirect-uris) with ${TENANT_DOMAIN} filled in, PKCE for a public one, and for a confidential one the secret the operator seeds at the extension's own key, apps/<app>-<extension>/oidc. Its audiences are held as the app's own. A client the profile's OIDC pack catalog holds a pack for is the pack Job's instead. The extension's release is handed its client through extensions[].valueMapping.oidc, with the keys package.valueMapping.oidc offers the app (issuerKey, clientIdKey, clientSecretKey, and the clientSecretOpenbao* overrides), read from its own key as the app's are from the app's.

Which catalog. A client's pack is looked up in one place only: the OIDCPackCatalog labeled gentianos.io/profile-name: <profile>, the one the profile's bundle brings; an extension's in its parent's. The operator reads that one, so a pack any other catalog holds does not make a client the pack Job's. A profile two catalogs are labeled for has its clients refused.

One client for many places. A client whose sign-ins come from many places -- an issuer that every cluster's App Store app signs in at, each with its own callback -- declares the parameter that says which place, and the one callback address computed from it:

requestParameter:
name: tenant_url
The realm then refuses an authorization request, with an error page, unless it
redirectUri: 'https://store.${host}/oauth/callback'
claim: tenant_url # optional: put the value in the access token

The realm then refuses an authorization request, with an error page, unless it carries the parameter once, the value matches the pattern as a whole, and redirect_uri is exactly redirectUri with each ${group} filled from the match; a request object or a pushed request is refused. The client is registered with https://*, narrowed by that refusal, and only once the realm has it. claim is put in the access token at the exchange of a code issued for that address, and in no other token; the client gets no refresh token, and the introspection answer does not carry the claim. Both halves are the platform's Keycloak extension (kernel/extensions/keycloak-event-listener), and both fail closed: without them no token carries the claim. The pattern uses the syntax Go and Java share ((?<name>...), no back-references); the parameter and the claim may not be ones OAuth, OpenID Connect or JWT already define. Such a client declares no redirectUris. Only an app's own client can be one: the operator binds the parameter on it alone, and an API entry has no release to render the client with.

A client that cannot be made as declared -- one with neither redirectUris nor a request parameter, or a request-bound one of an extension or an API entry -- is not made. The tenant's IdentityReady names it (OIDCClientRefused); the tenant's other apps are provisioned as before.

Language models are a need like the others. An app that calls models declares the platform's model gateway, and only an app that declares it is given a key there, the gateway's address and a network path to it:

spec:
requires:
services:
llm: {} # the need. It carries nothing.
package:
valueMapping:
llm:
baseUrlKey: openai.baseUrl # what THIS chart calls these values
apiKeyKey: openai.apiKey

The gateway speaks the OpenAI API; the address ends in /v1. The key is the app's own, generated per tenant and app and kept in the vault. A chart that reads its configuration from the environment can take the Secret llm-credentials-<app> whole instead of mapping keys (OPENAI_API_BASE, OPENAI_API_BASE_URL, OPENAI_API_KEY); a post-install job gets LLM_BASE_URL and LLM_API_KEY for the keys the profile maps. The path opened is the gateway's port and nothing else in its namespace, so a profile that declares llm does not name system-llm in gentianos.io/kernel-egress-namespaces. On a cluster that serves no models the app is not installed: its Component waits and says the cluster has no model gateway. An app that keeps the key somewhere of its own on first start has to take it again when it changes -- the platform replaces a key at the gateway, it cannot reach into an app's database.

A component the platform places on tenants itself (spec.placement, other than while: llm: the desktop) may declare the same need, at trustTier: platform only. It gets a key of its own per tenant under the same names, and its chart is told the Secret's name, never the key:

spec:
requires:
services:
llm:
optional: true # run without the gateway where there is none
package:
valueMapping:
llm:
availableKey: llm.available # true once the key is delivered
baseUrlKey: llm.baseUrl
secretNameKey: llm.apiKeySecretName # llm-credentials-<component>; key OPENAI_API_KEY

optional is for such a component: while the cluster serves no models, or the key is not delivered yet, it is released without credentials and without a path to the gateway, availableKey receives false, and it is rendered again when the key arrives. For an app a tenant installs the requirement holds the release whether or not optional is set. Nobody uninstalls a placed component, so its key goes when the tenant is deleted with its data, when the profile stops declaring llm, or when the platform takes the component away.

Calling an app of another cluster for the person (AD-17). An app that calls a partner of its tenant's realm — or takes such calls — declares it, and that is what puts it on the partner's list:

spec:
requires:
services:
partners:
call: [aluvian] # partners it asks assertions towards
accept: false # true: it takes partners' assertions
serviceAccount: my-app # the account its pods run as; default: the profile's name

The platform makes the client partner-<profile> in the tenant's realm, with no secret: the app authenticates with a projected service account token whose audience is the realm's issuer (the chart mounts it, the audience from valueMapping.platform.issuerKey). The Secret partners-<profile> says PARTNER_CLIENT_ID, PARTNER_TOKEN_URL and PARTNER_TOKEN_AUDIENCE. The one call, with the person's token the edge handed the app (forwardToken or exchangeToken):

POST $PARTNER_TOKEN_URL
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<the projected service account token>
subject_token=<the person's token>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
scope=partner-<name>

The answer's access_token is an assertion for the partner — its issuer the only audience, the person's pairwise subject and nothing else about them — sent once, as Authorization: Bearer. A receiving app posts it to its own realm (grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, assertion=…, the same client assertion) and acts as the account the answer names. A person not linked yet is answered 401 with WWW-Authenticate: Partner link="\<address>", which the calling app shows them (partners.md §4–5).

An authorization store of the app's own. An app that decides with relations (who may see which project, who administers which company) declares a store, with its model in the OpenFGA DSL; every trust tier may:

spec:
requires:
services:
authorization:
model: |
model
schema 1.1
type user
type group
relations
define member: [user]
type project
relations
define owner: [user, group#member]
define viewer: [user, group#member, user:*] or owner
groups: mirror # optional: keep the realm's groups in the store
tuples: # optional: written by the platform
- {user: "user:*", relation: viewer, object: "project:handbook"}

The model is compiled when the profile is admitted, and the starting tuples are held to it; either failing is a failed install. The app gets the Secret authorization-<component> (AUTHORIZATION_URL, AUTHORIZATION_KEY) and a network path to the arbiter's port alone. The arbiter speaks OpenFGA's API with the store and model taken from the key: POST /v1/check, /v1/batch-check (50), /v1/list-objects, /v1/list-users, /v1/read (pages of 100), /v1/write (100 tuples), GET /v1/model, each with Authorization: Bearer <AUTHORIZATION_KEY>; an unknown key is 401, and a key is limited to 100 calls a second. People are user:<realm>/<subject>. With groups: mirror the platform keeps every top-level group of the tenant's realm as group:<realm>/<name>#member (: in a name becomes /, so the tenant's members are group:<realm>/gentian/tenant/<tenant>/members); the model needs a type group with define member: [user], and a store created after a membership was made holds it from that person's next change in the realm on. An app's key may not write a tuple whose user or object is a group (403). A starting tuple the profile drops is taken out again; what the app wrote is never touched. The store is the tenant's data: exported, restored into a fresh store, kept on uninstall and deleted by a purge.

Never substitute an endpoint into extraValues. A literal hostname comes with no credential — an app wired that way can only attempt unauthenticated access, and it hard-codes an assumption about where the service lives. ${SMTP_HOST}, ${S3_ENDPOINT}, ${MYSQL_HOST} and ${REDIS_HOST} are not substituted, so such a placeholder reaches the cluster verbatim. The identity placeholders (${TENANT_ID}, ${TENANT_DOMAIN}, ${TENANT_NAMESPACE}, ${KERNEL_DOMAIN}, ${NODE_IP}) are — those describe who the tenant is, not where a service lives.


2. The ladder​

Ordered by how much of the app's own delivery artifact Gentian ends up owning — which is the thing that determines upgrade cost.

RungNameDebian/Linux analogueGentian ownsSurvives upstream minor upgrade?
L0Configureedit a value in /etc/foo.confnothing (values only)Yes
L1Drop-in/etc/foo.conf.d/50-gentian.conf, systemctl edit overrideone config/asset fileYes
L2Companionapt install a new program that talks to the old onea separate deployableYes
L3Extensionapt install foo-plugin-baran addon loaded by the appUsually — bound to the app's plugin API
L4Repackagerebuild the package (build flags, conffiles, wrapper) — source untouchedthe chart / composition / entrypointOften — bound to chart+image layout
L5Patchdebian/patches/series over pristine upstreama patch series + a rebuilt imageNo — rebases every upstream release
L6Forka derivative distribution with its own release trainthe source treeNo — full maintenance, incl. CVE duty
XHotfixforbidden——

Rule of thumb for the whole table: the cost of a customization is not the cost of writing it. It is the cost of writing it again at every upstream release. L0–L3 you write once. L4 you re-check. L5 you rebase. L6 you own forever.

2.1 L0 — Configure​

Change behavior using knobs the app already exposes. No new files, no new code.

ScopeWhereMechanism
Tenantthe tenant's manifest in the deployments repositoryTenant.spec.apps[].config.extraValues (deep-merged over profile)
Profilegentian-apps/profiles/<n>/profile.yamlspec.package.extraValues, spec.expose (with its tiles), spec.requires.services

The director's install route writes an app's profile, digest, catalog, add-ons and default grant, and nothing under config: a tenant-scoped value is an edit of the tenant's manifest that no route of the director makes.

Obligations: none beyond normal review. Test: chart renders; app starts.

Making L0 first-class: extraValues is PreserveUnknownFields on both ComponentProfile (spec.package.extraValues) and TenantAppConfig — a typo is silently accepted and only fails at Helm render time. A chart's published values.schema.json makes L0 machine-checkable in CI, and lets an agent discover whether the change it wants is already an L0 knob.

2.1.1 Tiles​

A tile image is a PNG wherever it is read; an SVG is only a template rendered to PNG before commit, never drawn by a browser or served. A person sees, first that is set:

  1. the tile they uploaded themselves,
  2. the tenant's tile, chosen in the App Store,
  3. the profile's default, spec.expose[].tile.logo: a square 128x128 PNG as a data:image/png;base64, URI of at most 65536 characters, rendered from a platform glyph (tile.icon) where the app has no art of its own.

The first two are the desktop's, in its own database. The App Store app stores a tenant's tile by calling the desktop's API in the same namespace with the person's token, at the address of the contract the desktop provides, desktop-api (spec.integrations and valueMapping.integrations.desktop-api.endpointKey), or asks for it with valueMapping.platform.desktopUrlKey. Nothing about them is in Git or in the cluster's state: the tile catalog the operator projects carries the profile's default only. The platform's glyphs are internal/tiles/glyphs/*.svg, rendered into internal/tiles/catalogue.json and the chart's own tiles by scripts/tools/render-tiles.py.

A profile annotated gentianos.io/portal-preopen: "true" has its primary tile (exposure web) marked preopen: the desktop opens the app hidden at mount, keeps its window alive, and draws the assistant widget in its place. The desktop knows the app by this mark only, never by its name.

2.2 L1 — Drop-in​

Add a file into a directory the app already treats as an extension point: a theme, a policy file, a locale bundle, an xml/yaml snippet, a logo, a config fragment. The app's binary and its own config files are untouched.

Mechanism: the app's profile declares its drop-in directories (§4), and the app's chart mounts a ConfigMap or Secret at each declared path. The platform generates no mount: the declaration is what the platform checks tenant content against (§2.2.1), and the chart's values name what is mounted.

Precedence (fixed, systemd-style):

image defaults (lowest)
→ chart values.yaml
→ ComponentProfile.spec.package.extraValues
→ profile drop-ins (profiles/<n>/dropins/*)
→ Tenant.spec.apps[].config.extraValues
→ tenant drop-ins (highest)

Within a drop-in directory, files apply in lexicographic order — reserve 00-–49- for platform, 50-–89- for profile, 90-–99- for tenant.

Obligations: the drop-in path must be declared in spec.customization.dropIns. Undeclared mounts into an upstream image are L4, not L1 — that distinction matters, because a declared drop-in path is a contract the upstream project maintains and an undeclared one is not.

2.2.1 Tenant-scoped drop-ins (S0 · L1)​

L1 is the highest rung a tenant admin may reach unaided, and the only rung where self-service makes sense: a tenant admin can supply a logo, a locale bundle, or a config fragment without a catalog PR, but cannot introduce code.

# the tenant's manifest — Tenant.spec.apps[]
- profile: odoo-cb-base
config:
extraValues: { }
dropIns:
- name: branding # must match a declared spec.customization.dropIns[].name
files:
90-brand.css: |
:root { --primary: #0b7285; }

Delivery: a tenant's drop-in takes effect through the app's chart. The operator reconciles Tenant.spec.apps[].config.dropIns into a ConfigMap app-dropin-<profile>-<name> in the tenant namespace (internal/controller/dropin_reconciler.go) and removes it when the entry goes. The platform generates no mount, and no chart of the catalog mounts that ConfigMap, so the content is validated, stored, and read by nothing. For it to take effect a chart has to mount the ConfigMap of that name at the declared path, after the profile's own, where tenant files win by the 90-–99- prefix.

Guardrails — checked by the operator when it reconciles the tenant. One entry that fails holds every drop-in of the tenant back (the Tenant's DropInsReady condition is false, reason InvalidDropIn); nothing is refused at admission:

RuleWhy
name must match a declared spec.customization.dropIns[].nametenants cannot invent mount paths — that would be L4 at tenant scope
Declared entry must set tenantEditable: truenot every drop-in dir is safe to expose (a policy file usually is not)
Filenames must match ^[9][0-9]-[a-zA-Z0-9._-]+$reserves the platform/profile ranges
Total size ≤ maxBytes (default 256Ki)ConfigMap limits; DoS
Content must parse as the declared formata malformed fragment must fail before it is mounted, not crash the app at boot
No secret material — values land in a ConfigMap, in etcdsecrets go through valueMapping, always

Set by a commit, and by nothing else. A tenant's values (config.extraValues, §2.1), its drop-ins (config.dropIns) and a customization record of tenant scope (§5) can be set only by a commit to the deployments repository. No route of the director writes any of them and the admin console has no screen for them.

2.3 L2 — Companion (side-by-side)​

Build new code as a separate deployable that talks to the target app only through its published API. The target app is not modified in any way.

This is the SAP "side-by-side extensibility" rung and the Nextcloud ExApp rung, and in Gentian it is already fully supported infrastructure: scaffold from gentian-apps/apps/_template, publish a profile, and wire it to the target with a contract.

# gentian-apps/profiles/acme-approvals/profile.yaml
spec:
integrations:
- contract: erp-core # the target lists it under spec.provides
provider: odoo-cb-base
capabilities: [read, write]

Declaring a contract opens nothing. Where both apps are installed in a tenant the operator writes an IntegrationBinding, and that is a request. The tenant's administrator grants it for the consumer (PUT /v1/tenants/{t}/grants/{app} at the director, {"consume": [{"contract": "erp-core", "granted": ["read", "write"]}]}). Only then are the two network policies written: the companion's pods may reach the target's, and the target's admit them. Withdrawing the grant takes both away. The operator grants nothing by itself and never changes a grant, so a grant narrower than the declaration is what holds; the binding's Granted condition names what was asked for and not granted. A target that names where it answers the contract (spec.provides[].endpoint: its Service, the port its pods listen on, a path) is reached on that port only, and the companion is told the address under valueMapping.integrations.<contract>.endpointKey; both can take the contract's shared password under passwordKey. Otherwise the path is to the target's pods as a whole. Once no granted target answers the contract (the target removed, the grant withdrawn), the address is emptied again and the password kept. The granted capabilities are recorded and not enforced, and the target is not told which app is calling. What a consumer may do there is the target's to check.

A companion that needs the Kubernetes API asks for a cluster role by name. It cannot bring a role of its own: requires.privileges.clusterRoles[] names one of the roles the platform defines and the ServiceAccount the chart runs under, and the role is bound only where the cluster permits it for the profile and it was granted on the install. Rules written on the entry are ignored (security/design.md §3.4).

L2 vs L3 tie-breaker — the one decision this ladder cannot make positionally:

Choose L2 Companion whenChoose L3 Extension when
The function can stand alone (own URL, own tile on the desktop, own data)The function must appear inside the app's own UI, menus, or workflow
It needs its own scaling, language, or release cadenceIt must extend the app's data model / ORM / permission model
The app's plugin API is absent, unstable, or undocumentedThe app has a documented, versioned plugin API (customization.extension.apiStability: stable)
You need the change to survive a major upstream upgradeRound-tripping through HTTP would be absurd for the semantics

Obligations: a Contract must exist (or be added to gentian-apps/contracts/); auth via oidc-token-exchange, never shared static credentials; the companion must degrade gracefully if the target app is not installed.

2.3a The sign-in sidecar — an app that can do neither OIDC nor SAML​

Some apps have single sign-on only in a paid edition. The platform signs people in to such an app itself: a person who is signed in at the platform opens the app and is in, with no second sign-in and no password. The profile declares it and brings one file, the handler:

spec:
requires:
services:
identity:
sidecar: # instead of identity.oidc or identity.saml
exposure: web # may be omitted when one gateway entry is behind a session
entryPaths: ["/", "/login"] # pages of the app that lead to the sign-in instead
database: true # the handler is given the app's own database
secrets: [app_secret] # and these of spec.secrets.generated
appPort: 3000 # and may call the app's own pods on this port
expose:
- name: web
surface: gateway
authMode: oidc
denyPaths: [/api/auth/login] # the app's own ways in, refused at the front door

The handler is the ConfigMap <profile>.sign-in-handler of the profile's bundle (key handler.js, label gentianos.io/asset: sign-in-handler), so the install's digest covers it.

What the platform does for it, per install, and removes with the app:

By
Registers the sidecar at the tenant's realmthe operatora SAML client named https://<app host>/sso, with one address its answer may be posted to, https://<app host>/sso/acs; the response and the assertion both signed; the person named by e-mail address
Has the realm say who administers the appthe operatorone role at that client, gentian-app-admin, granted to the tenant's group gentian:tenant:<tenant>:app-admins, and a mapper that lists a person's roles at that client in the signed assertion. No other role is in the client's scope, and no group is named
Runs the sidecar beside the appthe operatorone pod, with the handler from the bundle, told where the realm is. No service-account token, read-only, not root
Hands the handler what was declaredthe operatorDB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD; SECRET_<NAME>; APP_URL. From this app's own vault paths; a profile names which, never where
Routes two paths of the app's host to itthe operator/sso/login as a rule of the app's own route, behind the session and the bouncer; /sso/acs on a route of its own with no session (routing.md §4.1)
Sends the entry paths to /sso/loginthe operatorfor a page load (GET) only
Has the realm tell the sidecar of a sign-outthe operatorthe client's single-logout address is the sidecar's own Service, http://<app>-sign-in.<namespace>.svc.cluster.local:8081/sso/logout; the sidecar is told the same address and answers /sso/logout under that name only. Not routed: nothing outside the cluster reaches it
Opens the sidecar's network pathsthe operatorthe realm's certificate; the app's database server; the app's own pods on appPort. One port each, and nothing else: the sidecar is not one of the app's pods

A profile states no address, no client name and no Secret. They follow from where the app answers and what the app owns, so a profile cannot point a sign-in somewhere else or at somebody else's data.

An entry path cannot also be under denyPaths. The bouncer refuses a denied path before the Gateway redirects, and for every method, so the person would never reach the sign-in. Where an app's own form posts to the address of its page -- OpenProject's /login -- the page is the entry path, and what is posted there is the app's to refuse: its password sign-in is switched off in the app. The operator registers the sidecar at the realm when it renders the app; a bundle that brings a Composition of its own would have to compose the client itself.

Which handler runs. A handler is handed the app's signing key and its database: it can become anybody in the app. So it runs only where it is known to be the reviewed file:

  • from a bundle of a catalog of the whole cluster. A tenant's own catalog publishes profiles alone and has no handler to bring; a profile of one that declares a sidecar is held (SignInSidecarRefused) rather than installed with no way in;
  • for an install pinned to a digest, whose bundle brings the handler. The operator compares the cluster with the bundle before every rollout, gives the sidecar the bundle's own bytes, and tells it their sha256; the sidecar loads no other file;
  • never in the kernel realm.

Who may use the app is not the sidecar's to decide and not the handler's. A sign-in begins on /sso/login, which only a person the front door admits to this app reaches, and the answer the sidecar accepts has to be about that same person.

Who administers the app is who holds the platform's App Admin role in that tenant, and nobody else: not the tenant's administrator for being that, and not the first person to open the app. The role is membership of the tenant's group gentian:tenant:<tenant>:app-admins. A tenant's administrator appoints somebody in the admin console — Groups, under Roles, the group app-admins: add the person; or the person's own page, under their groups — and withdraws it in the same place. It is one role per tenant, the one provisioning.privilegedRole already maps for other apps: who holds it administers every app of the tenant that maps it.

The realm states it in the assertion it signs; the sidecar reads it there and nowhere else — no header, no form field — and tells the handler (person.appAdmin). At every sign-in the handler gives the app's own administrator role to a person who holds the role and takes it from a person who does not, in the app's own way, before it makes the session. So an appointment takes effect the next time the person opens the app, and a withdrawal at their next sign-in, which is within the hour a session lasts.

What a handler is given, what it answers and what it must not do is in gentian-apps, images/gentian-sidecar-sso-saml/README.md. In short: it is told who the person is, whether they administer the app and how long the session may last (an hour at most), it answers where to go and which cookies or browser storage carry the app's session, and the sidecar writes the response. It gives nobody a password and touches no license check.

Signing out is part of the handler. A handler may export a second function:

module.exports = {
async onLogin(person, ctx) { /* make the session; as before */ },

// Optional. The person signed out at the platform.
// person: { email } ctx: { origin, log(event, fields) }
async onLogout(person, ctx) {
// end every session this person has in the app, in every browser
},
};

When a person signs out, the realm posts a signed logout request to the sidecar inside the cluster. The sidecar accepts it only signed by the realm, addressed to its own address, fresh and not seen before, and then calls onLogout with the person it names. The handler ends that person's sessions in the app — all of them: it is told who signed out, not which browser. Docmost's and OpenProject's delete the person's session rows, which both apps look up on every request.

A handler without onLogout is one for an app whose session cannot be ended from outside: the sidecar answers the realm, logs no-sign-out-handling, and the app's session lasts what is left of its hour. Say so in the app's customization record.

Before reaching for it. It is the last of three ways to sign people in (iam.md §1.11), and the only one in which a program beside the app holds the app's keys. Use it when the edition installed offers nothing else, write the Customization record first (rung L2: the handler is a companion that integrates through the app's interface or its database), and record there anything the handler writes itself that the app's own interface would not have done.

2.4 L3 — Extension (in-app addon)​

Use the app's own extension system. Odoo addons (_inherit, view xpath/inherit_id), Nextcloud apps, XWiki extensions, Keycloak SPIs, Collabora — none.

spec.customization.extension.delivery names how an addon arrives:

DeliveryMechanismUse when
git-sidecargentian-sidecar-git-modules syncs a git repo into the app's addon path (odoo chart: gentian.git.repo → gentian.modulesPath)addons iterate faster than the app image
image-layeraddons baked into a Gentian-built image layer at build timereproducibility/airgap matters more than iteration speed
addon-profilea thin ComponentProfile whose package is spec.package.addon.{id,of}; the tenant selects it into a base via Tenant.spec.apps[].addonsthe addon is a catalog-visible product
app-store-apithe app's own runtime API installs the extension (Nextcloud occ app:install) via spec.hooks.postInstallthe app owns its own registry

How an addon-profile is activated​

The tenant selects profile names; the operator resolves them to whatever the hosting app calls the thing (an Odoo module, a Nextcloud app id) through each addon's own spec.package.addon (internal/customization/addons.go). Nothing in gentian-os knows those app-native names — putting that knowledge in a reconciler would move an app fact into the platform, which is exactly the boundary this framework exists to hold.

Activation itself has two shapes, and which one an app uses is a property of the app:

ShapeDeclared byUsed when
Valuesspec.customization.addonActivation on the base profile: a Helm values path and a script, with __GENTIAN_ADDON_IDS__ substitutedthe chart exposes a hook that runs inside the app container (Nextcloud's hooks.before-starting), or a value its start script reads (Odoo's gentian.addonModules, installed with odoo-bin -i before the server starts). Preferred — no Job, and the script runs with the app's config, data volume and secrets already mounted
Compositionthe app's own composition.yaml renders a Jobactivation is not expressible as chart values. A profile may bring its own Composition only at trustTier: platform

Write the script to reconcile, not to add. It re-runs on every pod start, which is what makes a changed selection converge with no migration step — and it is the only way deselection can work at all. A base that declares addonActivation has it rendered even when the selection is empty, because "none" is a selection: skipping it there would drop the values key and leave the last selection enabled forever.

Deselection is not uninstallation. Nextcloud's occ app:disable keeps the app's data, so deselecting is reversible. Odoo's -i has no safe inverse — uninstalling a module drops its tables — so an Odoo addon stops being activated but is not removed. Purging data is always a separate, explicit path: uninstalling an app keeps everything it stored, and only a purge of the uninstalled app destroys it (store-contract.md §8).

Multi-tenancy is the sharp edge here. An addon loaded into a shared runtime affects every tenant on that runtime. The existing Odoo pattern is the right precedent — per-tenant addon sets driven by group attributes (gentianos.io/keycloak-group-attributes: {"gentianOdooModules":["crm"]}) rather than per-tenant addon binaries. The framework makes this explicit:

L3 rule (namespace test). Per-tenant addons are allowed only if the app instance runs in that tenant's own namespace (tenant-<name>). If the instance lives in a shared namespace serving more than one tenant, addons are profile-scoped only — per-tenant behavior must come from the addon reading tenant context at runtime, never from divergent addon sets.

The namespace, not the profile, is the test: "one instance per tenant" is an intention, but "deployed into tenant-acme" is a fact that can be checked. The odoo-cb-* family passes (one Odoo per tenant, in the tenant's namespace); a shared-runtime app would not.

Sharing a runtime across tenants and then loading tenant-specific code into it is the single fastest way to turn a customization into a cross-tenant data leak. This is a review-time rule: the controller that judges records does not apply it.

Obligations: declare the addon repo + delivery in spec.customization.extension; pin the addon version alongside spec.package.chart.version; a Customization record (§5) is required from L2 upward; the addon must be tested against the pinned app version in CI.

2.5 L4 — Repackage​

The upstream source and image are unchanged, but Gentian now owns the packaging: a Gentian-authored Helm chart wrapping an upstream image, a composition.yaml with init containers or bootstrap Jobs, an entrypoint wrapper, a spec.hooks.postInstall job that calls the app's admin API, sidecar injection, or a Kustomize post-render over an upstream chart.

This is where most Gentian upstream apps already sit (charts/odoo, charts/gentian-sidecar-*, per-profile composition.yaml).

The operator renders every app's chart. A profile brings a composition.yaml of its own only at trustTier: platform, from a catalog of the whole cluster, pinned to a digest (custom-catalogs.md §2); any other profile whose bundle brings one is held (CompositionRefused).

A composition.yaml may compose only the Crossplane provider resource types a cluster creates: those in gentian-os's crossplane/providers/activation.yaml. One of another type renders and never appears. make lint-provider-activation in gentian-os reads the gentian-apps checkout beside it and names the type to add (reference/install.md §4).

The L4 boundary test: if you would have to change the file when upstream reorganizes its chart or its filesystem layout, it is L4. If upstream promises the path, it is L1.

Prefer, in order: (a) upstream chart + extraValues, (b) upstream chart + Kustomize post-render patch, (c) Gentian wrapper chart with upstream as a dependency, (d) vendored chart copy. (d) is a fork of the packaging and should be recorded as such (see UPSTREAM.md convention already used for vendored charts).

Obligations: Customization record; a rendered-manifest golden test (crossplane render diff); an UPSTREAM.md for any vendored chart; upstream-first check recorded.

2.6 L5 — Patch​

Gentian modifies upstream source and rebuilds the artifact. Modeled directly on Debian's 3.0 (quilt) source format: pristine upstream + an ordered, individually-documented patch series.

<build-repo>/
├── UPSTREAM # upstream URL + exact pinned tag/commit
├── patches/
│ ├── series # ordered list, applied top-down
│ ├── 0001-fix-oidc-logout.patch
│ └── 0002-add-tenant-header.patch
└── Dockerfile # FROM pinned upstream; apply series; build

Every patch header must carry DEP-3 fields:

Description: Propagate X-Gentian-Tenant through the OIDC logout flow
Author: platform-iam@gentian.org
Origin: other, https://github.com/gentian-org/…
Bug-Upstream: https://github.com/<upstream>/issues/1234
Forwarded: https://github.com/<upstream>/pull/1235
Applied-Upstream: no
Last-Update: 2026-08-06

Forwarded: is not optional. Forwarded: no requires a written reason in the Customization record; Forwarded: not-needed is only valid for Gentian-specific integration glue that upstream would rightly refuse.

Obligations: platform trustTier only; two-person review; SBOM + signed image; the patch series must be re-validated (rebased or dropped) at every upstream version bump, and CI must fail the bump if patches/series does not apply cleanly; review date ≤ 6 months.

Explicitly forbidden at L5 and L6 (restating the existing catalog prohibition, because this is the rung where the temptation lives): patches that bypass license-key validation, unlock enterprise features, or crack terms of service. No sed over minified bundles, no SQL triggers flipping *_enabled columns.

2.7 L6 — Fork​

Gentian owns a source tree with its own release train — e.g. gentian-org/ocb. Justified when the patch series has grown beyond rebaseability, when upstream is unmaintained, or when the divergence is strategic rather than tactical.

Obligations: named owner and a bus-factor ≥ 2; documented rebase/merge cadence against upstream; own CVE monitoring and response for the forked tree; an UPSTREAM-COMPARISON.md maintained per release (the upstream-rescue repo already does this); explicit product sign-off, because a fork is a product decision, not an engineering one; an exit strategy or an explicit "permanent divergence" declaration.

2.8 Rung X — Cluster hotfix (forbidden)​

kubectl patch, kubectl exec + edit, hand-created ConfigMaps shadowing image files, editing Secrets to change behavior. This is not the top of the ladder — it is off the ladder. See the absolute prohibition in gentian-apps/docs/app-profile-guide.md. The framework's job is to make Rung X unnecessary by ensuring there is always a reachable legitimate rung, and by making L0/L1 fast enough that nobody is tempted.

2.9 Who may author a customization​

The ladder is authorship-neutral: the rung is determined by what the change does, never by who wrote it. A partner's Odoo addon and a Gentian addon are both L3 and carry identical obligations.

Every record carries its authorship and repo ownership.

spec:
origin:
authorship: partner # gentian | tenant | supplier | partner | community
organisation: "Acme Integrators GmbH"
contact: platform@acme.example
repo: https://github.com/acme/gentian-odoo-modules
repoOwnership: external # gentian | external
reviewedBy: platform-erp # who at Gentian accepted it
supportContract: none # none | community | commercial

Customization.spec.owner is a free-form owner reference rather than a Gentian team enum, and the §8.1 approval matrix names roles (catalog maintainer, platform team) rather than Gentian individuals.


2.10 A public website surface, and the main address​

A profile publishes pages without sign-in by declaring a perimeter entry:

expose:
- name: site
surface: perimeter
authMode: none # written out: nobody signs in to read it
paths: ["/"] # only what is listed is published
backend: {service: website, port: 8080}

Declaring it publishes nothing. Once the app is installed the entry is a request: kubectl gentian exposures requests --tenant <t> lists it with the address it would be published at, its paths and who can reach it (the admin console shows the same under Apps → Details, and approves, reviews and withdraws there). The tenant's perimeter approver approves it with kubectl gentian exposures approve \<install> \<name> --tenant \<t> (PUT /v1/tenants/{t}/exposures/{install}/{name}). The approver is recorded as its owner, with a review date a year on at the latest; --expires <date> takes it down on a day, --reason is kept with it, and approving an approved entry again is its review. The director refuses an approval of an app that is not installed in the tenant, of an entry that asks for nothing an approver decides (a plain entry behind sign-in), and one whose apex is not the entry's. The entry then answers at <subDomain or component name>.<tenant's domain>, from a proxy that passes no cookies either way. exposures list shows what a tenant has published, and exposures withdraw \<install> \<name> --tenant \<t> takes one down.

The proxy checks no caller. A perimeter entry declares one of two modes:

authModeWhat the platform doesUse it for
noneForwards the declared paths for anyone. Removes Authorization and Cookie: no credential reaches the appPages and links for anyone; a link that carries its own secret in the path or query; a webhook signed in a header of its own
appThe same, and passes the caller's Authorization header to the app as sent. The app alone checks itSync clients, mobile apps, API keys and webhooks that present a credential the app itself issued
expose:
- name: dav
surface: perimeter
authMode: app # the app checks each caller's own credential
subDomain: dav
paths: ["/remote.php/dav/"]
backend: {service: files, port: 8080}

authMode: app is approved like any public entry, and the approver is shown, in the director's words, what it means: the caller's credential is passed to the app; the platform does not know or check who calls; an app password or token of a person removed from the tenant keeps working until the app itself revokes it; and the limit per client address, which is lower for such an entry (5 requests a second, 50 more at once, 20 at a time). Its limits: cookies pass in neither direction, so a client that needs the app's cookie does not work on it, and apex entries cannot use it. basic, bearer, jwt and signature are refused on a perimeter entry: the platform verifies no password, token or signature there.

Behind sign-in: the Authorization header as the app's own. A gateway entry may ask for one thing of the same approver:

expose:
- name: web
surface: gateway
authMode: oidc
clientAuthorization: app # the app's pages send the app's own token there
backend: {service: flows, port: 8080}

Declare it only for an app whose own pages call its API with a token of the app's in Authorization. It is listed with the tenant's requests as "Behind sign-in: keeps the app's own Authorization header", is no public address, and is approved, reviewed and withdrawn with the same commands. Until it is approved the entry works like any other behind sign-in — the header is removed, so those calls fail — and the Component's ClientAuthorization condition says so. Once approved, sign-in and the right to use the app are required exactly as before; the front door neither puts its own token in the header nor removes the page's, and no platform token reaches the app. It holds for every entry of the component on the same host, excludes forwardToken and exchangeToken anywhere in the profile, and is not for a service.

An approval is for the kind of entry it was given for. A profile that later changes an approved entry from none to app, or adds clientAuthorization, is a request again until it is approved as that.

Address names an app cannot take. No entry of an app or an add-on, on either surface, may use one of the platform's names as its subDomain (or as the component's name, where it states none): desktop, admin, store, console, platform, id, auth, login, signin, sign-in, sso, account, accounts, or anything below one (x.admin). desktop, admin and store are taken by the platform's own component for each and by nothing else; the others by nothing at all. Where the tenant's domain is the cluster's own -- the user tenant of a single-tenancy cluster -- the kernel's addresses are refused as well: argocd, corp, headlamp, imap, llm, mail, mail-egress, www. The install is refused by the director (422, naming the entry and the label), and a Component that exists anyway is held by the operator with HostReserved; stating trustTier: platform changes nothing (routing.md §3.1).

"For the main address" is the same entry with apex: true (and no subDomain). It asks for the cluster's bare domain and needs all of this:

  • the cluster's tenancy mode is single, the tenant is its user tenant, and that tenant is not on a domain of its own;
  • the approver sends {"apex": true, "acknowledgeMainAddressRule": true} with the request (the CLI: --acknowledge-main-address-rule, which --yes does not imply) -- an entry that says apex is not published at all without apex, and the director refuses apex without the acknowledgement (400, with the warning below);
  • no other surface holds the main address;
  • the entry declares no path inside /branding/, /sign-in, /.well-known/acme-challenge/ or /.well-known/pki-validation/. Those stay the platform's.

Before you approve a website for the main address. A script in a page on the main address can set cookies that browsers also send to the desktop, the consoles and sign-in. It cannot read anybody's session. It can stop people from signing in until they clear their cookies, and it can sign a person in to an account the script's author chose, without the person noticing. The platform cannot check what a website loads, so the rule is yours to keep:

  • only a site whose scripts your organization itself controls;
  • no third-party scripts (analytics, embeds, widgets, anything loaded from another host);
  • no pages uploaded by users.

"acknowledgeMainAddressRule": true says you were told this and the site meets it. It is asked on the first publication and on every review, and your name and the time are recorded with the entry in the exposure registry (apexAcknowledgedBy, apexAcknowledgedAt) (security/design.md §2.10).

What every published entry is held to. Whatever a profile declares, the publishing proxy refuses a request whose path has more than one reading (//, dot segments, encoded slashes, ;), the methods TRACE, TRACK and CONNECT, a body over 10 MB, and more than 20 requests a second from one client address (200 more at once, then 429) or 100 at a time; it waits 60 seconds for the app's answer. It matches paths by whole segments and denyPaths without regard to case, and it does not render a path with characters outside letters, digits and / . _ ~ -. It removes every identity header and Cookie on the way in, Authorization too unless the entry is authMode: app, and Set-Cookie on the way out. A profile has no field to change any of this; the numbers are the cluster administrator's (security/design.md §2.14). An app that needs larger uploads or cookies on a public path cannot have them on a perimeter entry.

What an entry behind sign-in is left with. On a surface: gateway entry with authMode: oidc the app is told who is asking in the front door's headers, and nothing else of the session reaches it: the front door puts its own token in Authorization, replacing whatever the page sent, and takes it out again before the app, with the session's cookies. So an app whose pages call its own API with a bearer token of their own loses that token on such an entry, unless the entry declares clientAuthorization: app and was approved (above). forwardToken: true passes the front door's token on instead, and needs trustTier: platform.

Telling the app that a person signed out. Signing out ends the session at the front door and at the realm. A session the app keeps itself is the app's: it ends then only if the realm tells the app and the app acts on it. An app with its own sign-in client says where it takes that notice:

spec:
requires:
services:
identity:
oidc:
clientId: gentian-example
backchannelLogout:
exposure: web # an entry under spec.expose
path: /oauth/backchannel-logout # the app's endpoint for it
expose:
- name: web
surface: gateway
authMode: oidc
backend: {service: example, port: 8080}

A path and an entry, never an address. The platform builds the address from the entry's own backend, http://<backend.service>.<namespace>.svc.cluster.local:<backend.port><path>, and registers it with the client at the realm. Keycloak then posts its logout token there when a person signs out: inside the cluster, at the app's own Service, not at the public address, where the front door would ask Keycloak for a session it does not have.

  • The entry must be this profile's and route to this component's own Service (no backend.component), and that Service's name must be a plain name.
  • The path is segments of letters, digits, _, ~ and -, with single dots inside a segment: no query, no fragment, no .., no //.
  • backchannelLogoutUrl, an address of the profile's own writing, is refused, and the message names this field.
  • It is served for the component's own client, by the operator. An extension's client may not declare it, and a profile that brings a Composition of its own has to build the address the same way itself.

The app sees its Service's name in the Host header. One that refuses a host it does not know needs that name (<service>.${TENANT_NAMESPACE}.svc.cluster.local) among the hosts it trusts; that lets it answer a caller that could already reach it and opens nothing.

Declare it only for an app that checks the token before it ends a session -- the signature against the realm's keys, the issuer, that the audience is its own client, and the back-channel logout event -- and say in the profile's customization record what it checks. Show it once against the app's real image (gentian-apps, e2e/oidc-sign-out). Keycloak posts once and does not try again, so the notice is the common case and the app's own session lifetime is the bound. An app behind the sign-in sidecar declares none of this: §2.3a. What it guards and what it leaves open: security/design.md §2.15.

The component's MainAddress condition says whether it is there and, if not, why. A profile that should also work on a multi-tenancy cluster declares a second entry without apex. A fixture profile is in internal/controller/testdata/main-address/website-profile.yaml.

3. The second axis — scope (blast radius)​

Rung and scope are independent. Minimize both.

ScopeAffectsAuthored inApproved by
S0 · Tenantone tenant's installthe tenant's manifest in the deployments repository (Tenant.spec.apps[].config)cluster admin
S1 · Profileevery tenant that installs this profilegentian-apps (profiles/<n>/, apps/<n>/, addon repo)catalog maintainer
S2 · Platformevery tenant, every appgentian-os (kernel, operator, compositions, policy)platform team

The S2 gate is the existing platform boundary rule and does not change: a customization may enter gentian-os only if it is generic across apps. App-specific behavior at S2 scope is forbidden regardless of rung — no case "myapp" in a reconciler, ever. If many apps need the same thing, extend the ComponentProfile contract generically; if one app needs it, it belongs in gentian-apps at whatever rung fits.

The cost matrix. Cells are (rung × scope); the diagonal to the bottom-right is where platforms die.

S0 TenantS1 ProfileS2 Platform
L0–L1routineroutineneeds generic justification
L2–L3allowed if per-tenant runtimethe target zonerarely correct
L4discouraged — prefer S1recorded, reviewedgeneric mechanisms only
L5–L6forbidden — never fork for one tenantproduct sign-offproduct sign-off

4. ComponentProfile.spec.customization — the per-app ladder declaration​

This is what makes the framework app-specific rather than generic advice. It is a generic block — it describes capabilities in app-neutral terms, so it does not violate the "no per-app fields" rule. The types are in api/v1alpha1/customization_surface_types.go. The example shows the block alone; a profile's required fields (classes, launch, trustTier, version, package) are left out.

apiVersion: gentianos.io/v1alpha1
kind: ComponentProfile
metadata:
name: odoo-cb-base
spec:
# classes, launch, trustTier, version, package: ...
customization:
# Reachability grade — see §4.1. Derived, but pinned here for agents.
grade: A
# Rungs at which THIS app can be customized. L2 never appears here — see note below.
supportedRungs: [L0, L1, L3, L4]
ladderDocs: https://gentianos.io/docs/apps/odoo/customization

configure: # L0
valuesSchema: chart/values.schema.json
hotReload: false # does a values change need a restart?

dropIns: # L1
- name: odoo-conf
path: /etc/odoo/odoo.conf.d
format: ini
source: configMap
upstreamDocumented: true # false ⇒ this is really L4
- name: branding
path: /opt/odoo/web/static/branding
format: files
source: configMap
tenantEditable: true # a tenant may supply files here (§2.2.1)
maxBytes: 262144 # the default

extension: # L3
mechanism: odoo-addon
delivery: [git-sidecar, addon-profile]
registry: https://github.com/gentian-org/odoo-modules
addonPath: /opt/odoo/custom-addons
apiStability: stable # stable | evolving | undocumented | none
apiDocs: https://www.odoo.com/documentation/18.0/developer.html
perTenantAddons: true # one runtime per tenant ⇒ allowed
testMatrix: ["18.0"]

# NOT a rung of this app. This is the surface Odoo offers so that SOME OTHER app
# can be built at L2 against it. Odoo itself is customized at L0/L1/L3/L4.
publishes:
apis:
- contract: erp-core
protocol: http-json
spec: openapi
path: /api/v2
auth: oidc-token-exchange

repackage: # L4
chartOwnership: gentian-owned # upstream | patched | gentian-owned | vendored

patch: # L5
allowed: true
buildRepo: https://github.com/gentian-org/ocb
seriesPath: patches/series
requiresApproval: platform-team

fork: # L6
allowed: true
repo: https://github.com/gentian-org/ocb
upstream: https://github.com/odoo/odoo
owner: platform-erp
cveWatch: true

A base that takes addons also states how the selection is activated — addonActivation (valuesPath, script) and addonValues[] (whenAddon, values), §2.4. rubricScore records the score behind grade (§4.1). An addon states none of this: it is spec.package.addon, and the schema refuses spec.customization.addon.

Why L2 is never in supportedRungs. Every other rung is a property of the app being customized: Odoo either has a drop-in dir or it does not, an addon system or not, a patchable build or not. L2 is a property of the customization, not of the target. A companion is a new app; it is always buildable, because nothing stops you writing a service that talks to Odoo's API — and if Odoo published no API at all, the companion could still be built against its database or not integrate at all. So L2 is unconditionally available for every app at every grade, which is exactly why grade C apps like Collabora fall through L1/L3 straight to L2 (§9).

What the target does contribute is how pleasant that companion will be to build, and that is what publishes.apis records — it is descriptive metadata for whoever builds at L2, not a declaration that Odoo is customized at L2. An agent reads supportedRungs to decide reachability and reads publishes only after step 3 of §6 has already selected L2.

Defaults when the block is absent: {grade: unknown, supportedRungs: [L0, L4]} — i.e. an uncharacterized app can only be configured or repackaged (L2 remains available per the above). Agents must not infer more, and must raise a task to characterize the app.

4.1 Customization readiness grades​

A Debian-package-style rating, scored against a rubric and shown in the App Store. This is the answer to "the way to do it depends on the app".

GradeMeaningReachable rungsExamples
APlugin API that is documented and versioned, plus declared drop-in dirs and a published API for companionsL0–L3Odoo, Nextcloud, XWiki, Keycloak
BA plugin system exists, but it is undocumented, unversioned, or ABI-unstable. Config, drop-ins and a published API as well.L0–L3, at the risk extension.apiStability recordsElement/Synapse (synapse-module), OpenProject (openproject-plugin), LiteLLM (litellm-callback)
CConfig only; monolithic; no extension surface at allL0, then L2 or L4Collabora, many appliance images
DAnything beyond a value change requires touching sourceL0, then L5/L6unmaintained or hostile upstreams
?Not yet characterizedL0, L4new catalog entries

Grading rubric (each +1; A ≥ 7, B 5–6, C 3–4, D ≤ 2): documented config reference · declared drop-in directories · documented plugin/addon API · plugin API versioned with a deprecation policy · published HTTP API with a spec · upstream accepts patches (PR turnaround < 90d) · plugin ABI survives minor releases · a test harness plugin authors can use.

A and B differ in the quality of the plugin system, not its existence. Three of the eight criteria are about the plugin API — documented, versioned, ABI-stable — so an app with a real but poorly-kept extension system loses those points and lands in B while still being extensible. Reading B as "no plugin system" contradicts its own rubric, and would force Element, OpenProject and LiteLLM to either be misgraded or to hide working extension mechanisms.

L3 therefore remains reachable at grade B. What changes is the warranty, and that is what extension.apiStability is for: stable at grade A, evolving or undocumented at B. An agent choosing L3 against an undocumented API is choosing to re-test it on every upstream bump, which is a decision the record must justify — not something the grade should silently forbid.

Only at C is L3 genuinely unreachable, because there is nothing to extend.

Assignment is manual. The catalog maintainer scores the rubric by hand, records the score in customization.md, and sets spec.customization.grade. Several criteria — "upstream accepts patches", "ABI survives minor releases" — are judgements about a community, not facts a script can read. CI checks that a grade is present and that the recorded score matches the banding.

Publishing the grade does two things: it sets expectations before a customization is requested, and it creates pressure on the catalog to prefer Grade A apps — the same pressure Debian applies by making well-behaved upstreams cheap to package.

4.2 Bases, addons, editions and packages​

The catalog shape L3 is delivered through. Referenced by the ComponentProfile, AppPackage and Tenant CRD field documentation.

profiles/<family>/
base/ <family>-base-<name> # deployable
addons/ <family>-<addon>-<name> # activated inside a base, never installed alone
packages/ <family>-<package> # not deployable — a UI preset

An addon declares spec.package.addon.{id,of} and is selected into an installed base, arriving in Tenant.spec.apps[].addons. It inherits the base's ladder — same image, same drop-in dirs, same plugin API — so it never restates grade, rubricScore or supportedRungs.

An addon can be pinned to a build, like an app. The pin is recorded beside the list, in Tenant.spec.apps[].addonPins (name, digest, catalogue); addons stays a list of names. An addon is pinned only inside an app that is itself pinned: the director refuses a build stated for an addon of an app whose entry carries no digest, and says to install the app at a stated build first (kubectl gentian apps install <app> --tenant <t> pins it). A pinned addon is activated only from a profile shown to be that build, and its base is held as it runs until it is. See store-contract.md §3 and §4.

An addon is entitled on its own: its group is gentian:tenant:<t>:app:<addon> and its tile asks can_launch on app:<t>/<addon>. An addon for everyone is listed in Tenant.spec.apps[].addonGrants (name, defaultGrant: true) and is granted once per install like an app's defaultGrant; any other addon is given per person. The director's PUT .../apps/{p}/addons takes "defaultGrant" on an object entry ({coordinate, digest, defaultGrant} or {name, defaultGrant}), and needs can_grant for true. An addon installed with its app takes the app's choice; one added later is asked for on its own.

Editions are ce · me · ie · pe · ee, and say who maintains and supports the entry, not who publishes it:

cecommunity edition: open source, as the upstream organization publishes it
memaintained edition: the community edition's code, curated and maintained by the catalog's maintainer, who runs it itself
ieinternal edition: somebody's own profile, in their own catalog source, for their own tenants
peproprietary edition: a supplier's proprietary product, free of charge
eeenterprise edition: proprietary and paid for, supported by its supplier

An me profile packages the community edition's code and is named -ce like it; the edition is the profile's gentianos.io/profile-edition label, never the name. ee is deliberately not "the upstream's enterprise build" — a third party's paid proprietary distribution is equally an ee. A supplier's name is never an edition. Editions are technically compatible with one another, and the OS gates none of them: an ee app or addon is installed and activated exactly like a ce one. Supply is controlled at the source — its chart and images arrive only where the tenant holds a credential for the repository they are pulled from (store-contract.md §2). What constrains addon↔base compatibility is version. There is therefore one addon set per family and no per-edition compatibility matrix.

The split that matters operationally is not free against paid but where the entry comes from. ce, me and ie are entries a cluster can hold and install on its own — ce and me because their code is public, ie because it is the operator's own — so a cluster browsing its own catalog sources lists exactly those three. pe and ee are a supplier's proprietary products, which is the App Store's business: a cluster counts them and sends the person to the store, because an entry whose whole value is a relationship with a supplier is not something a cluster can describe usefully.

An edition shares a family name and nothing else — nextcloud-base-ee may deploy a supplier's all-in-one chart from a credentialed registry where nextcloud-base-ce deploys the community chart — so it inherits no ladder and must be characterized on its own.

Naming is a hint, not a contract. Two profiles may both be edition ee from different authors under unrelated names. A profile carries neither an edition nor an author field: the edition is what the catalog's index entry states for it (custom-catalogs.md), and who supplies an entry is the store's to say. Never infer either from a profile name.

A package is an AppPackage: cluster-scoped, no status, no reconciler, no workload. It names a family and a set of addons, and preselects them in the install window. The user may still adjust the selection, which is why a curated bundle stays a preset rather than becoming an artifact.


5. The Customization record​

Required for every customization at L2 and above. Written before the code. Modeled on DEP-3 patch headers, generalized to the whole ladder.

Customization is a namespaced CRD. Records are authored in git, beside the artifact they describe: a profile's in gentian-apps/profiles/<n>/customizations/<name>.yaml, from where it reaches a cluster in the profile's bundle (below). Being a cluster object buys three things a file cannot:

  • the admin console reads live records (§8.3) instead of a CI-generated snapshot;
  • the operator judges every record against the §3 cost matrix — an L5 record at scope: tenant is marked Invalid (condition Valid false, with every violation listed). Nothing refuses a record at admission;
  • status carries derived state — reviewOverdue, upstreamStale, targetVersionDrift — so the debt report is computed by the operator, not by a script guessing from YAML.

Profiles and Compositions are cluster-scoped — there is no per-profile namespace. Profile-scoped records land in the one namespace the cluster's catalog is applied in (the provisioning namespace, kernel-provisioning), a cluster-wide constant that is not derived from the profile name. Tenant-scoped records belong in tenant-<name>; no route of the director writes one, so such a record reaches a cluster only by a commit beside the tenant's manifest.

A profile-scoped record arrives in its profile's bundle. A profile reaches a cluster one at a time, from a catalog, when a tenant installs it, as one file under one digest (custom-catalogs.md §2). A record travels in that file as a companion: named <profile>.<record>, spec.target.profile its own profile, spec.scope: profile, and only from a catalog of the whole cluster — a tenant's own catalog brings profiles alone. The director refuses to declare a role: apps, type: git repository.

apiVersion: gentianos.io/v1alpha1
kind: Customization
metadata:
name: acme-invoice-approval
namespace: tenant-acme # tenant-scoped example; profile-scoped records use the
# catalogue's fixed system namespace instead (see above)
spec:
# WHAT
summary: "Two-stage approval on vendor invoices above 10k"
target:
family: odoo
profile: odoo-cb-base
appVersion: "18.0"
chartVersion: "0.1.13"

# WHERE ON THE LADDER
rung: L3
scope: profile # tenant | profile | platform
tenants: [] # required and non-empty iff scope == tenant

# WHY NOT LOWER — mandatory, one line per rung skipped
rungJustification:
L0: "no configuration knob for approval thresholds"
L1: "approval logic is behaviour, not config"
L2: "must appear inside the Odoo purchase workflow and extend account.move"

# UPSTREAM-FIRST (P3) — mandatory for rung >= L4, recommended below
upstreamFirst:
attempted: true
forwarded: not-needed
reason: "Gentian-specific tenant policy; upstream would rightly decline"

# ARTIFACTS
artifacts:
- repo: gentian-org/odoo-modules
path: gentian_invoice_approval
version: "1.2.0"
delivery: git-sidecar

# AUTHORSHIP (§2.9)
origin:
authorship: gentian # gentian | tenant | supplier | partner | community
repoOwnership: gentian # gentian | external
supportContract: none

# LIFECYCLE (P5)
owner: platform-erp # free-form owner ref, not a Gentian team enum (§2.9)
created: 2026-08-06
reviewBy: 2027-02-06
exitCriteria: "drop when Odoo ships native multi-stage PO approval"
upgradeRisk: medium
testedAgainst: ["odoo 18.0"]
tests:
- gentian-apps/apps/../tests/test_invoice_approval.py

# SAFETY (P8)
security:
macWaivers: []
newEgress: []
handlesPersonalData: true
licensing:
effect: none # none | adds-dependency | changes-terms

Why a record at all: it is the only thing that makes P5 (descend over time) enforceable. A patch series without Forwarded: is how Debian derivatives accumulate hundreds of unowned deltas; a customization without exitCriteria is how a ServiceNow instance becomes unupgradeable.


6. Decision procedure (for agents and humans)​

Deterministic. An agent asked to "add function F to app A" must execute this in order.

INPUT: capability request F, target app A, requesting scope S_req

1. RESTATE
Express F as a capability ("users must approve invoices > 10k"),
not as an implementation ("patch account_move.py").
If F is actually two changes, split and run this procedure per change.

2. LOAD THE APP'S LADDER
Read ComponentProfile(A).spec.customization.
If absent → assume {grade: "?", supportedRungs: [L0, L4]}
and emit a task "characterise customization surface of A".

3. WALK THE RUNGS L0 → L1 → L2 → L3 → L4 → L5 → L6
For each rung R:
a. CAN R express F? (semantics — see §2 rung definitions)
b. Is R in supportedRungs? (or L2, always available)
c. Is R permitted at scope S_req? (§3 cost matrix)
Stop at the FIRST R where all three hold. That is the answer.
Record a one-line reason for every rung skipped → spec.rungJustification.

4. TIE-BREAK L2 vs L3
If both are viable, apply the §2.3 table. Default to L2 when
extension.apiStability is not "stable".

5. GATE
If R >= L4:
- search upstream for an existing feature/issue/PR; record findings
- STOP and request human approval — do NOT proceed autonomously
If R >= L5:
- additionally require platform trustTier and named owner
If R == X (cluster hotfix):
- refuse; this is never a valid outcome

6. MINIMISE SCOPE
Choose the narrowest scope that satisfies the request, independent of R.
Never L5/L6 at tenant scope.

7. RECORD BEFORE CODE
Write the Customization manifest (§5). It is the design review.

8. EMIT INTO THE OWNING REPO (§6.1) — never into a live cluster.

9. TEST
Add the regression test this rung requires (§8.2).

10. REPORT
State: chosen rung, scope, rungs skipped and why, upgrade risk,
review date, and what will break this at the next upstream release.

6.1 Rung → repository map​

The single table an agent needs to know where to type.

RungScopeRepositoryArtifact
L0tenantdeployments repositorythe tenant's manifest: Tenant.spec.apps[].config.extraValues
L0profilegentian-appsprofiles/<n>/profile.yaml → spec.package.extraValues
L1profilegentian-appsprofiles/<n>/dropins/ + the chart's mount of it
L1tenantdeployments repositorythe tenant's manifest: Tenant.spec.apps[].config.dropIns (§2.2.1)
L2profilegentian-appsapps/<new>/ (from template) + contracts/<c>.yaml + profiles/<new>/
L3profileaddon repo (odoo-modules, …)addon + pinned version in profile
L4profilegentian-appscharts/<app>/, profiles/<n>/composition.yaml, spec.hooks.postInstall
L5profilebuild repo (ocb, …)patches/series + DEP-3 headers + Dockerfile
L6profilefork repovendored source, UPSTREAM-COMPARISON.md
anyplatformgentian-osonly if generic for all apps (§3)

Note profiles/<n>/ is a bundle, not a fixed depth: singletons sit at profiles/xwiki/, members of a multi-profile family at profiles/odoo/odoo-cb-crm/. Locate a bundle by its leaf directory name, which CI requires to equal the ComponentProfile's metadata.name — never by counting path segments.

6.2 Why the artifact for each rung lives where it does​

The rung → repository map is not arbitrary; it falls out of gentian-apps being a distribution repo rather than an application monorepo (the full argument is in gentian-apps/docs/app-profile-guide.md §2). Three consequences bind this framework directly:

Artifact types live in separate flat trees, so a rung maps to a tree. A profile references its chart by OCI coordinate + version, never by a path relative to itself, and charts/odoo backs 10 different profiles. So L0/L1 (metadata and content) land in profiles/, while L4 (packaging) lands in charts/ — different rungs, different trees, because they are consumed by different pipelines and versioned independently. Colocating a chart inside the profile that happens to use it would imply a 1:1 ownership that mostly does not exist.

Placement never encodes a mutable fact. A tempting alternative is to put an artifact wherever its consumers' nearest common ancestor is — shared things high, specific things low. That fails: it makes a directory's location depend on how many things use it, so a second consumer forces a physical move. The customization ladder has the same property and resolves it the same way: scope (tenant/profile/platform) is a field on the record, not a directory level, exactly as spec.trustTier is a field rather than a certified/– experimental/ split. Anything that changes over time belongs in a field, where it can be queried and validated; only stable identity belongs in a path.

The repo versions packaging, not build output. This is why an L5 record points at a patches/series (the delta) rather than a vendored copy of upstream, and why a rung-L6 fork must still carry UPSTREAM pinning plus DEP-3 headers. Debian commits debian/patches/, not .deb files; Gentoo commits an ebuild that fetches source rather than embedding it. A vendored chart or a checked-in .tgz is the same category error, which is why chartOwnership: vendored is a signal to review rather than a normal state.


7. Best practices, templated in gentian-apps/apps/_template​

The practices are not prose but scaffolding, so that every first-party Gentian app is born at Grade A and never forces a consumer to L5.

7.1 The directory customization/​

gentian-apps/apps/_template/
└── customization/
├── README.md # the ladder, filled in for THIS app; the doc consumers read
├── profile-block.yaml # spec.customization block, ready to paste into the ComponentProfile
├── dropins/
│ ├── README.md # declared paths + precedence + numbering convention
│ └── 50-example.yaml
├── extensions/
│ ├── README.md # how to write a plugin for this app
│ └── example_plugin/ # a working, tested reference plugin
├── customizations/ # Customization records for deltas this app itself carries
└── patches/
├── README.md # DEP-3 header requirements; when this dir is legitimate
└── series # empty by default — a non-empty series is a debt signal

7.2 Backend — a real extension point​

Ship a loader, not a promise. Python entry points give a plugin system that works with the existing image-layer and sidecar delivery models:

# backend/app/extensions/api.py — the versioned public contract (P7)
EXTENSION_API_VERSION = "1.0" # semver; N-2 support; deprecations announced one minor ahead

class GentianExtension(Protocol):
api_version: str
def register_routes(self, router: APIRouter) -> None: ...
def register_settings(self) -> dict: ...
def on_event(self, event: str, payload: dict) -> None: ...

# backend/app/extensions/loader.py
# discovers entry_points(group="gentian.app.<app-id>.plugins"),
# refuses plugins whose api_version is outside the supported range,
# logs the loaded set at startup and exposes it at /api/v1/extensions

Plus a /etc/gentian/<app>/conf.d/*.yaml drop-in reader in core/config.py implementing the P6 precedence chain, so L1 works out of the box.

7.3 Frontend — extension slots​

// frontend/src/extensions/Slot.tsx
<ExtensionSlot name="dashboard.widgets" context={{ tenant }} />

Named slots + a manifest-driven dynamic import of /extensions/*.js, so a companion or addon can contribute UI without a rebuild. Slot names are part of the public API and follow the same versioning policy.

7.4 Chart​

  • values.schema.json — makes L0 checkable and discoverable.
  • extraObjects, extraEnv, extraVolumeMounts, podAnnotations — the standard L4-lite hooks, so packaging changes rarely need a chart fork.
  • A declared conf.d mount wired to a ConfigMap the composition can populate — L1 with no chart edit.
  • podLabels — the platform sets gentianos.io/app and gentianos.io/tenant in it for every chart it does not place on tenants itself, and the tenant's network policies select the app's pods by them. A chart without the key publishes them as pod annotations instead (kernel/security/kyverno/policies/gentian-app-labels.yaml copies them onto labels).
  • Volumes need nothing from the chart to survive an uninstall. The platform installs an app as a Helm release in the tenant's namespace and marks every PersistentVolumeClaim the chart renders helm.sh/resource-policy: keep. Uninstalling leaves the claims in place and the next install of the app in that tenant takes them over; only a purge deletes them. So: do not set the annotation yourself to mean something else, do not delete claims from a hook, and keep a claim's immutable fields (storage class, access modes) stable across chart versions, because a reinstall updates the kept claim rather than creating one.

7.5 Docs the template must ship​

  • customization/README.md — the app's own ladder, generated from profile-block.yaml.
  • An AGENTS.md section: "Before adding a feature to an installed app, run the §6 procedure."
  • A deprecation policy statement for the extension API (N-2, one-minor notice, a proposed/ namespace for unstable surface — VS Code's model).

7.6 Retrofit for upstream apps​

Upstream apps cannot be given a customization/ directory, but their profile bundle can:

gentian-apps/profiles/<n>/
├── profile.yaml # + spec.customization
├── customization.md # the app's ladder, written by the catalog maintainer
├── dropins/
└── customizations/ # Customization records

spec.customization on the catalog's profiles is what makes the agent procedure executable. Grades are recorded per family in profiles/<n>/customization.md; addon profiles (odoo-cb-*, nextcloud-office*) inherit their base profile's declaration rather than repeating it.


8. Governance, CI, and debt​

8.1 Approval matrix​

RungScopeReviewerExtra gate
L0–L1S0/S1normal PR reviewschema validation
L2S1catalog maintainercontract review; new profile
L3S1catalog maintaineraddon CI against pinned app version
L4S1catalog maintainer + platformrender-golden diff; UPSTREAM.md if vendored
L5S1platform team, 2 reviewersDEP-3 complete; Forwarded: set; SBOM; signed image
L6S1platform + product sign-offnamed owner, CVE watch, comparison doc
anyS2platform teammust be generic across apps

8.2 CI obligations per rung​

RungCI must verify
L0values validate against values.schema.json; chart renders
L1drop-in path is declared in spec.customization.dropIns; precedence test
L2contract exists; companion tolerates target-app absence; token-exchange auth
L3addon builds against every version in extension.testMatrix; addon version pinned
L4crossplane render golden diff reviewed; no plaintext secrets in values
L5patches/series applies cleanly to the pinned upstream tag; fails the version bump if not; every patch has DEP-3 Forwarded:
L6fork builds; CVE scan; UPSTREAM-COMPARISON.md regenerated
all ≥L2a Customization record exists, parses, and has reviewBy in the future

Additionally, the operator marks a record Invalid (it refuses none at admission) for:

Marked InvalidRule
rung: L5|L6 with scope: tenant§3 cost matrix
a rung the target does not list in supportedRungs§4
rung >= L4 with upstreamFirst.attempted: falseP3
a skipped rung with no rungJustification§6 step 3
a record whose target.profile does not resolve to a ComponentProfiledangling debt

A tenant drop-in naming an undeclared or non-tenantEditable entry is held on the Tenant (§2.2.1). The §2.4 namespace test for rung: L3 with scope: tenant is applied at review.

8.3 The customization debt report​

The operator computes Customization.status on every reconcile (reviewOverdue, upstreamStale, targetVersionDrift, rungAboveRecommended); the Admin Console aggregates live records alongside the existing platform/security views:

  • count of records by rung × scope, trended over time — the number that must go down
  • records past reviewBy
  • records with upstreamFirst.forwarded: no and no reason
  • patch series length per forked component
  • apps whose grade is ?
  • upgrade blast radius: for a proposed chart.version bump, which records claim testedAgainst values that exclude the new version

This is the artifact that turns the ladder from advice into a managed liability — the thing ServiceNow and SAP shops build after the damage is done, and that Gentian can build before.


9. Worked examples​

RequestTargetChosenWhy not lowerWhere
"Our brand colors in the ERP"Odoo (A)L1no L0 knob for asset filesprofiles/odoo-cb-base/dropins/50-branding/
"Approval workflow on invoices"Odoo (A)L3must extend account.move and the purchase UIodoo-modules/gentian_invoice_approval
"Dashboard combining ERP + project data"Odoo + OpenProjectL2stands alone; two targets; neither should own itnew gentian-apps/apps/insights + contracts
"Raise Collabora's document size limit"Collabora (C)L0it is a documented valuespec.package.extraValues
"Custom Collabora save hook"Collabora (C)L2grade C — no L1/L3 surface existscompanion service on the WOPI contract
"Tenant-specific SMTP sender name"anyL0/S0pure value, one tenantTenant.spec.apps[].config.extraValues
"Propagate tenant header through OIDC logout"upstream app (D)L5no extension point; must change request handlingbuild repo patches/, DEP-3, forwarded upstream
"Odoo without the enterprise-addon nag"OdooL3already solved as an addon (hide_enterprise_modules) — never L5odoo-modules/

10. Prior art consulted​

SourceWhat we took
Debian 3.0 (quilt) + quilt patch seriesL5 shape: pristine upstream + ordered, individually-documented series; series must apply cleanly or the bump fails
Debian DEP-3 patch taggingThe Customization record's Origin / Bug-Upstream / Forwarded / Applied-Upstream / Last-Update fields
Fedora "Upstream First"P3, and the framing of a downstream delta as a maintenance liability with a due date, not a decision
systemd drop-ins (/usr → /run → /etc, lexicographic *.conf.d)L1 precedence chain and the numeric prefix convention
dpkg conffiles / dpkg-divert / update-alternativesThe idea that "you now own this file" is a discrete, recorded event
SAP Clean Core (levels A–D; key-user → side-by-side → developer extensibility)Two-axis thinking, the grade concept, and "exhaust the cheap tiers before the expensive ones"
ServiceNow configuration-vs-customization + upgrade governanceThe debt report, review boards, and the empirical claim that customization-heavy instances take months to upgrade
Odoo module inheritance (_inherit, view inherit_id + xpath)The canonical L3 model: patch behavior without copying the original
Nextcloud apps + AppAPI ExAppsDirect evidence that L2 and L3 are both first-class and distinct — ExApps are containerized side-by-side extensions
Kustomize overlays / Helm post-renderersL4 preference order; "customize without forking the chart"
VS Code proposed API; Eclipse declared extension points & API freezeP7: extension APIs are versioned contracts with a deprecation window and an explicit unstable lane
Kubernetes/OTel API stability & N-2 deprecationThe concrete deprecation policy the template's extension API adopts

Sources: Debian maint-guide ch.3 · quilt for Debian maintainers · DebSrc3.0 · Fedora Upstream First · Red Hat: what is an upstream · systemd-system.conf(5) · SAP Clean Core extensibility levels · SAP clean core best practices · ServiceNow configuration vs customization · ServiceNow upgrade governance · Odoo: fork or addon? · Nextcloud AppAPI · Nextcloud patching guide · Helm post-renderer + Kustomize · Eclipse extensions & extension points · VS Code proposed API · OpenTelemetry versioning & stability