Two of them lived inside docs/ next to the pages they render, which is why both had grown a __file__ upward walk to find the repository root, a sys.path insert to import from utils, and a nocheck for each. A generator parked among documents reads as documentation and drops out of the checks that apply to code. Moving them surfaced six real violations that the old location had hidden: yaml.safe_load instead of the shared cache, two uncached read_text calls, five hard-coded role paths instead of ROLE_FILE_META_SERVICES and ROLE_FILE_META_MCP, two make targets without an alias, and a requirement reference in code. All six are fixed at the root, none with a suppression, and both generators lose their project-root exemptions because a package supplies PROJECT_ROOT. cli/build/readme joins them so the three generators that write into docs/ sit together. A new lint keeps docs/ to .md, .html, .rst and .nocheck; it enumerates through git ls-files so untracked __pycache__ is not its business. Checked against HEAD, it names exactly the three .py files that moved. 026-unified-addon-syntax stated that the matrix generator stays under docs/requirements/. The lint makes that impossible, so the requirement now records where the generator actually lives. Verified: every one of the twelve modules under the new tree imports, no old module path survives anywhere in the repository, 482 lint tests and 1246 cli unit tests pass, and both generators run without drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
47 KiB
026 - Unified Addon Syntax
User Story
As a contributor to Infinito.Nexus, I want every role that ships application-level extensions to declare them through one unified meta/addons/ contract (one file per addon), instead of today's per-app spelling (addons, plugins, extensions, modules, mu_plugins) scattered across meta/services.yml, vars/main.yml, and tasks/, so that addons are discoverable, lintable, and testable with a single schema, and so that any addon that bridges into another role declares that dependency explicitly through a service flag in meta/services.yml.
Background
The repository already deploys role-level extension units, but each role spells the same concept differently and stores it in a different place:
- web-app-friendica calls them
addonsand inlines them underfriendica.addonsin meta/services.yml. - web-app-odoo calls them
modules(splitcore/optional) underodoo.modulesin meta/services.yml. - web-app-nextcloud calls them
pluginsand keeps one file per plugin undervars/plugins/. - web-app-wordpress calls them
plugins(and separatelymu_plugins) undertasks/plugins/. - web-app-mediawiki calls them
extensionsand hard-codes the list in vars/main.yml. - web-app-xwiki calls them
plugins(with nesteditems[].id+version) underxwiki.pluginsin meta/services.yml. - web-app-joomla builds a single OIDC
pluginin tasks/08_oidc_plugin.yml. - desk-gnome-extensions calls them
plugins/extensionsand loops overservices.gnome-extensions.plugins.
The per-role meta layout already lists addons, plugins, and modules among the keys inlined under the primary service entity. This requirement promotes that concept to a first-class, per-file meta/addons/<addon_id>.yml topic (one file per addon, file root IS the addon spec), the same move requirement 011 made for meta/info.yml and requirement 008/009 made for other meta topics.
A recurring property of these extensions is that many of them exist only to bridge into another role: ldapauth and user_ldap bridge to svc-db-openldap; sociallogin, oidc-authenticator, OpenIDConnect, plg_system_keycloak, and the WordPress OIDC plugin bridge to the SSO provider (web-app-keycloak); wp-discourse bridges to web-app-discourse; XWiki's matomo plugin bridges to web-app-matomo. Today that bridge is implicit. This requirement makes it explicit: an addon that bridges to another role MUST declare the bridged service, and that service MUST be present in the role's meta/services.yml.
Confirmed Decisions
These choices are settled at requirement creation time and bound the implementation. Re-opening any of them MUST be recorded in the implementing PR.
- One file per addon. Addon definitions live under
roles/<role>/meta/addons/, one file per addon atmeta/addons/<addon_id>.yml. The file-root convention applies per file: the file content IS the addon spec, with no wrapping<addon_id>:key (the filename stem supplies the addon id); all files are collected intoapplications.<role_id>.addonskeyed by<addon_id>(see layout.md). The materialised path isapplications.<role_id>.addons.<addon_id>, read vialookup('config', application_id, 'addons.<addon_id>'). - Unified schema, native mechanism preserved. Every addon entry uses one schema across all roles. The app-native term is retained only as the
mechanismfield (addon,plugin,mu_plugin,extension,module,bridge). Tasks keep using the upstream installer for that mechanism; only the declaration is unified. App-specific runtime configuration is carried under an opaqueconfig:mapping that only the owning role's tasks interpret, so the surrounding schema stays uniform while each addon keeps its full per-addon payload. Eachmeta/addons/<addon_id>.ymlis therefore the single source for both the declaration and the runtime config: where a role previously split the enable/disable declaration from a per-addon config payload (e.g. Nextcloud'svars/plugins/), both move into that addon'smeta/addons/<addon_id>.ymlfile and the superseded per-addon config files are deleted by the migration. - Bridges are explicit and validated. When an addon itself integrates with another role, it MUST list the bridged service key under
bridges:. Each listed key MUST resolve to a service block already declared in the same role'smeta/services.yml. Lint fails otherwise. Front-door auth gates such as oauth2-proxy are not addon bridges unless the addon itself talks to that service. Two distinct meanings of "bridge" exist and MUST NOT be conflated: thebridges:field names an in-repo cross-role service dependency (e.g. an addon that talks tosvc-db-openldap), whereasmechanism: bridgemarks an addon that is a network/appservice bridge to an external system (e.g. a Matrix mautrix bridge to WhatsApp/Telegram). A single addon MAY be both: amechanism: bridgeaddon MAY also declare abridges:dependency on an in-repo service it relies on. - No new secret store. Any credential an addon needs continues to be declared in
meta/secrets.ymlcredentials:and read vialookup('config', application_id, 'secrets.credentials.<name>'). - Enable state defaults to off; deliberately-wired integrations auto-enable with their partner.
enabledis optional and defaults tofalseunlessrequired: trueis set. An addon that bridges a service MUST derive its effective enablement from that service'senabledflag (never a duplicated group-membership expression), so a deliberately-wired cross-role integration activates automatically whenever its partner role is co-deployed (services.<target>.enabledis true) and stays off otherwise. Any credential an addon needs is declared inmeta/secrets.ymlcredentials:and read vialookup('config', application_id, 'secrets.credentials.<name>')(Decision 4): auto-generated where possible, or supplied through the same credentials mechanism's manual-entry path where the operator must enter the secret. There is no separate opt-in exception: auto-enable-with-partner holds, and an operator-entered credential resolves throughcredentialslike any other. This auto-enable-with-partner rule is distinct from update-automation-discovered addons (Decision 10), which are added hard-off. - Required addons are explicit, and install failure is gated by
required. Every addon carries a booleanrequiredfield (defaultfalse). Core components that are part of the deployed app contract, such as Odoo's core modules, userequired: true: they are always installed, MAY omitenabled, and MUST NOT setenabled: false. The field also governs install-failure behaviour: when an addon's installation fails, arequired: trueaddon MUST hard-fail the play (the deploy stops), while arequired: falseaddon MUST only emit a warning, skip that addon, and let the play continue. - Workload-neutral contract. The
meta/addons/contract applies to any role type that declares role-level extensions, including desktop roles such asdesk-gnome-extensions. The materialised path still uses the role's assembled application entry,applications.<role_id>.addons. - First slice. The first end-to-end migration is
web-app-friendica(ldapauth, bridgesldaptosvc-db-openldap), because it already exercises the bridge rule and the service-flag enablement path. - External drift monitoring. Addon freshness is checked by opt-in external tests under
tests/external/update/addons/. These tests are warn-only, emit GitHub Actions warning annotations, and are excluded frommake testin the same way as Docker image and repository-ref external update checks. - Update PR behaviour. The scheduled update workflow MAY open or refresh an update pull request for addon version bumps and newly discovered catalog entries. Addon entries discovered by update automation MUST be added disabled by default (
enabled: false) and MUST NOT silently enable new runtime behaviour. This hard-off default applies to auto-discovered addons only; it does NOT override the auto-enable-with-partner rule for deliberately-wired integrations (Decision 5). - Every addon ships a Playwright test. Each addon with a user-facing surface MUST add a matching Playwright spec under
roles/<role>/files/playwright/that exercises that addon's behaviour (e.g. anldapauth/socialloginlogin path, awp-discourseround-trip, an enabled Odoo module's UI entry point). Desktop roles (desk-*) drive no web surface a Playwright test could exercise and therefore NEVER ship addon specs — the lint forbids spec files underdesk-*roles. For every web-facing role there is no per-addon exemption: an addon is considered migrated only once its Playwright spec is present and green. Cross-role integration specs (proving two roles are actually connected) run under a dedicated integration-pair deploy variant that co-deploys both partner roles, and are added incrementally as each ✅ pair is wired rather than all upfront. - Addon state is a variant axis. Optional addons (
required: false) MUST express their enabled/disabled split through the role'smeta/variants.ymlso CI matrix runs exercise both states, mirroring requirement 025's MCP variant axis. Required addons (required: true) are not a variant axis because they are always installed. - Network bridges are addons. Appservice / network bridges (e.g. the Matrix
mautrixbridges to WhatsApp, Telegram, Signal, Slack, Facebook, Instagram) are addons declared undermeta/addons/withmechanism: bridge, one file per bridged network. Each bridge owns its credentials inmeta/secrets.ymland isrequired: falseand disabled by default. The role's former bridge declarations (e.g.web-app-matrix'svars/bridges.yml) are absorbed intometa/addons/. - The integration matrix is the cross-role addon backlog. The full role×role capability map lives in the generated companion 027-integration-matrix.md (source of truth: cli/build/docs/integration_matrix). ☑️ cells are integrations already wired through an infinito-native service flag in the row role's
meta/services.yml(e.g.sso→keycloak,matomo,prometheus,email→mailu), derived by scanning the repo, no action required. ✅ cells are verified upstream app↔app plugins not yet declared as addons, the backlog this requirement commits to wire. 🪙 cells are the same as ✅ but gated behind a commercial tier (opt-in, operator-enabled). ❌ cells have no known integration. The matrix MUST be regenerated (not hand-edited) whenever a role or an integration is added; themeta/addons/migrations turn ✅ into ☑️ over time. - Scope is the full backlog, executed in phases. The definition of done is the FULL set: every ✅ cell in the matrix wired as an addon, and an exhaustive per-
web-app-*-role upstream-plugin survey completed so the ✅ layer is exhaustive rather than hub-biased. Delivery is phased: the shared contract plus the first-slice roles land first (see Implementation Strategy), then the remaining roles and ✅ wirings sweep incrementally, but no phase reduces scope. The requirement is not complete until the full ✅ set is wired and everyweb-app-*role surveyed. - The integration matrix is a generated artifact with a sanctioned generator. The generator lives under
cli/build/docs/integration_matrix/and runs throughmake integration-matrix;docs/holds only the rendered page. It MUST satisfy the repo lints: itsEDGESdata moves into a sibling data module so the entrypoint stays under the 500-line limit, it uses the shared project-root helper instead of a local__file__upward walk, and it carries no self-path literal orrequirement NNNtoken. The generated027-integration-matrix.mdcarries agenerated-artifact / not-a-requirementhead marker so the requirements-completeness/archival lint skips it. Dead upstream URLs inEDGESare fixed or dropped on regeneration. - Only bridging addons add a service block. A
<target>:service block is added to the row role ONLY for addons that declare abridges:dependency (per Decision 3), i.e. the in-repo cross-role integrations; a plain addon with no cross-role dependency adds no service block. Each bridging addon's<target>:block carries the standard dynamicenabled/sharedflags with matching true/falsemeta/variants.ymlcoverage, and (for web-facing pairs) a co-deploy Playwright spec. Across the in-repo ✅ set this is a bounded, mechanical expansion scoped to the bridging integrations, accepted as part of Decision 15's full scope. - Numbering. This document is
026-unified-addon-syntax.md, forming the trio025-mcp-role-integration→026-unified-addon-syntax→027-integration-matrix. The pre-existing025-mcp-role-integrationvs025-web-app-matrix-ansible-flavornumber collision is out of this requirement's scope; a separate doc owns its resolution.
Current State Audit
Each row is a migration target. The "Bridges" column is the cross-role dependency this requirement makes explicit. "Today's location" is where the declaration lives before migration.
| Role | App-native term (mechanism) |
Addon ids today | Bridges (service → role) | Today's location |
|---|---|---|---|---|
| web-app-friendica | addon |
ldapauth |
ldap → svc-db-openldap |
meta/services.yml friendica.addons |
| web-app-odoo | module |
crm, contacts, sale_management, account, website, project, stock (core); [] (optional) |
none today | meta/services.yml odoo.modules |
| web-app-nextcloud | plugin |
bbb, onlyoffice, richdocuments, sociallogin, spreed, user_ldap, whiteboard, xwiki |
sociallogin → web-app-keycloak, user_ldap → svc-db-openldap |
vars/plugins/ |
| web-app-wordpress | plugin, mu_plugin |
daggerhart-openid-connect-generic, wp-discourse (+ mu-plugins) |
OIDC → web-app-keycloak, wp-discourse → web-app-discourse |
tasks/plugins/ |
| web-app-mediawiki | extension |
PluggableAuth, OpenIDConnect |
OpenIDConnect → web-app-keycloak |
vars/main.yml |
| web-app-xwiki | plugin |
oidc-authenticator, ldap-authenticator, matomo |
oidc → web-app-keycloak, ldap → svc-db-openldap, matomo → web-app-matomo |
meta/services.yml xwiki.plugins |
| web-app-joomla | plugin |
plg_system_keycloak |
sso → web-app-keycloak |
tasks/08_oidc_plugin.yml |
| web-app-matrix | bridge |
mautrix-whatsapp, mautrix-telegram, mautrix-signal, mautrix-slack, mautrix-meta |
external networks (no in-repo service); each owns a DB credential | vars/bridges.yml |
| web-app-discourse | plugin |
docker_manager, discourse-activity-pub, discourse-akismet, discourse-ldap-auth |
discourse-ldap-auth → svc-db-openldap |
meta/services.yml discourse.plugins |
| web-app-pretix | plugin |
oidc (v2.3.1) |
sso → web-app-keycloak |
meta/services.yml pretix.plugins |
| web-app-mattermost | plugin |
volume-managed plugins (plugins, client-plugins) |
none declared today | vars/main.yml, meta/volumes.yml |
| desk-chromium | extension |
CRX ids, force_installed (e.g. uBlock Origin) |
none (desktop role) | meta/services.yml plugins |
| desk-firefox | extension |
XPI urls (uBlock Origin, KeePassXC) | none (desktop role) | meta/services.yml plugins |
| desk-gnome | extension |
gnome-shell extensions (enable/disable tuples) | none (desktop role) | meta/services.yml plugins |
| desk-gnome-extensions | extension |
configured list (services.gnome-extensions.plugins) |
none (desktop role) | meta/services.yml |
Out of scope: database/runtime engine extensions
Two roles declare an extensions: key that is not an application addon but a database/cache engine feature, provisioned at the storage layer. These MUST stay where they are and MUST NOT move into meta/addons/:
- web-app-mobilizon: PostgreSQL extensions
postgis, pg_trgm, unaccent(meta/services.yml). - web-app-bookwyrm: engine extension
bloom(meta/services.yml).
The audit MUST classify these as db-extension (out of scope), so a later sweep does not mistake them for app addons. If a future role mixes app addons and DB extensions under one key, the migration MUST split them by destination.
A grep for meta/addons/ before implementation MUST return nothing, and that baseline MUST be recorded in the implementing PR.
Cross-Role Integration Matrix
The companion 027-integration-matrix.md is a generated role×role matrix over the entity names of every web-app-* and web-svc-* role. Each cell marks whether the row role wires in the column role:
- ☑️ : already wired via an infinito-native service flag in the row role's
meta/services.yml(cell links to that declaration); - ✅ : a verified upstream app↔app plugin exists but is not yet declared as an addon (cell links to the plugin page), the backlog;
- 🪙 : same as ✅ but gated behind a commercial / paid tier;
- ❌ : no known integration;
—: diagonal (same role).
The matrix is directional (the row hosts the plugin/flag); bidirectional pairs such as nextcloud↔openproject carry a symbol in both cells. It is generated by cli/build/docs/integration_matrix: ☑️ edges are scanned from every role's meta/services.yml (the sso/matomo/prometheus/email/dashboard/css/logout/cdn/coturn/collabora/onlyoffice/libretranslate keys), and ✅/🪙 edges come from a curated, web-verified EDGES map. It MUST be regenerated rather than hand-edited. At creation time it covers 82 roles with 523 infinito-native (☑️) wired edges plus 96 curated upstream (✅/🪙) edges. The ☑️ layer is exhaustive (it is scanned from the repo); the ✅/🪙 layer is not yet exhaustive and grows as each role's upstream plugin ecosystem is researched in depth (e.g. a MediaWiki deep-dive added DiscourseSsoConsumer, PeerTubeEmbed, MachineTranslation→libretranslate, Extension:AWS→minio, and an LLM editor→openwebui). The ✅/🪙 set is the backlog: every ✅ pair is an addon this requirement commits to declare and wire (see Acceptance Criteria), and completing the per-role upstream survey is itself an acceptance criterion.
Target Schema
Addon declaration: meta/addons/<addon_id>.yml
Every role that ships role-level extensions MUST declare them under roles/<role>/meta/addons/, one file per addon at meta/addons/<addon_id>.yml. The file root IS the addon spec; there is NO wrapping <addon_id>: key (the filename stem supplies the addon id).
# roles/web-app-friendica/meta/addons/ldapauth.yml (file root IS the addon spec)
enabled: "{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}"
required: false # true for core modules that must always install
mechanism: addon # addon | plugin | mu_plugin | extension | module
source: upstream # upstream | bundled | vendored | built
bridges: # optional; service keys declared in meta/services.yml
- ldap
version: "" # optional upstream pin; "" means upstream default
group: optional # optional grouping label (e.g. odoo core/optional)
update:
monitored: true # optional; external tests check latest versions
catalog: friendica-addons # optional; supported upstream catalog adapter
upstream_id: ldapauth # optional; defaults to addon id
Field rules:
enabled(optional) defaults tofalsewhen omitted, except whenrequired: trueis set. When the addon bridges exactly one service,enabledSHOULD reference that service'senabledflag rather than re-deriving group membership.required(optional) defaults tofalse. Whenrequired: true, the addon is part of the role's baseline install contract, is treated as enabled even whenenabledis omitted, and MUST NOT setenabled: false.requiredalso gates install-failure handling: a failed install of arequired: trueaddon MUST hard-fail the play; a failed install of arequired: falseaddon MUST emit a warning, skip the addon, and continue.mechanism(required) MUST be one ofaddon,plugin,mu_plugin,extension,module,bridge. It records the upstream installation mechanism and selects which task path installs the addon.mechanism: bridgedenotes a network/appservice bridge addon (see Confirmed Decision 13) and is distinct from thebridges:field below.source(required) MUST be one ofupstream(installed from the app's own registry),bundled(ships with the image),vendored(committed underroles/<role>/files/), orbuilt(compiled/zipped during the play, e.g. the Joomla OIDC plugin).bridges(optional) MUST be a non-empty list when present. Each entry MUST name a service key that exists in the same role'smeta/services.yml. Omit the key entirely when the addon has no cross-role dependency.version(optional) MUST be a string.""means "track the app's default". A concrete pin MUST be a string, never an unquoted number.group(optional) is a free label used by roles that partition addons (e.g. Odoocorevsoptional). It MUST NOT affect enablement.update(optional) controls live upstream monitoring.update.monitoreddefaults tofalse;update.catalogMUST name a supported external-test catalog adapter;update.upstream_iddefaults to<addon_id>.config(optional) is an opaque, role-interpreted mapping holding the addon's full runtime configuration payload (e.g. Nextcloudocc config:appkey/value sets, OIDC provider blocks). Lint MUST NOT constrain its inner shape beyond requiring a mapping; only the owning role's tasks read it. Secrets insideconfigMUST resolve throughlookup('config', application_id, 'secrets.credentials.<name>')and MUST NOT be inlined literally. Lint enforces this with a bounded heuristic: anyconfigvalue whose key matches a secret-name pattern (*secret*,*password*,*token*,*client_secret*,*api_key*, case-insensitive) MUST be alookup(..., 'credentials.<name>')expression; a bare literal under such a key fails lint.
Worked example: absorbing per-addon config
A Nextcloud plugin that previously lived as a standalone config file moves wholesale into its config: block:
# roles/web-app-nextcloud/meta/addons/sociallogin.yml (file root IS the addon spec)
enabled: "{{ lookup('config', application_id, 'services.sso.enabled') | bool }}"
mechanism: plugin
source: upstream
bridges:
- sso
config:
plugin_configuration:
- appid: sociallogin
configkey: custom_providers
configvalue:
custom_oidc:
- name: "{{ lookup('domain','web-app-keycloak') }}"
clientId: "{{ OIDC.CLIENT.ID }}"
clientSecret: "{{ OIDC.CLIENT.SECRET }}"
# ... remaining provider keys, verbatim from the former vars/plugins/sociallogin.yml ...
The former roles/web-app-nextcloud/vars/plugins/sociallogin.yml is deleted; the install/enable/configure tasks read applications.web-app-nextcloud.addons.sociallogin.config instead.
Worked example: network bridge addon
A Matrix mautrix bridge becomes one addon entry per bridged network, absorbing the former vars/bridges.yml:
# roles/web-app-matrix/meta/addons/mautrix-telegram.yml (file root IS the addon spec)
enabled: false
required: false
mechanism: bridge
source: upstream
config:
bridge_name: telegram
database_username: mautrix_telegram_bridge
database_name: mautrix_telegram_bridge
database_password: "{{ lookup('config', application_id, 'secrets.credentials.mautrix_telegram_bridge_database_password') }}"
The bridge's database password stays declared in meta/secrets.yml credentials: and is referenced, never inlined. Each additional network (whatsapp, signal, slack, facebook, instagram) is its own mechanism: bridge addon.
Install-failure handling
The install task path for every addon MUST honour the required field:
- A
required: trueaddon whose install step fails MUST fail the play (nofailed_when: falseswallowing the error). - A
required: falseaddon whose install step fails MUST register the failure, emit a warning (role log line plus, in CI, a GitHub Actions warning annotation viautils.annotations.message.warning), skip the addon's remaining steps, and let the play continue. - The warning MUST name the role, the addon id, and the failing step so the operator can act without reading raw task output.
Bridge rule
The central rule of this requirement:
For every addon entry with a non-empty
bridges:list, each listed service key MUST be declared as a service block in the same role'smeta/services.yml, following the established cross-role flag pattern.
Example: the ldapauth addon above requires that web-app-friendica/meta/services.yml already declares:
# roles/web-app-friendica/meta/services.yml (excerpt)
ldap:
enabled: "{{ 'svc-db-openldap' in group_names }}"
shared: "{{ 'svc-db-openldap' in group_names }}"
Rules:
- An addon MUST NOT bridge a service that is absent from
meta/services.yml; lint fails with the missing key named. - A bridged service block MUST carry the standard
enabled/sharedflags so requirement 019 (services.yml parity) and the SSO/LDAP coverage requirements (017, 018) continue to apply unchanged. - The bridge declaration is one-directional metadata (this role depends on that service). It MUST NOT be used to auto-enable the other role; enablement stays driven by group membership and the existing
run_afterordering. - If a role uses another service around the application but the addon does not talk to that service directly, the service MUST stay in
meta/services.ymland MUST NOT be listed in the addon'sbridges:. Friendica's oauth2-proxy-backedssoservice is such a front-door auth gate forldapauth.
Materialised path and loader
meta/addons/<addon_id>.yml.<...>materialises atapplications.<role_id>.addons.<addon_id>.<...>, consistent with the file-root convention in layout.md.- The existing application loader (
utils/cache/applications.py) MUST load everymeta/addons/*.ymllike the other file-rooted meta topics. No new generated repository-wide dictionary is introduced. - The
addonskey MUST be removed from the "inlined under the primary entity" set in layout.md and documented as its own topic file.
Per-addon task hooks
- An addon that needs install/enable/configure logic beyond the generic
config:payload MUST place it inroles/<role>/tasks/addons/<addon_id>.yml, mirroring themeta/addons/<addon_id>.ymlfile-root convention. - The role's addon-processing loop MUST include
tasks/addons/<addon_id>.ymlwhen present and tolerate its absence. - Existing per-plugin task logic MUST be moved under
tasks/addons/so the schema is uniform across roles (superseding ad-hoc locations such as the former per-plugin hook directories).
Proprietary API credentials (the API SPOT)
- Credentials for proprietary / external (non-self-hosted) providers MUST be declared once in
group_vars/all/18_api.ymlunder theAPImapping (a key per provider, e.g.cloudflare,telegram,github,google,jira), sourced from the vault and defaulting to empty. - All access to those values MUST go through the
lookup('api', '<provider>.<key>')plugin (plugins/lookup/api.py), giving the proprietary-API logic a single point to change. - An integration whose partner is self-hostable in-repo (e.g.
gitlab) is wired as a localbridges:integration gated on the partner role being present. An integration whose target is purely proprietary / external (e.g.github,google,jira) carries NObridges:and MUST be enabled only when itslookup('api', '<provider>.client_id')andclient_secretare present.
Per-addon Playwright specs
- Every addon of a web-facing role MUST ship
roles/<role>/files/playwright/addons/<addon_id>.spec.js. There is NO per-addon exemption: no mechanism-based auto-exempt and no# nocheckescape; network bridges and auth extensions covered by the role's OIDC/LDAP login spec all ship their own spec asserting the addon-specific behaviour or skip-on-disabled gating. Desktop (desk-*) roles are the one exception — they drive no web surface, so they MUST NOT ship addon specs and the lint forbids them. - Each addon spec MUST guard on the addon being enabled via
skipUnlessAddonEnabled('<addon_id>'), backed by theADDON_<ID>_ENABLEDflags rendered intotemplates/playwright.env.j2, and skip cleanly when it is not. - The
test-e2e-playwrightrole MUST stagefiles/playwright/recursively (soaddons/is included) and discover specs recursively.
External update monitoring
Addon monitoring MUST follow the existing external-update pattern used for Docker image versions and repository refs.
Rules:
- Live upstream checks MUST live under
tests/external/update/addons/and run throughmake test-external, not through the defaultmake testflow. - The external addon test MUST emit warnings, not failures, when a monitored addon has a newer upstream version.
- The external addon test MUST emit warnings, not failures, when a supported catalog adapter detects a new relevant addon that is not listed in
meta/addons/. - Catalog discovery MUST be bounded by explicit adapters and curated relevance rules. The test MUST NOT warn for every package in broad public marketplaces such as the full WordPress plugin directory.
- New addon suggestions MUST include enough data for review: role id, addon id, upstream id, mechanism, source, catalog URL, and why the addon is considered relevant.
- Version suggestions MUST include the current pinned version, latest upstream version, source file, and line number where possible.
- The scheduled update workflow MUST be able to reuse the same discovery logic in update mode. Version bumps MAY update
version; new addon entries MAY be added withenabled: false,required: false, and completeupdatemetadata. - Update automation MUST preserve existing
enabled,required,bridges, and credential-related fields, and MUST NOT enable newly discovered addons automatically.
Acceptance Criteria
Repository-wide audit
- A deterministic audit command or test enumerates every role under
roles/and classifies addon support bymechanism(addon,plugin,mu_plugin,extension,module,bridge),db-extension(out of scope), ornone. - The audit reproduces every row of the Current State Audit, including the
bridge-mechanism (web-app-matrix), the newly surveyedplugin/extensionroles (web-app-discourse,web-app-pretix,web-app-mattermost,desk-chromium,desk-firefox,desk-gnome), and the out-of-scopedb-extensionroles (web-app-mobilizon,web-app-bookwyrm), and records, per role, the addon ids, theirmechanism, theirrequiredstate, and theirbridges. - A grep for
meta/addons/before implementation is recorded in the implementing PR to show the baseline was empty.
Shared contract
- layout.md documents
meta/addons/: file-root convention, the addon schema, field defaults, allowed values, and the materialised path; and removesaddonsfrom the inlined-key list. - The application loader reads
meta/addons/for every role and exposesapplications.<role_id>.addons.<addon_id>throughlookup('config', application_id, 'addons.<addon_id>'). - Role-meta lint under
tests/lint/ansible/services/rejects an invalidmechanism(outside theaddon/plugin/mu_plugin/extension/module/bridgeset), an invalidsource, an invalidupdate.catalog, an emptybridges: [], a non-stringversion, a non-booleanrequired,required: truecombined withenabled: false, a non-mappingconfig, a literal secret inlined inconfig, and missing required fields, honouring the# nocheck:suppression convention. - After the Nextcloud migration, no
roles/web-app-nextcloud/vars/plugins/*.ymlplugin-config file remains; a test asserts the directory's former per-plugin payloads now live underapplications.web-app-nextcloud.addons.<id>.config.
Bridge contract
- Lint fails when an addon's
bridges:entry names a service key absent from the same role'smeta/services.yml, and the error message names the role, addon id, and missing service key. - Lint verifies that every bridged service block carries
enabledandsharedflags. - A bridged addon's
enabledvalue referencing its single bridged service'senabledflag passes lint without a duplicated group-membership expression. - Lint allows role-level service dependencies that are not addon bridges, such as Friendica's
ssofront-door auth gate, to remain inmeta/services.ymlwithout appearing under an unrelated addon'sbridges:.
Per-role migration
- web-app-friendica declares
ldapauthinmeta/addons/withbridges: [ldap]; theldapandssoservice blocks remain inmeta/services.yml; tasks/03_addons.yml reads the addon list from the new path; deploy behaviour is unchanged. - web-app-odoo declares its
coreandoptionalmodules inmeta/addons/usingmechanism: module,required: truefor core modules, and thegrouplabel; the install path reads the new path. - web-app-nextcloud declares each plugin in
meta/addons/;socialloginbridgesssoanduser_ldapbridgesldap, both present inmeta/services.yml; each plugin's full runtime config fromvars/plugins/is absorbed into its addonconfig:block and the supersededvars/plugins/*.ymlfiles are deleted; the install/enable/configure tasks read the addonconfig:payload; deploy behaviour is unchanged. - web-app-wordpress declares its
pluginandmu_pluginentries inmeta/addons/; the OIDC plugin bridgesssoandwp-discoursebridges the Discourse service. - web-app-mediawiki declares
PluggableAuthandOpenIDConnectinmeta/addons/withmechanism: extension;OpenIDConnectbridgessso. - web-app-xwiki declares its three plugins in
meta/addons/, preservingversionpins; bridgessso,ldap, andmatomoresolve to service blocks. - web-app-joomla declares
plg_system_keycloakinmeta/addons/withmechanism: plugin,source: built, andbridges: [sso]. - web-app-matrix declares each mautrix network bridge in
meta/addons/withmechanism: bridge,required: false,enabled: falseby default, and its DB credential referenced frommeta/secrets.yml;vars/bridges.ymlis absorbed and deleted. - web-app-discourse declares its plugins in
meta/addons/withmechanism: plugin;discourse-ldap-authcarriesbridges: [ldap]resolving to theldapservice block, whiledocker_manager,discourse-activity-pub, anddiscourse-akismetcarry nobridges. - web-app-pretix declares its
oidcplugin inmeta/addons/withmechanism: plugin, the2.3.1pin preserved as a stringversion, andbridges: [sso]. - web-app-mattermost declares its plugins in
meta/addons/withmechanism: plugin; the plugin volumes remain inmeta/volumes.yml. - desk-chromium and desk-firefox declare their browser extensions in
meta/addons/withmechanism: extension, nobridges, and ship NO Playwright specs (desktop roles have no web surface; the lint forbids specs underdesk-*). - desk-gnome declares its gnome-shell extensions in
meta/addons/withmechanism: extensionand nobridges. - desk-gnome-extensions declares its extensions in
meta/addons/withmechanism: extensionand nobridges. - web-app-mobilizon and web-app-bookwyrm keep their
extensions:(PostgreSQL/engine extensions) inmeta/services.yml; they are classifieddb-extensionand explicitly NOT migrated tometa/addons/.
Cross-role integration (matrix backlog)
- 027-integration-matrix.md is regenerated from cli/build/docs/integration_matrix and committed; a check fails if the committed matrix differs from a fresh generation (no hand edits). The generator derives ☑️ edges by scanning every role's
meta/services.ymlintegration keys and renders ✅/🪙 from the curatedEDGESmap. - The matrix axes contain the entity name of every
web-app-*andweb-svc-*role; a role added later forces a matrix regeneration. - ☑️ cells (infinito-native service flags already declared in
meta/services.yml) are NOT re-declared asmeta/addons/addons; they remain service integrations, and only ✅/🪙 cells become new addons. - A per-role upstream-plugin survey is completed for every
web-app-*role, not only the integration hubs, so the ✅/🪙 layer is exhaustive rather than hub-biased. Each survey records, per partner role, the real upstream plugin (or its documented absence) with a source URL; the MediaWiki survey (which surfacedDiscourseSsoConsumer,PeerTubeEmbed,MachineTranslation→libretranslate,Extension:AWS→minio, an LLM editor→openwebui) is the template for depth. - Every ✅ cell is wired: for each ✅ pair (row → column), the row role declares an addon in its
meta/addons/that integrates the column role, withmechanismset appropriately and (when the column role is an in-repo role) abridges:entry resolving to the row role'smeta/services.ymlservice for that target. - Each wired integration addon is
required: falseand disabled by default, and its enablement is gated on the target role being present (the relevantservices.<target>.enabledflag), so an integration never activates when its partner role is not deployed. - 🪙 (commercial) cells are opt-in: they are declared in
meta/addons/but remain disabled, and the role README documents the required commercial tier; they are NOT enabled by default and do not block the requirement. - Where the cell links to an already-wired plugin (e.g. Nextcloud↔Collabora/BBB, WordPress↔Discourse), the migration reconciles the existing wiring with the matrix entry instead of duplicating it.
- Each newly wired ✅ integration with a user-facing surface ships a Playwright spec (per Confirmed Decision 11) proving the two roles are actually connected when both are deployed.
- SSO-to-
keycloakcells already satisfied centrally by thessoservice (requirement 021) are marked as such and are NOT re-implemented as duplicate per-role OIDC addons.
External drift and update PRs
tests/external/update/addons/contains a warn-only external test that checks every addon withupdate.monitored=truefor newer upstream versions.- The external addon test emits GitHub Actions warning annotations for outdated addon versions and for relevant new catalog entries that are not listed in
meta/addons/. - The external addon test is runnable through
make test-externaland is intentionally excluded from the defaultmake testflow. - The supported catalog adapters are documented and bounded so broad marketplaces do not produce unreviewable warning noise.
- The scheduled update workflow opens or refreshes an update pull request when addon versions can be bumped or when relevant new addon entries can be added.
- Update automation adds newly discovered addons with
enabled: false,required: false, completeupdatemetadata, and no credentials. - Update automation preserves existing enablement, bridge, required, and credential-related fields when bumping addon versions.
Tests
- Unit or integration tests validate the addon schema and reject unsafe or malformed entries.
- A parity test (in the spirit of requirement 019) asserts that every addon
bridges:key has a matchingmeta/services.ymlblock, repo-wide. - A schema test verifies that
enabledmay be omitted, defaults tofalsefor optional addons, defaults to true forrequired: true, and rejectsrequired: truewithenabled: false. - An install-failure test proves that a failing
required: trueaddon hard-fails the play, while a failingrequired: falseaddon emits a warning, is skipped, and the play continues. - Each role with optional (
required: false) addons expresses the enabled/disabled split as ameta/variants.ymlaxis so the CI matrix exercises both states (mirroring requirement 025). - Unit tests cover the external addon discovery logic with fixture catalogs for a version bump, a newly discovered addon, and a marketplace entry intentionally ignored by the curated relevance rules.
- Each migrated role's existing deploy path stays green; no addon behaviour regresses.
- LDAP- and SSO-bridging addons remain covered by the existing Playwright LDAP/SSO requirements (017/018) without new per-role wiring.
- Every addon of a web-facing role ships a matching Playwright spec under
roles/<role>/files/playwright/addons/covering that addon's behaviour, and that spec is green before the addon is marked migrated. Desktop (desk-*) roles ship NO specs (the lint forbids them).
Per-addon tasks, specs, and the API SPOT
- A lint (
tests/lint/ansible/services/test_addons_playwright_spec.py) requires, for every addon of a web-facing role, aroles/<role>/files/playwright/addons/<id>.spec.js(no mechanism-based auto-exempt and no# nocheckescape) and forbids specs under desktopdesk-*roles; it passes repo-wide. - The
test-e2e-playwrightrole stagesfiles/playwright/recursively and discovers specs recursively, sofiles/playwright/addons/*.spec.jsare staged and run. - Each addon spec guards on the addon being enabled (
skipUnlessAddonEnabled) via theADDON_<ID>_ENABLEDflags rendered intotemplates/playwright.env.j2. - Per-addon task hooks live in
roles/<role>/tasks/addons/<id>.yml; the former per-plugin task logic was moved there for a uniform schema. - Proprietary / external API credentials are declared once in
group_vars/all/18_api.yml(APImapping) and accessed exclusively through thelookup('api', '<provider>.<key>')plugin; the existing cloudflare/telegram code routes through it. - Self-hostable integration partners (e.g.
gitlab) are wired as localbridges:integrations; purely proprietary targets (github,google,jira) carry nobridges:and are enabled only when theirAPIcredentials are present. - Each cross-role integration spec drives the full OAuth connect-flow and is green when both the host and partner roles are deployed (deploy-gated; skips cleanly when the integration is not provisioned).
Documentation
- layout.md and the plugins README cross-reference each other so the Ansible-
plugins/concept and the application-addonsconcept are not conflated. - Each migrated role README documents its addons, their
mechanism, default state, and which services they bridge. - This requirement file is cross-linked from the implementing PR.
Validation Apps
The implementation MUST validate the bridge rule and the loader on the first slice before sweeping the rest:
INFINITO_APPS="web-app-friendica" \
make deploy-fresh-purged-apps INFINITO_FULL_CYCLE=true
After the first slice is green, each migrated role MUST pass its role-local deploy path and any matrix variants affected by the migration.
Prerequisites
Before starting implementation work, the agent MUST read AGENTS.md and follow all instructions in it.
Implementation Strategy
- Add the
meta/addons/schema and loader support, thetests/lint/ansible/services/lint rules (schema + bridge resolution), and the layout.md documentation. - Add the repo-wide bridge-parity test.
- Add the external addon update checker, its fixture-backed unit tests, and update-workflow integration for warn-only drift detection and update pull requests.
- Migrate
web-app-friendicaas the first end-to-end slice (addon list + bridge resolution + task rewire) and prove deploy parity. - Sweep the remaining roles one at a time: odoo, nextcloud, wordpress, mediawiki, xwiki, joomla, matrix (bridges), discourse, pretix, mattermost, desk-chromium, desk-firefox, desk-gnome, and desk-gnome-extensions. Keep each role's README, variants, and tests aligned. Leave the out-of-scope
db-extensionroles (mobilizon, bookwyrm) untouched. - Remove
addonsfrom the inlined-key set once every role is migrated.
Commit Policy
- The shared schema, loader, lint, and the first migrated role MAY land together.
- Each additional role SHOULD land in a focused commit or PR when it can be validated independently.
- The implementing PR MUST NOT mark any Acceptance Criterion complete until the behavior is verified end to end.