Skip to content
Reference

Contribution contracts

Every SDK contribution interface, its required method, and the rules a contributor class must satisfy to be discovered.

Contribution Contracts

Publish App-owned capabilities by implementing an interface in a manifest-discovered class. The example below registers a Resource identity; the inventory tells you which interface publishes each other capability. Discovery succeeds only when the class explicitly implements the interface and its directory is included in App manifest.

Minimal identity contribution

Complete Create a Resource first so Amuzcorp\Nexia\Workshop\Models\Note exists. This complete class demonstrates only the Resource identity interface; it requires no base class or trait.

Code example
PHP
<?php

declare(strict_types=1);

namespace Amuzcorp\Nexia\Workshop\Contribution\Resources;

use Amuzcorp\Nexia\Workshop\Models\Note;
use Nexia\Contribution\Contracts\ResourceCatalogContribution;

final class NoteCatalog implements ResourceCatalogContribution
{
    public static function resourceKey(): string
    {
        return 'workshop.note';
    }

    public static function resourceModelClass(): string
    {
        return Note::class;
    }
}

The generated NoteModule already publishes this identity together with its permissions, authorization, descriptor, and navigation. Keep that generated module; do not register NoteCatalog alongside it or replace the full module with this identity-only example. A Resource key must have one owner.

A class is discoverable only through its declared interface and the Manifest's contribution location. A trait supplying methods does not declare an interface.

Contribution interfaces

InterfaceNamespaceRequired method
ResourceCatalogContributionNexia\Contribution\ContractsresourceKey(), resourceModelClass()
ResourceAuthorizationContributionNexia\Contribution\ContractsresourceAuthorization()
ShellResourceContributionNexia\AppRuntime\ContractsshellResource()
PermissionContributionNexia\Permission\ContractscatalogPermissionDefinitions()
RolePresetContributionNexia\Permission\ContractsrolePresets()
LegalEntityMemberSelfAccessContributionNexia\Permission\ContractslegalEntityMemberSelfAccessKeys()
NavigationContributionNexia\Navigation\ContractsnavigationItems()
PaletteCommandContributionNexia\Palette\ContractspaletteCommandItems()
InheritedSearchVisibilityContributionNexia\Contribution\ContractsinheritedSearchVisibilityRelation(), plus Resource Catalog identity
DashboardWidgetContributionNexia\Dashboard\ContractsdashboardWidgets()
DashboardQuerySourceContributionNexia\Dashboard\ContractsdashboardQuerySources()
AgentToolContributionNexia\Agent\ContractsagentTools()
AppDescriptorContributionNexia\AppDescriptors\ContractsappDescriptors(): AppDescriptorSet
ResourceSummaryContributionNexia\AppDescriptors\ContractsresourceKey(), summarize()
BatchResourceSummaryContributionNexia\AppDescriptors\ContractsresourceKey(), summarize(), summarizeMany()
ResourceReferenceResolutionContributionNexia\ResourceReference\ContractsresourceKey(), resolve()
ResourceReferenceBatchResolutionContributionNexia\ResourceReference\ContractsResolution methods plus resolveMany()
ResourceReferenceSearchContributionNexia\ResourceReference\Contractssearch(), plus the resolution methods
ResourceReferenceEffectiveRangeSearchContributionNexia\ResourceReference\ContractsSearch and resolution methods plus searchEffectiveRange()
ResourceReferencePagedSearchContributionNexia\ResourceReference\ContractsresourceKey(), resolve(), search(), authorized(), searchPage()
CompositionQueryContributionNexia\ResourceComposition\ContractscompositionQueryProvider(), resourceKey(), resourceModelClass()
ResourceTransferExportSourceContributionNexia\ResourceTransfer\ContractsresourceTransferExportSources()
ImportRecipeContributionNexia\ResourceImport\ContractsresourceImportRecipes()
ResourceImportPipelineContributionNexia\ResourceImport\ContractsresourceImportPipelines()
SignatureDocumentDataSourceContributionNexia\Signature\ContractsappDescriptors(), signatureDocumentDataSourceProviders()
SignatureBulkBindingContributionNexia\Signature\ContractsappDescriptors(), signatureBulkBindingProviders()
CopyableTemplateContributionNexia\Templates\Contractstemplates(), apply()
FixtureContributionNexia\Fixture\ContractsappKey(), fixtureKeys(), seed()
SetupTaskContributionNexia\Setup\Contractstasks()
FeatureGuideContributionNexia\Guidance\ContractsownerKey(), guides()
SelfWorkContextContributionNexia\SelfService\ContractsworkContexts()
SelfServiceActionItemContributionNexia\SelfService\ContractsselfServiceActions()

A contribution carries exact catalog keys — labelKey, titleKey, descriptionKey — and the host resolves them for the requested locale at the payload boundary. Backend contributions supply keys rather than loading a locale map.

