Skip to content
Guide

Resource References

Publish an owner-authorized resolver and consume another App's current record without importing its code or tables.

Select or resolve a record owned by another App without importing its model. Start with an App Resource from Create a Resource.

Resolve one reference

  1. Store the canonical app_key, resource_key, and public resource_id. Treat display text and links as presentation, never identity or authority.
  2. Construct ResourceReferenceResolutionContext from the authenticated actor, authorized organization, effective date, and bounded business purpose. Call SDK ResourceReferences::resolve(); the owner authorizes and returns its permitted projection.
  3. Handle null as unavailable or unauthorized without distinguishing the two. Apply a business effect only after validating the resolved identity and owner-defined state, and snapshot values only when the owning contract permits an accepted historical snapshot.
  4. For browser pickers, declare accepted targets and selection metadata on the consuming Resource as shown below. Keep submission-time resolution even after the user selected a visible option.

Confirm the result

An authorized reference resolves to owner-approved fields; missing providers, forbidden actors, wrong organization, and stale/ineligible records cannot silently become usable references. Follow Test an App for contract checks.

How it fits: Time Closing to Payroll end to end

StageReal source
Stored identity and accepted evidencepackages/payroll/database/migrations/tenant/2026_07_19_023300_create_payroll_domain_support_tables.php
Consumer context and resolutionpackages/payroll/src/Domain/PayrollInputSourceResolver.php
Owner authorization and projectionpackages/time-absence/src/Contribution/ReferenceResolution/TimeClosingReferenceResolver.php
Owner behavior testpackages/time-absence/tests/Feature/TimeClosingReferenceResolverTest.php

Payroll consumes owner-authorized time-absence.time_closing.

1. Store a canonical identity, not a cross-App foreign key

payroll_input_source_links stores identity, display, and evidence; its only foreign key is to Payroll's snapshot:

Code example
PHP
$table->foreignId('input_snapshot_id')
    ->constrained('payroll_input_snapshots')
    ->cascadeOnDelete();
$table->string('source_app_key', 64);
$table->string('source_resource_key', 128);
$table->string('source_public_id', 128);
$table->string('source_display', 200);
$table->jsonb('source_snapshot');
$table->char('source_content_hash', 64);
$table->index(
    ['source_app_key', 'source_resource_key', 'source_public_id'],
    'payroll_input_source_owner_index',
);

Persist App key, full Resource key, public ID, and display fallback—never the owner numeric key. A snapshot is accepted cutoff evidence.

2. Create the context and call the dispatcher

Payroll accepts only APP_SOURCE_CONTRACTS and supplies actor, Legal Entity, cutoff, and purpose. This method excerpt assumes those authenticated context values, validated source IDs, and $contract are already available:

Code example
PHP
use DateTimeImmutable;
use Illuminate\Validation\ValidationException;
use Nexia\ResourceReference\Contracts\ResourceReferences;
use Nexia\ResourceReference\ResourceReferenceResolutionContext;
use Nexia\ResourceReference\ResolvedResourceReference;

$resolved = app(ResourceReferences::class)->resolve(
    $resourceKey,
    $resourceId,
    new ResourceReferenceResolutionContext(
        legalEntity: $legalEntity,
        actor: $actor,
        asOf: new DateTimeImmutable($asOf),
        purpose: 'payroll.input.collect',
    ),
);
if (! $resolved instanceof ResolvedResourceReference) {
    // An unavailable or unauthorized owner reference is never silently
    // converted into the external fallback contract.
    throw ValidationException::withMessages([
        'source_public_id' => [__('payroll.validation.input_source_unavailable')],
    ]);
}
if ($resolved->appKey !== $contract['app_key']) {
    throw ValidationException::withMessages([
        'source_public_id' => [__('payroll.validation.input_source_owner_mismatch')],
    ]);
}
Context fieldOwner uses it for
actorCurrent permission and record authorization
legalEntityOrganization scope
asOfTime-effective selection and evidence cutoff
purposeBounded consumer intent
operatingUnitOptional narrower organization scope
effectiveThroughOptional end of an owner-defined effective range; it cannot precede asOf
includeProtectedValuesExplicitly allow protected in-memory values on exact resolution only

3. Let the owner authorize and shape the result

Operational Apps contribute resolvers. TimeClosingReferenceResolver checks permission, Legal Entity, state, and record scope:

