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
- Store the canonical
app_key,resource_key, and publicresource_id. Treat display text and links as presentation, never identity or authority. - Construct
ResourceReferenceResolutionContextfrom the authenticated actor, authorized organization, effective date, and bounded business purpose. Call SDKResourceReferences::resolve(); the owner authorizes and returns its permitted projection. - Handle
nullas 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. - 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
| Stage | Real source |
|---|---|
| Stored identity and accepted evidence | packages/payroll/database/migrations/tenant/2026_07_19_023300_create_payroll_domain_support_tables.php |
| Consumer context and resolution | packages/payroll/src/Domain/PayrollInputSourceResolver.php |
| Owner authorization and projection | packages/time-absence/src/Contribution/ReferenceResolution/TimeClosingReferenceResolver.php |
| Owner behavior test | packages/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:
$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:
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 field | Owner uses it for |
|---|---|
actor | Current permission and record authorization |
legalEntity | Organization scope |
asOf | Time-effective selection and evidence cutoff |
purpose | Bounded consumer intent |
operatingUnit | Optional narrower organization scope |
effectiveThrough | Optional end of an owner-defined effective range; it cannot precede asOf |
includeProtectedValues | Explicitly 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:
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:
$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:
enum ReferenceStatus: string
{
case Available = 'available';
case Absent = 'absent';
case Disabled = 'disabled';
case Failed = 'failed';
case Stale = 'stale';
case Unauthorized = 'unauthorized';
}| Status | Current host meaning | Consumer behavior |
|---|---|---|
available | Owner is operational, provider exists, and type-level authorization allows | Resolve; still handle a record-level null |
absent | Owner App is not code-known or not installed for this tenant | Optional integration may offer manual/local fallback |
disabled | Installed owner App was intentionally turned off | Block new owner-dependent work; retain historical snapshots and wait for reactivation |
failed | Owner installation initialization failed | Block and expose recovery; do not fall back |
stale | Owner exists but is not operational, or catalog/provider state is inconsistent | Block and reconcile; do not trust a cached projection |
unauthorized | Type-level authorization denies this actor | Block without exposing protected details |
packages/app-sdk/packages/react/src/resource-reference.ts mirrors these lowercase rules:
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:
'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:
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:
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:
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.