Most methods are static. AppDescriptorContribution::appDescriptors() is the one static publication boundary for every typed descriptor; the immutable set may contain Resource, Slot Widget, Approval, Signature, Process, Decision, or Reporting descriptors together. Descriptor keys must be non-empty and have no surrounding whitespace. Core preserves FQCN contributor order and then each set's declaration order; host-only test registrations follow in call order. ResourceSummaryContribution, the ResourceReference interfaces, CopyableTemplateContribution, and FixtureContribution use instance methods because they resolve, apply, or seed work per request or per run. SetupTaskContribution is also instance-based: the host resolves the contributor through its container, infers its owner from the manifest discovery record, and registers the returned declarations. The two Signature contributions publish static descriptors through appDescriptors() and instance runtime providers through their second method; Core joins each exact descriptor/provider identity and fails closed when the provider is absent.

ResourceSummaryContribution is also the record-presentation boundary used by compact host surfaces such as Spotlight. The owner returns an authorized display, optional route, optional Shell icon name, and typed fields. A field may set ResourceSummaryFieldRole::Subtitle, Meta, or Status so a compact consumer knows the field's semantic placement; the host still owns the React component, spacing, truncation, and accessibility. Use BatchResourceSummaryContribution when the provider can summarize several resource IDs in one bounded query. Core keeps the single-record fallback for existing providers. Spotlight passes ResourceVisibility::Search only after it re-fetches and authorizes the search candidate, and the owner must still enforce record visibility or return null.

App-owned Agent tools

Use AgentToolContribution only when generic Resource data, a Dashboard query source, and an existing screen action cannot express the capability. The App owns a normal authorized Laravel route and publishes only immutable metadata:

Code example
PHP
use Nexia\Agent\Contracts\AgentToolContribution;
use Nexia\Agent\AgentToolDeclaration;
use Nexia\Agent\AgentToolLane;
use Nexia\Agent\AgentToolRouting;
use Nexia\Agent\AgentToolSurface;
use Nexia\Agent\AgentToolTier;

final class TimeAbsenceAgentTools implements AgentToolContribution
{
    public static function agentTools(): array
    {
        return [new AgentToolDeclaration(
            key: 'time-absence.personal_time_leave.read',
            tier: AgentToolTier::Auto,
            routing: AgentToolRouting::read(
                AgentToolLane::DirectData,
                AgentToolSurface::Data,
            ),
            description: 'Read the current user personal leave balance.',
            method: 'GET',
            path: '/api/legal-entities/{legalEntity:public_id}/time-absence/personal-leave',
            permissions: ['time-absence.leave_balance.read'],
        )];
    }
}

The first key segment is the stable App namespace and may be kebab-case. Every remaining dotted segment is lowercase alphanumeric/underscore. permissions is non-empty and must describe the real route guard. inputSchema, when present, is a non-empty JSON Schema object. Laravel scoped placeholders such as {legalEntity:public_id} keep their exact route syntax; the Gateway substitutes the active Legal Entity or an argument whose name exactly matches the placeholder before invocation. A generic id argument never aliases {user}, {ticket}, or another differently named placeholder.

Preconditions are exact Router capabilities rather than free-form tags. legal_entity_selected is satisfied only when the trusted delegation context contains an active Legal Entity; without it the Router withholds the tool. An unsupported precondition also keeps the tool unavailable.

Discovery calls the static method without resolving the contributor through the Core container. The declaration can publish only a Laravel route binding, routing lane/effect/surface, default confirmation tier, permission metadata, and invalidation hints. It cannot publish a Gateway handler, Closure, Core Action, recipe, host widget, or browser tool. External effects additionally require AgentToolTier::Confirm and the external-share facet.

Use this extension only for distinct business capabilities. Ordinary Resource CRUD and declared domain actions use Resource descriptors and the fixed generic data tools; they do not need AgentToolContribution.

Interface inheritance

Four interfaces extend ResourceCatalogContribution, so implementing them also requires resourceKey() and resourceModelClass():

InterfaceAlso requires
ResourceAuthorizationContributionresourceKey(), resourceModelClass()
ShellResourceContributionresourceKey(), resourceModelClass()
DashboardWidgetContributionresourceKey(), resourceModelClass()
InheritedSearchVisibilityContributionresourceKey(), resourceModelClass()

A dashboard widget contributor is therefore bound to a Resource by design.

Traits that supply methods

TraitNamespaceImplementsReads from
ContributesResourcePermissionsNexia\Permission\ConcernscatalogPermissionDefinitions()permissionResources()
ContributesNavigationDestinationNexia\Navigation\ConcernsnavigationItems()$navigation
ContributesNavigationDestinationActionsNexia\Navigation\Concernsagent action facetagentNavigationActionDefinitions()
HasShellResourceNexia\AppRuntime\ConcernsshellResource()Catalog identity

You still declare the interface. Using ContributesResourcePermissions without implements PermissionContribution produces a class with a working method that discovery never selects — the doctor reports permission contributors: 0 classes.

