📋 Shared instructions: shared-instructions.md — read first.
Add Dataverse
Two paths:
- Existing tables only — skip to Step 5 (just runs
npx power-apps add-data-sourceper table) - New / extended tables — full workflow with Web API mutations in dependency order
Workflow
- Verify project & auth → 2. Resolve plan/operation manifest → 3. Setup Dataverse Web API auth → 4. Validate manifest or reconcile live metadata → 5. Execute sequential metadata phases → 6. Add data sources → 6b. Publish fallback customizations → 6c. Verify tables → 6d. Write manifest → 7. Inspect generated files → 8. Type-check → 8.5. Offline profile reconciliation → 9. Summary
Step 1 — Verify project & auth
Confirm Power Apps mobile app:
test -f power.config.json && test -f app.config.js
node "${PLUGIN_ROOT}/scripts/resolve-environment.js" "$(node -e \"console.log(require('./power.config.json').environmentId)\")"
Capture the environment URL (https://orgXXX.crm.dynamics.com), environment ID, and tenant ID from resolve-environment.js — needed for Step 3. If only the environment URL is available, pass that URL instead of the ID.
Step 2 — Resolve plan
Telemetry checkpoint: resolve_dataverse_schema_plan
Look for native-app-plan.md in the project root:
test -f native-app-plan.md
Before reading plan content, inspect $ARGUMENTS for the five fast-path
artifact flags in Step 2a. When all are present, only confirm
native-app-plan.md exists for hash validation; do not parse its Data Model
section or build operations/service lists from Markdown.
If present and <operation_manifest_mode> = fallback: read the
## Data Model section. Extract:
- The target reconciliation table (
reuse/extend/create/adapt/deferdecisions and evidence) - The Mermaid ER diagram (informational)
- The "Creation Order" tier list
- Every table referenced by
## Screens, identity resolution, related-entity fields, forms, dashboards, or shared hooks, including standard reused tables such assystemuser,contact, andaccount
Build SERVICE_REQUIRED_TABLES as the union of:
- every non-deferred row in Target Reconciliation (
reuse,extend,create, oradapt); - every table in Creation Order;
- every table named by screen/hook data requirements.
Hard rule: reuse means "do not mutate schema"; it does not mean "skip generated service." If app code reads or writes a reused table, that table must be in SERVICE_REQUIRED_TABLES.
Carry forward any adapt (auto-renamed) and defer (out-of-scope this run) decisions with their recorded reasons, and apply the alias map to every name you use. A data-modelling conflict never halts this skill — it resolves to adapt or defer and is reported in Step 9.
If absent: check $ARGUMENTS for diagram hints (*.png, *.jpg, *.jpeg filename, erDiagram keyword, ||--o{ cardinality syntax).
Diagram hint present → Path A (Step 2.5).
No hint AND
$ARGUMENTSdescribes what the app does (the typical case) → silently take Path B (Step 2.6 — spawn architect). No prompt.No hint AND
$ARGUMENTSis empty / non-descriptive → only then prompt withAskUserQuestion:"How would you like to define the data model? (a) I have an existing ER diagram to upload (PNG/JPG path, Mermaid syntax, or text description) (b) Let the data-model-architect agent analyze and propose one (default) (c) Cancel — I'll plan it elsewhere first"
Default the answer to (b) so empty/cancel input auto-proceeds. The 99% case (user gave a description but no diagram) skips this prompt entirely.
Step 2a — Approved operation-manifest fast path
When $ARGUMENTS supplies all five paths below, record
<operation_manifest_mode> = candidate:
--schema-contract <working_dir>/.tmp/dataverse-schema-contract.json--approval-receipt <working_dir>/.tmp/mobile-plan-status.json--execution-reconciliation <working_dir>/.tmp/dataverse-execution-reconciliation.json--operation-manifest <working_dir>/.tmp/dataverse-operation-manifest.json--publish-checkpoint <working_dir>/.tmp/dataverse-publish-pending.json
Do not reconstruct tables, columns, relationships, keys, payloads, tiers, or
service requirements from Markdown on this path. The gate-owned approval receipt binds
the exact structured contract content/hash, final plan hash, and final
screen/service dependency list; native-app-plan.md remains the human review
artifact.
An entirely absent fast-path handoff means
<operation_manifest_mode> = fallback and preserves the standalone workflow
below, beginning with Step 2 initialization. A partially supplied handoff, or
a supplied manifest/contract/reconciliation/checkpoint that is malformed, stale,
incomplete, or bound to different context/files, must fail closed: print the
exact validation errors and return control to the orchestrator. Never jump to
Step 4 without Step 2 initialization, partially trust a candidate, or mix its
operations with agent-derived operations.
Step 2.5 — Path A: Parse user-provided diagram
Used when the user has an existing diagram from another tool (Visio, dbdiagram.io, screenshot, hand-drawn).
Accept three input formats:
| Format | How |
|---|---|
Image path (*.png / *.jpg / *.jpeg) |
Use Read on the file path. The vision-capable model extracts entities, columns, relationships. |
| Mermaid syntax | User pastes a erDiagram block in chat. Parse the entities, columns, and ||--o{ cardinalities directly. |
| Text description | User types a structured description ("Account has many ServiceVisits; each ServiceVisit has many WorkItems and Photos"). Spawn data-model-architect agent in parse-only mode with the text as input. |
Whichever format, normalize into the same structure used by the planner agent:
publisherPrefix: <from detected publisher prefix or user>
tables:
- logicalName: contoso_servicevisit
displayName: Service Visit
status: new # new | extend | reuse
columns: [...]
relationships: [...]
Then:
- Query existing Dataverse (Step 4 logic) to mark each table as
new,modified, orreused. - Generate a Mermaid ER diagram from the parsed structure for visual confirmation.
- Present back to the user via
EnterPlanModefor approval. - On
ExitPlanMode, write the approved data model intonative-app-plan.md## Data Modelsection (creating the file if it doesn't exist). - Continue to Step 3.
Step 2.6 — Path B: Spawn architect agent
If the user picked Path B (or the user-provided diagram parse failed), spawn the mobile-app:data-model-architect agent via Task (the mobile-app: plugin-name prefix is required) with the user's high-level requirements as input. The agent returns _dm_section.md. Embed it in native-app-plan.md, present via EnterPlanMode for approval, then continue to Step 3.
If they need new tables and refuse both paths, recommend they run /setup-datamodel (alias of this skill) explicitly, or native-app-planner for a full app-level plan. STOP if neither.
Step 3 — Setup Dataverse Web API auth
Required only if creating or extending tables. Skip to Step 5 for read-only add-data-source.
Step 3a — Environment consistency check
npx power-apps and az authenticate independently — they can point to different accounts. Verify power.config.json resolves and az can token for the target tenant before making any Dataverse API calls:
ENV_JSON=$(node "${PLUGIN_ROOT}/scripts/resolve-environment.js" "$(node -e \"console.log(require('./power.config.json').environmentId)\")")
echo "$ENV_JSON"
az account show --query "{user: user.name, tenant: tenantId}" -o json
Compare the resolved environment URL with <envUrl> captured in Step 1. If they differ, STOP and warn:
"⚠️ Environment mismatch detected:
- resolver reports:
<resolved_env_url>- This project targets:
<envUrl>The Dataverse API token comes from
az, which must target the same tenant as the selected environment. Run:az login --tenant <tenant-id> # switch az to the right tenantThen re-run
/add-dataverse."
Do NOT proceed with table creation if environments don't match — you'll create tables in the wrong org.
Step 3b — Acquire token
az account show --query "user.name" -o tsv
If empty, instruct az login and stop.
Script invocation contract — read this once, all subsequent calls in this skill follow it:
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> <METHOD> <apiPath> \
[--body '<json>'] [--include-headers] \
--tenant-id '<tenantId-from-resolve-environment>'
- Three positional args, in order:
<envUrl>,<METHOD>(GET / POST / PATCH / DELETE),<apiPath>(everything after/api/data/v9.2/). - Body is a flag, not positional.
--body '<json>'— required for POST/PATCH, never for GET/DELETE. Forgetting--bodyand passing the JSON as a 4th positional arg returns a usage error. --include-headersadds response headers (needed forOData-EntityIdafter a record create).- Output is JSON:
{ "status": <code>, "data": <body> }. Token refresh on 401 and back-off on 429 are automatic — never wrap with manual retry.
Pass the resolved tenant explicitly (HARD — saves discovery and survives fresh shells). resolve-environment.js already returned tenantId in Step 1. Substitute that literal value into every --tenant-id argument; do not rely on an exported or shell-local variable because separate tool executions may use fresh shells.
If the tenant is unknown, omit --tenant-id — discovery still works, it is just slower.
Acquire a Dataverse access token and verify connectivity:
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> GET WhoAmI \
--tenant-id '<tenantId-from-resolve-environment>'
The explicit tenant is also reused for token refresh after a 401 and takes priority over shell environment variables and Azure account discovery.
WhoAmI is the Dataverse identity endpoint — capital W/A/I (case-sensitive). The response gives UserId, BusinessUnitId, OrganizationId but does NOT include the publisher prefix. To get the publisher prefix, query the solution's publisher (defaults to Default; pass a different solution name if the env uses a custom solution):
node "${PLUGIN_ROOT}/scripts/detect-publisher-prefix.js" <envUrl> [solutionName] \
--tenant-id '<tenantId-from-resolve-environment>'
# solutionName defaults to "Default" if omitted
This runs the OData query:
/api/data/v9.2/solutions?$select=uniquename&$expand=publisherid($select=customizationprefix)&$filter=uniquename eq '<solutionName>'
Capture customizationprefix from the solution's publisher (typical value: cr123 → schema names like cr123_jobsite). Also capture the solution uniquename — needed for the --solution flag on every Step 5 / 5b POST so artifacts land in our solution rather than landing wherever Dataverse defaults. Write both to memory-bank.md Power Platform context block.
Requires the user to hold System Administrator or System Customizer in this environment.
When <operation_manifest_mode> = candidate, validate the manifest now against
the resolved environment, tenant (when available), publisher, solution, current
plan bytes, structured-schema bytes, and fresh reconciliation bytes:
node "${PLUGIN_ROOT}/scripts/build-dataverse-operation-manifest.js" \
--validate "<operation-manifest-path>" \
--contract "<schema-contract-path>" \
--approval-receipt "<approval-receipt-path>" \
--reconciliation "<execution-reconciliation-path>" \
--plan "<working_dir>/native-app-plan.md" \
--environment-id "<environmentId>" \
--env-url "<envUrl>" \
--tenant-id "<tenantId>" \
--publisher-prefix "<customizationprefix>" \
--solution "<solution-uniquename>" \
--publish-checkpoint "<publish-checkpoint-path>" \
--require-executable
Validation deterministically rebuilds the expected manifest from the bound structured schema, fresh reconciliation, plan, context, and pending-publish checkpoint, then compares the complete decisions, services, aliases, phases, API paths, and bodies. If validation fails, print every reported mismatch and fail closed to the orchestrator. Do not execute or salvage individual operations and do not switch a supplied candidate to the standalone fallback.
Validate with --require-executable. Step 8 already performed the one fresh
bounded reconciliation for every approved exact table and all of its
columns/relationships/keys, including the child/parent/M:N relationship
capability managed properties. Missing capability evidence fails closed. If
the manifest remains non-executable, report its
verification conflicts to the orchestrator. Do not add another read loop,
change an approved decision, or enter fallback mode. A non-executable
candidate authorizes no metadata write.
If validation with --require-executable succeeds, set
<operation_manifest_mode> = valid and continue directly to Step 5's manifest
execution branch. This is the fast-v2 path: it skips the repeated
agent-driven full reconciliation, not any safety check.
Step 4 — Reconcile every planned table and column against the target
Telemetry checkpoint: reconcile_dataverse_schema
If <operation_manifest_mode> = valid, print:
✓ Approved operation manifest validated — complete fresh reconciliation and derived metadata coverage are bound to this environment.
Use its decisions as the reconciliation matrix and skip the remainder of
Step 4/4a. Continue to Step 5. A valid manifest has no unverified items; its
explicit reuse, adapt, and defer rows remain visible in the final
summary.
Print before starting:
"→ Reconciling every planned table and column against live target metadata before any write…"
Do not use the custom-table list as the source of truth, and do not issue one request per table. Fetch every plan entry (Reuse, Extend, or Create) — including standard and managed dependencies — in a single filtered query that also expands their columns:
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> GET \
"EntityDefinitions?\$select=MetadataId,LogicalName,SchemaName,IsCustomEntity,IsManaged,IsCustomizable,CanCreateAttributes,PrimaryIdAttribute,PrimaryNameAttribute&\$filter=LogicalName eq '<table1>' or LogicalName eq '<table2>'&\$expand=Attributes(\$select=MetadataId,LogicalName,AttributeType,AttributeTypeName,RequiredLevel,IsManaged,IsCustomizable,IsPrimaryId,IsPrimaryName,SourceType,SourceTypeMask)" \
--tenant-id '<tenantId-from-resolve-environment>'
Build the $filter by OR-ing every planned logical name. This is the documented way to query multiple table definitions at once, and it replaces 2N requests (one entity GET plus one attributes GET per table) with one. Keep the $expand $select list to base AttributeMetadata properties only — a single query cannot cast to a derived column type, so fetch OptionSet details separately for the rare column that needs them.
Read the results as follows:
- A planned name present in
value[]— the table exists. Cache its expandedAttributesas that table's attribute snapshot for Steps 5a and 5b. - A planned name absent from
value[]— the table does not exist. This is the equivalent of a 404 in the matrix below. - Interpret
IsCustomizableandCanCreateAttributesas managed properties and read their.Valuefields.
If the batched query itself fails (non-2xx), retry it once; if it fails again, split it into per-table queries so one unreadable name cannot hide the rest. Any name still unreadable after that is unverified: STOP before writes for that reconciliation scope. Authentication, permission, timeout, and malformed-response failures are not evidence that a name is free. If the URL would exceed a practical length with very many tables, split it into a few filtered queries — still far fewer than one request per table.
Only if the plan contains alternate keys or M:N relationships, add the matching expands so Steps 5b and 5d never need their own per-item probes. EntityDefinitions also supports expanding Keys, ManyToManyRelationships, ManyToOneRelationships, and OneToManyRelationships:
&$expand=Attributes($select=...),Keys($select=SchemaName,KeyAttributes,EntityKeyIndexStatus),ManyToManyRelationships($select=SchemaName)
Do not add these expands when the plan has no keys or M:N relationships — they enlarge the response for no benefit, and standard tables carry many of both.
Step 4a — Targeted derived-metadata barrier
The base attribute snapshot is sufficient for ordinary columns, but it cannot prove that a same-named lookup, choice, Boolean, or computed column has the same semantics. Before classifying any such existing column as compatible:
Write the planned derived-column contract to
<working_dir>/.tmp/derived-metadata-expected.json. Each row contains:table,logicalName,kind,type,sourceType, plus:lookupTargetfor lookups;- exact integer/label
optionsfor Choice, MultiSelect Choice, and Boolean; - exact
sourceTypeMaskand serializedformulaDefinitionfor an explicitly approved, maker-created computed dependency.
Build one
BATCH-METADATAGET operation list for the affected existing tables only. Reuse one process/token and query:ManyToOneRelationshipsonce per child table containing planned lookups;- the applicable derived attribute collections
(
PicklistAttributeMetadata,MultiSelectPicklistAttributeMetadata,BooleanAttributeMetadata) once per table/type, expandingOptionSet; - the applicable typed attribute collection once per table/type for any
explicitly reused computed column, selecting
LogicalName,SourceType,SourceTypeMask,FormulaDefinition.
Do not issue one process per column and do not scan every customizable table. The exact planned names from Step 4 are the scope. Write the operation array to
<working_dir>/.tmp/derived-metadata-operations.json, then run:node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> \ BATCH-METADATA derived-reconciliation \ --operations "$(cat <working_dir>/.tmp/derived-metadata-operations.json)" \ --tenant-id '<tenantId-from-resolve-environment>'Do not pass
--continue-on-error; the first unreadable required metadata collection must stop the barrier.Any non-2xx response, missing result slot, malformed option metadata, absent lookup target, or unavailable
FormulaDefinitionmakes that scopeunverified. STOP before writes. Authentication, throttling, permission, and parse failures are never compatibility evidence.Normalize the live results into
<working_dir>/.tmp/derived-metadata-live.json. Each row uses:table,logicalName,type,sourceType,sourceTypeMask,lookupTargets,options: [{ value, label }], andformulaDefinition. Lookup target arrays must contain exactly the approved target. Choice mappings must be non-empty with unique integer values and non-empty labels; Boolean mappings must contain exactly values 0 and 1. Then run:node "${PLUGIN_ROOT}/scripts/validate-derived-metadata.js" \ --expected "<working_dir>/.tmp/derived-metadata-expected.json" \ --actual "<working_dir>/.tmp/derived-metadata-live.json"A lookup is compatible only when its complete target set matches. Planned choice values must exist with the same labels; extra live values are allowed. Ordinary planned columns require
SourceType0. A maker-created computed dependency is reusable only when its source type, source-type mask, and exactFormulaDefinitionmatch the approved artifact; theInvalidmask bit always blocks reuse.
This phase is read-only and uses the V2 long-lived executor. It must not add per-column child-process/token overhead back into the fast path.
Build and print a reconciliation matrix before Step 5:
| Target result | Table decision | Column decisions | Action |
|---|---|---|---|
| Present; all planned base and derived metadata compatible | reuse |
existing columns reuse |
No schema write. |
| Present; custom columns missing; table customizable and can create attributes | extend |
compatible reuse; absent custom create |
Queue missing ordinary columns for sequential creation; relationships remain Pass 2. |
| Absent; plan says Create; logical name uses the verified publisher prefix | create |
ordinary columns create inline; lookups deferred |
Create once after the complete-payload self-check. |
| Absent; plan says Reuse/Extend or dependency is standard/managed/required-existing | defer |
dependent columns defer |
Never recreate a standard or managed table. Drop the dependent lookups/columns from this run, continue with everything else, and list them under Deferred in Step 9. |
Present; same-name column has incompatible AttributeType / AttributeTypeName.Value |
extend |
incompatible column adapt |
Auto-rename the planned column via the probe sequence below, record it in the alias map, and create it alongside the existing one. Never modify or delete the existing column. |
Present; columns missing but IsCustomizable.Value=false or CanCreateAttributes.Value=false |
reuse |
missing columns defer |
The target cannot be extended by this workflow. Reuse the columns that do exist, drop the rest from this run, and list them under Deferred in Step 9. |
| Batched query failed (non-2xx) after retry and per-table split | unverified |
unknown | STOP before writes for the affected reconciliation scope and surface the concrete environment/auth/permission error. |
replace is not an automatic state in this workflow. Replacing a table or column requires an explicitly approved migration with dependency analysis and data movement, so a conflict resolves to adapt (rename beside it) or defer (leave it out) instead — both of which leave existing data untouched.
Decide-before-write barrier (HARD): finish reconciliation for every table and column before the first metadata write. Every item must come out of Step 4 as reuse, extend, create, adapt, or defer — never as an unresolved conflict. Deciding renames up front is what keeps relationships, screens, and sample data pointing at the same names.
No dead ends (HARD): a data-modelling conflict must never stop the run. Adapt it (rename beside the existing object) or defer it (drop it from this run), then keep going and report it in Step 9. Only environment faults stop this skill — failed auth, an environment mismatch, or a target the user has no privilege to write to. Those are not data-modelling problems and the user cannot resolve them by editing the plan.
Idempotency criterion (HARD): re-running this skill against an already-applied plan MUST perform zero metadata writes. Every table, column, relationship, key, and calc column resolves to reuse or an "already exists, skipped" outcome from the Step 4 snapshot. If a re-run issues any POST, the reconciliation missed something — report it rather than writing. Use this as the acceptance check after any change to Steps 4, 5, or 5a–5d.
Step 5 — Create / extend tables
Telemetry checkpoint: apply_dataverse_schema_changes
Valid operation-manifest execution branch
When <operation_manifest_mode> = valid, do not have an agent rebuild request
bodies. Read execution.phases in this fixed order:
tableCreates— dependency-tier table creates with all ordinary columns inline;extensions— missing ordinary columns on existing tables;relationships— lookups/1:N and M:N after both endpoints exist;alternateKeys— after target tables and columns exist;publish— onePublishXmloperation only when earlier phases contain writes or a bound publish-pending checkpoint requires retry.
For each non-empty phase, write just that phase's operations array to
<working_dir>/.tmp/dataverse-operation-phase-<name>.json. Read
integritySha256 and binding.reconciliationSha256 from the validated
manifest, then execute every phase with the same project-local atomic journal:
EXECUTION_JOURNAL="<working_dir>/.tmp/dataverse-metadata-execution-journal.json"
ALL_MANIFEST_OPERATIONS="<working_dir>/.tmp/dataverse-operation-all.json"
# Write the flattened operations from every manifest phase to ALL_MANIFEST_OPERATIONS once.
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> \
BATCH-METADATA "manifest-<phase-name>" \
--operations "$(cat <working_dir>/.tmp/dataverse-operation-phase-<name>.json)" \
--solution "<solution-uniquename>" \
--tenant-id "<tenantId>" \
--journal "$EXECUTION_JOURNAL" \
--manifest-file "$OPERATION_MANIFEST" \
--manifest-hash "<manifest.integritySha256>" \
--reconciliation-hash "<manifest.binding.reconciliationSha256>" \
--manifest-operations "$(cat "$ALL_MANIFEST_OPERATIONS")"
Wait for the entire phase to finish before starting the next. This executor is
not OData $batch; it sends one request at a time, reuses one token, preserves
manifest order, and stops on the first non-2xx response. Never pass
--continue-on-error, never parallelize phases or operations, and never
execute service.requiredTables through BATCH-METADATA.
The runner verifies the manifest file's integrity hash, requires the supplied
operation array to equal one complete manifest phase byte-for-byte, and rejects
that phase until every operation in preceding phases is journal-complete.
The runner atomically writes inFlight before each request and records each
successful operation before moving to the next. Completed fingerprints are
based on stable request identity, not the positional index; the complete
manifest's unique zero-based order is validated separately. Completed
fingerprints are skipped on an exact resume. If a process may have exited after Dataverse
accepted a request but before the journal completion write, the runner fails
with UNCERTAIN_METADATA_OPERATION; do not replay the old operation array.
Transport loss during a metadata mutation is immediately uncertain and is
never retried in-process; reads may retain transport retry behavior.
Perform a new bounded exact reconciliation, rebuild and revalidate the full
manifest, then resume with both new hashes. The runner mechanically treats an
uncertain operation omitted by the new manifest as already applied/superseded,
or retries it only when the fresh reconciliation proves it is still required.
If summary.metadataOperationCount is zero, issue zero metadata POSTs and
print ↻ Dataverse schema already fully applied — zero metadata writes. This
is the required idempotent rerun behavior after successful publish. A manifest
with zero schema operations but one checkpoint-driven PublishXml operation
must run that publish retry; it is not a zero-write completion.
The manifest builder writes
<working_dir>/.tmp/dataverse-publish-pending.json before any schema POST when
publish will be required. Leave this checkpoint in place after any schema or
publish failure. Delete it only after the validated publish phase returns
success. The next build validates its environment/solution/plan/contract
binding and integrity, merges its table list into the publish phase, and
therefore retries PublishXml even when every schema create is now
idempotently skipped.
On a hidden POST-time name collision, stop at the failed operation and discard
all not-yet-run phase arrays. The execution script must not choose Adapt or a
rename. Return to the existing planning revision path so the structured schema
artifact carries the approved Adapt names. Then perform a fresh bounded
reconciliation and regenerate the complete aliases, downstream relationship
and key bodies, service-required names, phases, manifest hashes, and phase
files. Revalidate before resuming through the journal. Never continue an old
array after a rename. Any non-collision failure stops the metadata path with
its exact result.
Before overwriting the old manifest, preserve its path. After the revised plan
and structured schema are approved through the existing flow, the top-level
planner/orchestrator must refresh the structured service dependencies and
mobile-plan-status.json receipt. This skill cannot create or restamp it.
Bind the contract through that pre-existing receipt, then roll the existing
publish checkpoint forward:
node "${PLUGIN_ROOT}/scripts/build-dataverse-operation-manifest.js" \
--roll-forward-checkpoint "$PUBLISH_CHECKPOINT" \
--previous-manifest "$OPERATION_MANIFEST" \
--journal "$EXECUTION_JOURNAL" \
--contract "$SCHEMA_CONTRACT" \
--approval-receipt "$APPROVAL_RECEIPT" \
--plan "<working_dir>/native-app-plan.md" \
--output "$PUBLISH_CHECKPOINT" \
--environment-id "<environmentId>" \
--env-url "<envUrl>" \
--tenant-id "<tenantId>" \
--publisher-prefix "<customizationprefix>" \
--solution "<solution-uniquename>"
This retains prior checkpoint bindings/tables as integrity-protected history, keeps earlier successful tables publication-pending, and maps only the journal-proven failed collision table to its revised in-contract alias. It fails closed if a completed write would disappear from the revised contract or change definition: completed tables/inline columns, extension columns, relationships (including cascade behavior), and alternate keys must each map to an equivalent revised structured component. It also rejects any unrelated out-of-contract publish target. Only after this succeeds may Step 8 overwrite the operation manifest.
The manifest builder never emits calculated/rollup/formula creation. Reused
computed dependencies have already crossed the exact derived-metadata barrier;
unsupported projections are explicit defer rows. After the publish phase succeeds, delete the publish checkpoint and continue
to Step 6. When there were zero writes and no checkpoint, continue without
deleting anything. Skip the
fallback mutation instructions in Steps 5a–5d and skip Step 6b because publish
was already part of the validated phase order.
Print before starting:
"→ Creating/extending tables in tier order (sequential — Dataverse serializes metadata writes). For each: pre-flight check, then 'Creating …' before the POST and '✓ ' on 2xx response."
⚠️ Concurrency rule — do not violate. All Dataverse metadata operations in Steps 5, 6, and 6b are strictly sequential: issue one HTTP request, wait for a 2xx response, then issue the next. Do NOT parallelize or use OData
$batch. Dataverse serializes metadata writes via an exclusive lock; parallel calls return429 TooManyRequests,MetadataLockHeldException, or404 EntityNotFoundfor lookups whose parent hasn't committed yet.Specifically:
- Within a tier: create tables one at a time.
- Across tiers: Tier 0 fully done (all tables + all columns committed) before any Tier 1 POST.
- Lookups: POST to
/RelationshipDefinitionsonly after both endpoint tables exist and have returned 2xx.- Extensions: column POSTs to an existing table are also serial — same lock applies.
For multiple already-reconciled operations, prefer the local BATCH-METADATA
executor. It is not OData $batch: one Node process reuses one token and
issues requests strictly one at a time in array order, stopping on the first
non-2xx response by default.
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> \
BATCH-METADATA schema-writes \
--operations '<ordered-json-array>' \
--solution '<solution-uniquename-from-memory-bank>' \
--tenant-id '<tenantId-from-resolve-environment>'
Each operation is { "index", "method", "apiPath", "body" }; an operation may
override the command-level solution. Build the array only after the full
metadata snapshot and desired/live diff. Preserve dependency order: new tables
with ordinary columns inline, extension columns, relationships, projections,
then alternate keys. Never pass --continue-on-error for schema creation. The
result includes per-operation status and durationMs; after a failure,
reconcile that component and resume with only the remaining operations.
Step 5a — Pre-flight collision check (from the Step 4 snapshot)
Before each create, confirm the target name is actually free: name-prefix collisions from stale solutions, reserved system names, and soft-deleted tombstones all fail the POST, and Dataverse takes ~1 minute to return the conflict error. A failure here can leave Tier 0 partially created and make a Tier 1 lookup fail on a phantom parent. Step 4 already collected this evidence for every planned name, so this step reads it rather than re-querying.
For every Create entry, resolve its target state from the Step 4 batch — do not re-query per table. Step 4 already fetched every planned logical name, so reuse that result:
| Step 4 result for this name | Meaning | Action |
|---|---|---|
Absent from value[] |
Name is free | Proceed with POST. |
Present + IsCustomEntity: true + MetadataId matches memory-bank |
We created this earlier — idempotent re-run | Skip the POST, mark as created, continue. |
Present + IsCustomEntity: true + MetadataId not in memory-bank |
Foreign collision | Reconcile live columns and customization properties below; never auto-extend an uncustomizable target. |
Present + IsCustomEntity: false |
Reserved system table name | Auto-recover via rename (see below). |
Only re-probe a single name when Step 4's batch did not cover it (for example a rename candidate generated later in this step):
node "${PLUGIN_ROOT}/scripts/dataverse-request.js" <envUrl> GET \
"EntityDefinitions(LogicalName='<prefix>_<table>')?\$select=MetadataId,LogicalName,IsCustomEntity,IsManaged,IsCustomizable,CanCreateAttributes" \
--tenant-id '<tenantId-from-resolve-environment>'
Tombstones and hidden collisions are not reliably visible to either form — Dataverse can report a name as free and still reject the POST minutes later. Those are caught by the POST-time collision rescue below, which is the real safety net:
| POST response | Meaning | Action |
|---|---|---|
5xx with 0x80060890 or message "object with same name exists in solution" |
Tombstone (soft-deleted, ~30 min purge TTL) | Auto-recover via rename (see below). |
400 with 0x80044363, "schema name ... is not unique", or "same name already exists" |
Hidden Dataverse collision / recent-delete tombstone | Auto-recover via rename (see below), then retry the POST once. |
Important: treat a POST-time collision as a recoverable name conflict, not a data-model failure — the schema name can stay reserved internally after a delete even when metadata reports it as free.
Auto-recovery — reuse/extend first, rename as last resort
Priority order when Step 5a hits a name collision:
- Adopt as Extend (preferred) — only if the existing table is the same concept, every same-name column is type-compatible, planned missing columns are custom additions, and live
IsCustomizable.ValueplusCanCreateAttributes.Valueboth permit extension. Add only the missing columns via Step 5b and log→ Extending existing <original> with <N> missing columns. - Adopt as Reuse — if the existing table's schema already covers all planned columns: skip Step 5b for this entry, keep it in Step 6 for service generation. No prompt. Log
→ Reusing existing <original> (all required columns present). - Rename and Create (last resort) — only when the existing table is a fundamentally different entity (e.g., planned table is an inspection log but existing
<original>is a payroll record — incompatible concept, incompatible columns). Prompt the user before proceeding.
When to auto-decide vs. prompt:
| Situation | Action |
|---|---|
| Foreign collision + compatible concept/schema + extension allowed | Auto-Extend (no prompt) |
| Foreign collision + all planned columns present | Auto-Reuse (no prompt) |
| Foreign collision + incompatible column or extension forbidden | Auto-rename the conflicting column beside it, or defer it (no prompt) |
| Foreign collision + incompatible concept | Prompt (see below) |
| Reserved system name | Auto-rename (no prompt) |
| Tombstone (0x80060890 / same-name-exists) | Auto-rename (no prompt) |
For the incompatible-concept case only — prompt via AskUserQuestion:
| Option | What it means |
|---|---|
| Rename and Create (default) | Use a free custom logical name for the genuinely different entity. Existing table stays untouched. |
| Reuse existing as-is | Point the generated services at the existing table and skip the planned columns it lacks. |
Never offer Extend for an incompatible concept or column shape. This prompt is a preference, not a gate: an empty, skipped, or unanswered response defaults to Rename and Create so the run always proceeds.
Maintain a run-level logical-name alias map for every auto-rename. Example:
{ "cr3e9_aircraft": "cr3e9_aircraftv2" }
Before building any later table, column, lookup relationship, sample-data payload, service-reference text, or screen data spec, resolve logical names through this map. A rename that only changes the table POST but leaves relationships/screens/sample data pointing at the old name is a bug.
Auto-rename probe sequence (cap at 4 probes — only used for reserved/tombstone cases):
<original>v2 → <original>v3 → <original>2 → <original>copy
For each candidate in order, GET EntityDefinitions(LogicalName='<candidate>')?$select=MetadataId,IsCustomEntity:
- 404 → free, take it, stop probing.
- 200 or 5xx (collision) → next candidate.
If all 4 collide, keep probing <original>3, <original>4, … through <original>20. This sequence is designed never to dead-end: if even those collide, use <original><4-char run token>, which is unique to this run. Never abandon a table for want of a free name.
On a successful auto-rename, do these in order BEFORE the POST:
- Update
native-app-plan.md—Editwithreplace_all: trueto swap the old logical name for the new one across the entire## Data Modelsection (Mermaid ER, Reuse/Extend/Create table, Creation Order, Notes). This catches downstream relationship POSTs in this same Step 5 too. - Update
## Screensper-screen specs — samereplace_allsweep for any service / data-source references using the old name. - Append to
memory-bank.mdCollision history —<original> → <new>with reason (foreign / reserved / tombstone) and timestamp. - Update the run-level alias map — every later metadata payload and plan edit resolves
<original>to<new>before use. - Inform the user — single line, no prompt:
→ Collision on <original> (<foreign|reserved|tombstone>). Renamed to <new> and updated plan + memory-bank. Continuing.
Then proceed with the POST using <new>.
Post-create collision rescue — hidden tombstone / recent delete
If the Step 5b table POST fails after a 404 preflight with any Dataverse name-collision signature, **do
…(truncated)