Skip to content

feat(model-apps): SDK uptake, silent-failure fixes, genpage input contract, 2.5.0 - #458

Open
Akshay Maloo (akshaymaloo) wants to merge 9 commits into
mainfrom
users/akmaloo/sdk-uptake-and-fixes
Open

feat(model-apps): SDK uptake, silent-failure fixes, genpage input contract, 2.5.0#458
Akshay Maloo (akshaymaloo) wants to merge 9 commits into
mainfrom
users/akmaloo/sdk-uptake-and-fixes

Conversation

@akshaymaloo

@akshaymaloo Akshay Maloo (akshaymaloo) commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Takes up the current maker SDK and fixes the two silent-failure classes it revealed, resolves the genpage sitemap-placement vs pageInput policy conflict, closes the actionable #456 follow-ups, and releases 2.5.0.

Closes #457. Addresses #456 (items 2–5; item 1 remains, with reasoning on the issue). Supersedes #459, folded in here so the whole language-code surface is reviewed and live-tested as one unit.

The SDK uptake found two silent failures, not stale tests

Re-vendored from the SDK's master (572 KB → 641 KB; the delta is 92 files / +8085/−656). The SDK moved both pushArtifact and publishArtifact from throwing to reporting by value. We were checking neither.

1. The 412 guard had been silently disarmed. PushResult.success was renamed to saved — the SDK's own type comment says the rename exists to "force every existing call site to be looked at once". requireSuccessfulPush checked success === false, which against the new shape reads undefined === false and simply stops firing. A concurrent Maker edit would be overwritten with no error and no log.

2. Every publish failure was silent — peer review caught this one, and it is worse. All nine publishArtifact call sites discarded the result. Measured: a /PublishXml 500 returns { publish: { kind: 'failed' } } on this bundle and threw on the previous one, and a build where every publish failed still reported ok: true with zero error events. It is not gated on --publish (reconciling an existing form or view publishes on every --apply), the trigger is routine (429 / 503 / customization-lock), and because those no longer throw, the transient-retry loop had become unreachable for them.

Also drains PushResult.warnings at all eleven push sites — that field exists so an app whose components could not all be pinned, or whose system-administrator role assignment failed (which yields an app nobody can open), is not read as a clean success. It is also the concrete saved vs shipped gap: an app create publishes inside the SDK, so a failed publish arrives as saved: true with no top-level error — a shape the push check alone waves straight through.

And closes the gap that let this land. publishArtifact was in the method-presence guard but had no behavioural contract test, so a throw→return change was invisible to CI. This PR adds one, plus the integration test that the engine is actually wired to read the result — which no unit test can stand in for, since the original bug was a wiring break.

Language code

  • languageCode in an App Spec, and --language-code on both CLIs, with a conflicting pair of spellings now rejected rather than silently resolved to the kebab form.
  • The flag now warns when a stage selector skips data-model, where it is a no-op.
  • A hand-pinned languageCode survives a download (was fix(model-apps): keep a hand-pinned languageCode across a download (#456) #459). Download still never reads the LCID from Dataverse — copying the source org's language into a portable spec is how it starts failing in an org that lacks that language — but a value the author wrote is carried over from the previous app-spec.json. Losing it was quiet: the next build resolved the org default, so new columns got one language while the pinned ones kept another.

Genpage: sitemap-placement vs pageInput

Two rules contradicted each other with no way for an author to satisfy both. Every page must be sitemap-placed — the sitemap is download's only membership oracle, so a page reached only by navigatesTo is invisible to download and gets re-created as a duplicate. But that means a detail page is reachable from the nav with no input, and nothing made the author account for it, so the generated page read undefined context on a path a user reaches by clicking.

Resolved by keeping the membership invariant and adding directEntry (selector | emptyState), which page-plan feeds to the generator so the .tsx actually implements that path. Plus the input-contract trace: every pageInput.data key must be produced by an incoming navigatesTo[].data edge. Allowing navigation-only pages was considered and rejected — the duplicate-page bug is worse than an extra nav entry.

Verification

1540 plugin tests + 159 eval tests pass; 7 validators green. Every guard is red-green verified — restoring success === false turns two red; making the publish reporter a no-op turns four red; unwiring all nine publish sites turns the integration test red; dropping the download restore turns the pin test red.

Live, against a real environment, all artifacts torn down (0 failures each):

run result
full build + --verify 17/17 created, verify PASS 11/11 — data-model incl. global choice, status reason, alternate key, relationship; views + default-view enrichment; charts; forms; app-shell
idempotent rebuild exercises the new etag/concurrency path by re-pushing view, form and app against existing rows
build with --publish 8/8, verify 6/6, publish phase clean
teardown 11 deleted incl. deleteAppCascade

Corrections recorded rather than quietly fixed

  • I twice reported this uptake's blocker wrongly — first "three contract tests", then "one" — both measured against a local SDK checkout 154 commits behind master. The real answer was four.
  • My comment claiming "a 412 is the only failure pushArtifact signals by return value" was false; ARTIFACT_ALREADY_EXISTS is a second, and the guard was reporting it with the opposite remedy.
  • parseManifest's comment claimed it rejected corrupt directEntry values when it only checked object-ness.
  • The first red-green of the publish-wiring test looked ineffective. It was the mutation that was incomplete — the mapLimit site ends in )) and slipped the regex, and it was the very site the test exercises. Re-verified with a paren-matching unwire.

