Skip to content

Commit 7ffd79d

Browse files
author
Shubham Agarwal
committed
Make mobile identity and GUID contracts deterministic
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 78870709-25fe-45d7-8669-dd0ba9470807
1 parent 5181c52 commit 7ffd79d

3 files changed

Lines changed: 17 additions & 5 deletions

File tree

plugins/mobile-apps/agents/screen-builder.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ You will be invoked by `/create-mobile-app` Step 11 or `/edit-app` screen-rebuil
3232
- **Use shared code via path aliases — NEVER re-define inline.** The project has `@/components`, `@/hooks`, `@/utils`, `@/tokens` configured in tsconfig. Import from them:
3333
- **Components:** `import { LoadingState, ErrorState, EmptyState, ScreenHeader, ModalHeader, BottomActionBar, FloatingActionButton, FilterChipRow, FormField, RowPick, StatusPill, AvatarInitials, InfoRow, ActionRow, SectionHeader } from '@/components'`
3434
- **Hooks:** `import { useListData, useCursorListData, useSearchFilter } from '@/hooks'` — use `useListData` only for bounded list screens whose spec says `pagination: none`. For unbounded Dataverse screens whose spec says `pagination: cursor`, use `useCursorListData`, `useInfiniteQuery`, or an app-specific cursor hook generated by the orchestrator. Use `useSearchFilter` only for bounded client-side lists; cursor lists must push search into the service call with `filter`.
35-
- **Utils:** `import { formatDate, formatDateTime, formatRelative, truncate, pluralize, choiceLabel, STATUS_TONES } from '@/utils'`
35+
- **Utils:** `import { formatDate, formatDateTime, formatRelative, truncate, pluralize, choiceLabel, STATUS_TONES } from '@/utils'`. Dynamic Dataverse routes additionally import `normalizeDataverseGuid`.
3636
- **Generated:** `import { FooService } from '@/generated/services/FooService'` and `import type { Foo } from '@/generated/models/FooModel'`
3737
- **Native:** `import { captureFromCamera } from '@/native/camera'`
3838
Do NOT define `function LoadingState()`, `function formatDate()`, `function Field()`, `function Section()`, or status color maps inside your screen. Do NOT write the `useState(loading) + useFocusEffect(load) + onRefresh` pattern manually — use `useListData` instead.
@@ -199,7 +199,11 @@ You will be invoked by `/create-mobile-app` Step 11 or `/edit-app` screen-rebuil
199199
- Grep `@odata.bind` in the generated **model**, not the generated service. If the key is absent, return `BLOCKED` rather than inventing it.
200200
- **Typed payload rule:** do not start write payloads as `Record<string, unknown>`. Define a `Pick<Parameters<typeof Service.create|update>[N], ...>` containing the exact writable keys, then use a single boundary cast only when generated base types incorrectly require server-managed fields. This makes misspelled or wrongly-cased lookup keys fail TypeScript.
201201
- **Lookup filter rule:** generated mobile services do not reliably support raw Web API relationship traversal such as `lookupNav/relatedColumn eq ...`. Query the related table service first, then filter the source table using its read lookup GUID property (`_lookuplogicalname_value eq <guid>`).
202-
- **Authenticated profile rule:** when the plan links a profile table to `systemuser`, import the generated `SystemusersService`. Resolve token `oid → systemuserid` by filtering `azureactivedirectoryobjectid`, reject disabled/missing/duplicate users, then filter the profile with `_systemuserlookup_value eq <systemuserid>`. If `SystemusersService` is absent from the Generated Services snapshot, return `BLOCKED`; do not replace it with email matching or relationship traversal.
202+
- **Authenticated profile rule:** when the plan links a profile table to `systemuser`, import the generated `SystemusersService`. Resolve token `oid → systemuserid` by filtering `azureactivedirectoryobjectid`, and reject disabled, missing, or duplicate users.
203+
- Before writing the profile filter, open the generated profile model and locate the exact declared read lookup property for the planned `systemuser` lookup column. For a planned lookup logical name such as `new_systemuserid`, the expected shape is `_new_systemuserid_value`, but the generated model declaration is authoritative.
204+
- Copy that exact model property into the filter: `<exact_generated_lookup_read_key> eq <systemuserid>`. Never emit guessed placeholders such as `_lookup_value`, `_systemuserlookup_value`, or `_<table>_value`.
205+
- If the planned lookup column is absent, multiple candidate `systemuser` lookups exist, or the exact read key is not declared in the generated model, return `BLOCKED [<screen_name>]: exact systemuser lookup read key not verified`. Do not fall back to email matching or relationship traversal.
206+
- If `SystemusersService` is absent from the Generated Services snapshot, return `BLOCKED`; do not invent a raw Web API client.
203207