Requirements a contributor must satisfy

RequirementWhy
Inside a declared contributionLocations() rootManifest generation indexes only those directories
Not abstractAbstract classes are skipped
File name matches class namePSR-4 autoloading
Keys App-prefixed and stableKeys appear in permission rows, translations, and stored tenant state
Labels from the App locale catalogA literal string ships untranslated
No tenant writes during discoveryThe registry is assembled before any actor or tenant context exists
Safe to call repeatedlyConsumer registries may read declarations more than once

The last two matter most. Declaration methods, including SetupTaskContribution::tasks(), run while a registry is being assembled — no tenant is initialized and no actor is known. Reading the database, resolving the current user, or writing anything there will fail or corrupt the registry. Setup evaluators are a separate phase: the host calls evaluate() only while building a tenant plan and supplies an opaque tenant, actor, locale, and time context. The contributor still performs no tenant work during discovery.

Output or return: Descriptor value objects

AppDescriptorContribution returns an immutable AppDescriptorSet of typed objects, all in Nexia\AppDescriptors. The host's AppDescriptorCatalog discovers that single contract, retains contributor provenance, and lets each consumer request only its descriptor class:

ClassRequired constructor arguments
ResourceDescriptorkey, version; optional labelKey naming the resource itself
EventDescriptorfull Event key, positive integer schemaVersion, aggregateType, payloadSchema
SlotWidgetDescriptorkey, version, slot, component, slotApiVersion
ApprovalFormBindingDescriptorappKey, resourceKey, actionKey, labelKey, entryModes, formWidgetKey, documentSchemaKey
ApprovalDocumentSchemaappKey, resourceKey, sections
ApprovalBusinessTemplatePresetDescriptorApp-owned preset key and business-template fields
ApprovalRoutePolicyPresetDescriptorApp-owned preset key and route-policy fields
SignatureTemplateBindingDescriptorApp-owned signature-template binding fields
ProcessStartBindingDescriptorappKey, bindingKey, definitionKey, processId, resourceKey, startPermissionKey, labelKey
ProcessWorkActionDescriptorkind, appKey, actionKey, labelKey, topic; optional payload/input, approvalTask, and outputContract metadata
ProcessApprovalTaskConfigurationbindingKey, outcomeTopic; optional outcomes, payload keys, businessTemplatePresetKey, and routePolicyPresetKey
ProcessUserTaskFormDescriptorkey, appKey, labelKey
ProcessTemplateDescriptorkey, version, appKey (nullable, no default), labelKey, category, structure
DecisionResultTemplateDescriptorkey, version, labelKey, fields; optional descriptionKey, decisionTable, hitPolicy
ReportingViewDescriptorkey, version, viewName, columns

Versioned top-level descriptors carry status: DescriptorStatus, normally defaulting to Active, with Deprecated and Removed as the other states. Most use a string version; EventDescriptor instead requires the positive integer schemaVersion carried by EventEnvelope.

These descriptor types remain distinct typed values. The generic set replaces their separate discovery interfaces and registry entry points; it does not remove Approval presets, Signature bindings, Public Event contracts, or any Process/DMN capability.

Descriptor constructors validate their own values eagerly. The strict host gate validates the contents of each set, enforces declared descriptor types, App-owned key prefixes, and per-family key uniqueness. A standalone public integration Event uses EventDescriptor; a public Resource lifecycle Event stays nested under ResourceDescriptor and must not be declared a second time. EventPayloadSchema accepts only the supported scalar, array, and object types, treats fields as required unless required: false, and rejects undeclared payload fields. Composable payload schemas additionally require labels through PublicEventPayloadSchema; use PublicEventPayloadSchema::withLabelKeys() when one translation key can be derived for each path.

An optional App with a malformed contribution is isolated while the registry is assembled so unrelated Apps can still boot. That tolerance is not an acceptance gate: nexia-apps:validate-app-descriptors, App activation, and tenant installation all run strict validation and refuse the invalid App before synchronization or tenant state writes.

Errors

SymptomCauseResolution
WARN contribution locations: no package contribution location is registeredDirectory not declared, or namespace mismatchFix contributionLocations()
WARN permission contributors: 0 classesTrait used without declaring the interfaceAdd implements PermissionContribution
WARN permissions discovered: 0 permission definitionsNo discovered contributor declares permissionsCheck the interface, then the class location
InvalidArgumentException from a descriptorDescriptor constructor validationRead the message; it names the invalid value
DescriptorValidationException during validation, activation, or installationA contribution is malformed or contradicts another contributionFix every reported descriptor path, then rerun nexia-apps:validate-app-descriptors
Contribution appears in some processes onlyA stale autoloader in a long-running processRestart workers and Vite after adding a class

Use in your App

Keep the contributor inside a directory returned by your manifest and explicitly implement its interface. A trait alone does not register a contribution.

Source of truth: docs/developers/content/en/app-sdk/contribution-contracts.md