akshay-viz and others added 4 commits August 21, 2026 14:45
…it deliberately broke

Re-vendors `scripts/vendor/cds-maker-sdk.cjs` from the SDK's master (572 KB -> 641 KB).

The important part is not the re-vendor, it is what it revealed. Four tests failed,
and only one of them was a stale assertion.

**`requireSuccessfulPush` had been silently disarmed.** The SDK renamed
`PushResult.success` to `saved`, and says why in its own type comment: "the rename
forces every existing call site to be looked at once." This guard is such a call
site, and it is the only thing standing between a 412 and a silently dropped Maker
edit. Its check was `result.success === false`, which against the new shape reads
`undefined === false` -> false. The guard would simply stop firing: no error, no log,
and a concurrent edit overwritten -- the exact failure it exists to prevent. It now
reads `saved` and falls back to `success`, so it fails CLOSED against both bundle
generations rather than assuming one; a mismatch in EITHER direction disarms it.

The rename also separates "saved" from "shipped" (live only after a VERIFIED
publish). Recorded in the guard so a future reader does not conflate them.

The other three:

- `deleteAppCascade` no longer cascades generative-page deletes -- a `uxagentproject`
  is REFERENCED by an app, not owned by one -- and reports the page in a new
  `retained[]` array. `lib/sdk-teardown.js` was ALREADY written for this; only the
  contract test lagged. Re-pinned to `retained[]`, since silently reverting to a
  cascade would delete a page another app still surfaces.
- Two `pushArtifact` tests asserted `success`; migrated to accept either spelling so
  they pin the contract rather than one bundle.
- The default-view fixture carried no `@odata.etag`, and the SDK now REFUSES an
  unconditional write (`ARTIFACT_UPDATE_NO_ETAG`) rather than risk overwriting a
  concurrent edit. That is a hardening, not a regression; real Dataverse always
  returns an etag, so the fixture was simply unrealistic.

Live-verified end to end against a real environment, artifacts torn down:

  build     17/17 created, 0 failed  (solution, data-model incl. global choice,
                                      status reason, alt key and relationship;
                                      views + default-view enrichment; charts;
                                      forms; app-shell)
  verify    PASS 11/11
  rebuild   idempotent -- exercises the new etag/concurrency path by re-pushing
            view, form and app against existing rows
  teardown  11 deleted, 0 failed, incl. deleteAppCascade

Offline tests are mock-based and could not have proven any of that; the only phase
not covered was `pages`, which fails on `pac genpage list` auth and does not touch
the SDK.

Note the earlier claim that the uptake broke three contract tests, then that it broke
one, were both measured against a checkout 154 commits behind master. The real delta
is 92 files / +8085 lines.

1519 pass, 0 fail. The push-guard migration is red-green verified: restoring the old
`success === false` check turns two tests red.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
Three of the five deferred items from the #447 fix, plus the version bump.

- A conflicting `--language-code 1031 --languageCode 1036` pair silently took the
  kebab form. That is the wrong answer when the two disagree: the user passed both
  and believes one is in effect, so guessing produces a build they did not ask for
  with no indication why. Now rejected. Extracted as `readAliasedFlag` in
  dataverse-auth.js next to `parseArgs` rather than duplicated in both CLIs.
- `--language-code` was accepted, validated and threaded for stage selectors that
  skip `data-model`, then silently discarded -- only that phase creates labels. It
  now warns, because finding out afterwards means re-running the whole build.
- The #447 entry sat under `### Added` while its headline was a fix. Split: the
  fix under `Fixed`, the new `languageCode` field and flag under `Added`.

Also records the push-guard fix under `Fixed`, since a 412 being silently swallowed
is user-visible even though it was found while taking up the SDK.