204208
- **Form picker UI**when the form's spec calls for a parent picker (e.g., "select Project"), the picker stores the selected record's `id` (GUID string), and the submit handler converts it to the bind string at write time. Never store the bind string in component stateonly in the API payload.
205209
- **Pagination rule:** If your spec says `pagination: cursor`, do NOT use `useListData` or `useSearchFilter`. Use the skeleton's `useCursorListData` call, React Query's `useInfiniteQuery`, or an app-specific `use<Entity>CursorList` hook with FlatList `onEndReached` per the pattern in [`data-performance.md`](${PLUGIN_ROOT}/shared/references/data-performance.md). Never fetch all records at once, and never treat `top: 50` as pagination. Real generated Dataverse services use SDK `maxPageSize` for page size and return `IOperationResult.skipToken` for the next page; pass that value back as `skipToken`. Always include deterministic `orderBy` with a unique key and `select` in the service call. Push search/filter into Dataverse with `filter`. If the generated service in the app does not expose `maxPageSize`/`skipToken` for an unbounded table, return `BLOCKED [<screen_name>]: generated service does not expose cursor paging for <Service>; do not downgrade to useListData`.
@@ -948,7 +952,13 @@ Before finishing the screen, mentally verify:
948952
24. **Forms wrap content in `<KeyboardAvoidingView>`** with `behavior="padding"` on iOS.
949953
25. **Form submit calls `router.back()` on success by default.** Exception: create-then-navigate workflows that immediately continue into the created record MUST pre-generate a Dataverse GUID with `newId()` from `@/utils` (which wraps `Crypto.randomUUID()`), include it as the primary ID field in the create payload, then navigate using that known ID. Do not read the new ID from `result.data`, and do not refetch/search after create just to find it. Never use meaningful/sensitive IDs, and do not use this exception for normal saves, bulk inserts, or sample data unless an immediate follow-up operation requires the ID.
950954
25. **Route intent matrix is enforced in code.** Singleton routes use `router.navigate(...)`; detail drill-down uses `router.push(...)`; auth/guard redirects use `router.replace(...)`. `router.push(...)` to known singleton routes is a generation error.
951-
25. **Dynamic route IDs are validated before Dataverse calls.** Every detail/edit/upload route normalizes `useLocalSearchParams()` values and rejects missing, `'undefined'`, `'null'`, or non-GUID IDs before calling `Service.get`, `Service.update`, or `Service.upload`. `enabled: !!id` is not sufficient. Bug killed: HTTP 400 `table(undefined)` after create-then-navigate.
955+
25. **Dynamic route IDs use the shared Dataverse GUID normalizer before every Dataverse call.** Every detail/edit/upload route MUST import `normalizeDataverseGuid` from `@/utils`; never write an inline UUID/GUID regex. Normalize array-valued Expo params first, then call the helper:
956+
```ts
957+
const params = useLocalSearchParams<{ id?: string | string[] }>();
958+
const rawId = Array.isArray(params.id) ? params.id[0] : params.id;
959+
const id = normalizeDataverseGuid(rawId);
960+
```
961+
Reject an undefined result before calling `Service.get`, `Service.update`, `Service.delete`, `Service.upload`, or `Service.download*`. `enabled: !!rawId`, a generic RFC UUID validator, and checks for only `'undefined'` / `'null'` are forbidden. Dataverse sequential GUIDs do not guarantee RFC version bits. Bug killed: valid Dataverse IDs rejected locally and HTTP 400 `table(undefined)` after create-then-navigate.
952962
25. **Scanner Dataverse writes are locked and reset on focus.** Any scanner callback that creates/updates Dataverse rows, uploads evidence, or navigates to a created record uses a `useRef` in-flight lock plus `paused` state, passes `paused` and `resetKey` to `BarcodeScannerView`, resets lock/paused/resetKey inside `useFocusEffect`, and routes manual code entry through the same guarded mutation. Bug killed: rapid QR callbacks creating duplicate or broken scan rows, and scanner stuck when returning from detail.
953963
25. **Camera evidence screens show a visible Take picture action.** Any evidence/photo capture flow has a first-class `Take picture` / `Take evidence photo` button wired to `takePhoto()` from `src/native/camera`. Gallery/upload/file picker actions may exist only as secondary siblings, never as the only visible capture path. Bug killed: evidence screen where camera capture exists technically but users cannot find it.
954964
26. **Create / update payloads NEVER include server-managed columns.** Forbidden keys in any `*Service.create({...})` or `*Service.update({...})` object literal: `ownerid`, `owneridtype`, `statecode`, `statuscode`, `importsequencenumber`, `overriddencreatedon`, `timezoneruleversionnumber`, `utcconversiontimezonecode`, `versionnumber`, `createdon`, `modifiedon`, `createdby`, `modifiedby`. If the generated model type marks them required, put the unavoidable cast inside the narrow write helper — never emit junk like `ownerid: ''` or `statecode: 0` to satisfy the type. Bug killed: every HTTP 400 on save.

