Resource contracts
The exact generator signature, Resource Module contract, generated output, and validation errors for an App-owned Resource.
Resource contracts
Dashboard composition fields
publicForBuilder only makes a Resource eligible for catalog review. It does
not make every descriptor field visible or authorize rows. Publish
composition.selectable, groupable, aggregate operations, and time buckets
only for direct fields the existing read path may expose. For a custom scope,
redaction, or projection, implement CompositionQueryContribution; its provider
returns an AuthorizedCompositionQuery with the restored actor and exact
organization targets. Core uses only its published aliases. Reuse the Resource
read permission and predicate; never add a Dashboard permission or accept SQL
from a Dashboard or Agent client.
Use Create a Resource for the generation workflow. This reference explains the command options and the generated module contract: stable identity, model, permissions, navigation, and query fields. A discoverable module is not proof of authorized runtime access; preserve the authorization invariants below.
For monetary values, publish required_dimensions: ['currency'] beside
aggregations and unit in the field's composition metadata or provider
fields. The named same-source field must be groupable. Core allows value
aggregates only when that field is an unbucketed dimension or has a conjunctive
scalar eq constraint, globally or on the measure. OR/NOT/IN do not qualify.
Derived measures and comparison queries retain the requirement; count and
distinct-count are exempt. This does not convert currencies.
Favorite target metadata
Core can emit favorite_target metadata for a standard Resource only after it
has the catalog model, its declared .read permission, a standard stored-owner
authorization contract, a Shell Resource show shape, and a declared
destination. The metadata is a server-verified record identity, not a route or
presentation snapshot. Use it with FavoriteButton; do not reconstruct it
from a list URL or model class.
An App does not persist or send a title or URL as favorite identity. Core
re-resolves the identity under the current actor whenever it reads or changes a
favorite. An owner summary may still provide the current authorized title and
route as presentation after that resolution.
Standard App Resources use the same exact-record authority path as Core
Resources. A custom App Resource can use its existing authorization-safe
ResourceSummaryContribution when it supplies the current display and a
canonical reentry route. For example, the People Worker summary resolves a
favorite without a page organization selection through the Worker visibility
scope and record policy; an explicit organization selection still uses its
narrower relationship rule.
ResourceRef canonical identity metadata identifies an App and fully-qualified
resource key for Core's candidate classification. It does not authorize a
record or create a favorite. Unknown keys, unsupported discriminators, missing
routes, and records that fail the current authorization check resolve to null.
Do not point a read favorite at an edit route just to make it open.
A list response that is eligible for this capability may expose
meta.list_schema.resource_key and the favorites.only filter. The key is the
same canonical record key used by FavoriteTarget; the host uses it to refresh
an active Favorites-only list after a mutation. Apps forward the list schema
unchanged and do not derive a cache key from a route. The published filter has
the ordinary list option { value: "1", label: "favorites.only" }; do not add
a favorite-only contribution, endpoint, or parallel pagination contract.
When a primary cell links to a related Resource but represents its row's owner,
pass the owner identity as favoriteCandidate. The host first authorizes that
candidate through its status endpoint before it renders a button. Set
favoriteable={false} on a related-record cell that must not receive a star.
Use favoriteTarget only when the response already carries the authorized
favorite_target metadata; it does not make an App authorize a target.
Signature
The generator that produces an App Resource:
nexia-apps:make-package-resource
{package} Existing package app key or display name (e.g., quality-inspection)
{name} Resource name in PascalCase (e.g., WorkOrder)
--icon=box Shell icon name for the app-local resource navigation entry
--sort= App-local navigation sort order; defaults to the next slot
--navigation-group=operations insights | management | operations | master-data | settings
--navigation-subgroup= Optional App-owned subgroup id
--record-owner=legal_entity Canonical record owner (tenant | legal_entity)
--label-ko= Authored Korean singular label
--label-ko-plural= Authored Korean plural/collection label
--label-zh= Authored Chinese singular label
--label-zh-plural= Authored Chinese plural/collection label
--without-navigation Routable and cataloged, but no App Menu entry
--dry-run Report files and package-local edits without writing
--force Rewrite generated resource files in place
The generated Resource Module extends the SDK descriptor bridge and implements five catalog interfaces:
use Nexia\AppRuntime\Contracts\ShellResourceContribution;
use Nexia\Contribution\AbstractResourceModule; // + appDescriptors() set
use Nexia\Contribution\Contracts\ResourceAuthorizationContribution; // + resourceAuthorization()
use Nexia\Contribution\Contracts\ResourceCatalogContribution; // + resourceKey(), resourceModelClass()
use Nexia\Navigation\Contracts\NavigationContribution; // + navigationItems()
use Nexia\Permission\Contracts\PermissionContribution; // + catalogPermissionDefinitions()Three of those methods are supplied by SDK traits rather than written by hand.
ContributesResourcePermissions implements catalogPermissionDefinitions()
from your permissionResources() array; ContributesNavigationDestination
implements navigationItems() from your $navigation array;
HasShellResource supplies the Shell resource facet.
AbstractResourceModule::appDescriptors() publishes the optional
ResourceDescriptor returned by resourceModuleDefinition() through the
single AppDescriptorContribution contract. ResourceDescriptorContract
validates Resource lifecycle-event facets; it does not revive the removed
per-type descriptor contribution interface.
Minimal example
This standalone example uses the Note model and NoteStatus enum from Create a Resource. It shows the required interfaces without the generator's optional Agent navigation action. Keep the generated module when following the tutorial; do not register a second owner for workshop.note.
<?php
declare(strict_types=1);
namespace Amuzcorp\Nexia\Workshop\Contribution\Resources;
use Amuzcorp\Nexia\Workshop\Models\Note;
use Amuzcorp\Nexia\Workshop\Enums\NoteStatus;
use Nexia\AppDescriptors\DescriptorStatus;
use Nexia\AppDescriptors\ResourceDescriptor;
use Nexia\AppRuntime\Concerns\HasShellResource;
use Nexia\AppRuntime\Contracts\ShellResourceContribution;
use Nexia\Contribution\AbstractResourceModule;
use Nexia\Contribution\ResourceAuthorizationContract;
use Nexia\Contribution\Contracts\ResourceAuthorizationContribution;
use Nexia\Contribution\Contracts\ResourceCatalogContribution;
use Nexia\Contribution\ResourceLegalEntityParticipation;
use Nexia\Contribution\ResourceModuleDefinition;
use Nexia\Contribution\ResourceRecordOwner;
use Nexia\Navigation\Concerns\ContributesNavigationDestination;
use Nexia\Navigation\Contracts\NavigationContribution;
use Nexia\Permission\AssignmentScope;
use Nexia\Permission\Concerns\ContributesResourcePermissions;
use Nexia\Permission\Contracts\PermissionContribution;
final class NoteModule extends AbstractResourceModule implements NavigationContribution, PermissionContribution, ResourceAuthorizationContribution, ResourceCatalogContribution, ShellResourceContribution
{
use ContributesNavigationDestination;
use ContributesResourcePermissions;
use HasShellResource;
protected static array $navigation = [
'path' => 'notes',
'icon' => 'box',
'order' => 50,
'context' => 'app',
'group' => 'operations',
'visible' => true,
];
public static function resourceKey(): string
{
return 'workshop.note';
}
public static function resourceModelClass(): string
{
return Note::class;
}
public static function resourceModuleDefinition(): ResourceModuleDefinition
{
return ResourceModuleDefinition::fromDescriptor(new ResourceDescriptor(
key: self::resourceKey(),
version: '1.0',
status: DescriptorStatus::Active,
labelKey: 'workshop.note.list.title',
fieldSchema: [
'public_id' => ['type' => 'string'],
'name' => ['type' => 'string'],
'status' => [
'type' => 'string',
'enum' => NoteStatus::values(),
'enum_labels' => NoteStatus::labelKeys(),
],
],
searchSchema: ['label_fields' => ['name']],
));
}
public static function permissionAssignmentScope(): AssignmentScope
{
return AssignmentScope::LegalEntity;
}
public static function resourceAuthorization(): ResourceAuthorizationContract
{
return ResourceAuthorizationContract::standard(
ResourceRecordOwner::LegalEntity,
AssignmentScope::LegalEntity,
ResourceLegalEntityParticipation::RecordOwner,
);
}
public static function permissionResources(): array
{
return [
'workshop.note' => ['read', 'create', 'update', 'delete'],
];
}
}ResourceModuleDefinition is also the canonical naming boundary. A public
descriptor supplies its labelKey automatically. A catalog-only Resource can
remain descriptor-less while still declaring the same screen-independent name:
return ResourceModuleDefinition::withoutDescriptor(
self::resourceKey(),
'workshop.note.resource.label',
);List titles, navigation copy, and page descriptions may stay contextual. Reuse the module label wherever a surface needs the Resource name itself instead of adding a surface-specific alias with the same text.
Parameters
| Parameter | Type | Required | Default | Behavior |
|---|---|---|---|---|
package | string | Yes | — | Existing package app key or display name. Resolved against packages/{app_key}/composer.json; app_key and app_table_prefix are read from extra.nexia.app and never re-derived. |
name | string | Yes | — | PascalCase Resource name. Seeds the class names, the snake-case Resource Key suffix, the plural table name, and the English labels. |
Options
| Option | Default | Effect |
|---|---|---|
--record-owner | legal_entity | tenant or legal_entity. Determines table shape, API route prefix, permission middleware, Policy scope check, list predicate, and frontend request context. No other value is accepted. |
--navigation-group | operations | One of insights, management, operations, master-data, settings. The Shell owns these five group labels. |
--navigation-subgroup | none | App-owned subgroup id. Requires a valid group. Omit to keep the destination flat within its group. |
--icon | box | Shell icon name for the App Menu entry. Unknown names fall back to a neutral icon. |
--sort | next slot | App Menu order; lower appears earlier. The first Resource gets 10. With --force, an existing order is preserved unless --sort is passed explicitly. |
--without-navigation | off | Writes visible => false. Routes, permissions, catalog metadata, Shell resource routes, and Filament registration all remain; only the App Menu entry is suppressed. |
--label-ko, --label-ko-plural | — | Korean singular is required. The collection label defaults to the singular label. |
--label-zh, --label-zh-plural | English labels | Optional authored Chinese labels. The collection label defaults to the Chinese singular; omitting both records the derived English labels as explicit fallback values. |
--dry-run | off | Reports the file and edit plan; writes nothing. |
--force | off | Rewrites generated Resource files. Package-local manifest, route, and frontend-entry edits stay idempotent. Does not permit a record-owner change. |
English labels derive from name. A Korean singular label is required unless an
exact automation default is configured, and the Korean collection label defaults
to it. Chinese labels are optional and use the derived English labels as explicit
fallbacks when omitted. A Korean value equal to its English counterpart is
rejected unless it appears in
nexia.translation_catalog.validation.identical_value_allowlist.
Output
For nexia-apps:make-package-resource workshop Note --record-owner=legal_entity --label-ko=노트 --label-ko-plural=노트,
all paths relative to packages/workshop/:
src/Enums/NoteStatus.php
src/Models/Note.php
src/Policies/NotePolicy.php
src/Http/Controllers/NoteController.php
src/Contribution/Resources/NoteModule.php
database/migrations/tenant/{timestamp}_create_wsp_notes_table.php
src/Filament/Tenant/Resources/Notes/NoteResource.php
src/Filament/Tenant/Resources/Notes/Pages/{ListNotes,ViewNote}.php
resources/js/resources/notes/note-resource-contract.ts
resources/js/resources/notes/note-information-schema.tsx
resources/js/resources/notes/surface/Note{List,Show,Form}Surface.tsx
resources/js/resources/notes/section/Note{List,Show,Form}Section.tsx
resources/js/resources/notes/inspector/{NoteInspector.tsx,register-note-inspector.ts}
tests/Feature/NoteAuthorizationTest.php
Twenty files. The generated Show and Form sections both project
note-information-schema.tsx, so field order, labels, requirements,
spans, and deliberate View/Create/Edit omissions have one source. The generated
test asserts the Module's declared ownership
and scope — it makes no HTTP request, so endpoint authorization tests are yours
to add.
Two existing files are edited; the entry’s glob registration discovers the new Inspector without an index.ts import. Locale keys are merged into
resources/lang/{en,ko,zh}.json:
| File | Edit |
|---|---|
src/{App}AppManifest.php | Registers the tenant Filament Resource only |
routes/routes.php | Controller import and canonical CRUD routes, plus explicit-target compatibility routes for Legal Entity ownership |
Nothing outside packages/{app_key}/ is written. Host routes/api/, host
permission providers, host Shell files, host i18n bootstrap, and host frontend
aliases are never edited.
Generated API routes
| Record owner | Prefix | Middleware pattern |
|---|---|---|
tenant | /api/{app_key}/{resources} | can.tenant_wide:{resource_key}.{action} |
legal_entity | /api/{app_key}/{resources} | Controller resolves requested list/create targets or the stored record owner, then authorizes the operation |
legal_entity (compatibility) | /api/legal-entities/{legalEntity:public_id}/{app_key}/{resources} | can.scope:{resource_key}.{action},core.legal_entity,legalEntity |
Five CRUD routes under each prefix:
GET {base} read
GET {base}/{parameter} read
POST {base} create
PUT {base}/{parameter} update
DELETE {base}/{parameter} delete
A command route such as POST {member}/retire is not generated. Add it, and its
action, when your Resource needs one.
Member routes constrain the parameter with whereUuid and resolve public_id.
All routes additionally carry app.installed:{app_key}, which fails closed
unless the App and its transitive prerequisites are code-loaded, active, and
successfully initialized for the current tenant.
Table and naming contract
| Element | Rule | Example |
|---|---|---|
| Table name | {app_table_prefix}_{plural} | wsp_notes |
| Foreign keys | Semantic, unprefixed | parent_note_id |
| Resource Key | {app_key}.{resource_snake} | workshop.note |
| Translation keys | {resource_key}.* | workshop.note.status.active |
| Frontend registry key | App-prefixed PascalCase | WorkshopNoteListSurface |
| Public identifier | public_id UUID, unique | public_id |
The generated {Resource}Status enum is the single source for the closed value
set. The model casts status to it, the list filter derives options from
filterOptions(), the controller validates with Rule::enum(), and the
descriptor derives fieldSchema enum from values() and enum_labels from
labelKeys(). Declaring a status once on the enum keeps all four in sync.
Resource list field catalog
The model declares backend list-query capabilities through one
ResourceListFields catalog. Declare a field key once, then attach any search,
sort, and filter capabilities it supports:
use Amuzcorp\Nexia\Workshop\Enums\NoteStatus;
use Nexia\Laravel\Filters\Types\Exact;
use Nexia\Laravel\Resources\ResourceListFields;
public function resourceListFields(): ResourceListFields
{
return ResourceListFields::make()
->field(
'status',
sortable: true,
filter: new Exact('status'),
label: 'workshop.note.status.label',
options: NoteStatus::filterOptions(),
)
->field('name', searchable: true, sortable: true)
->field('updated_at', sortable: true);
}Filterable derives request validation, query application, the Scout search
projection, and meta.list_schema from this catalog. A derived or aliased sort
uses a Sort instance instead of true; lazy filter options may be supplied as
a closure and are evaluated only when the frontend schema is built. Duplicate
field keys are rejected.
This is not the public ResourceDescriptor::$fieldSchema: the descriptor tells
other platform capabilities what the Resource exposes, while
resourceListFields() is the allowlist for collection-query input. Table
rendering and column visibility remain frontend concerns because a visual
column need not map one-to-one to a query field.
Manual Resource Composition catalog
The host derives the manual Dashboard composition catalog from the Resource
descriptor and existing authorization contract. A standard Resource publishes
direct descriptor fields; a custom-scope, redacted, or projected Resource may
bind one CompositionQueryContribution. Do not add a reporting table or a
second graph contract.
A Resource is eligible only while all of these remain true:
- its descriptor is active, has a translated
labelKey, and keepspublicForBuilder: true; - the descriptor and Resource catalog have the same owning App, and that App is operational for the tenant;
- the Resource declares supported standard SQL authorization, or binds an authorized composition provider for its custom visibility profile; and
- its field schema includes direct
public_ididentity and only fields it explicitly marks safe for composition.
The public catalog strips models, tables, columns, permission slugs, and other
physical bindings. It includes the public_id identity and only direct
descriptor fields whose composition metadata grants the requested capability:
selectable, groupable, aggregation operations, or time buckets. A usable
output field needs a translated label_key; enum choices use enum_labels.
resourceListFields() contributes only supported Exact eq/in filters and
does not automatically return a field value. Unsafe, derived, sensitive, actor,
hash, token, and internal _id fields remain excluded. Set
publicForBuilder: false when the Resource should never appear in the builder.
An authorized provider receives the restored actor, request, Resource key, and exact Legal Entity/Operating Unit target pairs. It returns its already-scoped Eloquent builder plus the aliases and capabilities Core may use. Core wraps that query and never accepts client SQL, a physical column, or an undeclared alias. The provider reuses the Resource's read permission and row predicate; it does not create a Dashboard permission.
Relationships are also derived rather than declared as fixed App pairs. A
direct resource_reference can become a row relationship only when its
descriptor, physical field, accepted target, and the target's unique
public_id all agree. Two Resources can become an aggregate relationship over
Party only when each declares party_public_id for directory.party and Core
can prove the matching party_id foreign-key topology. The browser receives
only opaque relationship keys and semantic endpoints.
An Agent call without search or resource_keys receives a complete compact
authorized Resource index, without field or relationship details. It must make
a focused follow-up for those details; the manual Builder retains its complete
unfiltered catalog response.
The app-neutral CompositionSpec wire has one current schema,
schema_version: 1. It carries a bounded list of source aliases and opaque
relationships; every relationship identifies its source, target, and explicit
match behavior. population is required for aggregates, and row results also
require row_source. The SDK validates the wire and declared aliases; Core
alone resolves keys, proves graph semantics and costs, and enforces the live
catalog limits: eight sources, seven relationships/hops, 100 row results, 500
aggregate buckets, and a 3,000 ms SQL timeout. Apps do not publish a second
composition graph contract. For the App decision path, including optional
reporting metadata and the boundary between Report, Dashboard, and Agent use,
read Governed Reports and Resource Composition.
Committed mutation identity
resourceKey() and resourceModelClass() also enroll the Resource in the
host's committed-mutation observer. Ordinary Eloquent created, updated,
deleted, and restored events publish the stable Resource Key, operation,
and string/integer route key after commit. The generated controller and stub do
not add save/delete event code, and the signal contains no model fields.
This automatic CRUD observation is not a public lifecycle declaration.
Declare every ResourceLifecycleEventDescriptor and its payload schema
manually in ResourceDescriptor::$lifecycleEvents; ResourceDescriptorContract
validates that declared public contract. The observer only calls the generic
MutationCollector::resourceChanged for a catalog-bound model after commit and
never invents a lifecycle event or payload.
Query-builder bulk updates, raw SQL, and other writes that bypass model events
are not observed automatically. Inject
Nexia\Mutation\Contracts\MutationPublisher in the owning service and call
resourceChanged(resourceKey, operation, optionalResourceId) after the write
succeeds. If the business action has no Resource Catalog root, publish a stable
owner-prefixed action key with actionSucceeded() instead.
These identities let an already-parked browser waiter decide whether to recheck authoritative state. They are not durable events and never prove that a Setup criterion or business invariant is satisfied. Do not instrument every controller, match routes, or treat every successful response as a Resource change.
Authorization invariants
ResourceAuthorizationContract rejects incoherent combinations in its
constructor, so an invalid declaration fails at boot rather than at request
time:
| Combination | Result |
|---|---|
Tenant + participation other than None | InvalidArgumentException: a tenant-owned Resource cannot use direct Legal Entity-owner participation |
LegalEntity + participation other than RecordOwner | InvalidArgumentException: a direct Legal Entity-owned Resource must identify its record owner as the Legal Entity participant |
AssignmentScope::supportsGrantAt() accepts the same or a broader scope. A LegalEntity permission accepts tenant and Legal Entity Grants, but not Operating Unit Grants.
Use ResourceAuthorizationContract::custom() instead of standard() only when
the visibility rule is not plain tenant or Legal Entity ownership — it selects
ResourceVisibilityProfile::Custom and makes you responsible for the predicate.
Errors
| Message | Cause | Resolution |
|---|---|---|
Package app not found at {dir}. Run nexia-apps:make-package-app first. | No package at the resolved path | Create the App Package baseline first |
Package routes must include [app.installed:{app_key}] before adding resources. | Route group lacks the install guard | Add the middleware to the group in routes/routes.php |
App manifest [{class}] must end with AppManifest. | Manifest class name does not match the convention | Rename the manifest class and its extra.nexia.app.manifest value |
Unknown --record-owner value. Supported values: tenant, legal_entity. | Any other value, including organization | Pass tenant or legal_entity |
A Korean resource label is required. Re-run with --label-ko=<label>. | Required Korean singular label is missing in a non-interactive run | Supply an authored Korean label; its collection label defaults to it and Chinese labels are optional |
Localized resource label --label-ko must not copy the English singular label [X]. | Localized value equals the English value | Provide a real translation, or add the token to the identical-value allowlist |
Invalid navigation placement. Use group insights, management, operations, master-data, or settings and an optional subgroup. | Unknown group, or a subgroup without a valid group | Use one of the five Shell-owned groups |
--icon must be a non-empty shell icon name. | Empty --icon | Pass a name or omit the option |
--sort must be an integer. | Non-integer --sort | Pass an integer |
Refusing to change record ownership from tenant to legal_entity through scaffold overwrite. | --force with a different --record-owner than the existing declaration | Write and apply an explicit persistence/data migration, then update the Module declaration and enforcement paths together |
Refusing to reinterpret an existing Resource that has no record-ownership declaration. | Existing Module file has no ResourceRecordOwner:: declaration | Add the declaration explicitly before regenerating |
CONFLICT {path} (anchor '{anchor}' not found exactly once) | A package-local file the generator edits was hand-modified past recognition | Restore the anchor comment, or apply the edit manually |
Use in your App
Generate your own Resource with Create a Resource, then inspect the generated module, model, policy, controller, migration, and authorization declaration test together. The Quality names above are illustrative; they are not paths to a maintained Resource implementation.
Related
- Create a Resource — the step-by-step task this contract supports
- Contribution contracts — the other contribution families discovered the same way
- Fix contribution discovery — when a contributor exists but is not registered
Resource operation metadata
ResourceDescriptor accepts optional actions (list<ResourceActionDescriptor>) and mutation (ResourceMutationDescriptor). They describe existing Resource APIs for generic platform consumers. Actions declare their exact route, permission, input schema, read/mutate effect, and optional label and business description. Mutation metadata separates create and update payloads. These descriptors do not register individual Agent tools or replace controller validation. See Agent Resource Data Runtime.