Skip to content

Commit f17a7f2

Browse files
author
Shubham Agarwal
committed
Harden mobile Dataverse runtime contracts
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 78870709-25fe-45d7-8669-dd0ba9470807
1 parent 58b6086 commit f17a7f2

10 files changed

Lines changed: 126 additions & 26 deletions

File tree

plugins/mobile-apps/agents/data-model-architect.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ Standard table mappings to bias toward:
126126
| An activity event | `appointment`, `task`, `phonecall`, `email` |
127127
| A user / system identity | `systemuser` (read-only — never propose extending) |
128128

129+
For every `Reuse` decision, add `Service required: yes|no`. Use `yes` whenever any screen, hook, role check, lookup picker, related-field fetch, or authenticated-identity flow reads the table. `systemuser` identity resolution is always `Service required: yes`; read-only means no schema mutation, not no generated data source.
130+
129131
## Step 5 — Reconcile Target and Score Reuse / Extend / Create / Block
130132

131133
**Print before starting:**

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,17 @@ You will be invoked by `/create-mobile-app` Step 11 or `/edit-app` screen-rebuil
153153
};
154154
155155
export async function createTask(input: CreateTaskInput): Promise<void> {
156-
const payload: Record<string, unknown> = {
156+
type CreateFields = Pick<
157+
Parameters<typeof Cr3e9_tasksService.create>[0],
158+
'cr3e9_name' | 'cr3e9_status' | 'cr3e9_projectid@odata.bind'
159+
>;
160+
const payload: Omit<CreateFields, 'cr3e9_projectid@odata.bind'>
161+
& Partial<Pick<CreateFields, 'cr3e9_projectid@odata.bind'>> = {
157162
cr3e9_name: input.title,
158163
cr3e9_status: input.status,
159164
};
160165
if (input.projectId) {
161-
payload['cr3e9_Project@odata.bind'] = `/cr3e9_projects(${input.projectId})`;
166+
payload['cr3e9_projectid@odata.bind'] = `/cr3e9_projects(${input.projectId})`;
162167
}
163168
164169
const result = await Cr3e9_tasksService.create(payload as Parameters<typeof Cr3e9_tasksService.create>[0]);
@@ -174,24 +179,27 @@ You will be invoked by `/create-mobile-app` Step 11 or `/edit-app` screen-rebuil
174179
if (!validId) return <MissingRecordIdState />;
175180
```
176181
- **Lookup writes use `@odata.bind`, NEVER raw GUIDs.** When a form creates or updates a record with a parent reference (TaskProject, CommentTask, InspectionSite, etc.), the foreign key field is set with the entity-bind syntax. Setting it any other way either silently saves `null` (data lossform looks like it succeeded) or 400s with a cryptic Dataverse error.
177-
- **Required pattern**use the lookup's **schema name** (PascalCase navigation property), suffix with `@odata.bind`, value is `/<entitySetName>(<guid>)`:
182+
- **Required pattern**open the generated target model and copy the exact quoted property ending in `@odata.bind`; value is `/<entitySetName>(<guid>)`:
178183
```ts
179184
await Cr3e9_tasksService.create({
180185
cr3e9_name: title,
181-
'cr3e9_Project@odata.bind': `/cr3e9_projects(${projectId})`,
186+
'cr3e9_projectid@odata.bind': `/cr3e9_projects(${projectId})`,
182187
cr3e9_status: TaskStatus.Open, // choice = number
183188
});
184189
```
185190
- **Forbidden patterns:**
186191
```ts
187192
{ _cr3e9_project_value: projectId } // _value props are READ-ONLY; silently dropped on create
188193
{ cr3e9_project: projectId } // raw GUID on nav property; 400
189-
{ 'cr3e9_project@odata.bind': projectId } // missing /entitySet(guid) wrapper; 400
194+
{ 'cr3e9_projectid@odata.bind': projectId } // missing /entitySet(guid) wrapper; 400
190195
```
191196
- **Finding the right names:**
192-
- **Schema name** (left of `@odata.bind`): the lookup column's PascalCase logical name, usually exposed in the generated model file (`src/generated/models/<Entity>Model.ts`). Often differs from the `_value` read property by case + dropped underscore (read `_cr3e9_project_value`, write `cr3e9_Project@odata.bind`).
197+
- **Write property** (left of `@odata.bind`): use the exact key declared in `src/generated/models/<Entity>Model.ts`. It is case-sensitive and may be lowercase even when raw Web API metadata exposes a PascalCase navigation property. Never guess or transform the read `_value` property.
193198
- **Entity set name** (inside `/(...)`): always the **plural** logical collection name`cr3e9_projects`, not `cr3e9_project`. Use `pluralName` from the model file or check the generated service filename (`Cr3e9_projectsService.ts`entity set is `cr3e9_projects`).
194-
- When in doubt, grep `@odata.bind` in `src/generated/services/` for an existing example, or ask the `microsoft-learn` MCP server. Full reference: [`skills/add-dataverse/references/dataverse-reference.md` § Setting Lookups](${PLUGIN_ROOT}/skills/add-dataverse/references/dataverse-reference.md#setting-lookups-creatingupdating-records).
199+
- Grep `@odata.bind` in the generated **model**, not the generated service. If the key is absent, return `BLOCKED` rather than inventing it.
200+
- **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.
201+
- **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.
195203

196204
- **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.
197205
- **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`.

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,14 @@ Always include these baseline screens (already in template — keep them):
210210
Then design the user's screens. For a typical CRUD app:
211211

212212
- **List screen** per primary entity (e.g., `accounts/index.tsx`)
213-
- **Detail screen** per primary entity (e.g., `accounts/[id].tsx`)
213+
- **Detail screen** per primary entity (e.g., `accounts/[id].tsx` when it has no children, or `accounts/[id]/index.tsx` when it owns child workflows)
214214
- **Create/edit form screen** per primary entity (e.g., `accounts/new.tsx`, `accounts/[id]/edit.tsx`)
215215
- Plus any workflow-specific screens (e.g., `capture-receipt.tsx`)
216216

217217
**Folder rule (HARD — prevents phantom tabs):** any entity that has children (`[id]`, `new`, `edit`, sub-screens) becomes a **folder** with `<entity>/index.tsx` for the list/root view and the children inside. Never use a flat `accounts.tsx` AND a sibling `accounts/[id].tsx` — expo-router auto-registers every top-level `.tsx` under `app/(app)/` as a tab/drawer entry, so a flat `accounts.tsx` next to an `accounts/` folder produces both a phantom "accounts" tab AND the real "accounts" tab. Folders collapse the whole stack into one navigable entry.
218218

219+
**Dynamic-route collision rule (HARD):** never emit both `<parent>/[id].tsx` and `<parent>/[id]/<child>.tsx`. Expo Router maps the file and folder to the same `[id]` navigator entry and crashes with `duplicate screen named '[id]'`. When a detail route has child workflows, its detail file is `<parent>/[id]/index.tsx`; child files and `_layout.tsx` live in that same `[id]/` folder.
220+
219221
Decision rule per top-level destination:
220222

221223
| Destination has any sub-routes? | Layout |
@@ -226,7 +228,10 @@ Decision rule per top-level destination:
226228
Examples:
227229
- `home.tsx` (no children) → flat file `app/(app)/home.tsx`
228230
- `profile.tsx` (no children) → flat file `app/(app)/profile.tsx`
229-
- `inspections` (list + detail + form) → folder `app/(app)/inspections/` with `index.tsx`, `[id].tsx`, `new.tsx`
231+
- `inspections` (list + detail + form, no detail children) → folder `app/(app)/inspections/` with `index.tsx`, `[id].tsx`, `new.tsx`
232+
- `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`
233+
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.
230235

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

@@ -428,7 +433,7 @@ For each screen the user adds, provide this compact shape:
428433

429434
**Hard rule:** if the screen displays a related-entity field but you do NOT emit a `related_entity_fields` block for it, the data-model-architect cannot propose the calc column, the screen-builder will hit `BLOCKED` at scaffold time, and the user will see a `—` cell in the built app. The block is the ONLY signal — there is no fallback inference.
430435
- **Audit** (omit for read-only / non-write screens) — one line per audit-bearing action: `<trigger>: event <code> (<event label>); payload: <field, field, field>`. Example: `On submit: event 100000006 (Inspection Submitted); payload: inspectionId, submittedAt, defectCount, openCriticalCount.` The screen-builder wraps the payload field list in `JSON.stringify({...})` and writes the full `cr3e9_audit_log_entriesService.create(...)` call from the Generated Services table — do NOT spell out the wrapper or service name.
431-
- **Lookup writes** — for form/edit screens that set a parent reference (Task → Project, Comment → Task, etc.), explicitly list each lookup field with its `@odata.bind` name + entity set, e.g. `'cr3e9_Project@odata.bind': '/cr3e9_projects(<guid>)'`. Without this the screen-builder will guess and silently lose the relationship. Skip for read-only and no-lookup screens.
436+
- **Lookup writes** — for form/edit screens that set a parent reference (Task → Project, Comment → Task, etc.), explicitly copy the exact quoted `@odata.bind` property from the generated target model and pair it with the entity set, e.g. `'cr3e9_projectid@odata.bind': '/cr3e9_projects(<guid>)'` when that exact key exists in `src/generated/models/<Entity>Model.ts`. Never derive casing from Dataverse schema-name conventions. Without the generated-model key, mark the spec `BLOCKED: lookup write key not verified`. Skip for read-only and no-lookup screens.
432437
- **Pagination** — `cursor` if the table has no natural record ceiling (visits, inspections, work orders, tickets, any user-created records over time); `none` if the table is a bounded lookup (status types, categories, job types). When `cursor`, include SDK `maxPageSize: 50`, deterministic `orderBy` with a unique key, `select`, `skipToken` continuation support, and server-side `filter` for search in the data spec. Do not imply that `top: 50` alone is pagination.
433438
- **Native capabilities** — which native modules/wrappers it uses, and which iOS/Android platforms or permission states need fallback handling. For PDF/pen screens, be precise: `document-picker` (`expo-document-picker`) for user-picked files; `pdf-report` (`expo-print`, plus `expo-sharing` only when present and sharing is required) for generated local PDFs; `native-pdf-viewer` (`@microsoft/power-apps-native-pdf-viewer` 0.2.9+) for HTTPS PDF URLs and local `file://` URIs; `pen-input` (`@microsoft/power-apps-native-pen-input`) for signature/ink capture. For location screens, distinguish `geolocation` (`@microsoft/power-apps-native-bglocation`) — continuous/background tracking with native Dataverse sync, needs start/stop/tracking-status UI plus a permission-denied state — from one-shot `location` (`expo-location`) for a single foreground coordinate read.
434439
- **Calendar library** — REQUIRED for screens with `Calendar pattern` unless the pattern is `timeline-day-list`. Write `react-native-calendars` and name the exact components expected, for example `CalendarProvider`, `ExpandableCalendar`, `AgendaList`, `Calendar`, `CalendarList`, or `Agenda`. The package must also appear in `### JavaScript Dependencies`; the screen-builder imports it directly after the orchestrator installs it. No `/add-native` wrapper or native rebuild is involved.
@@ -475,7 +480,7 @@ This is the target shape for every spec. ~120 words, ~450 tokens. No inlined cat
475480
- **UX contract:** header title = current zone name; primary action = `Save & Continue` bottom CTA; disabled reason = "Capture required photo first" when evidence missing; FAB = `extended FAB` on defects, label "Add defect"; badge count = `defects.filter(d => d.zone === currentZone).length`.
476481
- **Data:** `Cr3e9_zoneprogressService.getAll({ filter: 'cr3e9_inspectionid eq <id>', orderBy: 'cr3e9_zone asc' })`, `Cr3e9_zoneprogressService.update(...)` on save.
477482
- **Audit:** On zone Save: event 100000001 (Zone Step Completed); payload: zoneIndex, zoneName, completedAt, evidenceCount, defectCount.
478-
- **Lookup writes:** `'cr3e9_Inspection@odata.bind': '/cr3e9_inspections(<id>)'` on every zone-progress upsert.
483+
- **Lookup writes:** exact generated-model key, for example `'cr3e9_inspectionid@odata.bind': '/cr3e9_inspections(<id>)'`, on every zone-progress upsert.
479484
- **Pagination:** `none` (6-row bounded set).
480485
- **Native capabilities:** `expo-camera`, `expo-image-picker` (capture tiles).
481486
- **Navigation:** from inspection detail; pushes to defect form; pops back to inspection summary on last zone Save.
@@ -536,7 +541,7 @@ Section format (same in all phases):
536541
| OAuth callback | `/oauth-callback` | `app/oauth-callback.tsx` | default | Connector consent return ||| template (keep) |
537542
| Home | `/(app)/home` | `app/(app)/home.tsx` | default | Today dashboard: assignment, progress, stats, recent inspections | `cr123_inspectionService.getAll({ top: 5 })` || replace template |
538543
| Inspections list | `/(app)/inspections` | `app/(app)/inspections/index.tsx` | default | List + filter | `cr123_inspectionService.getAll` || new |
539-
| Inspection detail | `/(app)/inspections/[id]` | `app/(app)/inspections/[id].tsx` | default | View + edit one | `getById`, `update` || new |
544+
| Inspection detail | `/(app)/inspections/[id]` | `app/(app)/inspections/[id]/index.tsx` | default | View + edit one | `getById`, `update` || new |
540545
| New inspection | `/(app)/inspections/new` | `app/(app)/inspections/new.tsx` | modal | Create form, slides up from list | `create` || new |
541546
| Capture photo | `/(app)/inspections/[id]/photo` | `app/(app)/inspections/[id]/photo.tsx` | modal | Take or pick photo | `update` (photo column) | `expo-camera`, `expo-image-picker` | new |
542547
| Profile | `/(app)/profile` | `app/(app)/profile.tsx` | default | User info + sign out | `useAuth()` only || new |
@@ -690,7 +695,7 @@ Navigation: <Stack | Tabs | Tabs + Stack | Drawer>
690695
|-----------------|-----------------------------|-----------------------------------------|--------------|-----------|-------------------|---------------|
691696
| Home | /(app)/home | app/(app)/home.tsx | default | Tab-root | - | - |
692697
| Inspections | /(app)/inspections | app/(app)/inspections/index.tsx | default | List | InspectionService | - |
693-
| Inspection ID | /(app)/inspections/[id] | app/(app)/inspections/[id].tsx | default | Detail | InspectionService | - |
698+
| Inspection ID | /(app)/inspections/[id] | app/(app)/inspections/[id]/index.tsx | default | Detail | InspectionService | - |
694699
| New Inspection | /(app)/inspections/new | app/(app)/inspections/new.tsx | modal | Form | InspectionService | camera |
695700
| Profile | /(app)/profile | app/(app)/profile.tsx | default | Tab-root | - | - |
696701

0 commit comments

Comments
 (0)