Code example
PHP
public function resolve(
    string $resourceId,
    ResourceReferenceResolutionContext $context,
): ?ResolvedResourceReference {
    $legalEntityId = $context->legalEntity?->getKey();
    if (! is_numeric($legalEntityId) || (int) $legalEntityId <= 0) {
        return null;
    }
    $legalEntityId = (int) $legalEntityId;

    $decision = $this->authorization->allowsPermission(
        $context->actor,
        'time-absence.time_closing.read',
        $legalEntityId,
        resourcePolicyRequired: true,
    );
    if (! $decision) {
        return null;
    }

    $closing = TimeClosing::query()
        ->where('public_id', $resourceId)
        ->where('legal_entity_id', $legalEntityId)
        ->where('state', 'CLOSED')
        ->first();
    if (! $closing instanceof TimeClosing
        || ! $this->resources->recordMatches($closing, $this->resourceKey(), $legalEntityId)) {
        return null;
    }

    return new ResolvedResourceReference(
        appKey: 'time-absence',
        resourceKey: $this->resourceKey(),
        resourceId: (string) $closing->public_id,
        display: $closing->displayLabel(),
        href: "/apps/time-absence/time-closings/{$closing->public_id}",
        fields: [
            'period_start' => $closing->period_start?->toDateString(),
            'period_end' => $closing->period_end?->toDateString(),
            'purpose' => (string) $closing->purpose,
            'closed_at' => $closing->closed_at?->toIso8601String(),
        ],
        asOf: $context->asOf,
        revision: (int) $closing->revision,
        state: 'CLOSED',
    );
}

null covers unavailable and unauthorized cases. Payroll reports input_source_unavailable without changing source.

4. Use batch capabilities without weakening authorization

For bounded known IDs, call resolveMany(). Core removes duplicate or blank IDs, calls ResourceReferenceBatchResolutionContribution when available, otherwise uses authorized resolve() per ID:

Code example
PHP
$resolvedById = app(ResourceReferences::class)->resolveMany(
    'time-absence.time_closing',
    $closingPublicIds,
    $context,
);

For an inclusive effective-date range, call searchEffectiveRange() only when the owner implements ResourceReferenceEffectiveRangeSearchContribution; otherwise it returns an empty list. Both paths retain identity, authorization, and non-disclosure rules.

5. Capture only an explicitly accepted snapshot

Verify owner/cutoff; store snapshot() with content_hash. Use display for rendering.

Owner projections and protected exact values

Owners add purpose-bounded safe fields; People exposes effective_planned_schedule for workforce.effective-planned-schedule.

Protected values require exact people.employment IDs, payroll.bank-export, and includeProtectedValues: true. People checks actor, employment, profile, dates, account state, currency, and record authorization. Only flagged exact resolve() and resolveMany() return protectedValues(); search, snapshots, debug, and serialization omit them. Payroll uses them in memory. Current reads require real actors; actorless work consumes a cataloged public Event into an App-owned projection, such as people.worker_planned_schedule.projected.

Canonical availability and frontend handling

ReferenceStatus reports type availability, never a record null reason:

Code example
PHP
enum ReferenceStatus: string
{
    case Available = 'available';
    case Absent = 'absent';
    case Disabled = 'disabled';
    case Failed = 'failed';
    case Stale = 'stale';
    case Unauthorized = 'unauthorized';
}
StatusCurrent host meaningConsumer behavior
availableOwner is operational, provider exists, and type-level authorization allowsResolve; still handle a record-level null
absentOwner App is not code-known or not installed for this tenantOptional integration may offer manual/local fallback
disabledInstalled owner App was intentionally turned offBlock new owner-dependent work; retain historical snapshots and wait for reactivation
failedOwner installation initialization failedBlock and expose recovery; do not fall back
staleOwner exists but is not operational, or catalog/provider state is inconsistentBlock and reconcile; do not trust a cached projection
unauthorizedType-level authorization denies this actorBlock without exposing protected details

packages/app-sdk/packages/react/src/resource-reference.ts mirrors these lowercase rules:

Code example
TypeScript
export function isResourceReferenceBlocked(
    status: ResourceReferenceStatus,
): boolean {
    return status !== 'available' && status !== 'absent';
}

export function allowsManualReferenceFallback(
    status: ResourceReferenceStatus,
): boolean {
    return status === 'absent';
}

Only absent permits manual fallback; other states could bypass access or failure.

Declare a browser selector on the consumer

