Resource Reference
Owner-authorized resolver를 공개하고 다른 App의 코드·테이블을 import하지 않은 채 현재 레코드를 소비합니다.
다른 App의 모델을 가져오지 않고 해당 레코드를 선택하거나 해석합니다. 소비하는 App Resource는 Resource 만들기에서 준비합니다.
참조 하나 연결
app_key,resource_key, 공개resource_id를 정식 식별자로 저장합니다. 표시 문자열과 링크는 식별자나 권한으로 사용하지 않습니다.- 인증된 사용자, 허가된 조직, 기준일, 길이가 제한된 업무 목적을
ResourceReferenceResolutionContext에 담아 SDKResourceReferences::resolve()를 호출합니다. 소유 App이 권한을 검사하고 허용한 필드만 반환합니다. null은 해석 불가 또는 권한 없음으로 처리하고 둘을 구분해 노출하지 않습니다. 반환된 식별자와 소유 App이 정의한 업무 상태를 검증한 뒤에만 효과를 적용하며, 이력 스냅샷도 소유 계약이 허용한 값만 확정합니다.- 브라우저 선택기는 아래 예시처럼 소비 Resource에 허용 대상과 선택 메타데이터를 선언합니다. 선택 당시 보였던 항목도 제출 시 다시 해석해야 합니다.
결과 확인
허가된 참조는 소유 App의 공개 값으로 해석되고, provider 부재·권한 부족·조직 불일치·사용 불가능한 레코드는 정상 참조로 통과하지 않습니다. 계약 검사는 App 테스트하기를 따릅니다.
전체 흐름: Time Closing에서 Payroll까지
Payroll과 Time & Absence가 완결된 경로를 제공합니다.
| 단계 | 실제 소스 |
|---|---|
| 저장된 identity·수용한 evidence | packages/payroll/database/migrations/tenant/2026_07_19_023300_create_payroll_domain_support_tables.php |
| Consumer context·resolution | packages/payroll/src/Domain/PayrollInputSourceResolver.php |
| Owner authorization·projection | packages/time-absence/src/Contribution/ReferenceResolution/TimeClosingReferenceResolver.php |
| Owner 동작 test | packages/time-absence/tests/Feature/TimeClosingReferenceResolverTest.php |
Payroll은 owner가 승인한 time-absence.time_closing projection을 소비합니다.
1. Cross-App foreign key가 아닌 정규 identity 저장
Payroll은 payroll_input_source_links에 owner identity, display,
content-addressed evidence snapshot을 저장합니다. foreign key는 Payroll 자체
input 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',
);정규 App key, fully-qualified Resource key, public ID, display fallback을 저장하고 owner 숫자 key는 저장하지 않습니다. Snapshot은 수용한 cutoff evidence이지 현재 상태가 아닙니다.
2. Context를 만들고 dispatcher 호출
Payroll은 APP_SOURCE_CONTRACTS 조합만 받고 actor, Legal Entity, cutoff,
제한된 purpose를 제공합니다. 아래 메서드 발췌는 인증된 context 값, 검증한 source ID, $contract를 앞에서 준비한 상태입니다.
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는 명시적입니다.
| Context 필드 | Owner가 사용하는 곳 |
|---|---|
actor | 현재 permission·record authorization |
legalEntity | 조직 scope |
asOf | time-effective selection·evidence cutoff |
purpose | 제한된 consumer intent |
operatingUnit | 선택적이고 더 좁은 조직 scope |
effectiveThrough | Owner가 정의한 유효 범위의 선택적 종료 시점. asOf보다 앞설 수 없음 |
includeProtectedValues | Exact resolution에서만 보호된 in-memory 값을 명시적으로 허용 |
3. Owner가 승인하고 결과 모양을 정하게 하기
Dispatcher는 owner App이 operational일 때만 contributed resolver를 찾습니다.
TimeClosingReferenceResolver는 permission, Legal Entity, state,
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이 unavailable·unauthorized 경우를 모두 덮어 probing을 막습니다.
Payroll은 source를 조용히 바꾸지 않고 input_source_unavailable을 반환합니다.
4. Authorization을 약화하지 않고 batch capability 사용
이미 아는 ID의 제한된 목록에는 resolveMany()를 호출합니다. Core는 빈 값과 중복 ID를
정규화하고 owner가 ResourceReferenceBatchResolutionContribution을 구현하면 batch
호출 하나를 dispatch합니다. 구현하지 않았으면 같은 권한 적용 resolve()를 ID마다
호출하는 fallback을 씁니다.
$resolvedById = app(ResourceReferences::class)->resolveMany(
'time-absence.time_closing',
$closingPublicIds,
$context,
);Owner가 정의한 exact key를 inclusive effective-date range와 겹치는 범위에서 찾을 때는
searchEffectiveRange()를 호출합니다. Owner는
ResourceReferenceEffectiveRangeSearchContribution으로 opt in하며, capability가
없으면 ordinary search를 무제한 반복하는 대신 빈 목록을 반환합니다. 두 경로 모두
반환된 Resource identity를 검증하고 단건 resolution과 같은 record authorization 및
non-disclosure 규칙을 유지합니다.
5. 명시적으로 수용한 snapshot만 포착
성공하면 Payroll은 owner와 cutoff를 검증하고
ResolvedResourceReference::snapshot()과 content_hash를 저장합니다. 일반
UI에서 display는 rendering fallback일 뿐이므로 현재 진실이 필요한 결정 전에는
다시 resolve하세요.
Owner projection과 보호된 exact 값
Owner는 자기 Resource의 안전한 fields에 purpose-bounded projection을 추가할 수
있습니다. People은 workforce.effective-planned-schedule 목적일 때만
people.employment에 effective_planned_schedule을 추가합니다. Time & Absence는
asOf와 effectiveThrough을 모두 전달한 뒤 반환된 employment, Legal Entity,
선택적 assignment identity를 검증합니다.
보호 값에는 더 엄격한 경로를 사용합니다. Payroll은 알고 있는
people.employment ID를 payroll.bank-export 목적과
includeProtectedValues: true로 resolve합니다. People은 actor, employment,
payroll profile, 유효 기간, bank-account 상태, 통화, record authorization을 각각
확인한 뒤에만 protectedValues()로 계좌번호와 예금주명을 반환합니다. Core는 이
명시적 flag가 있는 exact resolve() 또는 resolveMany()에서만 보호 값을
허용합니다. Search는 보호 값을 거부하고 snapshot(), debug 출력, PHP
serialization에도 보호 값이 들어가지 않습니다. Payroll은 승인된 이체 workbook을
쓸 때만 값을 메모리에서 사용하며 저장하지 않습니다.
두 경로 모두 실제 현재 actor가 필요합니다. Actor가 없는 scheduled·recovery
작업은 catalog에 등록된 공개 Event를 App 소유 local projection으로 소비해야
합니다. Time & Absence는 가짜 actor로 People을 live 조회하지 않고
people.worker_planned_schedule.projected에 이 규칙을 적용합니다.
정규 availability와 frontend 처리
ReferenceStatus는 현재 tenant와 actor의 type-level 가용성을 나타내며 특정
record가 null인 이유는 밝히지 않습니다. SDK enum은 여섯 값입니다.
enum ReferenceStatus: string
{
case Available = 'available';
case Absent = 'absent';
case Disabled = 'disabled';
case Failed = 'failed';
case Stale = 'stale';
case Unauthorized = 'unauthorized';
}| 상태 | 현재 host 의미 | Consumer 동작 |
|---|---|---|
available | Owner operational, provider 존재, type-level authorization 허용 | resolve하되 record-level null도 처리 |
absent | Owner App을 code가 모르거나 이 tenant에 미설치 | optional integration은 manual/local fallback 제공 가능 |
disabled | 설치된 Owner App이 의도적으로 비활성화됨 | 새 owner 의존 작업을 차단하고 이력 snapshot을 보존하며 재활성화를 대기 |
failed | Owner installation initialization 실패 | 차단하고 recovery 노출, fallback 금지 |
stale | Owner가 있지만 non-operational이거나 catalog/provider 상태 불일치 | 차단·정합화, cached projection 신뢰 금지 |
unauthorized | Type-level authorization이 이 actor를 거부 | protected detail 노출 없이 차단 |
packages/app-sdk/packages/react/src/resource-reference.ts의 frontend 계약이 이
lowercase string을 그대로 mirror합니다. fallback 규칙은 명시적입니다.
export function isResourceReferenceBlocked(
status: ResourceReferenceStatus,
): boolean {
return status !== 'available' && status !== 'absent';
}
export function allowsManualReferenceFallback(
status: ResourceReferenceStatus,
): boolean {
return status === 'absent';
}Manual fallback은 **오직 absent**에서만 허용됩니다. unauthorized,
failed, stale에서 제공하면 access·operational 실패가 조용한 우회로
바뀝니다. Payroll 같은 required workflow는 absent여도 fallback을 거부할
수 있습니다. helper는 fallback 제공이 안전한 때를 말할 뿐 모든 consumer가
제공해야 한다고 말하지 않습니다.
Consumer에 브라우저 selector 선언
Field의 의미에 따라 Resource를 고릅니다.
- 사람이나 조직을 고르면 Party입니다.
- 내부 법적·회계 책임 법인을 고르면 Legal Entity입니다.
- 부서·사업부·공장 같은 운영 계층을 고르면 Operating Unit입니다.
- 돈, 권리, 근태, 계약 효과를 특정 고용이나 배치에 귀속해야 한다면 Party를 먼저 선택하고 소유 도메인의 exact backend binding을 사용합니다. 일반 사람 selector에 User나 Worker를 노출하지 않습니다.
로그인할 수 있는 사람만 필요한 field도 Resource는 directory.party로 유지하고
party_selection constraint에 active_legal_entity_member를 추가합니다.
Membership은 선택 가능한 Party를 좁힐 뿐 Resource identity가 되거나 consuming
action을 승인하지 않습니다.
브라우저 picker는 consuming Resource와 field에 결합됩니다. 그 field의
ResourceDescriptor::fieldSchema에 허용할 owner Resource key, 제한된
owner-facing purpose, consumer permission을 선언합니다.
'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',
],
],directory.party를 허용하는 모든 field는 party_selection을 선언해야 하며,
directory.party를 허용하지 않는 field에서는 이 constraint가 유효하지 않습니다.
Typed types 목록에는 person, organization 또는 둘 다를 넣고, eligibility는
none이나 active_legal_entity_member를 사용합니다. Core가 Party resolver에서
constraint를 적용하므로 consumer는 Party, User, membership, workforce join을 다시
구현하지 않습니다.
같은 consumer 좌표를 SDK route helper에 전달하고, 알 수 없는 응답을 화면에 표시하기 전에 parser로 검증합니다.
const route = resourceReferenceOptionsRoute(
legalEntityPublicId,
'directory.party',
{
consumerResourceKey: 'operations.work_order',
consumerField: 'assignee_party_public_id',
},
);
const page = parseResourceReferenceOptionPage(response, 'directory.party');Core는 consumer가 inactive이거나 그 App이 operational하지 않은 경우, field가 요청한 target을 허용하지 않는 경우, actor에게 선언된 consumer permission이 하나도 없는 경우 요청을 거부합니다. 그 다음 owner가 resolution context로 자체 authorization과 filtering을 적용합니다. Picker metadata는 mutation을 승인하지 않습니다. consuming App은 저장 전에 write 시점의 actor, Legal Entity, as-of, purpose로 제출된 public ID를 다시 resolve하고 정규 display와 snapshot evidence를 저장해야 합니다. Selector Route는 보호 값을 요청하거나 반환하지 않습니다.
nexia-runtime:generate-app-map은 schema version 3 code-loaded 목록을 씁니다.
reference_resources는 provider를 나열하고, reference_consumers는 active
Resource Reference field에서 consumer owner, Resource·field key, 허용 target,
selector purpose·permission, target별 provider 존재 여부와 capability를
파생합니다. 생성기는 provider 중복·owner 불일치, paged search가 없는 알려진 active
target, inactive selector permission을 거부합니다. 알 수 없는 선택 target은 soft
dependency로 남고 tenant·actor 가용성은 계속 runtime에서 결정합니다.
수동 Resource Composition에서 relationship 재사용
수동 Dashboard builder는 증명된 Resource Reference metadata를 재사용하며 별도의 App 간 relationship 선언을 만들지 않습니다.
Row 결과에서는 source descriptor가 직접 연결된 non-derived
resource_reference field를 선언하고, 그 field가 actor에게 보이는 target
Resource를 허용하며, source에 대응하는 물리 field가 있고, target에 고유한 직접
public_id가 있을 때 Core가 resource_reference relationship을 게시할 수
있습니다. Public catalog에는 불투명 relationship key와 의미적 source·target
field만 들어갑니다. Planner가 사용하는 App model, table, column, join expression은
노출하지 않습니다.
Aggregate 결과에는 더 좁은 shared Party 경우가 있습니다. Resource 두 개가 각각
directory.party를 허용하는 party_public_id를 선언하고 live schema가 두 field
모두 정규 party_id foreign key에 대응함을 증명하면 host가 shared_dimension
relationship을 게시할 수 있습니다. 이는 독립 count를 Party 기준으로 맞추는
동작이며 row join이 아니고 어느 App도 다른 App에 의존하게 하지 않습니다.
선언은 증거일 뿐 권한이 아닙니다. Catalog 발견 단계에서 두 Resource를 현재 actor에 맞게 거릅니다. Preview와 저장 위젯의 모든 query는 명시적 조직 target을 요구하고 source App 설치 lock을 잡은 뒤 relationship을 다시 해석하고 owner가 승인한 Resource query를 새로 구성합니다. Permission 부재, App 비활성화, descriptor drift, 증명되지 않은 topology는 cached relationship으로 fallback하지 않고 composition을 닫습니다.
Builder는 graph를 자동 탐색하지 않습니다. 현재 Resource Composition 계약은 사용자가 선택하고 catalog가 증명한 제한된 graph와 명시적인 population을 서버 발급 relationship key로 지정합니다. Core는 각 Resource subquery를 독립적으로 권한 검사하고 endpoint, topology, cardinality, 결과 grain, 비용을 증명하지 못하는 graph를 거부합니다. Client는 join expression을 보내거나 Core에 path 탐색을 요청하지 않습니다.
Resource picker에서 문맥 생성하기
Picker는 승인된 quick-create dialog나 정규 /new Route에서 만든 identity를
돌려받을 수 있습니다. 복잡한 form에는 Route를 우선합니다.
Route 기반 생성에서는 하나의 안정적인 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은 작은 출처 표시에 쓰는 현지화 필드명이고 continuation
routing은 receiverKey를 사용합니다. 생략하면 원래 Work Tab 이름만 보이며
receiver key와 Resource ID는 노출하지 않습니다.
Owner form은 평소 navigation보다 먼저 continuation을 완료하거나 취소합니다.
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());
};완료되면 host는 owner boundary로 public ID를 다시 resolve합니다. Resolver가
없으면 public ID와 다른 비어 있지 않은 사람용 display가 있을 때만 owner
mutation을 로컬로 유지합니다. Host는 ID를 picker label로 쓰지 않으며 안전한
결과가 없으면 선택값을 바꾸지 않습니다. 이 fallback은 현재 projection이나
authorization 우회가 아닙니다. Host는 시작 combobox만 선택하고 mount된 원래
Work Tab으로 돌아가 임시 tab을 닫습니다. Shell이 두 tab을 인접한 임시
continuation group으로 관리하며 App은 관여하지 않습니다. 생성된 FormSurface
stub에는 completion·cancel 분기가 있습니다.
경계 규칙
Owner가 모든 현재 읽기를 승인합니다. Consumer가 reference를 통해 access를 넓힐 수 없습니다.
Reference는 referential integrity가 아닙니다. Owner가 absent일 수 있고 특정 record가 resolve되지 않을 수 있습니다. cross-App foreign key 없이 둘 다 모델링하세요.
Availability와 record resolution은 다릅니다. available은 특정 ID의
resolution을 보장하지 않고, null은 missing과 unauthorized를 구분하지
않습니다.
Snapshot은 evidence이지 현재 진실이 아닙니다. Consumer workflow가 frozen
view를 명시적으로 소유할 때만 revision, asOf, content hash와 함께
저장합니다.