plugins/mobile-apps/agents/screen-planner.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ Examples:
231231
- `inspections` (list + detail + form, no detail children) → folder `app/(app)/inspections/` with `index.tsx`, `[id].tsx`, `new.tsx`
232232
- `inspections` (detail owns photo/edit children) → `app/(app)/inspections/[id]/index.tsx`, `app/(app)/inspections/[id]/photo.tsx`, and `app/(app)/inspections/[id]/edit.tsx`; never also create `inspections/[id].tsx`
233233

234-
**Authenticated Dataverse identity rule:** when app identity links through `systemuser`, require `SystemusersService` in the data-source/service plan. Resolve the access-token `oid` with `SystemusersService.getAll({ filter: "azureactivedirectoryobjectid eq <oid> and isdisabled eq false", top: 2 })`, then query the profile table with `_lookup_value eq <systemuserid>`. Do not use relationship traversal (`lookupNavigation/azureactivedirectoryobjectid`) in generated mobile-service filters.
234+
**Authenticated Dataverse identity rule:** when app identity links through `systemuser`, require `SystemusersService` in the data-source/service plan and record the profile table's planned `systemuser` lookup logical column. Resolve the access-token `oid` with `SystemusersService.getAll({ filter: "azureactivedirectoryobjectid eq <oid> and isdisabled eq false", top: 2 })`. The screen-builder must open the generated profile model and use the exact declared read property corresponding to that lookup column (for example `_new_systemuserid_value` for `new_systemuserid`). Never put generic placeholders such as `_lookup_value` or `_systemuserlookup_value` in a concrete filter, and do not use relationship traversal (`lookupNavigation/azureactivedirectoryobjectid`).
235235

236236
Keep total screen count tight — under 8 for v0 unless the requirements explicitly demand more. The user can iterate later.
237237

plugins/mobile-apps/skills/add-dataverse/references/dataverse-reference.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,9 @@ const newRecord: CreateFields = {
256256

257257
Raw Dataverse Web API examples often use a case-sensitive navigation-property schema name. Generated Power Apps services instead expose their accepted write key in `src/generated/models/<Entity>Model.ts`. For generated services, that model declaration is authoritative; never convert it to PascalCase or hide an unverified key inside `Record<string, unknown>`.
258258

259-
For filtering across lookups, avoid `lookupNavigation/relatedColumn eq ...` in generated mobile-service options. Resolve the related row through its generated service, then filter with the source record's `_lookuplogicalname_value` GUID property.
259+
For filtering across lookups, avoid `lookupNavigation/relatedColumn eq ...` in generated mobile-service options. Resolve the related row through its generated service, then open the source generated model and copy the exact declared read lookup property corresponding to the planned lookup logical column (for example `_new_systemuserid_value` for `new_systemuserid`). Never substitute generic placeholders such as `_lookup_value` or `_systemuserlookup_value` into generated code.
260+
261+
Dynamic route IDs used in Dataverse calls must be normalized with the shared `normalizeDataverseGuid` helper from `@/utils`. Do not generate an inline RFC UUID regex: Dataverse sequential GUIDs may not contain RFC version bits even though their hexadecimal `8-4-4-4-12` structure is valid.
260262

261263
The `@odata.bind` value must be an entity set path with the GUID: `/<entitysetname>(<guid>)`
262264

0 commit comments

Comments
 (0)