Use Party for people or organizations, Legal Entity for internal entities, and Operating Unit for hierarchy. For exact employment or placement, select Party; never expose User or Worker generally. A login-capable person uses directory.party with active_legal_entity_member; membership is neither identity nor authorization.

The field declares owner keys, purpose, and consumer permissions:

Code example
PHP
'assignee_party_public_id' => [
    'type' => 'resource_reference',
    'accepted_resource_keys' => ['directory.party'],
    'selector_purpose' => 'operations.work-order-assignee',
    'selector_permissions' => ['operations.work_order.update'],
    'party_selection' => [
        'types' => ['person'],
        'eligibility' => 'active_legal_entity_member',
    ],
],

Every directory.party field declares party_selection; other fields cannot. Core enforces person/organization and none/active_legal_entity_member.

Pass consumer coordinates to the SDK route helper and validate before rendering:

Code example
TypeScript
const route = resourceReferenceOptionsRoute(
    legalEntityPublicId,
    'directory.party',
    {
        consumerResourceKey: 'operations.work_order',
        consumerField: 'assignee_party_public_id',
    },
);

const page = parseResourceReferenceOptionPage(response, 'directory.party');

Core requires an operational consumer, accepted target, and declared permission; owner authorization follows. Before a write, resolve submitted ID with write-time actor, Legal Entity, as-of, and purpose. The route never handles protected values.

nexia-runtime:generate-app-map writes schema-v3 reference_resources and derived reference_consumers; it rejects duplicate/wrong owners, active known targets without paged search, and inactive permissions. Optional unknown targets are soft; runtime decides tenant and actor availability.

Relationships reused by manual Resource Composition

The Dashboard builder reuses this metadata. resource_reference needs a direct, non-derived source field, accepted actor-visible target, matching schema field, and unique direct target public_id; the catalog exposes opaque keys and semantic endpoints only.

Two party_public_id Resources may expose aggregate shared_dimension only when both declare directory.party and live schema proves canonical party_id FK topology. It aligns counts, never rows or dependencies.

Declarations are evidence, not authority. Discovery filters both Resources for the actor. Preview and live queries require explicit organization target, lock App lifecycle, resolve again, and rebuild owner-authorized queries. Failed permission, App, descriptor, or topology closes composition. The current Resource Composition contract names only the selected bounded graph and requires explicit population semantics. Core rejects unproven topology, cardinality, grain, or cost. No automatic path search; clients supply no joins.

Contextual creation from a Resource picker

A picker can quick-create or use /new; use one continuation target:

Code example
TSX
const continuation = {
    receiverKey: 'work-order.assignee',
    receiverLabel: t('work_order.assignee.label'),
    resourceKey: 'directory.party',
} as const;

<NxCombobox
    value={partyId}
    options={partyOptions}
    onChange={setPartyId}
    resourceCreateContinuation={continuation}
    createAction={{
        label: t('work_order.assignee.create'),
        onCreate: (seed) =>
            openResourceWorkTab(
                {
                    type: 'directory.party',
                    id: 'new',
                    label: t('work_order.assignee.create'),
                    route: `/settings/parties/new?name=${encodeURIComponent(seed)}`,
                },
                { openInNewTab: true, contextualCreate: continuation },
            ),
    }}
/>

receiverLabel labels the origin; routing uses receiverKey.

The owner completes or cancels before navigation:

Code example
TSX
const resourceCreateCompletion = useResourceCreateCompletion();

onSuccess: async (response) => {
    const resourceId = response.party.public_id;
    if (await resourceCreateCompletion.complete({
        resourceId,
        display: response.party.display_label,
    })) return;
    navigate(partyShowRoute(resourceId));
};

const cancel = () => {
    if (resourceCreateCompletion.cancel()) return;
    navigate(partyListRoute());
};

On completion, re-resolve through owner. Without a resolver, retain only a non-blank human display different from ID; otherwise leave selection. Return to the initiating combobox and close the tab; FormSurface handles completion and cancel.

Boundaries

The owner authorizes every current read. A consumer cannot widen access.

A reference is not referential integrity. Owner or record can disappear; model both without a cross-App foreign key.

Availability and record resolution differ. available does not guarantee an ID; null reveals neither missing nor unauthorized.

A snapshot is evidence, not current truth. Persist only for an explicitly owned frozen view, with revision, asOf, and content hash.

Source of truth: docs/developers/content/en/platform-extensions/resource-references.md