Not done, and left on #456 with reasoning: validating the resolved LCID against
`RetrieveProvisionedLanguages` (needs an httpClient threaded into
resolveLanguageCode), and preserving a hand-pinned `languageCode` across download
(the naive fix -- stamping the source org's LCID into a portable spec -- causes the
very failure #447 was).

Version 2.4.4 -> 2.5.0 in both the Open Plugins manifest and the legacy mirror;
minor, since this adds a spec field and two CLI flags on top of the SDK uptake.

1523 pass, 0 fail; 7 validators green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
…onflict

Two rules contradicted each other, and an author had no way to satisfy both:

  - Every `pages[]` entry MUST be a sitemap subarea. The sitemap is download's
    only membership oracle, so a page reached only by `navigatesTo` is invisible
    to download and gets re-created as a DUPLICATE on the next build.
  - But a detail page declares `pageInput` because it is opened from a list with
    a row id.

Being sitemap-placed means the page is also reachable straight from the app
navigation, with no input at all. That is a real user-reachable state -- it is
what clicking the nav entry does -- and nothing made the author account for it,
so the generated page read `undefined` context on that path.

Resolved in favour of keeping the membership invariant. Allowing navigation-only
pages was considered and rejected: it would require the sitemap to stop being the
membership oracle, and the duplicate-page bug that prevents is worse than an extra
nav entry.

A page declaring `pageInput` must now declare `directEntry`:
`{ behavior: 'selector' }` (show a picker, then the record) or
`{ behavior: 'emptyState' }` (explain, render nothing broken), with an optional
`note` passed to the generator verbatim. `page-plan` emits it into the generation
prompt, so the .tsx actually implements the path rather than being told about it.

Also adds the input-contract trace the review asked for: every key in
`pageInput.data` must be produced by an incoming `navigatesTo[].data` edge. An
input no caller supplies is either a typo or a page that can only be entered
directly -- both generate a page reading a key nothing ever sets.

`directEntry` round-trips via the page manifest and hydrate-spec, because a
downloaded spec that lost it would fail its own validation on the next build.

The one existing test that broke is itself the conflict: it declared a `detail`
page with `pageInput` and no answer for direct entry, and validated cleanly.

1527 pass, 0 fail; 7 validators green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
…s throw-to-return refactor

Peer review found that I migrated the push half of this refactor and missed the
publish half, which is the worse one. The SDK moved `publishArtifact` from throwing
to reporting by value in the same way it moved `pushArtifact`, and all NINE call
sites in sdk-build.js were bare `await provision.publishArtifact(...)` with the
result discarded. Measured against the real bundle: a `/PublishXml` 500 returns
`{ publish: { kind: 'failed', error } }` on this bundle and THREW on the previous
one, and a build in which every publish failed still reported `ok: true` with zero
error events and zero warnings.

Three things made it worse than it sounds. It is not gated on `--publish` --
reconciling an existing form or view publishes on every `--apply`. The trigger is
routine rather than exotic: 429, 503 and customization-lock are the everyday
PublishXml failures in a real tenant. And because they no longer throw, the
existing transient-retry loop -- which enumerates exactly those codes -- had become
unreachable for publish failures.

Adds `reportPartialPush` next to `requireSuccessfulPush` and wires it into all nine
publish sites. It never halts: the primary write committed, so the build should
continue -- but silence turns a partial success into a reported clean one.

Also drains `PushResult.warnings` at all eleven push sites. That field exists
precisely so an app whose components could not all be pinned, or whose
system-administrator role assignment failed (which yields an app nobody can open),
is not read as a good build. This is also the concrete `saved` vs `shipped` gap: an
app CREATE publishes inside the SDK, so a failed publish arrives as `saved:true`
with no top-level error -- a shape the push check alone waves straight through.

Two further review findings:

- The guard reported an `ARTIFACT_ALREADY_EXISTS` collision as a version conflict,
  giving two contradictory remedies in one message. They are now distinguished:
  adopt the existing row, versus re-download after a concurrent edit.
- My comment claiming "a 412 is the only failure pushArtifact signals by return
  value" was false as of this bundle. Corrected.

Six test mocks still returned the OLD `{ success: true }` PushResult and a void
publish, so the entire sdk-build integration surface was exercising the legacy
branch of the new guard and was blind to the shape it actually ships against.
Migrated to the real shapes.

Tests +6, red-green verified: making the reporter a no-op turns four red.
1533 pass, 0 fail; 7 validators green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
Copilot AI lite review requested due to automatic review settings August 21, 2026 22:15
@akshaymaloo
Akshay Maloo (akshaymaloo) requested a review from a team as a code owner August 21, 2026 22:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the model-apps plugin to a newer vendored Maker SDK, hardens the build pipeline against SDK by-value failure reporting (push/publish), and resolves the genpage sitemap-membership vs pageInput contract conflict by introducing directEntry. This culminates in a 2.5.0 release bump with corresponding documentation and changelog updates.

Changes:

  • Uptake of the newer Maker SDK surface by updating push/publish result handling and refreshing affected test mocks/contracts.
  • Adds and enforces the directEntry + pageInput input contract for genpages (including manifest/download round-trip support).
  • Introduces stricter CLI flag alias handling (--language-code vs --languageCode) and bumps plugin version/docs to 2.5.0.

Reviewed changes

Copilot reviewed 25 out of 26 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
plugins/model-apps/scripts/tests/vendor-sdk-smoke.test.js Updates SDK contract tests (PushResult rename handling; deleteAppCascade now asserts retained[]).
plugins/model-apps/scripts/tests/sdk-build.test.js Updates SDK build mock push result shape (needs publish mock alignment per review).
plugins/model-apps/scripts/tests/sdk-build-pages-order.test.js Updates page build harness mocks to new push/publish result shapes.
plugins/model-apps/scripts/tests/sdk-build-pages-migrate.test.js Updates page migration harness mock push result shape (needs publish mock alignment per review).
plugins/model-apps/scripts/tests/sdk-build-pages-deploy.test.js Updates page deploy harness mocks to new push/publish result shapes.
plugins/model-apps/scripts/tests/helpers/mock-sdk.js Refreshes shared mock SDK to return publish-by-value results.
plugins/model-apps/scripts/tests/hardening2-real-bundle.test.js Pins push conflict contract across success/saved rename.
plugins/model-apps/scripts/tests/entity-provision.test.js Adds tests for renamed push contract, publish-by-value reporting, and warnings propagation.
plugins/model-apps/scripts/tests/default-view-createdon.test.js Makes the view fixture realistic by adding required @odata.etag.
plugins/model-apps/scripts/tests/build-model-app.test.js Adds tests for rejecting conflicting aliased language flags.
plugins/model-apps/scripts/tests/app-spec-keys.test.js Adds validation tests for directEntry requirements and page input traceability.
plugins/model-apps/scripts/provision-entities.js Switches language flag parsing to readAliasedFlag to reject conflicting spellings.
plugins/model-apps/scripts/lib/sdk-build.js Starts consuming publish results via reportPartialPush and threads warning sink into push guard.
plugins/model-apps/scripts/lib/page-plan.js Includes directEntry behavior in generated page plan output.
plugins/model-apps/scripts/lib/page-manifest.js Persists and parses directEntry in the durable page manifest.
plugins/model-apps/scripts/lib/hydrate-spec.js Hydrates directEntry from manifest/spec into the in-memory spec shape.
plugins/model-apps/scripts/lib/entity-provision.js Hardens push guard to handle saved vs success, adds reportPartialPush, distinguishes already-exists vs 412.
plugins/model-apps/scripts/lib/dataverse-auth.js Adds readAliasedFlag helper to reject conflicting alias pairs.
plugins/model-apps/scripts/lib/app-spec.js Adds directEntry enum + validation rules tying pageInput keys to incoming navigation edges.
plugins/model-apps/scripts/build-model-app.js Uses readAliasedFlag for language-code parsing and warns when LCID is no-op for selected phases.
plugins/model-apps/references/app-spec-schema.md Documents the new directEntry + pageInput input contract and rationale.
plugins/model-apps/CHANGELOG.md Updates release notes for 2.5.0, including SDK behavioral changes and contract fixes.
plugins/model-apps/AGENTS.md Documents the membership invariant corollary and the new directEntry contract.
plugins/model-apps/.plugin/plugin.json Bumps plugin version to 2.5.0.
plugins/model-apps/.claude-plugin/plugin.json Mirrors plugin version bump to 2.5.0 for legacy manifest compatibility.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread plugins/model-apps/scripts/lib/entity-provision.js Outdated
Comment thread plugins/model-apps/scripts/lib/page-manifest.js Outdated
Comment thread plugins/model-apps/scripts/tests/sdk-build.test.js Outdated
Comment thread plugins/model-apps/scripts/tests/sdk-build-pages-migrate.test.js Outdated
CI caught what I did not: the `2-orders-multipage` app-builder eval fixture is
itself an instance of the conflict the previous commit resolved. Its `order-detail`
page declares `pageInput: { orderId }` and is sitemap-placed, with nothing saying
what opening it from the nav shows -- so it now fails validation, correctly.

Given `behavior: "selector"`, which is the honest answer for an order-detail page:
opened with no orderId, show a picker and then render the chosen order.

My miss was procedural rather than analytical: I looked for an eval runner under
`plugins/model-apps/` and concluded there was none. The harness lives at the repo
root under `evals/model-apps/`, in a separate CI job gated by the same path filter.
Both suites have to be run:

  cd plugins/model-apps && node scripts/run-tests.js
  node --test evals/model-apps/tests/*.test.js \
              evals/model-apps/app-builder/tests/*.test.js \
              evals/model-apps/genpage/tests/*.test.js

159 eval tests pass; 1533 plugin tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
Copilot AI review requested due to automatic review settings August 21, 2026 22:21
@akshaymaloo

Copy link
Copy Markdown
Contributor Author

Peer review found the half I missed — and it was the worse half

An adversarial review of this branch caught something I had not: I migrated the push half of the SDK's throw-to-return refactor and missed the publish half.

All nine publishArtifact call sites in sdk-build.js were bare await provision.publishArtifact(...) with the result discarded. Measured against both bundles:

NEW bundle: publishArtifact RETURNED  { publish: { kind: 'failed', error: HTTP 500 ... } }
OLD bundle: publishArtifact THREW     HTTP 500 from /PublishXml

End to end, a build in which every publish failed still reported ok: true, zero error events, zero warnings.

Three things make it worse than the push bug I did catch:

  • Not gated on --publish — reconciling an existing form or view publishes on every --apply.
  • Routine trigger — 429 / 503 / customization-lock are the everyday PublishXml failures in a real tenant.
  • It disabled existing machinery. Those codes are exactly what isTransientHalt enumerates. On the old bundle they threw and hit the retry loop; here they were swallowed, so the transient-retry path had become unreachable for publish failures.

Fixed with reportPartialPush, wired into all nine sites. It never halts — the write committed — but silence turns a partial success into a reported clean one.

Also from the review

PushResult.warnings was dropped at all eleven push sites. That field exists so an app whose components could not all be pinned, or whose system-administrator role assignment failed — which yields an app nobody can open — is not read as a clean build. This is also the concrete saved vs shipped gap I had asked about: an app create publishes inside the SDK, so a failed publish arrives as saved: true with no top-level error — a shape the push guard alone waves straight through. Now drained.

My own comment was false. I wrote "a 412 is the only failure pushArtifact signals by return value". ARTIFACT_ALREADY_EXISTS is a second one, and the guard was reporting it as a version conflict — telling the operator to re-download when nothing had changed under them. Now distinguished: adopt the existing row, versus re-download.

Six test mocks still returned the old { success: true } / void-publish shapes, so the entire sdk-build integration surface was exercising the legacy branch of the new guard and was blind to the shape it actually ships against. That is why my red-green only turned two tests red — those two were the new unit tests. Migrated.

Where the review corrected me

  • I called the push-guard migration "the highest-stakes change on the branch". It was the half I caught; the unguarded publish half was higher-stakes.
  • I said the live run "covered the artifact surface". It covered the happy path — 17/17 created and verify 11/11 means every publish succeeded, so the run never induced the failure mode at all.
  • My Array.isArray(phases) guard can never be false (stagePhasesOrResolve returns an array or throws). Left in, but I added the phases.length check it actually needed — an empty selector rendered the warning as …for the selected phases ().

Verification

1533 plugin tests + 159 eval tests pass; 10/10 CI green. Both guards red-green verified — restoring success === false turns two red; making the publish reporter a no-op turns four red.

CI also caught one thing I missed procedurally: the 2-orders-multipage eval fixture is itself an instance of the genpage conflict this PR resolves, and now fails validation correctly. I had looked for an eval runner under plugins/model-apps/ and concluded there was none — the harness lives at the repo root under evals/model-apps/, in a separate job. Both suites have to be run; the commit message records the commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

plugins/model-apps/scripts/lib/app-spec.js:860

  • pageInput is only validated as an object, but the new input-contract logic treats pageInput.data as a plain object and calls Object.keys(...) on it. If pageInput.data is missing or not an object/array (e.g., string/number), validation can produce misleading errors (or treat string indices as keys) instead of a clear schema error. Add an explicit validation that pageInput.data is a non-null, non-array object whenever pageInput is present (or at least before computing inputKeys).
    for (const p of spec.pages || []) {
      const key = p.key || p.name;
      const inputKeys = Object.keys((p.pageInput && p.pageInput.data) || {});
      if (!inputKeys.length) continue;

plugins/model-apps/scripts/lib/page-manifest.js:154

  • parseManifest validates that pageInput is an object, but it doesn't validate the pageInput.data sub-shape even though the rest of the codebase treats it as { data: object } (and validates navigatesTo[].data similarly). A corrupted/hand-edited manifest could round-trip a non-object pageInput.data and then fail later in spec validation/build. Consider validating pageInput.data is a non-null, non-array object when present (fail-closed here, like navigatesTo[].data).
    if (p.pageInput !== undefined) {
      if (!p.pageInput || typeof p.pageInput !== 'object' || Array.isArray(p.pageInput)) return null;
    }

    // directEntry — same shape rule. Validated here too so a corrupt manifest is rejected whole

…est check, two stale mocks

Four review findings, all correct.

- `reportPartialPush` told the operator to "re-run with --publish". It runs for
  three callers and that is right for only one of them: an explicit publishArtifact
  has already attempted the publish, and an app CREATE publishes inside the SDK --
  so both were being told to pass a flag they had effectively already used. The hint
  now says what is true for all three: the save survived, the build is idempotent,
  re-run once the cause clears.

- `parseManifest` claimed in a comment to reject a corrupt `directEntry` so it could
  not round-trip into a rebuild that fails validation, but only checked object-ness.
  A bogus `behavior` sailed through and failed App Spec validation later, far from
  its cause -- which is what parsing a STORED artifact strictly is supposed to
  prevent. It now validates `behavior` against the same list App Spec uses, imported
  rather than mirrored so the two cannot drift into disagreeing about what is legal.

- Two `publishArtifact` mocks were still void. My earlier migration matched
  `async () => undefined` and `async () => {}` but missed the
  `async (t, id) => { calls.push(...) }` form, so `sdk-build.test.js` and
  `sdk-build-pages-migrate.test.js` were still feeding `undefined` into a
  `reportPartialPush` that production now feeds a real result.

Tests +1: the manifest parser rejects every illegal `directEntry.behavior` and
accepts both legal ones. 1534 plugin + 159 eval tests pass; 7 validators green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
Copilot AI review requested due to automatic review settings August 21, 2026 22:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 27 out of 28 changed files in this pull request and generated no new comments.

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

plugins/model-apps/scripts/lib/page-plan.js:266

  • buildPagePlan prints a “Direct entry (no input)” plan whenever pageInput is present, but App Spec validation only requires directEntry when pageInput.data declares at least one key. For pages with pageInput: { data: {} } (or other empty/non-object shapes), this output is misleading and defaults the behavior to emptyState without the author opting in. Consider gating this block on Object.keys(p.pageInput?.data ?? {}).length > 0 (or p.directEntry being present) so the plan output matches the input-contract rules and doesn’t invent a default behavior.
    if (p.pageInput !== undefined) {
      out.push('- **Page Input:** reads `pageInput` (caller-supplied context)');
      // The page is sitemap-placed, so it is ALSO reachable straight from the app navigation with
      // no input at all. The generator has to be told what that renders, or it writes a page that
      // reads `undefined` context on a path a user can reach by clicking the nav entry.

plugins/model-apps/scripts/lib/app-spec.js:860

  • validateAppSpec uses Object.keys((p.pageInput && p.pageInput.data) || {}) to drive the new directEntry/input-trace rules, but there’s no validation that pageInput.data itself is a plain object. Non-object values (e.g. null, arrays, numbers, strings) can make inputKeys empty (silently skipping the contract) or produce unexpected keys. Add an explicit check that pageInput.data is an object (and not null/array) whenever pageInput is present, so malformed specs fail fast with a clear error.
      const key = p.key || p.name;
      const inputKeys = Object.keys((p.pageInput && p.pageInput.data) || {});
      if (!inputKeys.length) continue;

plugins/model-apps/scripts/lib/sdk-build.js:1080

  • The new publish-result warnings use labels like form ${formId} / view ${viewId}. These GUID-only identifiers make the warning hard to act on (especially in large builds) even though def.name is available in this scope. Consider using a human-friendly label (e.g. form ${def.name} (${formId})) so reportPartialPush warnings clearly identify the artifact.
    requireSuccessfulPush(await provision.pushArtifact('form', formId), `form ${def.name}`, opts.warn);
    reportPartialPush(await provision.publishArtifact('form', formId), `form ${formId}`, opts.warn);
    await provision.addSolutionComponent({ componentId: formId, componentType: COMPONENT_TYPE.form, solutionUniqueName: sol.uniqueName });

akshay-viz and others added 2 commits August 21, 2026 16:09
)

Download deliberately does not read `languageCode` from Dataverse, and that stays
true: an LCID copied out of the source org would be re-applied verbatim when the
spec is rebuilt somewhere else, which is exactly how a spec starts failing in an org
that has not provisioned that language (#447). Leaving it absent lets every target
org resolve its own base language.

But silently dropping a value the AUTHOR wrote is its own bug, and a quiet one. The
next build resolves the org default, so columns created from then on get one
language while the ones from the pinned build keep another -- a mixed-language app
with no error anywhere to explain it.

So the value is restored from the previous `app-spec.json` at the output path -- the
author's own file -- and never synthesized from the environment. That keeps the
portability property (a spec carries no org-specific LCID unless someone chose one)
while making a deliberate pin survive a round trip.

Best-effort by design: a missing, unreadable or malformed previous spec simply means
there is nothing to preserve, and an invalid value is not carried forward, since
failing the next build for something the operator did not do on this run would be
worse than the wart being fixed.

This was deferred from the #447 work with the reasoning recorded on #456, because
the obvious fix -- emitting the org's LCID -- is actively harmful.

Tests +4, red-green verified. 1523 plugin + 159 eval tests pass; 7 validators green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
…ing that reads it

Closes the gap that let the publish regression land unnoticed in the first place.

`publishArtifact` was in the method-PRESENCE guard but had no BEHAVIOURAL contract
test, so the SDK moving it from throwing to reporting by value was invisible to CI.
Adds a real-bundle test asserting it RESOLVES with `publish.kind === 'failed'` on a
failed PublishXml rather than throwing. Which one the bundle does is load-bearing in
both directions: a throw silently re-arms the transient-retry path, a by-value
failure silently disarms it.

Also adds the integration test that no unit test can stand in for: that a by-value
publish failure actually reaches the warn channel through the whole `runSdkBuild`.
The unit tests pin `reportPartialPush`; they say nothing about whether the engine is
wired to it, and a wiring break is exactly what the original bug was -- nine call
sites that discarded the result, producing `ok: true` with zero warnings.

A note on verifying that second test, because the first attempt was misleading: my
mutation replaced only 8 of the 9 publish sites -- the `mapLimit` form ends in `))`
rather than `);` and slipped the regex -- and the surviving site was the one the test
exercises, so it stayed green and looked ineffective. Re-run with a paren-matching
unwire that reaches all 9: the test turns red, as it should. A test that passes for
the wrong reason is worse than no test, so this is recorded rather than quietly
fixed.

1540 plugin + 159 eval tests pass; 7 validators green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
Copilot AI review requested due to automatic review settings August 21, 2026 23:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 30 changed files in this pull request and generated 1 comment.

Suppressed comments (5)

Previously missed (2) — in code that hasn't changed since the last review.

plugins/model-apps/scripts/lib/page-plan.js:266

  • directEntry.note is appended into the plan without passing through mdText, even though this file otherwise hardens all spec-sourced strings against markdown structure injection (newlines/headings/tables). Since the plan is executed as instructions by a write-capable worker, de.note should be sanitized the same way (and ideally only rendered when directEntry is actually present/valid, rather than defaulting missing/unknown behavior to the emptyState text).
    if (p.pageInput !== undefined) {
      out.push('- **Page Input:** reads `pageInput` (caller-supplied context)');
      // The page is sitemap-placed, so it is ALSO reachable straight from the app navigation with
      // no input at all. The generator has to be told what that renders, or it writes a page that
      // reads `undefined` context on a path a user can reach by clicking the nav entry.
      const de = p.directEntry || {};
      const behavior = de.behavior === 'selector'
        ? 'render a picker/list so the user can choose the record, then show it'
        : 'render an explanatory empty state (do NOT render a broken/blank detail)';
      out.push(`- **Direct entry (no input):** the page is in the sitemap, so it can be opened from the nav with no \`pageInput\` — ${behavior}.${de.note ? ` ${de.note}` : ''}`);

plugins/model-apps/scripts/lib/sdk-build.js:1080

  • The publish warning label uses the GUID (form ${formId}), even though the human-friendly name is available as def.name and is already used for the push label. If a publish fails, warning output like publish form <guid> FAILED is much harder to act on than publish form <name> FAILED; consider using the same label string for both push and publish here.

This issue also appears in the following locations of the same file:

  • line 1099
  • line 1299
  • line 1339
    requireSuccessfulPush(await provision.pushArtifact('form', formId), `form ${def.name}`, opts.warn);
    reportPartialPush(await provision.publishArtifact('form', formId), `form ${formId}`, opts.warn);
    await provision.addSolutionComponent({ componentId: formId, componentType: COMPONENT_TYPE.form, solutionUniqueName: sol.uniqueName });

plugins/model-apps/scripts/lib/sdk-build.js:1101

  • Same as the form path above: the publish warning label uses view ${viewId} (GUID) even though def.name is available and used for the push label. Using the name for publish warnings would make failures much more actionable.
    requireSuccessfulPush(await provision.pushArtifact('view', viewId), `view ${def.name}`, opts.warn);
    reportPartialPush(await provision.publishArtifact('view', viewId), `view ${viewId}`, opts.warn);
    await provision.addSolutionComponent({ componentId: viewId, componentType: COMPONENT_TYPE.view, solutionUniqueName: sol.uniqueName });

plugins/model-apps/scripts/lib/sdk-build.js:1301

  • When wiring form events, the publish warning label is form ${id} (GUID) while the surrounding step labels already use d.f.name || d.f.entity. For consistency and debuggability, consider passing the same human-readable label into reportPartialPush (e.g. form ${d.f.name || d.f.entity} events) so a failure points at the form/step the operator recognizes, not just the GUID.
            requireSuccessfulPush(await provision.pushArtifact('form', id), `form ${d.f.name || d.f.entity} events`, opts.warn);
            reportPartialPush(await provision.publishArtifact('form', id), `form ${id}`, opts.warn);
          }

plugins/model-apps/scripts/lib/sdk-build.js:1341

  • In the quick-view placement path, the publish warning label is form ${hostId} (GUID) while the push label uses form ${f.name || f.entity} quick-views. Consider using the same human-readable label for reportPartialPush so publish failures don’t require correlating GUIDs back to a form.
          requireSuccessfulPush(await provision.pushArtifact('form', hostId), `form ${f.name || f.entity} quick-views`, opts.warn);
          reportPartialPush(await provision.publishArtifact('form', hostId), `form ${hostId}`, opts.warn);
        }

Comment thread plugins/model-apps/scripts/download-model-app.js
@akshaymaloo

Copy link
Copy Markdown
Contributor Author

#459 folded in, plus the contract test that would have caught this class

Combined per request — one PR is easier to live-test and peer-review as a unit, and both halves touch the same language-code surface. #459 is closed; its commit is bcae5a2f, unchanged.

Also added the two things that were missing rather than broken:

  • A behavioural contract test for publishArtifact against the real bundle. It was in the method-presence guard but had no behavioural one, which is precisely why the throw→return change was invisible to CI. Which way the bundle behaves is load-bearing in both directions: a throw silently re-arms the transient-retry path, a by-value failure silently disarms it.
  • An integration test that the engine is wired to read the result. The unit tests pin reportPartialPush; they say nothing about whether anything calls it — and a wiring break is exactly what the original bug was.

One verification note worth recording

The first red-green of that integration test looked ineffective — I unwired the publish sites and it stayed green. That was the mutation being incomplete, not the test: the mapLimit site ends in )) rather than ); and slipped my regex, and it was the very site the test exercises. Re-run with a paren-matching unwire that reaches all nine, it turns red as it should. A test that passes for the wrong reason is worse than no test, so I chased it rather than accepting the green.

What is and is not live-verified

Live, artifacts torn down, 0 failures: full build 17/17 + verify 11/11, idempotent rebuild (the new etag/concurrency path), a --publish build 8/8 + verify 6/6, and teardown 11 deleted incl. deleteAppCascade.

Not live-verified: the download path (the languageCode preserve). I tried, but the test environment began returning 401 on WhoAmI partway through — access lapsed after my earlier runs today, which is environmental rather than a code problem. That change is a pure function with injected fs dependencies, covered by 4 red-green-verified tests including corrupt-JSON, invalid-value and read-throws cases, and it is best-effort by construction (any failure leaves the field absent, which is the pre-existing behaviour). I would still rather say it is untested live than imply otherwise.

1540 plugin + 159 eval tests pass; 7 validators green; 10/10 CI.

…ross a download

Review catch: the guard validated the previous value with normalizeLanguageCode but
then assigned the RAW one, so `"1031"` or `" 1031 "` round-tripped verbatim. A
downloaded spec is a generated artifact and should be canonical -- writing the
author's string or whitespace form back out makes the file's diff noisy and its type
inconsistent with every other numeric field the download emits.

Assigns the normalized number instead. Adds a test over `"1031"`, `" 1031 "` and
`"01031"` asserting all three land as the number 1031.

1541 plugin + 159 eval tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
Copilot AI review requested due to automatic review settings August 21, 2026 23:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

plugins/model-apps/scripts/lib/sdk-build.js:1080

  • reportPartialPush warnings will currently identify artifacts by GUID (form ${formId} / view ${viewId}), even though the human-readable def.name is available at these call sites. That makes publish-failure warnings hard to act on (operators typically know the form/view name, not the id). Consider using a label like form ${def.name} (${formId}) / view ${def.name} (${viewId}) for clearer output, consistent with the push labels on the previous line.
    requireSuccessfulPush(await provision.pushArtifact('form', formId), `form ${def.name}`, opts.warn);
    reportPartialPush(await provision.publishArtifact('form', formId), `form ${formId}`, opts.warn);
    await provision.addSolutionComponent({ componentId: formId, componentType: COMPONENT_TYPE.form, solutionUniqueName: sol.uniqueName });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

model-apps: re-vendor the SDK bundle (blocked only by one stale contract test; needs live verification)

2 participants