Add a protected API
Extend Note without losing action permissions or record scope.
Add a protected API
Protect Note updates at the endpoint. Create a Resource already generates the permission, Policy, routes and controller. Start by opening src/Policies/NotePolicy.php in your App; its update method combines both required checks:
public function update(Actor $user, Note $note): bool
{
return $this->allowsPermission($user, 'workshop.note.update')
&& $this->matchesAuthorizationContract($note);
}The imports and private helpers are already generated. In src/Http/Controllers/NoteController.php, keep the sequence: resolve the record by public UUID, restore its stored owner, authorize update, validate the input, then write. To accept another field, use the complete Data Model and Migrations.
When a development App is prepared in the sandbox, its resource and permission declarations are registered against that source revision. Registering permission entries does not assign them to users or Roles. Keep both the Policy action check and the record-scope check. Development permissions removed from the new declaration are retired in the sandbox while their records are preserved. Do not reuse another App's permission keys or ownership.
Prepare the archive state
The generated Note has no status column or enum. This example uses the strings draft and archived. Add a new file after the Note creation migration, database/migrations/tenant/2026_10_04_130000_add_status_to_wsp_notes.php; do not edit the applied creation migration. Substitute your actual table name if its prefix differs.
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('wsp_notes', function (Blueprint $table): void {
$table->string('status', 16)->default('draft');
});
}
public function down(): void
{
Schema::table('wsp_notes', function (Blueprint $table): void {
$table->dropColumn('status');
});
}
};Follow Data model and migrations to synchronize, run nexia db migrate, confirm completion and resume development. Existing rows receive draft, so check that this default fits any existing business data. Keep status out of $fillable: only the dedicated action changes it. Add status: 'draft' | 'archived' to the Note response type.
Add an archive action
This example permits archiving a draft Note and returns 409 for a repeated archive. In the App-relative files below, add these members to the generated classes and the route inside its existing guarded group. Do not replace the whole files.
// NoteModule::permissionResources(): keep the existing actions and add archive.
return [
'workshop.note' => ['read', 'create', 'update', 'delete', 'archive'],
];// Add to NotePolicy; reuse its existing Actor, Note and authorization helpers.
public function archive(Actor $user, Note $note): bool
{
return $this->allowsPermission($user, 'workshop.note.archive')
&& $this->matchesAuthorizationContract($note);
}// routes/routes.php: inside the existing api/workshop prefix and guarded App group.
Route::post('/notes/{note}/archive', [NoteController::class, 'archive'])
->whereUuid('note');// NoteController: add the DB import; the other imports already exist.
use Illuminate\Support\Facades\DB;
public function archive(Request $request, string $note): JsonResponse
{
$record = $this->resolveNote($request, $note, 'workshop.note.archive');
return DB::transaction(function () use ($request, $record): JsonResponse {
$locked = Note::query()->whereKey($record->getKey())
->lockForUpdate()->firstOrFail();
$this->authorize('archive', $locked);
abort_if($locked->status === 'archived', 409);
$locked->status = 'archived';
$locked->save();
return response()->json([
'note' => $this->serialize(
$request,
$locked,
app(ResourceActionDecisions::class),
),
]);
});
}In NoteController::store() and update(), add 'status' => ['missing'] to each validation array. New Notes keep the database draft default; ordinary updates cannot bypass the archive permission. The missing rule rejects even an explicitly supplied null or empty status; keep all other field rules. Add permissions.keys.workshop.note.archive.label and .description in every locale: Archive Notes / Allows archiving Notes., 노트 보관 / 노트를 보관할 수 있습니다., 归档笔记 / 允许归档笔记。.
The action accepts no caller-supplied status or owner. The existing resolver restores owner scope and the locked row prevents two concurrent archives from both succeeding. For a button, use Build an App screen and expose an authorized action decision; do not infer authorization from status alone.
After changing declarations, run nexia check and check the reflected permission definitions and resource wiring in the Sandbox. The platform manages Host registration and caches. Adding a definition does not automatically grant the permission to a user.
Protect collections too
A Policy on one record does not filter a list. The generated model uses ResourceAuthorization::scopeVisible() and the Legal Entity list controller resolves authorized page targets before querying. Preserve those predicates for list, export and bulk work. Filter in SQL before pagination; filtering returned rows leaks totals and loads forbidden data.
Create uses creationAttributes() and one authorized target. Never accept numeric owner IDs from the request or let a request overwrite the stored owner. For standard list pages, the selector belongs in NxPageFrame.organizationScope through NxOrganizationTargetSelector and useOrganizationListScope. Tenant-owned standard common data has no selector.
Keep list enrichment bounded
Resolve App-owned labels, counts and dependency flags for the authorized page before serialization. Avoid a database query or host callback inside each row's mapping. Use eager loading for related records, aggregates for counts, or EXISTS for a boolean dependency check; do not load all matching history just to test whether one record exists. Empty pages need no enrichment query. Keep related queries within the same tenant and applicable authorization scope, including soft-delete rules.
For example, checking six dependency tables for each of 25 rows can issue up to 150 queries. A page-scoped query with correlated EXISTS can return the locked IDs once, then serialization looks up each ID in that result. The App still owns which dependencies prevent editing. Recheck current dependencies and permissions on update/delete; a list's cached display flag never authorizes a later write.
For cross-App references use the public ResourceReferences::resolveMany()
contract with the proper resolution context; never join another App's or Core's
tables. A batch API is not proof of one backend query: measure the provider too.
Report repeated platform work through developer support instead of reproducing
Core authorization, signature or catalog caches in the App. See
Test an App for query-growth and timing checks.
Collect IDs from the already-authorized page, resolve them before mapping rows,
then look up results by ID. For example, with a page of records holding
owner_party_public_id and the page's $context:
use Nexia\ResourceReference\Contracts\ResourceReferences;
$ids = $page->getCollection()->pluck('owner_party_public_id')
->filter()->unique()->values()->all();
$owners = app(ResourceReferences::class)->resolveMany('directory.party', $ids, $context);
// During row serialization: $owners[$row->owner_party_public_id] ?? null.An empty page makes no reference call. The SDK deduplicates IDs and automatically splits
remote requests into batches of at most 100. Keep different resource keys and authorization
contexts (actor, organization, effective date and purpose) in separate batches.
Missing or unauthorized references are omitted; do not retry each missing ID or
reveal its existence. Results are display data, not permission for a later write.
The owner's optional ResourceReferenceBatchResolutionContribution controls
whether resolution inside that App is also batched. SDK transport batching alone
does not remove App-owned SQL N+1 queries.
Verify the HTTP boundary
For an authorized Note, POST /api/workshop/notes/{public_id}/archive returns 200 with status: "archived"; repeating it returns 409. A normal create/update request that supplies status returns 422, so it cannot bypass the archive action.
Use an installed-App tenant fixture and call the endpoint, not only the Policy. Cover an allowed actor, an actor without permission, another organization and another tenant. An out-of-scope record can deliberately resolve to 404; the generated controller conceals some owner-resolution failures. Assert the actual endpoint contract rather than requiring every denial to be 403.
Test an App distinguishes the generated declaration assertion from these behavioral checks. Navigation, disabled buttons and actions response metadata improve UX; the server remains authoritative.
For isolated development requests, Core evaluates existing user AccessGrants and signs the accepted App permission decisions into that request. The runtime exposes those decisions through the existing SDK PermissionAuthorizer; absent, denied, foreign-App and mismatched-actor permissions are rejected. Tenant-scope decisions do not authorize a legal-entity or operating-unit scope. Keep record ownership checks in App policies.
Workspace selection is optional view state, not permission scope. Core verifies the user still belongs to the selected workspace before signing its ID; an inaccessible selection becomes absent. The isolated request session contains only this verified view hint and is cleared after handling. Core session cookies are never forwarded.
See the public CLI documentation for command and option details.
Save result recovery
Core 0.8.173 checks an uncertain save's existing receipt independently of the browser. It never automatically resends the business mutation. Browser status checks and the scheduler share a maximum of two runtime inspections: the first becomes eligible after one minute; only a transient failure can schedule one further inspection five minutes later. These are eligibility times, not a completion-time guarantee. Authority or database-continuity errors end inspection immediately. Requests still unresolved after ten minutes leave automatic recovery without another runtime call.
While confirmation is pending, retain the input and use Check save result rather than creating another request. A completed receipt returns the actual saved response or confirmed rejection. A 202 response proves acceptance only; it does not prove an App transaction committed. Partial Core changes require review, and App exceptions still need an App fix.
If confirmation ends with recovery_failed, the form stops loading, navigation is released and Retry save is hidden. Keep the Save request reference and send it to support. This state means automatic confirmation ended, not that the save failed, was cancelled or rolled back. A late verified receipt can still establish the outcome. Do not use a new request or key to work around an unknown result. Before this terminal state, any offered Retry save action requires explicit user choice and retains the original input and idempotency key.
To verify an App's handling, check a confirmed save and reload, a denied write, a lost response and an exhausted confirmation. Preserve the original request number and ensure uncertainty never enables an automatic duplicate save. This host behavior needs no SDK update; it does not establish that every underlying connection fault has been fixed.
External clients and Developer API
The Developer API exposes existing Core and App business routes to external HTTP clients, including iOS, Android and .NET. A Nexia client SDK is optional. The Developer API page under External Connections provides operation discovery, OpenAPI download, JSON request execution and request/response history. Existing External API connections instead let Nexia call external services.
Keep permissions, validation and response definitions in the owning App. With matching Core and Manager/runtime support installed, selected development and installed-release catalogs use the same API projection. A development App does not require a release submission, but its runtime catalog must be accepted. Source edits that have not reached the selected runtime are not yet callable.
Register a client for the selected operations. Interactive clients use Authorization Code with PKCE through the tenant's existing login, including SSO. Unattended devices use their own service account and roles. The management-page permission does not replace the caller's business, organization or record permissions. Apps receive the generic Actor contract; never treat a service key as a numeric User ID. Person-only signing and assigned human tasks still require a User.
Call /api/v1/<business-path> with a bearer token. /api/v1/openapi.json returns only operations allowed by that token and current grants. Existing schemas are reused, with source inference supplementing validation and response types. Dynamic or untyped fields can remain marked by x-nexia-schema-gaps; the actual handler remains authoritative. Review actual validation errors and the request ID in call history, and never automatically retry an uncertain write.
Credentials belong to one tenant and origin; sandbox credentials do not authorize production. Matching Core and Manager/runtime adoption is required; this change does not require a new App SDK release. The portal captures bounded JSON bodies and masks credentials; binary, streaming and oversized bodies have explicit omission reasons.