# AGENTS.md — Model Apps Plugin

This file provides guidance to AI Agents when working with the **model-apps** plugin.

## What This Plugin Is

A plugin for building Power Apps for **model-driven apps**. Two **authoring** skills do the work
(plus `/report-issue` and `/telemetry` — four user-invocable skills in total):

- **`/genpage`** — build and deploy standalone **generative pages** (genux): React 17 + TypeScript +
  Fluent UI V9 single-file components, deployed via PAC CLI. Orchestrates specialist agents (planner,
  optional entity builder, parallel page builders).
- **`/app-builder`** — build and edit a **whole model-driven app** (tables, columns,
  relationships, adaptive forms, views, charts, generative pages, app + sitemap, sample data, and
  admin-gated AI features) from a natural-language intent, via the vendored headless `cds-maker-sdk`.

**The two authoring skills are independent entry points — neither requires the other.** Use `/genpage`
to add pages to an app that already exists; use `/app-builder` to build or edit a whole app.
`/app-builder` does *reuse* `genpage-page-builder` to generate its page `.tsx` (Phase 1.5), but that
is an implementation detail of code generation, not a dependency: `/genpage` never invokes
`/app-builder`, and `/app-builder` never invokes the `/genpage` skill. They install together (the
marketplace copies the whole plugin directory), but **either can be invoked without the other, and
neither leaves state the other depends on**. Keep it that way — shared **agents and libraries** are
fine, a skill-to-skill call is not, and neither may write a file the other treats as authoritative
(this is why `write-page-plan.js` emits `app-builder-page-plan.md`, not `genpage-plan.md`).

Plus **`/report-issue`** to file bugs against this repo. Dataverse mutation flows through the
shared, vendored SDK (`scripts/vendor/cds-maker-sdk.cjs`) — see `## Building & Testing` — except for
the few surfaces it does not model, which use `dataverseRequest()` (see `## Dataverse Access From
Scripts`).

**Requirements:**
- **PAC CLI > 2.10.0** — for app and generative-page deploy operations (incl. the genpage `upload` connector/Custom API flags)
- **Azure CLI (`az`)** — Dataverse Web API auth (SDK + entity builder); must be logged in with the
  same identity as the active `pac` profile

No Dataverse Skills plugin or Python dependency.

## Documentation Map

Keep these in sync — **update the relevant doc(s) in the same PR as the change** (a reviewer should be
able to tell what moved from the docs alone):

| Doc | What it holds | Update when… |
|-----|---------------|--------------|
| `AGENTS.md` (this file — `CLAUDE.md` symlinks to it) | Per-component behavioral specs, the canonical file tree, conventions, build/test | You add/rename a script, change a component's behavior, or change how to build/test |
| [`docs/architecture.md`](docs/architecture.md) | Wiring / flow **diagrams** for both skills (`/genpage` + `/app-builder`) | You change the orchestration, phase pipeline, or how the pieces connect |
| [`docs/app-builder-capabilities.md`](docs/app-builder-capabilities.md) | `/app-builder` **capabilities** — what ships today, with the evidence for each | You ship an app-builder capability |
| [`docs/app-builder-design.md`](docs/app-builder-design.md) | `/app-builder` **design record** — Part I staged-flow architecture (**cited from code by section number — never renumber**), Part II the `--changed-only` contract | You change the staged flow or the partial-apply contract |
| [`CHANGELOG.md`](CHANGELOG.md) | Keep-a-Changelog — concise bullets (detail lives in PRs/docs) | Any user-visible change |
| [`references/app-spec-schema.md`](references/app-spec-schema.md) | The App Spec contract (always-present fields) | You change the App Spec shape or validation |
| [`references/app-spec-schema-advanced.md`](references/app-spec-schema-advanced.md) | The conditional App Spec fields (business rules, BPFs, commands, web resources, global choices, dashboards, roleGrants) — split out so the always-read contract stays small | You change one of those features |

Don't duplicate content across these — **cross-link instead** (a second copy only drifts, as the file
tree and teardown order both did before).

**Issue references belong in history and in code, never in use-the-plugin reference material.**
`references/*.md` and `skills/*/SKILL.md` are loaded verbatim into an agent's context so it can
*author a spec*. A tracker link there costs tokens, cannot be dereferenced by the reader it is shown
to, and goes stale while the doc lives on — so state the rule and the **why**, and stop. The test is
simple: if deleting the link loses nothing operational, it was provenance, not explanation.

Provenance is welcome in the *other* class of doc — the material that explains **why the code is the
way it is** rather than how to use it: this file, `CHANGELOG.md`, and code comments, where the
repo-root `AGENTS.md` actively asks for one. Its reader is a contributor who can open the link and
act on it. Prefer the bare id (`AB#6686428`, `#537`) over a full URL even there: it carries the same
provenance, costs less, and cannot rot.

A corollary for **error messages**: an error must stand alone. `operator 'X' is not usable — see
<issue url>` sends an author to a tracker to find out what they did wrong; say what is wrong instead.

**This repo is public.** Before adding to any of these docs, re-read the repo-root `AGENTS.md` →
*"This Repo Is PUBLIC"*. The docs here have already had to be scrubbed once for internal repo paths,
a real Dataverse environment name, review provenance, and indexes into documents an outside reader
cannot open. Record that kind of context in the PR conversation instead.

## app-builder — intent → model-driven app

A second skill (`/app-builder`) builds a whole **model-driven app** (tables, columns,
relationships, adaptive forms with sub-grids, views, Choice-column charts, app module +
sitemap) from a natural-language intent — distinct from `/genpage`, which builds generative
*pages*. The **whole flow runs in the main conversation loop, never a `Task` subagent** — subagents
are headless, so `AskUserQuestion` and plan mode cannot reach the user. **This applies to `/genpage`
too**: its agents are headless discovery/generation workers, and an agent that needs a decision
returns a `needs_input` request for the main loop to ask (`references/agent-interaction-contract.md`).
`scripts/validate-agent-interactivity.js` fails the build if any `plugins/model-apps/agents/*.md`
declares an interactive tool — the frontmatter is prose to every other test, which is how `/genpage`
Phase 1 specified an unreachable interactive flow for ~2.5 months. The same validator also fails a
**one-sided tool declaration**: tool names are host-specific and every host silently ignores a name
it does not recognize, so a capability named only in one scheme is absent on the other host and the
agent launches without it. `TaskCreate`/`TaskUpdate`/`TaskList` are the live example — no published
alias table lists them, so `todo` must be declared alongside. Both skills also support **unattended
runs** (Copilot autopilot / Claude auto-accept) via
`scripts/resolve-interaction-mode.js`; suppressing a prompt never authorizes destructive work.
For the end-to-end flow,
stage→phase mapping and page-identity model, see
[`docs/architecture.md`](docs/architecture.md) → `## /app-builder — build pipeline`; that doc owns
the pipeline and delegates each script's **behavioral spec** to the entries below.

- **`references/authoring-flow.md`** — the Phase-1 authoring playbook the skill executes itself:
  validate prereqs, select the env via PAC (`pac auth list` / `pac org who`), detect existing
  tables/apps, author the **App Spec** in confirmed levels (**(a0) personas & jobs-to-be-done
  first** — they drive which tables and surfaces exist — then data model, then forms/views/charts +
  page-intents + sample data, then access), run the `spec-lint.js` guardrail, get plan-mode approval.
  Writes `app-spec.json` (the machine contract) + `model-app-plan.md` (rendered by
  `scripts/write-app-spec-doc.js`, never hand-written).
  **Artifact naming:** both skills derive their working directory from a slug off the user's
  request, so they can land on the SAME folder and file names are a namespace. A file written by
  BOTH is unprefixed (`workflow-log.md`); a file owned by ONE is prefixed with it
  (`genpage-plan.md`, `genpage-entity-creation-log.md`, `app-builder-page-plan.md`). A name chosen
  to prevent confusion must not *contain* the name it guards against — see
  `scripts/write-page-plan.js` for why the app-builder page plan is not `genpage-*`.
- **`scripts/lib/spec-lint.js`** — pure App Spec guardrail (`lintAppSpec → { ok, errors,
  warnings }`): errors block the plan gate (e.g. the relationship-name-vs-lookup-name
  collision Dataverse rejects), warnings teach.
- **`scripts/lib/app-source-path.js`** — shared resolver for page `codeFile` and web-resource
  `contentPath` reads/uploads and hashing. Validation/lint share its lexical rule; the resolver
  requires a relative, regular file inside the canonical app folder and refuses source symlinks
  and junctions. Source-file checks report rejected paths rather than silently skipping them.
- **`scripts/lint-app-spec.js`** — the CLI surface for both gates, for a headless author or a CI
  job (#560). Runs `migrateAppSpec` → `validateAppSpec` (what the build runs on load) → `lintAppSpec`
  (guardrails the builder does **not** run), and exits non-zero on errors (`--strict` also fails on
  warnings). Prefer it over calling the library through `node -e`: linting the file **as written**
  rather than the migrated shape is what let a spec pass the lint and then fail the build (#545).
  `--profile` defaults to **`plan`**, not `deploy`, because the authoring flow runs this while pages
  are still intents — a deploy default rejects a normal work-in-progress spec. `plan` relaxes only
  that rule. Gate a final, deployable spec with `--profile deploy`.
- **`scripts/build-model-app.js` → `scripts/lib/sdk-build.js`** — the deterministic, **idempotent**
  build engine, run after approval. Runs in two engine invocations (staged flow): (1) `--stage data
  --apply` materializes tables + columns + relationships (solution·data-model only; sample-data
  deferred) so `generate-types` can emit `RuntimeTypes.ts`; (2) a full `--apply` build (re-discovers
  the data model, then ui · app · publish) finalizes everything idempotently. `--stage
  <data|ui|app|publish>` selects phases by stage name; **apply-safe only for `data`** — the full
  build (run 2) is always a complete idempotent run. Discovers existing tables/columns/relationships via the SDK
  (`findTables`/`findColumns`/`fetchEntityMetadata`) and creates only what's missing
  (`createSolution`/`createTable`/`createColumn`/`createRelationship`), seeds sample data via
  `seedRecordGraph` (SDK-owned parent-bind + resolve-by-name idempotency), enriches default
  Active/Inactive views via `enrichDefaultViews`, then
  `createWebResource` for form JS, then builds each artifact through the SDK's **generic mutation
  surface** (`createArtifact`+`addElement`/`updateElement`/`removeElement`+`pushArtifact`) driven by the
  pure **`scripts/lib/artifact-intent.js`** compiler: a form is a minimal `createArtifact` plus a coarse
  `addElement` of each authored tab (sub-grids/quick-views are canonical control cells; form JS is the
  root-bag `/bag/c` `<events>` region), a view is `updateElement('/columns')`, an app is
  `updateElement('/siteMap')`, a dashboard tile is `addElement('/components')`. Form reconcile adds the
  spec's fields and — for an author-controlled **explicit** layout — prunes fields it dropped (never the
  primary) via `findFieldCellPointer`+`removeElement`, keyed by a declared semantic identity so a rebuild
  never duplicates a control. A row that a pruned or moved cell leaves holding nothing is removed too
  (`strandedRows` in `lib/form-occupancy.js`, shared by the prune pass and both move paths). A row that
  a row-spanning cell above still reserves is kept, and so is a row that was already empty.
  Explicit layouts converge **tab/section order**, not existing-field order. Newly created listed
  fields and `fieldOptions[x].after` share a copied occupancy-checked plan, committed as one artifact
  edit so an interrupted split cannot persist duplicated cells. Anchors run in dependency order
  after new-field insertion; remaining unsatisfied requests are warned in `skipped.layout`.
  Existing fields are not list-reordered. Verify checks form-wide cell/control ID uniqueness,
  including auto forms, but not field order or anchor adjacency. See the
  [form-layout contract](references/app-spec-schema.md).
  **The Notes timeline** (#680, `lib/form-timeline.js` — one definition shared by the compiler, the
  build's repair and verify) is written in the shape Dynamics renders: the fixed control id
  `notescontrol` in a 23-row cell with `auto="false"` (its `auto`/spelled-out `showlabel`/`colspan`/
  `rowspan` ride in the cell's bag), over the 22 empty spacer rows the cell spans. The old shape — a
  generated control id in a one-row cell — is published and never rendered. What platform forms share is
  the id, `showlabel="false"` and a cell of N rows followed by N−1 empty rows — N is 5, 20, 23 on different
  forms, `auto="false"` is on some and not others, `colspan` is 1 or 2 — so the helpers take ANY N of two or
  more as in shape: 23 and `auto` are what a NEW host is written with, never what an existing one is held to.
  A rebuild repairs the engine's own host (the `section_notes` section holding the timeline control)
  **in place** on every layout: one atomic rows write, same section, position and cell identity, and only
  what is missing — the id, a span of one row, rows under the span (to the span the maker chose; extra rows
  are never deleted); `auto` is written only with the conversion of an old cell, never alone. The repair
  (`repairTimelineHost`) runs **last** of the passes that touch a form's fields — after the prune and the
  `fieldOptions.after` moves — because it rewrites only the host's own rows and refuses a host that holds
  another control: run first, it would refuse an old host whose stray field the same build then prunes, and
  the form would stay old until a second build. The topology
  pass does not narrow that host below its timeline's `colspan` (a widened timeline is the maker's width).
  It never edits a maker's timeline elsewhere and never writes a second `notescontrol` — a form that
  already has a control by that id gets no new host (the explicit-layout topology pass and the auto-layout
  path share `timelineCreationBlocked`), and a host whose id cannot be renamed for that reason, or that
  still holds another control once the prune and moves are done, is left whole and reported in
  `skipped.layout`. That is one rule (`timelineRefusal`) applied to two different trees, not one decision
  that both reach alike: the build judges the form as its field passes have left it, `--verify` the form as
  published, BEFORE them. So verify predicts what the passes take out of the host (`notesHostInspector`
  in `verify-spec.js`: a field an explicit layout declares in another section; one a `fieldOptions.after`
  anchor places beside an anchor that is not in a section the build owns — an anchor that sits in the host
  but is itself anchored is moved out first, because anchored fields are placed in dependency order, and
  the field follows it; one an explicit layout with prune on does not list — `formRemovals`, the destructive
  gate's own function) and hands the rule those cells as `leaving`. What a pass does is predicted only for
  a field the form has ONE cell of. Each pass acts on a single cell of a field — the first it finds once the
  topology pass has reordered tabs and sections (`planOrderMoves`), and the prune removes one cell of a
  field per run — so for a field the form carries twice that a pass would act on (or one anchored, directly
  or down a chain of anchors, to a field it carries twice with a cell in a section the build owns; the
  reason names that field, not an anchor of one cell on the way to it) verify cannot know which cell a
  build acts on. It says so (`unsure`), in every shape of its advice — the taken-id one included, since
  freeing the id does not decide that cell: no promised repair (a full build repairs the timeline
  *unless* it leaves the cell, which verify cannot tell), no certain refusal, and no sending the maker off
  to do what the build may do itself. Only a pruned duplicate becomes predictable by itself (the prune
  removes one cell per run, so verify is exact once one is left); one a declaration or an anchor places can
  stay unpredictable build after build, since a build that acts on the cell already in place leaves the
  others — the maker moves them, as the advice says. A field that no pass touches stays whichever cell is
  acted on, one cell of it or several, so that is still said plainly. It is a prediction from the spec and the
  published form, not the build: it also cannot see a removal that was not authorized or that the build
  holds back, or a `fieldOptions` move skipped for a row-span or an overflowing row. Where it and a build
  differ the build's report is the authority — the cases in `form-timeline-real-bundle.test.js` run both on
  each shape, including forms whose published section order is not the build's, so a change to which
  fields a pass takes out is made in the pass and the prediction together. An `auto` form that asks for
  Notes and has no host gets one on rebuild (`addTimelineHost`, before the sub-grids: it has no topology
  pass to add it). Fields never go into an
  engine host: the no-section fallback skips hosts (`firstFieldSectionRowsPointer` — quick views use it
  too), a `fieldOptions.after` anchor inside one is refused, and the narrowing re-flow never runs on one (it
  would delete the spacer rows as "beyond the new count").
  Verify's `form-timeline` check needs ONE timeline that renders (id `notescontrol`, a cell spanning
  more than one row) on any form the spec gives Notes — and, for the engine's own host, the N−1 rows under
  its span, since the build converges them; a maker's timeline is judged by id and span alone. So an
  old-shape form fails it until rebuilt — and its detail says what a build WILL do about that form
  (`timelineVerifyProblem`): "run a full build" where the build repairs or adds it, what the maker must do
  first where it declines (another control holds `notescontrol`; the host holds a control no pass of the
  build takes out — a maker's own control, or a field no rule of the spec moves or removes, which the maker
  moves out of the section; on an `auto` layout, which declares no section for a field and prunes none,
  that is any field already on the form that no `fieldOptions` anchor places). A field the build itself
  prunes or places elsewhere is not the maker's to move:
  the advice stays "run a full build" and names what the build takes out first, a removal with the
  `--allow-destructive` it needs. A field it cannot predict is neither: where a full build would repair the
  timeline the advice says it does so unless it leaves that field, which verify cannot tell; where the
  advice is something else first (a control that stays, a taken id) it says verify cannot tell whether the
  build takes the field out first — and, either way, what to do if the build's report says the timeline
  was left as it is. Always a
  FULL build: a `--changed-only` run of an unchanged spec is a no-op ("nothing to apply"), so a form an older
  version left — which no change to the spec reaches — is never repaired by it.
  Download does not reconstruct `forms[]` (see `hydrate-spec.js`), so `notes`/`hasNotes` are authored
  and there is no form read-back to adapt.
  **Where a sub-grid's section goes** (AB#6651699, `lib/form-subgrid-placement.js` — the pure rules the
  spec gate, build, verify and wireframe all read): `forms[].subgrids[]` takes `after`/`before` (a
  section of the declared layout), `tab`, `column`. An `after`/`before` grid sits in its anchor's tab and
  form-column DIRECTLY beside it (`columnSequence` builds the order — grids that share an anchor keep
  declared order, each directly beside the one declared before it — and `neighbourOf` names the token a
  grid must touch); a `tab`(+`column`) grid is a question of MEMBERSHIP, created at the end of that column
  and, once in it, left wherever it is within it. The names are what `formLayoutNames` (app-spec.js)
  derives — an auto layout is `tab_general` holding `section_general`, pinned to the compiler by a test —
  and `subgridPlacementProblems` is the one function behind both the spec gate and the lint (a key a
  sub-grid entry does not read is only a warning — `subgridUnknownKeys` — because entries written before
  the placement keys existed may carry extras the build never read). A form holds ONE grid per
  relationship, so `subgridDuplicateProblems` (gate and lint, one function) refuses a relationship listed
  twice when an entry places the grid — those keys are new, so no spec that built is turned away — and only
  warns for a repeat that places nothing (the build has always ignored the extra entry); verify fails the
  later entry rather than pass it. A grid with
  NO key takes the original path exactly (end of the first tab's first column, never moved): a test
  records its writes. The build (`addSubgrids` → `placeGridSection`) runs after the
  topology pass, using the tab/section matches that pass recorded (`reconcileFormTopology` returns
  `{ sectionTargets, tabIndexOf }`) — so an anchor matched by label under another name is still the
  anchor. Grids with no anchor are appended first, in declared order, as they always were; the anchored
  ones follow — `after` grids in declared order, `before` grids in reverse — each put directly beside the
  nearest token toward its anchor already on the form (`anchoredPosition`), which is where a NEW form
  puts it: a new grid is added there in one write, an existing one is MOVED (same section, one
  `moveElement`, within a column or across form-columns), and one already directly beside it writes
  nothing. ONLY THE GRID MOVES: `planOrderMoves`, the fewest-moves planner the topology pass uses, is
  deliberately not used here — it moves whichever section is cheapest, and on the layout every released
  build left behind (`notes: true` + a grid: the grid BEHIND the Notes section) that is an authored
  section or the timeline, leaving the Notes section mid-form; a section in between (the Notes section, a
  maker's own) is simply left behind the grid. Only the engine's own `section_grid_*` host is ever moved;
  a grid a maker put in a section of their own is reported in `skipped.layout` — unless it is already
  where the spec puts it, which is neither written nor reported — as is an anchor/tab the
  deployed form lacks (the grid is then added at the end of the first tab, never lost). Adding or moving
  a section shifts the indices of the sections behind it, so everything that holds a section across
  `addSubgrids` is kept by what it is, not where it is: `shiftSectionTargets` corrects the topology pass's
  recorded targets, and a field being ADDED is queued with its section's id and name
  (`queuedSectionPointer` finds it again through `findSectionById`, then by name and place) — an index goes
  stale when a grid section is inserted ahead of it or taken out of the column, and the field would land
  in the grid's section or halt the forms phase on a pointer that addresses nothing; a section gone
  altogether sends the field to the first field section, reported. Quick views go to the first FIELD
  section too, never a grid's. Verify's
  `form-subgrid` check judges each placed grid
  against the DEPLOYED form with the same `neighbourOf` — directly beside its neighbour, anything between
  them named — (`RelationshipName` is element text — `parseFormTopology` reads it), on explicit and auto
  layouts; a failing detail says to run a full build where the build moves the grid, and what the maker
  must do where it is in a maker's own section. The wireframe and the offline eval reader read a compiled
  column by PLACE (`columnItems`; `columnSequence` tokens carry `at`), never by name — a layout may declare
  a section named `section_notes` too. A grid is as wide as its form-column, so a full-width grid wants a
  one-column tab (documented,
  not engineered). Download does not reconstruct `forms[]`, so placement is authored too.
  **Which form a table opens with** (AB#6736948, `lib/form-order.js`) is decided by the table's Main
  Form Set order — each Main form's formxml `<DisplayConditions Order>` — not by `systemform.isdefault`
  (measured: moving the flag reorders nothing, and a table's three new forms, all at the same order,
  were served with the `isdefault` form last). After every form exists, the forms phase promotes the default (an explicit
  `isDefault` or `entities[].mainFormOrder[0]` on any table; the first Main form only on a table the
  spec owns) and then writes Order 0..n-1 onto the spec's Main forms, the ones it does not list keeping
  their stored relative order. On a table it orders nothing on, a Main form it creates is created after the
  table's others: its order is written into the new form's own `<DisplayConditions>` before the first
  push (`createInPlace`), so no interrupted run or later failure can leave it at the Order 0 every new
  form gets — a later build could not tell it is new. Only declared forms are written, order-only through `setFormSecurityRoles` so roles
  survive; a table ordered by hand (`securityRoles.order`) is left alone; the step is best-effort (✗ and
  a warning, never a halt). Verify checks the stored order (`form-order`) and what the public
  `RetrieveFilteredForms` serves the user running it (`form-order-served`). On `build --apply`, the destructive preflight writes `.maker-workspace/destructive-approval.json`
  when it refuses form-field or sitemap removals, and also when an approved destructive run starts so
  retries and later failures stay bound to that gate-time list — an empty list included, when the run
  includes the removal phases. A later `--allow-destructive` run may
  remove only that recorded set; the record is consumed only after a successful run that included both
  removal phases (`forms` and `app-shell`) and kept no field, and is otherwise kept on failure, partial
  runs, changed-only runs, or when the fence kept a field. An approved run consumes or rewrites only a record it
  found at its start or wrote itself (each record carries its run id, compared by content fingerprint),
  checked and changed under the workspace lease the apply snapshot uses, so two builds sharing a
  workspace cannot consume each other's record. A refusal still replaces the record with the list it
  just showed, because that is the list the maker is looking at, and a halt naming new removals refreshes
  it with the full current list (the halt shows only the new ones). An approved
  run halts before any write if the record
  changed after it was read, or if the lease stays held. The gate fails closed if the record is
  unreadable or if discovery fails while a record exists, because live removals cannot be compared with
  the approved list. If live state contains a new removal, the build halts, lists only that new removal,
  refreshes the record, and the engine keeps any field or sitemap target that appears during the run
  rather than pruning beyond the preflight-approved set. Every push routes through `requireSuccessfulPush` (a 412 version conflict
  halts the build for a fresh download instead of silently dropping the edit) — so new, existing, and mixed
  envs all work. The data model is **complete** (all column types, global choices, status reasons,
  alternate keys, N:N). It also builds **quick-create/quick-view forms** (`formType`) with **quick-view
  placement** (`forms[].quickViews[]` — embed a QuickView form via a lookup), **modern command-bar
  buttons** (`commands[]` — functional JS on-click + static hidden/disabled, incl. **flyout/split-button
  menus** via `type`+`children[]`), and **dashboards** (`dashboards[]` — chart/list/iframe/webresource
  tiles) with **sitemap placement** (a `dashboard` subarea auto-pins the dashboard as an app component).
  It also builds **business process flows** (`businessProcessFlows[]` — the staged process bar on a
  record): a `workflows` row of category 4 / type 1 / businessprocesstype 0 whose XAML the SDK compiles
  from ordered `stages[]`/`steps[]`, activated in the same push. The phase is additive
  discover-reconcile keyed on `(entity, name)` like `business-rules` — it creates what is missing and
  converges only Active/Draft **state**, never stage structure — and `bpfFilter` scopes every query in
  build/verify/teardown to the DEFINITION row, excluding both the platform's activated `type 2` copy and
  same-named TASK flows (also category 4). v1 is deliberately single-entity and linear: cross-entity
  stages, branching and stage actions are **rejected at the spec gate** rather than silently dropped,
  because the build cannot yet verify them. `securityRoles` was rejected alongside them and is now
  **supported** (`{ "personas": [...] }`): a BPF's role grants are `create/read/write/delete` privileges
  on the backing table that ACTIVATION creates, so they are applied in the **`security` phase**, after
  both the flow and the personas' roles exist — persona lookup there is **case-insensitive**, since a
  persona is a display name the author repeats by hand across two sections of the spec, and the target
  table is the flow's **deployed `uniquename` read back** from the workflow row rather than derived
  from its display name (a flow authored in Maker, or renamed later, keeps an unrelated unique name). There is no
  `everyone`/`fallbackForm`/`order`: those are `forms[].securityRoles` concepts written into formxml,
  and a privilege has no such equivalent. The rejection is an
  **allow-list at flow, stage AND step level**, because the SDK's normalizers copy a fixed key set and
  discard the rest — so a `branch` written on a stage (where the SDK actually models it), or the very
  plausible `fieldLogicalName` instead of `field` on a step, would otherwise validate clean and deploy
  as if it had never been written. Two further platform facts the validator encodes: a flow's **name
  must be unique across the whole spec**, because the SDK derives `uniquename` as
  `new_<name lowercased, non-alphanumerics stripped>` — **ignoring the table** — and activation creates
  a backing table with that name; and a stage must carry **at least one step**, because the SDK
  substitutes a placeholder step named "New Step" for an empty one (its own `stage-needs-step` rule
  never fires, as the placeholder is injected before it looks).
  Following a **genpage-first policy**, overview/dashboard/analytics surfaces are authored as **generative
  pages** (`pages[]`) rather than classic dashboards — the build's `pages` phase uses a **three-authority
  model**: IDENTITY (durable `<app>_pagemanifest`, outranked by a downloaded spec's `pages[].pageId`),
  EXISTENCE (env-wide `pac model genpage list` — crash-safe create-vs-reuse via `enumerateEnv`), and
  MEMBERSHIP (the app's sitemap `GenPageId` set, read fail-closed via `fetchSitemap` in
  `scripts/lib/sitemap-pages.js` — drives placement, download enumeration, and verify; a read failure
  HALTs). All page matching is by id. Every `pages[]` entry must be sitemap-placed (validation rejects
  headless pages) — because 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.
  The corollary is that a detail page is reachable from the nav with **no input**, so a page
  declaring `pageInput` must also declare **`directEntry`** (`selector` | `emptyState`) saying what
  that renders; `page-plan` passes it to the generator and the page manifest carries it through
  download. Every `pageInput.data` key must also be produced by an incoming `navigatesTo[].data`
  edge. See `references/app-spec-schema.md` → *the input contract*.
  The build halts on safety violations (`pages-removed`, `pages-shared-across-apps`,
  identity conflicts, read failures) and, before any page is created or updated, on a page that would ship a Griffel
  shorthand (`pages-griffel-shorthand`: a page it creates, or one whose source changed since the last recorded apply — a page it
  sends again as it is gets a warning; see "Griffel shorthands"). The cross-app shared-page scan (`fetchAppsForPages`) enumerates every app via the vendored SDK's
  `@odata.nextLink` pagination (`queryRecords({ paginate:true })`), so it verifies EVERY app in the
  environment rather than one 5000-row page; a pagination fault (the SDK's repeated-nextLink guard) still
  fails **closed** rather than scanning a partial list. It reads each other app's PUBLISHED sitemap and also its
  SAVED (current, unpublished) one: a page another app has saved into its navigation but not published still
  halts the update (`pages-shared-across-apps`, naming the app `[saved, not published]`). The scan is a snapshot: another maker
  can take a page into another app's navigation while the build runs, and nothing reaches far enough to stop it — the lease is
  this workspace's, Dataverse offers no write conditional on another app's sitemap, and the update goes through `pac`, which
  takes no precondition. So a build that writes pages in place takes the scan twice more, over the pages it began with and now
  overwrites (one this run created, a navigation target included, was not there to share and is left out): right before the
  first of them is written, where a page that became shared halts the build before any page it began with is updated in place
  (`pages-shared-across-apps`, "became shared across apps while this build ran", `transient: false`); and after the last — or
  after the loop stops early, since what it wrote is written — where a page taken in while the uploads ran is reported as a
  warning naming the page and the app (one that only saved it shows the new content once that app is published; a scan that
  fails there is a warning too: nothing is left to refuse). Each scan reads every other app's navigation (three requests per
  other app, and three more), which is why it is not taken before every page. The saved layer of
  every other app is **one** extra request — the `sitemap` collection's `RetrieveUnpublishedMultiple`, joined to each app by the
  sitemap id its published read found, with the same current-row rule `navigationProof` uses — and it is fail-closed too: a
  read that fails as a whole halts as `pages-shared-check-failed`, and an app whose saved row is missing, ambiguous or
  malformed is listed unreadable. An app that was **never published** has no row in the published app list, so the scan lists those
  as well — one request, the `appmodule` collection's `RetrieveUnpublishedMultiple` (`draftOnlyApps`) — and joins each one's saved
  sitemap from the same saved-layer read, by the sitemap id its layer's sitemap component names (one more read per such app); it has
  no published layer, so whatever it holds is saved only. That list is fail-closed like the others: a read that fails halts as
  `pages-shared-check-failed`, and a never-published app whose layer, sitemap component or saved sitemap cannot be read is listed
  unreadable. A never-published app whose component read **succeeds** and finds **no sitemap component** is not yet shown to have no navigation: the
  vendored SDK links an app to its sitemap **by name** — it looks the sitemap up with `sitemapnameunique eq '<the app's unique name>'` and takes one for the app only
  while that column holds the app's name, and `ensureSitemapInSolution` (sdk-build.js) resolves it the same way — so such an app may hold a sitemap, and pages in it, that no component row links to it.
  The saved-layer read the scan makes anyway (it asks for `sitemapnameunique` too — no further request) shows whether it does: a sitemap of that name is joined
  like any draft app's (case-insensitively, as Dataverse compares names), several sitemaps of the name leave the app unreadable, and **none** means the app has no
  navigation at all and holds no page, so it is skipped — one draft app saved with only a name must not stop every page update in the environment (a read that
  *fails*, or a component that names no sitemap, proves nothing and is still unreadable; so is a *published* app with no sitemap
  component, which is not changed here). A single-app environment reads no other app's navigation: it pays the one small request for the list of
  never-published apps, and nothing more when there are none (one saved-layer read as well when a never-published app has no sitemap component, to rule out a sitemap linked by
  name). Classic `dashboards[]` are opt-in.
  **Every `pac model genpage` call is time-bounded** (`lib/genpage-cli.js` `runPac`): 3 minutes to list or download and
  10 minutes to upload by default, or `POWER_PLATFORM_SKILLS_PAC_TIMEOUT_MS` (1 s to 60 min; one value sets all three). When
  the time is up the whole process tree is ended — a page upload once stalled for 17 minutes with no CPU and no network — and the call is
  reported as `timedOut`, which no exit code is. What happens next follows from what the call can have done. pac's upload writes the
  page, records the prompt and the agent message (two more requests), prints `Page ID`, then publishes, so a stopped upload has done
  anywhere from nothing to all but its last step. A timed-out list or download is read-only, so it is retried once. A timed-out
  **update** is **not** retried, though it names a page that exists: a re-send can record the prompt a second time (a call stalled in the
  publish has already written everything) and goes out minutes after the caller's own checks, so it can overwrite a change saved in
  between. It is reported as one that *may have landed*, and what it sends you to look at depends on the caller (`upload()`'s `caller`):
  the upload script's message names `genpage-base.js check` (the deployed page against the file and the base marker beside it), and a
  re-run goes back through the script's own guards. The **build** keeps no base marker, so that check could only answer "unknown", and
  every page's code file is a staging copy its cleanup deletes, so the command would fail on a missing file: the build's message
  tells you to read the deployed page with `pac model genpage download` (the call the build already uses, for that page id — and to open the `page.tsx` in the folder named for the id, which pac writes in its own letter case) and then run
  the build again, which sends the page once more to the same id — nothing new is created, but if the stalled send did land, the page's
  history now records its prompt a second time, and anything saved to the page in Maker since is replaced by the app folder's version.
  The error carries `transient: false`, so the build's own retry of transient halts never re-sends it. An **update** that **fails** — a non-zero exit, or an
  exit 0 with no `Page ID` — is handled the same way, for the same reason (pac can fail in the publish, after everything else is written): it is **not** retried,
  it is reported as one that *may have landed*, with what pac had printed (a `Page ID` means the page and its messages were written and the failure came in the
  publish) and the same caller-specific check, and it carries `transient: false`. Only a non-zero exit that pac reports for its own arguments or input files
  (`isDeterministic`) is an ordinary error, because pac checks those before it sends anything — and only when pac printed no `Page ID`:
  after the Page ID its one remaining step is the publish, whose error carries the server's own text, so a Page ID proves the page
  was written whatever the words. An update answered with another page's id also carries
  `transient: false`. That covers the halt an upload causes; a transient halt *later* in the same run (a 503 on a later read or
  write, say) still re-runs the whole build, which sends each page this run sent once more — as each rebuild does — so the page's
  history then records the prompt once more. A timed-out **create** is an
  uncertain create and is never run again, because a second create is how a duplicate is
  made. A `Page ID` pac had already printed names the page it created, whether the call then stalled or **failed** (exited non-zero): it is reported as
  *created as `<id>`, stalled (or failed) before publish or placement*, the create is **not** sent again however the environment's listing looks (the page need
  not be in it yet), and the way on is to place it (`pac model genpage add`, or Maker) and then update it, or to delete it and re-run the
  create — never "update it" outright (refused: not in the app's navigation) and never a bare re-run. With no id printed, the
  env-wide id diff reports any page already visible — a create that failed and left a candidate gets the same way on, not "re-run it as an update with `--page-id`" — and a page that
  is not visible *yet* proves nothing: a create that stalled with nothing visible, or whose listing cannot be read, is reported as uncertain with the advice to wait and list
  `pac model genpage list --environment <envUrl> --include-unpublished` before anything is created again. **Every error that follows a create whose outcome is not known to be nothing carries
  `transient: false`** (`neverRetriedError`) — a stall, a printed Page ID, a candidate in the listing, a listing that cannot be read: the build re-runs a phase when a halt reads as transient (`isTransientHalt`, build-model-app.js), and it reads the halt's *text* — which
  quotes the page's name (a page called "SQL Timeouts" matched) and pac's own words ("… try again later") — so without the marker a re-run would be a second create and a
  duplicate page, with the run reporting success. The page that was created but whose local receipt could not be saved (`pages-creation-receipt-failed`) carries it too. A create that
  failed outright and left nothing in the listing, and a listing that fails *before* the create was sent, leave nothing to duplicate: both are still retried — by the upload (3 sends) and,
  when the error's text reads as transient, by the build — as in 2.13.0. (`pac connection list` and
  the auth probes have their own limits or none; this bound is the genpage calls'.)
  **All of the build's Dataverse access is via the SDK** (see "Dataverse Access From Scripts" for the
  sanctioned exceptions elsewhere), so metadata is persisted under
  `<app-folder>/.maker-workspace/` for reuse/edits. The 16 phases
  (`solution·data-model·sample-data·web-resources·views·charts·forms·business-rules·business-process-flows·commands·dashboards·app-shell·pages·ai-features·security·publish`)
  are unchanged; independent ops run with bounded parallelism.
  Off-sitemap ids require a local CREATE receipt for this app/key/id plus an exact decoded stored-name
  match, including when the spec and remote manifest agree. Otherwise `unproven-manifest-id` causes
  `pages-identity-conflict`, never a replacement CREATE. Names corroborate identity, never establish it.
  `scripts/lib/page-ownership-records.js` writes receipts immediately after an acknowledged create,
  before remote manifest persistence or sitemap placement. Version-2 records bind the target's
  normalized HTTPS origin with a SHA-256 fingerprint, never a stored environment URL. This is the only
  receipt format. Unknown versions, malformed data and unreadable files halt with the record's full
  path and recovery guidance, never an empty ownership set. Foreign fingerprints grant no authority
  and are never consumed for another target. Baselines, downloads and journals are not receipts.
  Business-rule reuse keeps additional same-name definitions; after a push every extra id not returned
  by the SDK is kept and reported as not attributable to this run. A rejected AI summary
  exposes no created-model id to this caller, so same-name AI models are reported and kept.
  Emits `[n/total]` events the orchestrator narrates + a `BuildHalt` it gates on. Dry-run by
  default; `--apply` writes, `--sample-data` / `--publish` opt-in (`--publish` gates the final *bulk*
  publish; without it the edit/finalize paths — reconciling an existing form/view, form events,
  quick-views, existing-app sitemap, page finalize — still publish their one artifact so the change
  takes effect, and with or without it the lookup-label repair publishes the tables whose columns it
  relabelled). The bulk publish is ONE `PublishXml` envelope (`publishArtifacts`) for every table and
  the app, and with `--publish` the default-view enrichment, the reconcile of an existing view or form,
  form-event wiring and quick-view placement all defer their own publish to it: an entity publish ships
  every pending customization on that table, so one per artifact only repeated it, at ~2-2.5 s each.
  The tables the reconcile, form-event and quick-view paths touched are also published together once,
  in one envelope at the start of the app step, before anything publishes the app
  (`publishReconciledTables`). Measured live: a `PublishXml` naming only the app, sent after an existing
  form's save and before its table's publish, leaves the form's label changes unpublished for good, while
  the structure saved with them goes live. The enrichment's default views (columns only) wait for the
  bulk publish. A table whose early publish the SDK refuses stops the build before the app is written
  (`reconciled-publish-failed`), since the app's publish would strand its labels; the halt carries what
  is owed, so the CLI's retry, or its settle once no retry follows, publishes the table without the app.
  A deferred publish is recorded as owed, and a build that halts before the publish phase hands what is
  still owed to the CLI, which keeps each attempt's debt across transient retries until a publish phase
  pays it, and settles the rest per target once no retry follows (`settleOwedPublishes`) — so a halted
  build leaves those views and forms live, as it did before the batching. The record also keeps the NAME
  of every view and form deferred onto a table (`view Open Tickets`, `form Ticket events`; the CLI
  unions them across attempts), and `default views of <table>` when that table's default-view
  enrichment saved its generated Active/Inactive pair — whether the enrichment makes the record or joins
  one an authored view's reconcile already made — because one artifact stands for its whole table in the
  envelope: when that publish fails or cannot be confirmed, the warning names what is saved but not
  live, as the per-artifact publish it replaced did, rather than the table's Main form. That warning
  also names the views, forms and charts the run created on the table (and a chart whose description it
  wrote), each name once: a first build creates them all and defers no publish for them, so its record
  would otherwise name only the default views and the line would read as if only the generated pair
  were not live. Those names are labels only and stay out of the owed record, which is what a halted
  build publishes, so the warning after a halt names what was deferred. Settling covers
  a halt the build THROWS. A process killed outright (Ctrl-C, a crash, a closed terminal) settles
  nothing, so what a reconcile had saved stays saved but unpublished until the next build, which
  publishes it (a clean workspace copy is still published, below): no marker outlives the process, the
  default-view enrichment has the same exposure, and the app-builder skill's recovery guidance says to
  re-run. A default-view push refused by a concurrent edit fails the enrichment step, as any refused
  push does.
  **An unchanged rebuild writes almost nothing, and no write is skipped on a guess.** An existing view
  or form is pushed only when the SDK reports that its workspace copy differs from its last-synced
  snapshot (`diffArtifact`, `lib/unsynced-edits.js`): an edit this run made, or one an interrupted run
  left in the workspace — which a plain fetch keeps while the server has not moved, so comparing what
  this run computed with what it fetched would call it unchanged and never push it. An SDK without the
  method, or a diff that fails, pushes as before. A clean copy is the UNPUBLISHED layer, so it says
  nothing about what is published: without `--publish` the reconcile still publishes its artifact even
  when it skipped the push, since an earlier run may have saved and stopped. The reconcile of a reused
  component also re-asserts its solution membership, and one read answers all of them
  (`lib/solution-membership.js`: a single paginated `solutioncomponent` query for the component types
  the build adds plus the solution's tables, made at first use, so a build that reuses nothing and
  deploys no pages never makes it). A component the read proves present is not added again — views,
  forms, charts, business rules, the process flow, the app, its sitemap, the page manifest and persona
  roles. There are two proofs: a row of the component's own, or, for a view, chart or form ONLY, its
  table being held by the solution with ALL its subcomponents (`rootcomponentbehavior` 0). A table the
  build creates is held that way, and the platform does not list such a table's views, forms and
  charts one by one — an explicit `AddSolutionComponent` for one leaves no row — so a rebuild that
  looked only for rows sent those adds every time (measured live: 10 of the 19 an unchanged rebuild
  sent). The table's MetadataId, which is the `objectid` of its row, is read once per table, only for a
  component without a row and only when the solution holds some table that way, through the raw client
  the build already uses for table metadata (`tableRefs`). A table held at 1 (its own metadata only) or
  2 (shell only) proves nothing, and neither does a row with no behavior: those components need rows of
  their own, and are added without one. Anything the read does not prove (the read or the table lookup
  failed, the solution is a built-in one) is added exactly as before. A component this run creates is
  added directly, except the page manifest: it is persisted through the same check after every page
  create and once more at the end, so a first build that deploys pages makes the read too — and still
  adds the manifest, which the solution does not hold yet. What an unchanged rebuild still sends: the
  phase-8 envelope; the SDK's own default-view enrichment (it pushes both generated views of each table
  it enriches); the default-form promotion of a table the build owns (`isdefault`); the existing app's
  sitemap push with its component pins and publish; the AI-feature setting writes; and each persona
  role's privilege replace and app association.
  **The modern ("new look") shell is opt-in via `app.newLook`.** It is a per-app SETTING
  (`NewLookAlwaysOn`, written through the SDK's `saveSettingValue` scoped to app + solution), NOT an
  app-module column — `navigationtype` is Single/Multi *session* and unrelated, which is the reason
  this looked unavailable for a long time. Best-effort: the feature rolls out by tenant, so a failure
  warns and records `created.newLook: false` rather than failing an otherwise-good build or reporting
  a silent success.
  **`app.aiDescription` (the routing description) is written only when the spec sets it** — at create,
  and on an existing app when it differs from the fetched draft, riding that run's app push. The
  platform may author this text itself, so an omitted field is never written or blanked. A
  never-published app refuses the write (`APP_DRAFT_HEADER_NOT_WRITABLE`), which halts with
  `app-header-unpublished` — publish, then re-run — after resetting the workspace copy, which a plain
  re-fetch would otherwise refuse to replace (`LOCAL_EDITS_WOULD_BE_LOST`). An app with an UNPUBLISHED
  header change takes it: the vendored SDK conditions the header write on the appmodule's row token, so
  the push saves and keeps that change (live-verified). A 412 is a concurrent edit, on the appmodule
  row as on the sitemap, and is never re-explained as "publish first". Any
  other failed push that carried the change resets the copy too — except a concurrent edit
  (`VERSION_CONFLICT` / a code-less 412), where the unrecorded copy is what stops a blind re-run — and
  without the pages phase the change is applied only after the live-page gate, so a gate halt leaves
  nothing behind; a page-backed app's standalone header push refuses a copy still holding an earlier
  run's unpushed edits (`app-copy-unpushed-edits`) rather than replay them.
  **An existing app's sitemap is rewritten ONTO its live nodes (AB#6726727).** The App Spec describes a
  node's label/title, target and icons only, and the SDK serializes a node without its fetched `bag` from
  scratch (new `Id`, `ResourceId="SitemapDesigner.NewSubArea"`, broad `Client`/`Sku`,
  `AvailableOffline="true"`). Both existing-app writers (the app-shell write and the pages finalizer)
  therefore pass appDef's tree through `adoptLiveSitemap` (`scripts/lib/sitemap-merge.js`) first: a
  subarea corresponds by navigation target (a URL keeps the case of its path and query), an area or
  group by a label its level uses once, then by the navigation entries it holds, then by the id an
  earlier build gave it (a node none of these identifies is written as new rather than guessed); the
  live `id` and `bag` are kept, and only
  what the spec sets is overlaid — chrome it does not name is still removed, as before. Because the SDK
  only patches an existing `<Titles>` on a node with a bag, and reads an empty title as "no edit", a
  title added to an entry that had none, or removed from one, is reconciled in the adopted bag at the
  SDK's language (other languages' titles are kept). Every dashboard entry carries exactly
  `Url="/workplace/home_dashboards.aspx"` — the designer writes it on every dashboard entry and
  recognizes one only by the whole Url, and the runtime keys the dashboard glyph on it with a
  case-sensitive match — and verify fails an entry without it (`subarea-dashboard-launcher`, which
  reads only real nav entries — a `SubArea` directly under `SiteMap/Area/Group`, never one in a comment
  or elsewhere — as the kept-icon check below does). With a
  **baseline** — `.maker-workspace/last-applied.json`, written atomically by a successful apply and
  by download, stamped with its environment and app (`scripts/lib/deployed-baseline.js`) — a nav entry's
  title/icon that the spec has not changed since, but the designer has, is kept and reported rather
  than reverted (`keepsLiveValue`); without one the spec wins and every change to an existing entry is
  reported. Baseline entries are lined up with live ones by the ids the baseline RECORDED for its
  environment (`__deployedIds`: what the apply resolved, or what the download read), never by the
  spec's own `dashboardId`/`pageId`, which may be another environment's. Verify accepts a kept icon by
  the same rule and identities, and only on a live entry that exists. A downloaded dashboard carries
  `dashboards[].dashboardId`, which build, verify and teardown resolve before the name
  (`findPinnedDashboard`) — when it resolves it is the only candidate, and teardown still requires
  solution membership — so a dashboard renamed in the designer is reused, with a warning since a build
  never renames one, instead of a second being created under the stale name.
  **DATA-MODEL Dataverse labels are stamped with the ORGANIZATION's base language, not a hardcoded
  1033.** `resolveLanguageCode` (`scripts/lib/entity-provision.js`) reads `organization.languagecode`
  once per build and threads it into every label-emitting SDK call in that phase (tables, columns,
  customer columns, global choices, status reasons, alternate keys, relationships); precedence is
  `--language-code` → App Spec `languageCode` → the org's base language → 1033. Without this, an org
  that has not provisioned 1033 fails the data-model phase with `The language code 1033 is not a valid
  language for this organization`
  (#447) — and confusingly only on
  *some* column types, because (observed 2026-08) Dataverse tolerates an unprovisioned LCID on
  `EntityMetadata` and `PicklistAttributeMetadata` but rejects it on `DateTime`/`Memo`. Every fallback
  to 1033 warns, as does an explicitly supplied LCID that had to be discarded.
  An **explicitly supplied** LCID is additionally checked against `RetrieveProvisionedLanguages`
  (`readProvisionedLanguages`, `scripts/lib/dataverse-auth.js`, injected as `deps.provisionedLanguages`)
  and halts the data-model phase before any write if the org does not have it — because of the
  split behaviour above, the alternative is a table created with silently-wrong labels followed by a
  failure on the first `DateTime`/`Memo` column. Only *explicit* overrides are probed; the org's base
  language is provisioned by definition. The probe is best-effort — an unreachable or non-2xx
  `RetrieveProvisionedLanguages` resolves to `null` ("unknown") and the build proceeds unchanged, so
  the diagnostic can never itself break a build. Note
  `updateTable(logical, { quickCreateEnabled })` deliberately passes no language: it only builds a
  Label when a `displayName`/`pluralName`/`description` is supplied, and otherwise round-trips
  Dataverse's own labels under `MSCRM.MergeLabels`.
  **Scope — this now extends to the `forms`, `dashboards` and `app-shell` phases too.** Those go
  through the vendored SDK's artifact serializers, which used to hardcode `1033` into FormXML
  (`<label languagecode="1033">`), SiteMap XML (`<Title LCID="1033">`) and dashboard XML with **no
  caller override** (#455). The SDK
  now takes the authoring LCID as a **construction-time** option (`MakerSdkOptions.languageCode`),
  which is why `main()` resolves the language over the transport hatch (`resolveAuthoringLanguage`,
  `scripts/lib/entity-provision.js`) **before** calling `makeSdk`, and passes the identical value on
  as `opts.preResolvedLanguageCode` so the data-model phase cannot resolve a different one. Both SDK
  instances get the same LCID deliberately: `pushArtifact` refuses a push whose stored artifact
  language disagrees with the SDK performing it (`ARTIFACT_LANGUAGE_MISMATCH`, for registrations
  marked `languageSensitive` — App, Form, Dashboard). Passing nothing preserves the SDK's own 1033
  default exactly, so the option is opt-in rather than a silent re-labelling.
  **One language per BUILD, and there is no per-table override.** Because the LCID is resolved once
  and is a construction-time SDK option, a second language would need a second SDK instance — and
  multi-language labelling is blocked a layer lower anyway, since the SDK's label serializer emits a
  one-element `LocalizedLabels` array by design. `entities[].languageCode` and
  `entities[].localizedLabels` are therefore **rejected by validation**
  (#537) rather than accepted and
  dropped: `entities[]` had no allow-list, so both validated clean and were silently ignored, and an
  author asking for one table in a second language got a successful build with the request gone. Any
  unknown table key now fails the same way (see `ENTITY_KEYS`, `scripts/lib/app-spec.js`). Note that
  `references/localization.md` is about generated **page** code, not Dataverse labels.
  Note the SDK deliberately does **not** language-parameterize BusinessRule: its mapper's language
  parameter is the *environment base* language, a different concept.
  **`languageCode` is the language for a PLAIN label, not the only language available.** An
  author-facing name (`entities[].displayName`/`pluralName`, `primaryAttribute.displayName`,
  `columns[].displayName`, `alternateKeys[].displayName`, `relationships[].lookup.displayName`,
  and an **inline** Choice option on `columns[].options[]`) may instead be a **map keyed by LCID**,
  which the SDK's label serializer turns into a multi-entry `LocalizedLabels` array
  (AB#6686428 / #537). ⚠ **`globalChoices[]` is the exception and is REJECTED, not supported** —
  neither its `displayName` nor its `options[]` may be localized, because Dataverse accepts the
  multi-language payload for a global option set and stores only the base language (measured, even
  through a raw `POST` that bypasses the SDK). Listing it here as localizable sent authors into a
  validation error; use an inline Choice on the column when option labels must be localized. The plugin
  passes a supported value through **unflattened** — flattening it here would silently restore the
  English-only behaviour while validation and the design doc still claimed two languages. Everything
  that RENDERS or DERIVES FROM a label must go through `labelText()` (never string-interpolate a
  label), and anything that resolves an author's reference BY label text must go through
  `labelAliases()` so a reference written in any provisioned language matches. Both live in
  `app-spec.js`; `references/localization.md` is about generated **page** code, a separate concern.
  Guarded by `scripts/tests/lcid-real-bundle.test.js`, which drives the REAL vendored bundle — a mock
  would keep passing against a bundle that ignored the option.
  **A label-bearing metadata read must not run ahead of a create that may carry a localized label**
  (AB#6686428). Measured, order-controlled: a broad read that returns labels (`findTables`,
  `findColumns`, `fetchEntityMetadata`) made right before `createTable` / `createColumn` /
  `createRelationship` leaves Dataverse with only the base-language label of a multi-language one, with
  no error. That is why the existence probes are narrow keyed reads without labels, and why a probe that
  cannot answer halts, or assumes absent, instead of falling back to the broad read when a label is
  localized (`findExistingTable`, `relationshipExists`). The view-filter **pre-flight**
  (`preflightViewLabels`, `lib/sdk-build.js`) reads option labels and whole global choices before the
  first write, and those reads were never measured to be exempt — so a spec that carries ANY localized
  label (`localizedLabelLcidsInSpec`: a map on a table, primary column, column, inline option, alternate
  key or lookup; which of them the environment lacks cannot be known without more reads) does not run it.
  The views phase, which follows every create, makes the check instead, so a label that names no option
  still stops the build before any view is written; what is lost is only the guarantee that nothing had
  been written yet. Offline lint and validation, and the dry run (it creates nothing, so no create
  follows its reads), are unchanged. Lookup labels keep the same discipline: the parent-label read sits
  right before a single-language create, the relationships whose lookup label is localized are created
  before any other (`relationshipsInCreateOrder`, `lib/entity-provision.js`) so that none follows such a
  read whatever order the spec declares them in, and the repair's reads come after every create. Lifting
  the skip needs a live measurement of these reads ahead of a localized create in a two-language environment.
  The vendored SDK now sends `Consistency: Strong` on every metadata read (see "Newer SDK behaviors" below);
  whether that changes this effect has not been measured, so every rule above stands until a live
  two-language rebuild says otherwise.
  **A lookup column's label defaults to the PARENT table's name** (ADO 6691205). An omitted
  `relationships[].lookup.displayName` reached the SDK as `lookupDisplayName: undefined`, which the
  vendored SDK fills with the referenced table's **logical name** (`label(opts.lookupDisplayName ??
  opts.referencedEntity)`), so the column — and every form cell the SDK derives from it — read
  `new_project`. One rule, `lookupLabelFor` (`lib/app-spec.js`), now serves the build, the form cell,
  the previews, `app-spec-doc`, `schema-facts` and the download: the explicit label → the declared
  parent's `displayName` (string or LCID map, kept as the map) → the display name the **environment**
  reports for a parent the spec cannot name → a humanized table name. A parent the spec cannot name is a
  table it does not declare, **or one it declares WITHOUT a `displayName` that the build did not create**
  — `existing: true`, or found already there by the data-model phase — because the environment's name is
  then the only one it has (an `existing: true` `incident` is "Case", `systemuser` "User", not a
  humanized "Incident"/"Systemuser"). `lookupParentNeedsEnvironment` (`lib/app-spec.js`) decides who asks;
  the read is `readTableLabel` (`lib/lookup-labels.js`), and a label that is only the table's own
  identifier — what a build writes for a table with no `displayName` — is ignored. A parent that sets a
  `displayName`, and one the build creates itself without one, are never read. Offline surfaces cannot read
  the environment, so they show the humanized name wherever the build would read one — the plan adds "or the
  label from the environment if `<parent>` is already there" for a declared parent with no `displayName`
  that the build may find already present.
  The build has no general column-label reconcile, so the **rebuild repair** is its own pass
  (`reconcileLookupLabelsPass`, `lib/entity-provision.js`; opt-in through `reconcileLookupLabels`, which
  only `sdk-build.js` sets, because `provision-entities.js` counts its own steps). It acts only when the
  spec omits the label AND the live label is exactly the old default — for a **column**, the lower-case
  logical name of the parent (what the SDK labels the column with; `isOldLookupColumnLabel`),
  **case-sensitive** (a correct "Contact" is not one, so it costs no read and a maker's "Contact" is never
  overwritten; neither is a column that carries the lookup's own name — no build ever wrote that on a
  column) — in a single language that is the
  **build's own** (an old default in another language is left alone, with a warning: the write would add
  the build's language beside it, leaving two languages of which one is still an identifier, and every
  later rebuild would skip the column) — and writes **one language** (the authoring LCID) through
  `updateColumn`'s merge, never a map. It converges to the same label a fresh build writes, the
  environment's name included. A column's label is part of its table's definition, which Microsoft documents as
  reaching model-driven apps only once the table is published, so the pass then publishes every table it relabelled,
  in one `<entities>` PublishXml, with or without `--publish` — in the data-model phase, before anything can publish
  the app; a failed publish is a warning that names the tables. Nothing else in the build is sure to publish them:
  its other table publishes follow from what the spec puts on a table (a form, view, chart or icon, or its enriched
  default views), and a downloaded spec carries no forms, views, charts or table icons and marks every table
  `existing: true`, whose default views the build leaves alone. No metadata read can confirm that publish: measured
  live, `RetrieveEntity` returns the new label right after the PUT even with `RetrieveAsIfPublished=false`, so a
  live check of it has to trace the request. The labels are read **once per child table**
  (`Attributes?$select=LogicalName,DisplayName&$filter=LogicalName eq '…' or …`, the read the SDK itself makes for
  column hints), for the lookups the run did not just create, and a failed read is warned about once per table.
  `lib/lookup-cell-labels.js` applies the same rule to
  the form cell that stored the old label (the SDK takes a cell's label from the column only when the
  cell has none), whose old default is the parent's lower-case logical name **or the lookup's own** — what the
  SDK labels a field with when it cannot resolve the column at push (`isOldLookupFieldLabel`); it is a separate
  module so `entity-provision.js` and `artifact-intent.js` do not
  require each other. A cell that still holds the old default follows the column's CURRENT label — the
  repaired one, or a maker's. Every cell of the lookup is held to that rule on its own label: a form can hold a
  field more than once (`findFieldCellLocations`), and a first cell that is right, or a maker's, says nothing
  about a second one an older build left. A label-bearing metadata read immediately before a labelled create makes Dataverse
  keep only the base-language label, which is why the existence probe stays narrow for a localized
  label, the live parent-label read sits right before the plain create only (a localized lookup is created
  before any relationship that needs one), and repair reads run
  after every create. Download leaves `lookup.displayName` out when it equals what a rebuild would
  write **or is the old default** (the parent's logical name): an explicit label is never repaired, so
  writing the identifier out would carry the defect through download → edit → rebuild, where leaving it
  out hands it to the repair. Any other label is written as it is. Pinned by
  `scripts/tests/lookup-label-default.test.js` (its REAL BUNDLE tests drive the `createRelationship`
  body, the `updateColumn` PUT and a pushed form cell through the vendored bundle) and
  `scripts/tests/lookup-label-download.test.js`.
  **Choice labels in view filters resolve against the ENVIRONMENT** (ADO 6691114,
  `lib/view-filter-labels.js`). The spec alone (`choiceValueMap`, 100000000 + index) is right only for an
  option set this build created. A view on a table the spec reuses without declaring the column
  (`{ "schemaName": "account", "existing": true }` filtered on `industrycode`, a LOCAL option set — or a
  column bound to a system global choice) sent the LABEL as the condition value; lint passed, and Dataverse
  rejected the saved view MID-BUILD, after the tables and columns were written, leaving a half-built app.
  A declared global choice that already existed with other values did too (`createGlobalOptionSet` REUSES an
  existing set unchanged). A column bound to a global choice the spec does NOT declare is a lint ERROR
  (`spec-lint.js`, "references unknown globalChoice") — the build would create it with no options — so it is
  not a case the resolver serves, and lint adds no second word about it. (That check is lint's only:
  `validateAppSpec`, which the build runs, does not repeat it.)
  `readChoiceAttributes` reads a table's option sets (four narrow cast reads — Picklist,
  MultiSelectPicklist, State, Status — expanding `OptionSet`, which a global-bound column fills as a local one
  does) once per reader, and a build has up to two: one for the pre-flight and a fresh one for the views phase
  (the data model has been written since), so a table is read **at most twice per build** — and not at all for
  a table no label names — and a label matches in any language, an ambiguous alias refused. It is deliberately not
  `fetchEntityMetadata`: that costs about eleven requests, WRITES the workspace (a dry run must not) and keeps
  only the caller's language. The label is judged twice, on purpose:
  - **Before the build writes anything** (`preflightViewLabels`, `lib/sdk-build.js`, run by any apply that
    includes `solution` or `data-model` — so the staged flow's data run too — **unless the spec carries a
    localized label**, see the AB#6686428 note above) through
    `checkViewFilterLabelsBeforeBuild`. Before the build runs the environment cannot hold what the build is
    about to create, so each label is judged by whichever side is AUTHORITATIVE and only a DEFINITE answer
    halts: a column the environment already has (its options are final), or a column the spec declares and the
    environment lacks (the spec's inline options — or, bound to a declared global choice, the environment's
    copy when `readGlobalChoiceOptions` finds the set, since it is reused unchanged, else the spec's). A
    status reason the build inserts, a table it creates, a read that failed or was partial, and a label that
    the spec's own number stands in for (see the views phase) are NOT problems
    here — a false halt would stop a good build — and are the views phase's to judge. What the environment
    already held and resolved is handed on as final — a column's own options, and the values of the
    existing global choice a column the build creates will be bound to — so a failed read in the views phase
    cannot make it fall back to the spec's 100000000 + index.
  - **In the views phase, once the data model exists** (`resolveViewLabelsOrHalt`, a fresh reader): the final
    answer and the values. For a column bound to a declared global choice whose own options could not be read,
    the **set itself** is read (`readGlobalChoiceOptions`), because that is what the column is bound to — which
    is how a spec with a localized label, having no pre-flight to hand a value on, still resolves it. The spec's
    own options answer only where the environment cannot (table not there yet, failed read), for a declared
    Choice column — a FALLBACK, right only for an option set this build created, so the build **warns**
    whenever it uses it. An undeclared column whose table cannot be read is used as written, with a warning.
    **Where the spec numbers the options** — a column with inline `options[]`, or one bound to a global choice
    that `globalChoices[]` declares — and the environment has options for the column (or the set) but none
    labelled as the filter says (a maker renamed "Gold" to "Gold Tier", or a localized option's translation never
    reached the environment), the spec's own number is used, **with a warning**, when the environment has an
    option with that number (`specNumberStands`). That is what the build did before labels were resolved against
    the environment, so halting the apps it built would be a regression — and for a declared global choice a
    maker's rename would make `build --apply --verify` fail on every run with nobody having touched the spec.
    That the option on the number is the one the label named is an ASSUMPTION, so the warning says so and says
    how to take the choice out of the build's hands (use the option's integer value). It is **never made against
    the environment's own evidence**: the number is refused, and the label is a problem — the build halts, before
    it writes anything where the pre-flight can see it — when the environment has no option on the spec's number
    (a set from another publisher's range has none, so it halts too), or when the option on it goes by the name of
    another option of the spec (in any of its languages), or when the environment carries a label of this spec only
    on other numbers **and that shows the numbering moved**: the label is the filter's own option's (any alias, in any
    language), or it sits on a number the spec gives another option — options the author inserted or reordered after
    the column was built, since a rebuild never changes an existing column's options; a column made elsewhere; a set
    another app created in another order. A label on a number the spec does not use at all is an option a maker added
    or re-made (one added in the Default solution takes another publisher's range, one deleted and re-created takes
    the next free number): every number the spec has is where it was, so it is not held against the spec — a maker who
    adds an option and renames another does not stop a build the numbers still serve, as they served 2.13.0. That
    check compares labels without regard to case (a label still **resolves** by its exact text only), since an
    environment that differs from the spec in case alone must not hide a different numbering. **A refusal says why**:
    the problem line adds *The spec numbers "Gold" 100000001, but the environment shows another numbering ("Bronze" is
    numbered 100000003 there, 100000002 in the spec), so that number is not used.* — in the halt, in the dry run's
    reason, in `--verify`'s detail and in the line for a view that already exists. What the rule cannot see is the
    residual: where nothing the environment carries contradicts the number — every option renamed, a column made
    elsewhere in another order with options the spec lacks and the filtered option renamed, or options the author
    removed from the spec so that later ones now sit past its numbers — the number is the one earlier builds used (so
    never a different value than 2.13.0 sent), and the warning, which names the option the number landed on, is the
    only trace; `--verify` passes such a view, as it did under 2.13.0. A label two options share resolves the same
    way, to the one the spec numbers, when the spec's number is one of them.
  Both halt with `BuildHalt` code `view-filter-label-unresolved` (phase `preflight` for the pre-flight's halt,
  `views` for the views phase's, `recoverable: false`,
  `transient = false`), naming each view, column and the valid labels, and only a view the build would CREATE
  can halt it: `reconcileView` never reapplies an existing view's filters, so for one the build only says — as a
  warning — that its filters are left as they are and verify will report the label. The dry run
  (`annotateLivePlan`) asks the pre-flight's question and reports such a view as `? unknown` — with
  `wouldHalt` on the plan item, so the summary counts it as "would stop the build" and not as "could not be
  read", which it was not — and says (as a warning, once) what the apply will say: for a view it leaves as `create`
  or `reuse` because the spec's number stands in for a renamed option, the assumption it will warn of; for an
  existing view whose label names no option, which stays `reuse`, that its filters are left as they are and verify
  will report it; `--verify`
  expects the resolved value through the reader-gated `choiceAttributes` capability (`verify-model-app.js`),
  and a label whose option metadata could not be READ is reported as **unresolved**, with the reason, never as
  a missing filter — unless the deployed view has no condition on that column with that operator at all, which
  is a missing filter whatever the options are (a deployed value equal to the build's fallback still verifies);
  and `lint-app-spec` says offline that a label is "checked against the environment at build time" when the
  spec alone cannot prove it. Sample-data labels still resolve from the spec alone. Pinned by
  `scripts/tests/view-filter-labels.test.js`, which includes REAL BUNDLE reads of a global-bound column's
  option set and of a global option set, a build test per gap, tests that no write precedes the
  pre-flight's halt, and tests that no label-bearing read precedes a create of a localized spec.
  `--verify` (opt-in) auto-runs the read-only reconcile after a successful apply and exits non-zero on a silent partial build (the same
  check `verify-model-app.js` runs standalone). Recovery from a halted build is a full rerun (idempotent).
  **`--changed-only`** (Preview, off by default) is a fail-closed SAFE partial apply: after a FRESH
  `--apply --changed-only` baseline, a later `--apply --changed-only` for a page `.tsx` byte edit runs ONLY
  the pages phase (uploads just the changed page, skips the full build) — gated on an identity-bound
  snapshot (`.maker-workspace/apply-snapshot.json`); any non-page edit (or an edit to a pre-existing app)
  falls back to a full build. Teardown tombstones+deletes the snapshot. See
  [`docs/app-builder-design.md`](docs/app-builder-design.md) for the v1 scope + contract.
  **App TABLE components are pinned by OData REFERENCE** (ADO 6612527). The SDK sends
  `{ '@odata.id': '<EntitySetName>(<MetadataId>)' }` per sitemap table, NOT an `@odata.type` instance:
  `Microsoft.Dynamics.CRM.entity` names a real Dataverse table (metadata-as-data), so the old instance
  payload pinned the `entity` TABLE and every app exported as
  `<AppModuleComponent type="1" schemaName="entity" />`. The **set segment** decides the resulting
  `objectid`; an unknown set 400s, so a typo cannot silently pin the wrong table. The reference form is
  also the ONLY one that can express an abstract EDM table (`activitypointer`, `principal`), which an
  instance payload rejects outright. Three consequences the build depends on: ONE bad component fails
  the WHOLE `AddAppComponents` call (zero rows), so an unresolvable table **HALTS** the build naming it
  rather than shipping an app whose nav points at content it lacks; `AddAppComponents` does NOT
  de-duplicate (N components → N rows); and because a 204 says only that the request was *accepted*,
  the SDK **reads the components back** and asserts each declared table has a `componenttype: 1` row
  carrying that table's MetadataId, failing closed on an inconclusive read. That last point is the
  general rule this bug taught: **assert what you PRODUCED, not what you intended** — "some table
  component exists" was true of the corrupt apps too, and `ValidateApp` reported success on them.
  Pinned by `scripts/tests/app-entity-components-real-bundle.test.js`.
  **Hidden table membership is explicit via `app.tables`** (AB#6603388), not inferred from
  `entities[]`. `scripts/lib/app-components.js` is the one home for component inventory, pin/proof
  and layer reads. It resolves `MetadataId,EntitySetName` before any app write, pins only missing
  tables right after app identity exists on both create and reuse, then re-resolves the CURRENT
  layer and proves the rows. An SDK create already publishes (the build keeps it that way — see
  "Newer SDK behaviors" under `## Building & Testing`), so a fresh app's new pins are
  published again even without `--publish`; an existing app's normal finalizer/publish includes
  them. Dropping a table from the list never unpins it.
  **`app.mainForms` is a name-based Main-form allow-list for sitemap Entity tables.** Names resolve
  after forms exist, within `(table, Main, normalized name)`; a same-named QuickView does not reject
  a stock Main. Even a known `forms[].formId` must pass the active Main catalog's name-ambiguity
  check before any app write; a created/reused id counts once if metadata has not caught up.
  Every app push carries the SDK's `components.mainFormsByTable`, after every fetch,
  and excludes conflicting explicit Main pins by ID. Create is exact; update adds only listed forms
  and never removes old ones. SDK extras are surfaced by name with the Maker remedy, and verify
  fails them. This does not set a default/order/security roles. Measured: changing membership after
  first publish did not reach the runtime list within 89 minutes; remembered forms are per table.
  Both fields require a `minimumPluginVersion` floor of at least `2.13.0` (lint advisory;
  download-emitted). `app-main-forms-real-bundle.test.js` proves the serialized create excludes a
  disallowed Main ID and the SDK refuses an explicit conflicting pin.
  `--verify` now asserts the same membership INDEPENDENTLY of the write path: it resolves the app's
  PUBLISHED `componenttype: 1` rows and fails, by table name, when a sitemap table or `app.tables`
  reference is not among them,
  fails closed when that list cannot be read, and reports any leftover `entity` placeholder row. The
  SDK's read-back only covers what a build intended to pin, so an app that drifted afterwards (or was
  edited elsewhere) still verified clean — the sitemap named the table and the table existed, which
  was all verify checked.
  The same rule binds **tests and evals**, with a distinction that is easy to get backwards:
  an EXPECTATION must come from the CONTRACT, independent of the code under test, while the FIXTURE
  that stands in for the environment should be generated from the builder's real output so it cannot
  drift from what ships. `smoke-eval.js` got both wrong at once: it asserted a `VectorIcon` the
  builder deliberately drops, and its unit test hand-wrote sitemap XML containing that value — so the
  offline suite stayed green while every live run failed. Deriving the expectation from `appDef`
  instead is the opposite failure and is just as bad: when the builder stops emitting a value, a
  derived "must be present" check silently becomes "must be absent" and still passes, leaving the
  eval unable to contradict the very code it exists to check. Assertions are therefore fixed by
  contract, the offline fixture is rendered from `appDef`, and `vendor-sdk-smoke.test.js` checks the
  bytes the real vendored SDK serializes.
- **`scripts/teardown-model-app.js` → `scripts/lib/sdk-teardown.js`** — the first-class, **classifier-safe**
  teardown (reverse of the build), for cleaning up live-verification probes or a failed build. Deletes
  exactly the artifacts a given App Spec declares, in dependency-safe order (**app module → proven generative pages → security
  roles → dashboards → command bars → business rules → business process flows → forms → charts → views
  → reset enriched default views to drop
  parent lookups → relationships → AI row summaries → tables [reverse-topological, children-first] →
  web resources (generated app icon + page manifest + declared) → global choices → solution**). Forms/charts/views/relationships
  are deleted **explicitly before tables** (a table delete does not reliably cascade cross-references; it
  does remove the table's own columns). A business rule and a business process flow are `workflows` rows
  bound to the entity, so they too are deleted before their table — and each is **deactivated first**,
  because Dataverse refuses to delete an activated process. Deleting an activated BPF is also what
  removes the org-owned **backing table** the platform creates on activation. **Web resources are deleted after tables**: a table's vector/raster
  **icon** web resource is referenced by the table itself, so Dataverse refuses to delete it until the table
  is gone (form JS, referenced by its already-deleted form, is safe either way). Teardown also removes the
  build's **generated default app icon** (`<appUnique>_icon`, created in-solution when the spec sets no
  `app.icon`) so it doesn't leak as an orphan. It keeps the **page manifest** while the generative-pages
  step fails (a failed manifest or page read fails that step too), so a re-run still finds the pages the
  app's manifest lists. Before deleting the app, it resolves page candidates, their stored names
  and this app's sitemap. Only navigation members or corroborated local creation receipts authorize
  deletion; names alone do not. Navigation is either layer: the published sitemap, and the saved but
  unpublished one when the published layer leaves a page unproven (`navigationProof`, the layer a download reads);
  when that saved layer cannot be read and a page stays unproven, the app is left intact for a re-run. The BUILD
  binds a page by its published navigation or a receipt only; a page only in the saved layer halts with advice to
  publish the app first (the build's shared-page scan reads OTHER apps' saved navigation as well — see above). The verified ownership set is persisted locally before app deletion,
  so a page-less retry still has proof after the app is gone. Unreadable proof or a failed record write
  leaves the app intact. Proven pages still undeleted, including dependency-blocked pages, keep the
  manifest, solution and local teardown record and make the run fail. Records are consumed only for
  completed deletions or confirmed absence. Form-only page references are not scanned.
  **An app is TWO rows** — an `appmodule` AND a `sitemaps`
  row, with no lookup between them and no server-side cascade; the only link is
  `sitemap.sitemapnameunique === appmodule.uniquename`. Deleting only the appmodule strands the sitemap
  forever and, because `sitemapnameunique` is unique-constrained, permanently **burns that unique name**:
  a later build of an app with the same name fails with *"The name &lt;x&gt; is already in use by an
  existing site map"*, which the maker cannot act on. `deleteAppCascade` therefore resolves the sitemap
  BEFORE deleting the app, deletes both rows in **one atomic OData `$batch`**, and **fails closed** — any
  delete it cannot prove is rejected rather than guessed. Each row's delete is conditioned on its
  **row** token from a by-id read, not the unpublished-aware read's content token, which runs ahead
  of the row while a sitemap edit is unpublished (that made such an app impossible to tear down —
  412 every time). This is why
  **`scripts/lib/sdk-http-client.js` must implement `postRaw`**: the SDK will not fall back to two
  sequential deletes, so a transport without it fails every teardown with `APP_DELETE_NOT_ATOMIC` (see
  that file for the wire contract, why a `$batch` is sent once unless its answer proves nothing in it
  ran — a SQL deadlock or a 429 for conditional deletes, a 429 for a change set that only creates columns — and why a conditional write that gets no answer is never re-sent).
  The SDK re-sends an app delete itself when the platform's search-index insert race refuses it (HTTP
  400, SQL 547) — an answer this client never re-sends — so the two layers add and never multiply
  (counts pinned in `scripts/tests/sdk-resend-layers.test.js`).
  Pinned by
  `scripts/tests/app-delete-real-bundle.test.js` against the real bundle — every other teardown test
  drives a mock and would stay green through a regression here.
  The empty solution container goes last — but a **built-in
  system solution** (`Active`/`Default`/`Basic`) is **skipped** (Dataverse 400s any delete of a restricted
  solution), so a downloaded spec whose real solution could not be recovered (and defaulted to `Default`)
  still tears down cleanly instead of erroring. It is also **kept while any earlier step failed**:
  dashboards are found by name, and the app's own are the ones its solution holds (a built-in container
  holds every dashboard, so with no real solution teardown deletes none), which makes the solution the
  only proof a re-run has — deleted after a failure, the retry kept the app's dashboards for good. A
  membership read that fails is itself a failed step, never a skip or a not-found (as either, the
  solution was deleted right after it). Command teardown
  removes the whole command bar for an entity the spec authored commands on (the SDK models a command bar
  per entity, not per button). Every id is resolved from a spec-declared name/logical/uniquename via an
  exact-match OData filter, so it can never wildcard-scan an org. **Dry-run by default** (`--apply`
  writes); dry-run lists every page candidate by id and stored name without writes, marking absent
  rows as "not found" with their manifest names only.
  Best-effort continue (a failed step is recorded, teardown proceeds). A not-found (already-gone)
  error is treated as deleted, the table delete's **not-found-on-success** is tolerated (`tolerateNotFound`),
  and system/managed artifacts that cannot be deleted are recorded as `skipped` rather than failing.
  `--clear-workspace` prunes `.maker-workspace/` after a clean apply (not while another teardown of it
  still holds the changed-only fence). It also refuses while any unconsumed ownership records remain,
  including another app/environment. The refusal names each file and explains that teardown retires it
  on deletion or confirmed absence; a record for a page confirmed gone can be removed by hand.
  The path-safety guard remains generic;
  ownership checks run before isolation and before recursive removal. A record arriving during isolation
  keeps that isolated folder and reports how to resume from it. `planTeardown(spec)` is pure (dry-run +
  unit-test surface); reuses `appUniqueName`/`commandsByEntity`/`topoOrderEntities` from the build engine (DRY).
- **`scripts/download-model-app.js` → `scripts/lib/hydrate-spec.js`** — the **edit flow**: pulls a
  server-current app back into an editable App Spec + page code, including saved unpublished
  Maker changes (sitemap → `appShell` with icons, **every**
  generative page via `pac model genpage download`, referenced entities/tables, icon web resources,
  dashboards, solution). It reads through a throwaway SDK workspace, never the folder's
  `.maker-workspace` — a copy an interrupted build left there made the download fail, or describe edits
  that were never saved to Dataverse — and writes only the baseline (`last-applied.json`) into it.
  The fetched SDK navigation must agree with the selected current XML's full target multiset:
  entities, generative pages, URLs, dashboards and custom pages, including repeated shortcuts.
  A missing SDK sitemap or a mismatch is refused with publish-then-download guidance, never emitted
  as hidden-table membership or lost Main restrictions. The immutable name is recovered from the
  fetched `uniqueName`/`uniquename` before manifest/prefix lookup; an unresolved identity is refused.
  **Round-trip scope (be precise — do not claim "complete"):** tables, sitemap/appShell, generative pages,
  classic dashboards, icons, and solution round-trip; **forms, views, charts, and commands do NOT yet
  round-trip.** (View hydration was tried and reverted — LIVE-verified that the deployed savedquery set
  can't reliably tell app-builder-authored views from Dataverse's auto-generated Active/Inactive/QuickFind
  system views: `isdefault` is TRUE on the authored primary view and FALSE on the system Inactive view, so
  no filter isolates author views. Charts/commands need structured reads the SDK doesn't expose. FORMS are
  a deliberate refusal rather than a missing capability: the SDK does expose `formTypes` on its form
  listing, but the App Spec form shape cannot express everything a deployed `formxml` carries — header/
  footer, business-process control, related-entity nav, control parameters, event libraries — and a LOSSY
  form declared in the spec is *worse* than an absent one, because a rebuild into a fresh environment
  recreates it having silently lost those controls while reporting success.)
  All four survive on the live app (a rebuild into the SAME environment preserves them), but are absent
  from the downloaded spec, so edit them in Maker or a fresh spec.
  **The omission is reported, not silent** (AB#6686423): every deployed form/view/chart is listed in the
  spec's `descriptionInventory`, and each run prints a note naming the counts, the tables and the
  artifacts, plus a `notRoundTripped` block on the JSON result. It is a NOTE, never a gate — every app
  has forms and views, so failing the download would break every download, and the omission is not
  destructive in the environment the app came from. Do not "fix" this by reconstructing `forms[]`
  without also solving the lossy-layout problem above.
  **Table membership also comes from type-1 components**, in addition to the sitemap and the app's
  VIEW / CHART / FORM owners. Hidden type-1 members emit sorted `app.tables`; custom hidden tables
  hydrate into `entities[]` only when `IsCustomEntity === true`. A non-custom or unclassified hidden
  type-1 member remains a reference even when a view/chart/form also references it; unknown status
  gets a classification loss note, never schema-authoring instructions. Tables found
  ONLY through view/chart/form components keep their prior hydration behavior.
  Corrupt `entity` placeholder rows and deleted/unresolvable metadata IDs are
  omitted with named not-round-tripped notes, not silently treated as valid tables.
  **Measured: hidden-table pinning works, and layers matter** (AB#6603388). Adding a hidden `task`
  with `{ '@odata.id': 'tasks(<MetadataId>)' }` returned 204 and created a new unpublished layer
  (`componentstate: 1`, new `appmoduleidunique`) containing it. The plain published app row still
  pointed at the old layer without it; after app `PublishXml`, the published row carried the new
  layer and table. The earlier “204 but no row before/after publish” note read the wrong layer.
  Never cache `appmoduleidunique` across a write/publish: build proof reads
  `RetrieveUnpublishedMultiple` (one row, or the single unpublished row when several). Download uses
  that same CURRENT layer for navigation, table inventory and Main membership because the vendored
  SDK fetch reads the current app/sitemap; verify describes the PUBLISHED layer.
  An `entities[]` entry alone still does not request membership.
  **Main membership round-trips separately from form layout.** Download emits `app.mainForms` for a
  non-empty strict active Main subset with unambiguous names. Inactive/unclassifiable pins are named
  in `notRoundTripped.appMembership`, preserving an encodable active restriction even when those
  extra pins cannot be authored. It shares the component inventory with description capture.
  Both membership fields raise `minimumPluginVersion` to `2.13.0`, retaining a higher authored floor.
  Component reads remain best-effort with explicit loss notes rather than failing the download.
  Each schema-hydrated entity's **`primaryAttribute` comes from
  real Dataverse metadata** (`primaryNameAttribute`) and is **never synthesized**. The old
  `<entity>_name` guess was wrong for most OOB tables (`account` → `name`,
  `contact` → `fullname`) while looking plausible on custom ones, which is why it went unnoticed.
  Because validation *requires* `primaryAttribute`, a table without one cannot simply be emitted: a
  **sitemap** table missing it FAILS the download (actionable — the user asked for that table), while a
  **schema-hydrated component-only** table missing it is dropped with a warning (it arrived via a best-effort read and
  was absent from the spec entirely before this change, so aborting over it would regress a previously
  working download with no override flag). A membership-only stock table never needs this read;
  its `app.tables` reference survives even when it has no primary-name column.
  The **solution** is recovered as the app's one *real* unmanaged solution — `recoverAppSolution` enumerates
  the app's solution memberships and excludes the built-in `Active`/`Default`/`Basic` system solutions the
  app is also a member of (see `scripts/lib/system-solutions.js`), so the downloaded spec can cleanly tear
  down its own solution instead of targeting the restricted `Default`. Its **publisher prefix is read from
  that solution's owning publisher** via the SDK's `getSolution` — NOT inferred from the app's uniquename,
  which is silent-wrong whenever the app name doesn't encode the publisher (an app named
  `new_customermanagement` inside publisher `contoso`, an app with no prefix, or a prefix longer than the
  guess assumed all collapsed to a literal `new`). The app-derived value remains the fallback, and
  `prefixResolved` is true for BOTH trusted sources — it gates the icon own-vs-foreign classification, so
  a downgrade there silently stops custom nav icons from round-tripping. Recovered **tables are flagged
  `existing: true`**, so a teardown of a downloaded spec never deletes a table (+ its data) this build
  cannot prove it created — download can't distinguish app-created from merely-referenced tables, so it
  fails safe (an orphaned table is recoverable; deleted customer data is not). The **same flag is set on
  every recovered relationship and global choice** and teardown honours it for all three (#587 item 6):
  deleting a relationship strips its lookup column off a retained table, and an option set is org-wide.
  An app in **several unmanaged solutions** is never resolved to one of them (#587 item 9): Dataverse
  has no "owning" solution, a rebuild adds to the spec's solution and teardown deletes it, and the
  app-name heuristic is the unreliable guess above. The spec keeps `Default` (never torn down), the
  download names the candidates in `solutionCandidates` and scopes its inventory by all of them, and
  keeps a publisher prefix only when every candidate's publisher agrees. A membership that cannot be
  read is reported as such — business rules and solution-owned global choices become "unknown" —
  rather than read as "no solution".
  Edit the downloaded spec and re-run the build (idempotent) — create and edit share one path. Always
  pull fresh at the start of an edit session (the build reads an etag; a write against an artifact
  changed in Maker throws a version conflict → re-pull, never clobber). **Classic DashBoard subareas
  round-trip** too — `readDashboards` reconstructs each into `dashboards[]` with **id-passthrough tiles**
  (every tile carries the deployed view/chart ids), so a rebuild recreates the dashboard against the
  existing views/charts without re-declaring them (genpage/entity/URL subareas round-trip losslessly). A
  dashboard whose tiles cannot be reconstructed is dropped and surfaced in `droppedSubareas`.
  **A URL subarea that TARGETS a web resource round-trips too.** The Site Map Designer's "custom page
  backed by an HTML web resource" writes `$webresource:<name>` (Dataverse also serves it at
  `/WebResources/<name>`) into a URL subarea. Passing that token through used to fail the WHOLE
  download on validation — the http(s) guard rejected it — so no spec was written at all and download
  → edit → rebuild was blocked for the entire app over one nav entry (issue #430). The reference now
  **passes validation as-is**, exactly like a platform icon ref and for the reason recorded there: it
  is a live/OOB value a downloaded app carries, and rejecting it broke the round-trip on real apps.
  Requiring it to be *declared* would re-make that mistake, because such a page is frequently managed
  or owned by another publisher.
  Download additionally captures the page's CONTENT when it can safely do so. `collectSitemap` returns
  these as **`navRefs`, separate from icon `customRefs`**, because the type policy differs: the icon
  path gates on `IMAGE_WR_TYPES` by design and such a page is `html` (type 1), so routing it through
  the icon rules silently declared nothing and left a rebuild pointing at a resource the spec could
  not recreate. A nav ref takes the same safety gates as an icon — own prefix, unmanaged, has content,
  `external: true` so teardown never deletes it — minus the image-type gate. A managed/foreign one is
  left as a bare reference (it exists in the target env). The build needed no change: it already
  passes a subarea `url` straight through to the sitemap.
  This does **not** weaken the http(s) guard, which exists to stop an *arbitrary* scheme
  (`javascript:`, `file:`) becoming a nav entry in a shipped app — a web-resource reference names a
  resource inside Dataverse, not a script or a local file. A genuinely unexpressible url is still
  dropped and counted in `droppedSubareas`.
- **`scripts/verify-model-app.js` → `scripts/lib/verify-spec.js`** — read-only reconcile of the App Spec
  against what actually deployed; exits non-zero and lists anything missing, catching silent partial
  builds. It works through a throwaway SDK workspace, never the folder's `.maker-workspace` (which belongs to
  the environment it was first opened for): every read it makes goes to the server, so it verifies any
  environment the app is deployed to and creates or binds nothing in the folder. It only READS the build's
  baseline, `last-applied.json`, from `--workspace`, and only when that baseline describes this
  environment. Checks **existence** (entities/columns/views/charts/forms + sitemap subareas + icons + pages by
  id) AND, best-effort, **content** so an *unapplied edit* is caught (not just a missing artifact): a
  view's **column set** (parsed from `layoutxml`; every spec column must be deployed — an extra deployed
  column passes by design, so a column REMOVED from the spec, which the additive `reconcileView` leaves
  in place, is not flagged), plus **relationship** and **command-bar existence** (previously unchecked).
  Content checks are additive + reader-gated (they only fire when the reader supplies `layoutxml` /
  `entityRelationships` / `commandBar`), so existence-only callers are unaffected. A view filter's
  Choice label is expected as the value the **environment** gives it, which needs the reader's
  `choiceAttributes` (one memoized read per table per pass); without it the spec-only expectation
  applies. A label whose option metadata could not be read is reported as **unresolved** with the
  reason — never as a missing filter, which would call a correct view wrong because a read failed (a
  deployed value equal to the build's own fallback still verifies) — unless the view has no condition on
  that column with that operator at all: that filter is missing, and is said to be. **Dashboards** are
  checked too (#586): each declared dashboard must exist. A name can also match another app's
  dashboard, so when it matches several, verify checks the one the app's solution holds — the one the
  build reuses (`dashboardsInSolution`, shared with the build and teardown) — and fails only when the
  solution cannot single one out. All three find the candidates with `findDashboardsByName`: the
  vendored lookup reads one page of ten in no defined order, so a full page is re-read in full and the
  app's own cannot sort out of sight. The sitemap subarea check asks the same question (one lookup per
  name, shared), so the two cannot pick different dashboards — it used to take the first match, and live
  a same-named dashboard of another app sorted first and failed a correctly wired nav entry. When the reader supplies `dashboardComponents`, every chart tile's
  visualization and view must belong to the tile's `TargetEntityType` — the cross-wiring a same-named
  chart on another table produced. A tile whose owner rows cannot be read is reported unverified, not
  passed. **App membership is checked on the published layer:** sitemap tables union `app.tables`
  must have type-1 rows; each `app.mainForms` table gets an `app-main-forms` check comparing resolved
  active Main IDs with classified type-60 members. Missing members fail with “re-run the build”,
  extras fail with the Maker Forms remedy, and unreadable/unclassifiable membership is unverified,
  never passed. Dashboards (systemform type 0) and other non-Main forms are not Main members.
  It also reconciles
  **AI app features**: for every `ai.appFeatures` entry it proves an APP-SCOPE OVERRIDE ROW exists in
  `appsettings` holding the requested value. Verify previously had no awareness of `spec.ai` at all, so
  a run whose every AI feature was skipped (admin gate off) or silently not persisted still reported a
  clean PASS. The oracle is deliberately the override row and NOT the effective value:
  `RetrieveSetting(name, { appUniqueName })` **falls back to the environment value** when the app has no
  override, so an effective-value compare passes whenever the environment already holds the requested
  value — a false PASS for an app that was never configured, and the same oracle the SDK uses for its
  `applied` bucket. It fails CLOSED when the proof cannot be read, and the check needs BOTH
  `retrieveSetting` and `queryRecords` on the reader (an existence-only reader skips it rather than
  degrading to the unsound compare). Note the per-app settings are DISTINCT from the org readiness gates —
  `nlSearch`'s gate is the boolean `EnableNLGridSearch` but its per-app setting is the numeric
  `NLGridSearchSetting`; conflating them is what let NL grid search report "applied" while writing
  nothing. This is the F5 "convergence" mitigation: the build is additive (edits to existing artifacts
  aren't re-applied in place — teardown + rebuild to converge), and verify makes any resulting
  divergence **loud**.
- **`scripts/ai-preflight.js`** — standalone preflight report: prints each AI feature's on/off status
  and the exact admin action needed (Power Platform Admin Center → Environments → Settings → Product →
  Features) for anything off. Never fails. The `ai-features` build phase calls this logic internally and
  uses `RetrieveSetting`/`SaveSettingValue` (SDK) for app-level feature flags and `AIModelPublish` +
  `aiskillconfigs` for per-table row summaries. Feature values are `true`/`false` — encoded by the
  plugin to each setting's own On/Off value (On is `2` everywhere; Off is `1`, but `0` for `nlChart`;
  `AI_SETTING_CODEC` in `lib/ai-app-settings.js`, AB#6714731) before the SDK sees them — or an explicit
  integer written verbatim, bounded to `0..1000000` — the same range
  the SDK enforces, so validation rejects an out-of-range value up front instead of aborting the build
  half-applied. The SDK **proves every write** against the app-scope override row, retrying with backoff
  (an immediate read can still return the environment fallback, which previously produced a false
  `notPersisted` on first apply). `applied` is the ONLY success bucket; a feature otherwise lands in
  `notPersisted` (no override observed for the whole retry budget — Dataverse
  can accept an app-scope `SaveSettingValue` with HTTP 204 and store nothing), `skipped` (the same
  absence, PLUS the feature's org readiness gate reads off, which is offered as the explanation),
  `unverified` (the write
  was issued but the proof could not be read) or `failed` (the write threw; the rest of the batch still
  reports). **A gate is never a PRECONDITION** (AB#6688904): the SDK attempts every write and reads a
  gate only afterwards, to explain an absence that actually happened. It used to read the gate first,
  and for four of the seven features that "gate" IS the per-app setting the write is about to set — so
  on a new app its platform-default `0` looked like an admin refusal and the build shipped an app with
  no AI features while reporting success. The build surfaces **every** non-success bucket as a `⊘` warning plus in the phase detail
  — buckets are read off the result object, so one a future SDK adds is reported verbatim rather than
  silently dropped — and `--verify` fails on any of them. **An app-scope setting WRITE is a no-op until
  the app is published** (live-measured: the write reported `notPersisted` and `appsettings` held no row
  at all, while publishing and re-issuing the same call applied every feature). This is not read lag, so
  re-*proving* after publish cannot fix it — the build **re-issues** the write after publish for anything
  the first attempt did not apply, `skipped` INCLUDED. (The SDK now also proves a write against the app's
  UNPUBLISHED layer — `appsettings/Microsoft.Dynamics.CRM.RetrieveUnpublishedMultiple()` — so a row it
  saved but that is not published yet comes back `applied` with `pendingPublish` instead of
  `notPersisted`. That is still not in force: `--verify` and the runtime read the published row. So
  `rebucketPendingPublish` in `lib/ai-app-settings.js` moves it out of `applied` into a `pendingPublish`
  bucket of the plugin's own, and the post-publish pass gives it the publish — not a second write, since
  its row is already saved: with `--publish` the publish phase has put it in force and the pass only
  PROVES it, otherwise the pass fetches and publishes the app first. A feature the SDK did not save at all
  is still re-issued after that publish, and the re-issue's result is filed the same way: a publish that
  failed, or could not be confirmed, leaves the row in the unpublished layer, and the SDK then answers
  `applied` + `pendingPublish` again. Whatever the proof does not find in the published layer is reported
  `NOT YET PUBLISHED` (and listed under `pendingPublish` in `--json`), never `applied`. That proof reads
  an absent row up to three times, 500 ms apart — the budget `--verify` has (`AI_PROOF_BUDGET`, one value
  in `lib/ai-app-settings.js`; `sdk-build.test.js` and `verify-spec.test.js` pin both sides) — because a
  publish can answer before its row is readable, and a single read
  reported that lag as `NOT YET PUBLISHED` in a run whose own `--verify` passed. A check that could not run
  (the app, the definition or the row could not be read) says so first in the warning — "the check after
  the publish could not run: …" — ahead of what the SDK said, so a failed read is never taken for a look that
  found nothing. Without the move,
  a build with no `--publish` would end with unpublished rows that `--verify` fails on. Pinned in
  `ai-app-features-real-bundle.test.js`, `sdk-build.test.js` and `sdk-build-publish-batch.test.js` — the
  batched publish tells the pass whether the app's own publish went through.) On a fresh app nothing persists pre-publish, so
  the gate diagnosis fires for every feature that has one; abandoning those would leave `--verify`
  failing on a row the build declined to write a second time, and a reported customer environment ran a
  working app at `NLGridSearchSetting = 2` with `EnableNLGridSearch` off. Anything still `skipped` after
  the re-issue is reported as an admin action. Verification proves the override ROW, keyed by `appmoduleid`, so the
  build passes the id it already holds rather than have the SDK resolve it by name (an unpublished
  appmodule is not readable). See the `ai-features` phase in `scripts/lib/sdk-build.js` for the full
  sequence and its bounds.
  The flag set is resolved ONCE by `scripts/lib/ai-app-settings.js` and shared by the build and the
  verifier: a spec with an `ai` block but no `ai.appFeatures` still gets defaults written, so
  reconciling only the DECLARED features left them applied-but-unverified.
  All AI features need an environment admin to have enabled them: the skill preflights
  and warns; it cannot flip admin or tenant switches. What it does NOT do is decline to write on the
  strength of that preflight — see the bucket note above. `scripts/lib/ai-candidates.js` selects
  good-candidate tables for auto row-summary mode; `scripts/lib/ai-prompt.js` generates tailored summary
  prompts. The `ai` block in the App Spec configures the full set; see
  [`references/app-spec-schema.md`](references/app-spec-schema.md) → `## ai`.
- **`scripts/preview-form.js` → `scripts/lib/form-preview.js`** — renders an ASCII **form
  wireframe** (tabs, sections, fields with widget hints and authored `hidden`/`readOnly` state, the
  Notes/timeline block, sub-grids, form JS) from the App Spec, so the user can review a form
  visually during authoring before approving. A hidden field is annotated rather than omitted, and
  state is never truncated — the widget hint and then the label give way first, because a
  half-printed `(read-on…` is the silent-state failure the annotation exists to prevent.
  **`scripts/preview-app.js` → `scripts/lib/app-preview.js`** — renders the WHOLE app design
  (data model + sitemap tree + views/charts + per-form wireframes + page-intents + design contract)
  as a single ASCII preview — the design gate #2 / plan-mode approval artifact.
  **`scripts/write-app-spec-doc.js` → `scripts/lib/app-spec-doc.js`** — renders `model-app-plan.md`,
  the durable **Markdown design document** the user reviews and keeps alongside the app (jobs →
  surfaces traceability, data model, every surface incl. generative pages, navigation, the access
  model per role, sample data, design contract, AI features). Distinct from `app-preview.js`: that is
  an ASCII console preview for the in-conversation approval gate, this is durable Markdown for review
  and archival. It is **rendered, never hand-written** — the freehand version drifted from the spec
  and shrank to a counts summary. Also returns `warnings[]` naming design gaps (no jobs captured, a
  job with no covering surface, no generative pages) so the orchestrator surfaces them in chat.
- **`scripts/vendor/cds-maker-sdk.cjs`** — the SDK vendored as a self-contained headless bundle
  (rebuild via `scripts/_vendor-build/`); **`scripts/lib/sdk-http-client.js`** injects an
  `az`-token HttpClient. No browser, no relay — the SDK reuses the designer's own serializers.
- The build log is **phase-grouped with per-step status** (`▶ phase` / `[n/total] ✓ created` /
  `⊘ skipped` / `✗ failed`) + a closing summary. A **dry run resolves each item against the live
  environment** (#559) and marks it `+ create`, `= reuse`, or `? unknown` when the read failed —
  never guessing, because a wrong confident answer is worse than none. Items with no live identity
  (sample data, publish, generated icons) stay `▢` unprobed and are counted separately in the
  summary. The probe reuses the build's OWN discovery helpers (`findExistingTable`,
  `findExistingColumns`, `relationshipExists`, `artifactIdentityQuery`) rather than a parallel
  implementation, so the plan cannot disagree with what the apply then does; it is read-only, and
  `--no-live-plan` restores the offline, spec-only listing.
- **Bounded parallelism in the read-heavy and publish-heavy paths.** Each item below has tests that count
  requests and prove the overlap (`verify-parallel-checks`, `verify-page-code-batch`,
  `download-parallel-reads`, `teardown-publish-once`, `table-columns-in-create`, and the check-auth tests),
  and each keeps a one-at-a-time form (`concurrency: 1`, `inlineColumns: false`) that is the oracle its
  output is compared with byte for byte. Every fan-out is
  `mapLimit` (`lib/entity-provision.js`; its `firstInOrder` option makes the thrown error the
  lowest-numbered task's, deterministically), never an unbounded `Promise.all`, and each request still
  retries its own 429s (`lib/sdk-http-client.js`) — so a throttled environment slows the pass down rather
  than being hit by a burst.
  - **Verify** (`lib/verify-spec.js`) runs its checks as ordered tasks on a pool of 4
    (`verifySpec(spec, read, { concurrency })`). Each task reports into its own buffer and the report is
    the buffers joined in listing order, so it is the same whichever read answers first; a failure stops
    anything new from starting and the failure first in report order is the one thrown. A failed pass does
    not wait for the page checks' early reads (a hung `pac` run would turn a fast failure into a hang): the
    early read and the check that consumes it both stop waiting once a check listed BEFORE the page check has
    failed. A failure in a check listed after it does not end the wait — the page check's own failure is the
    lower-numbered one and must be the one thrown, as when the checks ran one at a time. A read the pass gave
    up on is ended before the pass returns or throws: `verifySpec` calls the reader's `endPageReads()`, which
    ends every `pac` run in flight with its whole process tree (`killProcessTree` — `pac` can be a `.cmd`
    shim, and Node's own kill reaches only the `cmd.exe`), starts no new run — a listing paused between two
    attempts included: the pac client's `abort()` makes every run of a call that began before it answer
    "ended" without starting anything — and makes no download folder until the next pass begins, and waits
    (bounded) for the downloads in flight to remove their `verify-page-*` folders — the verify CLI exits the
    moment the pass does. Reads that several
    checks share are single-flight: the root business unit (`resolveRoleBusinessUnit` caches the *lookup*
    in flight — caching only its answer made every concurrent caller but the first report its role
    missing), a table's and a role's privileges, the sitemap, the resolved Main forms. The reader sends the
    first read of each table alone, so the SDK resolves its `EntitySetName` once instead of once per
    overlapping read. The page checks' slow reads (the `pac` listing, the manifest) start with the pass,
    and the code of every page whose navigation is checked comes from `readerFor().pageCodes`: one
    `pac model genpage download --page-id a,b,c` per chunk of 25 ids instead of one run per page. A page the
    batch cannot answer (a failed batch, an id with no directory in the output) is asked for by itself, so
    one bad page costs its own miss, never its neighbours'.
  - **check-auth** starts `az account show` and (with `--env`) the Dataverse token read together, and
    WhoAmI as soon as the token is ready, so it overlaps the `pac` probe. The verdict order is unchanged
    (account → token timeout → pac → no environment → WhoAmI), and an early verdict cancels the probes
    still running — on Windows the token read's whole process tree (the `cmd.exe` that runs `az.cmd`, and
    the Python process it starts), through `killProcessTree` in `lib/process-runner.js`, because Node's own
    abort signals only the `cmd.exe` wrapper. The early WhoAmI is sent with `refreshOn401: false`: a 401
    would otherwise refresh the token with a synchronous `az` call (seconds, with every other probe's timer
    frozen), so a 401 on the early request is re-sent at WhoAmI's old place, after `pac`, where that refresh
    costs nothing.
  - **Download** overlaps each table's auxiliary reads (entity and attribute labels, the money
    base-currency flags, option sets) and the per-table relationship reads, bounded at 4
    (`runDownload({ concurrency })`; `1` is the sequential oracle), and merges them in the original fixed
    order, so the written spec, pages and stderr are byte-identical. Tables themselves are still read one
    at a time (the SDK's `fetchEntityMetadata` already fans out), and dashboards are not overlapped.
  - **Teardown** saves the default-view resets with `publish: false` and publishes ONCE, in one
    `publishArtifacts` envelope, at a barrier after the last reset and before the relationship deletes. A
    failed envelope is retried table by table with `publishArtifact` (the SDK fails every target of an
    envelope with one shared error), and a table that still cannot be published is named in a warning —
    teardown does not fail on it. A single table keeps the one-table publish.
  - **Data model** (`provisionDataModel`): a NEW table is created exactly as the previous release created it — one
    request that carries only its primary name attribute — and its ordinary columns (Text, Memo, the number types,
    Money, DateTime, Boolean, Choice and MultiChoice with inline options, AutoNumber; at most 25) then follow in ONE
    atomic change set instead of one request each: the SDK's `createColumns`, an OData `$batch` of POSTs to the table's
    `Attributes` collection. Dataverse runs the operations in order and, if one fails, rolls back the ones already done
    ([change sets](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/execute-batch-operations-using-web-api#change-sets)),
    so a refused change set has created nothing, and the build then creates the columns one by one as it always did,
    with a warning that says why (the platform's own message when its answer carries one). The same fallback covers a
    transport that cannot send a change set — the SDK refuses before sending anything when its HTTP client has no
    `postRaw` (`DataverseClient.supportsAtomicBatch`; `lib/sdk-http-client.js` has it) — and a change set whose answer
    was lost or could not be read (a transport failure, a server error, an unreadable success), which may have
    committed: the table is read once, and when it has EVERY column the change set is taken as committed and its
    columns are narrated as created, with their ids, after a warning that the answer was lost; otherwise each column
    already there answers "already exists" and is skipped, so a lost answer costs requests, never a column or a
    duplicate. A refusal (any 4xx) is not followed by that read. The HTTP client sends the change set again
    only when its answer is a 429 — the platform refused it, so nothing in it committed (the change set is atomic), as it can while the table
    just created is still held by its customization lock — on the schedule of any other throttled write (six attempts,
    1 s / 2 s / 4 s / 8 s / 8 s with jitter, longer when `Retry-After` asks, `isColumnCreateBatch` in
    `lib/sdk-http-client.js`). It never sends it again after another transient status or a lost answer (a change set can
    commit behind those), so such a refusal costs one request and then the per-column path's own 429 retries, never a
    burst; a change set still refused with a 429 once the attempts are spent falls back the same way, with a warning
    that says the table was throttled rather than that the request failed. The columns are not put in
    the table's own create request, which Dataverse also accepts (an `Attributes` array beside the primary name
    attribute, [Create columns](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/create-update-column-definitions-using-web-api#create-columns)):
    a table create that carries columns is not a request the previous release ever sent, and an environment can treat it
    differently (see the next bullet). The SDK builds each operation's body with the same function `createColumn` uses,
    so a column's body is its per-column body — `table-columns-in-create.test.js` compares them column by column and,
    where `MODEL_APPS_RELEASED_SCRIPTS` names the `scripts` directory of the previous release (the CI job that installs
    the parser oracles sets it), with the requests the
    released code itself posts — and each operation carries the solution header (the one on the outer `$batch` request is
    ignored by the platform). Customer, File, Image, Calculated, Rollup and global-choice columns, columns past 25, and
    every column of a table that already exists, or that was found after its create (the answer to the create was lost,
    so nothing shows what it holds), keep the one-request-per-column path. A column also stays out of the change set
    when its name is one the platform would create twice — the primary name, `<table>id`, a companion of a column of the
    table (a Money column's `<name>_base` twin, a Choice, MultiChoice or Boolean column's `<name>name` shadow, reserved
    even when that column takes the per-column path itself), or a repeat of an earlier name in any case — because
    Dataverse then refuses the WHOLE change set
    ([0x80060304](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/web-service-error-codes),
    "Attribute {0} already exists for entity {1}.") where the one-by-one path meets the collision in one
    column's own request and skips it. A column whose name EXTENDS the name of a column that takes the
    per-column path (Customer, File, Image, Calculated, Rollup, global-choice) stays out as well, and the
    build does not try to list what the platform makes beside such a column: `<name>name`, `<name>yominame`
    and `<name>idtype` beside a Customer column (measured), `<name>_name` beside a
    [File column](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/file-column-data#file-name-column),
    `<name>id`, `<name>_timestamp` and `<name>_url` beside an
    [Image column](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/image-column-data#download-url),
    `<name>_date` and `<name>_state` beside a
    [rollup column](https://learn.microsoft.com/en-us/power-apps/maker/data-platform/define-rollup-fields).
    Such a column is made AFTER the change set, so a twin inside it would be made before its owner and the
    owner's own create would answer "already exists" — a skip — dropping the owner without a word, where one by
    one the owner comes first and the twin is the skip. The twin is made in spec order after the change set, as
    before; a longer name that merely starts like its neighbour's costs one request. The mirror rule keeps the order
    the spec's for the columns that DO go in the change set: one by one the first column to name something creates
    it and the next is skipped, so when a twin is listed BEFORE its owner the twin is the column kept — and the
    change set, which runs before every per-column create, would reverse that. A column therefore stays out of the
    change set when an earlier column has a name that extends its own — every name a column's creation can take
    (a companion listed for it, or one the platform makes that the build does not know) is named after the column —
    and is made after that column, in spec order; where an earlier column merely starts like a later one's name that costs one
    request. One narrow
    read of the table's `Attributes` (ids and names, no labels) then confirms the columns and returns the ids
    `createColumn` would have (a change set answers none), keyed by the column object, never by its name: a second
    column of exactly the same name is a repeat, skipped as already existing and never given the first one's id. A
    column the read does not show — unreadable, lagging, or ignored by the platform — is created the per-column way,
    where an already-exists answer is a skip. A table whose create answer was lost and that is found afterwards
    is given no change set. Used only when the SDK has `createColumns` and a raw Dataverse client is there to read the
    columns back (`inlineEnabled`); otherwise every column takes the per-column path.
  - **Primary name column length.** Every table create sends the primary name attribute's `MaxLength` explicitly: the
    spec's `primaryAttribute.maxLength` (a whole number from 1 to 850, one rule — `primaryNameLengthProblem` in
    `lib/app-spec.js` — applied by `validateAppSpec` AND by the provision-entities input gate that `/genpage` and
    `/app-builder` pass through), else 100 — what the previous release always sent (it is the SDK's own default for the
    option), for a plain and an auto-numbered name column alike. So a spec without a length posts the previous release's
    table request byte for byte and gets its result on every environment. What an environment makes of the length asked
    of a create that carries only the primary name attribute was measured on two: one keeps it (100 → 100); the other
    makes a plain text name column 850 characters long whatever length is asked (100 and 120 both came back 850) but
    keeps the length asked of an auto-numbered one (100 → 100), and keeps the length asked in a create that also carries
    other columns (120 → 120) — which is why the columns are not put in the table's create: no one length asked of such
    a create matches the previous release on both environments. More than 850 is refused (HTTP 400, "The maximum length
    of Primary attribute '…' is 4000. The maximum allowed limit is 850." — measured in a create that carried other
    columns), and a PUT of the finished column — the SDK's `updateColumn`, with `MSCRM.MergeLabels: true` — is honored
    (850 → 120 → 850). So when the spec gives a length and THIS build created the table, it reads the length back with
    one typed `…/Attributes(LogicalName='…')/Microsoft.Dynamics.CRM.StringAttributeMetadata?$select=MaxLength` GET and
    calls `updateColumn` only when it differs — LAST in the data-model phase, once every table, column, status reason,
    alternate key and relationship has been created, and inside a `finally`, so that a phase that halts after creating a
    table still sets that table's length (a re-run finds the table and leaves an existing table's name column alone; a
    length that cannot be set while the phase is halting is warned about as one a re-run will not set either).
    Last, because `updateColumn` starts by reading the column WITH its labels, and a label-bearing metadata read ahead of
    a labelled create is what strips non-base languages (measured; see `findExistingColumns`): within one attempt, nothing
    labelled is created after it. The build's automatic retry of a transient halt (a 429, a lock) runs the phase again as a
    second attempt: the attempt that created the table has already set its length (the `finally` runs after a halt too), and
    the retry finds the table and keeps its name column as it finds it, so the length is set once — but what the retry
    creates comes after the first attempt's read, and the loss was measured only for a create straight after the read, so
    whether it reaches across the pause between two attempts is not known (`table-columns-in-create.test.js` pins what
    each attempt does). That is one narrated `primary column length` step, planned (after the relationships) and counted
    only when the spec gives a length (`planFor`, and the provision-entities plan and total): `ok … -> N` when it set the
    length, or a skip — `(already N)`, `(table exists — kept)` or `(table found after its create — kept at N, asked M)` —
    or a FAILED step naming the table, the length asked and the length the platform made (for a read as well as an
    update), with a warning, that never stops the build. There is no read for the default: a spec without a length
    leaves the column as the environment made it, which is what the previous release did. A rebuild leaves an existing
    table's name column alone, like every column's `maxLength`, because it must not shorten a column that may hold data
    or undo a length widened in Maker. A table found after the answer to its create was lost (or that another maker
    created first) is treated as not made by this build, since nothing shows that it was not made by someone else, so
    its column is never written — but it is read, once, and a length that differs from the spec's, or cannot be read, is
    a warning (`reportFoundPrimaryNameLength`). And a build that stops on `TABLE_METADATA_NOT_VISIBLE` out of the create
    warns that the length asked may not have been applied (some environments make a name column created this way 850
    characters long), since the re-run that follows treats the table as an existing one — except for an auto-numbered
    name column, which keeps its length, and for 850, which the column comes out as either way. The
    build reads only `schemaName`, `displayName`, `autoNumberFormat`, `required` and `maxLength` from
    `primaryAttribute`; any other key inside it is still accepted and ignored (unlike the table level, it has
    no key allow-list).

The end-to-end flow (Phase 0 working dir → Phase 1 author **in the main loop** per
`references/authoring-flow.md` → Phase 2 narrated SDK build → Phase 3 verify & iterate; **edit** an
existing app via the same path — `download-model-app.js` pulls it back into a spec, then re-run Phase 2
idempotently) is diagrammed in [`docs/architecture.md`](docs/architecture.md) → *`/app-builder` —
build pipeline*. **Upcoming:** shippable-defaults provisioning (security role / standard views; the
quick-create table flag now ships via `entities[].quickCreate` / an authored `QuickCreate` form —
auto-generating the Quick Create form's field layout is the remaining follow-up).

## Local Development

Test this plugin locally:

```bash
claude --plugin-dir /path/to/plugins/model-apps
```

## File Tree

The canonical layout of the plugin (architecture **diagrams** live in
[`docs/architecture.md`](docs/architecture.md)):

```
.plugin/plugin.json            ← Open Plugins metadata (name, version, keywords)
.mcp.json                      ← MCP server config (Playwright for browser verification)
AGENTS.md                      ← Plugin guidance for AI agents (this file)
CLAUDE.md                      ← Symlink → AGENTS.md
README.md                      ← User-facing intro and prereqs
CHANGELOG.md                   ← Keep-a-Changelog
feature-flags.json             ← Feature flags (custom-api, custom-telemetry)
.claude-plugin/plugin.json     ← Legacy plugin metadata mirror
docs/
  architecture.md              ← Wiring/flow diagrams for BOTH skills (/genpage + /app-builder)
  app-builder-capabilities.md       ← /app-builder capabilities (what ships today, with evidence)
  app-builder-design.md        ← /app-builder design record (Part I staged flow · Part II --changed-only)
agents/                        ← Agent definitions (invoked by skills via Task tool)
  genpage-planner.md           ← Requirements, discovery, plan doc, user approval (create flow)
  genpage-connector-builder.md ← Orchestrator-invoked connector gate/discovery; writes connector bindings
  genpage-entity-builder.md    ← DV entity creation via plugin's Web API scripts (create flow)
  genpage-page-builder.md      ← Writes one .tsx file; runs in parallel for multi-page (create flow)
  genpage-edit-planner.md      ← Reads download artifacts, plans edits, writes edit plan (edit flow)
  genpage-customapi-builder.md ← Single owner of the custom-api gate; discovers bound Custom APIs, writes ## Custom API Bindings + actions.json (create & edit flows)
references/                    ← Shared reference docs
  rules.md                     ← Full code-gen rules, DataAPI types, layout patterns, common errors
  custom-api.md                ← Dataverse Custom API (Action/Function) invocation contract (loaded when the plan has ## Custom API Bindings)
  page-telemetry.md            ← props.appInsights page telemetry contract (custom-telemetry gated; loaded only when the maker asked to measure something)
  connectors.md                ← GenPage connector binding contract and runtime patterns
  plan-schema.md               ← Schema contract for genpage-plan.md
  app-spec-schema.md           ← The App Spec contract (always-present fields) — /app-builder
  app-spec-schema-advanced.md  ← Conditional App Spec fields (business rules, BPFs, commands, web resources, global choices, dashboards, roleGrants)
  authoring-flow.md            ← /app-builder Phase 1 authoring playbook, run in the main loop (not a subagent)
  agent-interaction-contract.md ← Agents are headless: no AskUserQuestion / plan mode inside a Task subagent
  data-caching.md              ← Rule 15 on-mount fetch: de-dupe + cache (loaded conditionally)
  localization.md              ← Multi-language + RTL pattern (loaded conditionally)
  supported-dependencies.md    ← Versioned package list for generated pages
  troubleshooting.md           ← Deployment/runtime/env issues
  verified-icons.txt           ← ~5000 Fluent UI icon names; Grep-validated by page-builder
samples/                       ← Example .tsx files (13 samples) plus app-builder spec samples
scripts/
  launch-playwright-mcp.js     ← Playwright MCP server launcher (fullscreen; uses lib/detect-browser.js)
  playwright-mcp-fullscreen.config.json ← Fullscreen browser config for the launcher
  regenerate-verified-icons.js ← Regenerates references/verified-icons.txt from npm
  check-auth.js                ← Pre-flight: az present + logged in, pac identity, WhoAmI, identity match (the az probes, the token read + WhoAmI and pac overlap; the verdict order is fixed); a CLI too slow to answer is `az_timeout`/`pac_timeout`
  check-version.js             ← Skill-start update notice: compares the plugin clone with its origin/main (git runs in the plugin, never the user's repo)
  resolve-interaction-mode.js  ← Reports whether a human can answer in this run (unattended defaults)
  lint-app-spec.js             ← Validate + lint an App Spec without touching an environment
  dataverse-request.js         ← General Dataverse Web API wrapper (escape hatch)
  list-connections.js          ← Connector discovery: PAC connections + Dataverse connection references
  create-connection-reference.js ← Creates Dataverse connectionreference rows for connector bindings
  list-custom-apis.js          ← Discovers bindable Custom APIs (Global + entity-bound) + parameter kinds (custom-api gated)
  list-languages.js            ← /genpage planner: the environment's base language and each provisioned language (LCID, culture code, right-to-left) — the SDK's `getEnvironmentLanguages()` over the az-token client, three reads and no write, printed as one pure-ASCII JSON line. The planner's FIRST choice: when it exits non-zero, for any reason (most often the Azure CLI), the planner runs `pac model list-languages --environment '<envUrl>'` instead (as before the script existed, but pinned to the same environment), and says so
  add-page-to-solution.js      ← Adds GenPages and optional connection references to a solution
  provision-entities.js        ← CLI wrapper for entity provisioning (solution + data-model + sample-data; --language-code)
  provision-solution.js        ← Creates a Dataverse solution via the SDK
  write-page-plan.js           ← app-builder Phase 1.5: projects an App Spec into the genpage-plan.md read by page-builder workers
  promote-intent-pages.js      ← app-builder Phase 1.5: validates every generated page, then atomically flips source: intent → tsx
  build-model-app.js           ← app-builder: narrated, idempotent SDK build (dry-run default; --stage data|ui|app|publish; --changed-only; --language-code)
  download-model-app.js        ← app-builder: pull a deployed app into an editable spec (edit flow)
  teardown-model-app.js        ← app-builder: classifier-safe reverse-of-build teardown
  verify-model-app.js          ← app-builder: reconcile the spec against the deployed app
  preview-form.js              ← app-builder: ASCII form wireframe for authoring review
  preview-app.js               ← app-builder: ASCII whole-app design preview (data model + sitemap + forms + page-intents + design)
  write-app-spec-doc.js        ← app-builder: renders the readable model-app-plan.md design doc from app-spec.json
  ai-preflight.js              ← app-builder: preflight AI feature availability (admin-gate report)
  run-tests.js                 ← one-command plugin + SDK regression runner
  smoke-eval.js                ← scripted live smoke eval (build → assert → teardown)
  generate-page-manifest.js    ← Phase 0.5: writes working-dir package.json + genpage.d.ts
  genpage-upload.js            ← /genpage: deploy one page via the shared wrapper (prompt passed BY FILE, never on a command line; an update must name the app the page is placed in, and keeps the page's name, model and bindings unless given; refuses a create whose `makeStyles` writes a Griffel shorthand (`griffel-shorthand`, before anything is read) and an update that ADDS one the deployed page did not already have — one it already had goes ahead with a warning — and, for an update, no-base / deployed-changed / deployed-unreadable unless --overwrite-deployed)
  genpage-base.js              ← /genpage: record or check the deployed-page base marker next to a page.tsx (#673)
  genpage-plan-provenance.js   ← /genpage: quarantine a stale plan before the planner writes, then verify the written plan targets the pages the approval named and, for an edit, carries the exact approved change list
  check-page-files.js          ← /genpage: pre-dispatch gate — the page file names of the plan's one ## Pages table are safe write targets (lib/page-file-targets.js)
  genpage-worker-output.js     ← /genpage: accept a parallel worker's page only if complete (default export, balanced, no elided code)
  check-griffel-styles.js      ← /genpage and /app-builder: a page's `makeStyles` calls hold none of the ten shorthands Griffel's runtime drops (lib/griffel-styles.js; see "Griffel shorthands" below)
  capture-fixture.js           ← Copies /genpage working dir into an eval fixture and runs both runners
  lib/
    entity-provision.js        ← Shared entity-provisioning core (solution + data-model + sample-data): a new table is created with only its primary name attribute and its ordinary columns follow in one atomic change set (`createColumns`), falling back to one request per column; the primary name column's length is always sent (100 unless the spec gives one) and, where the platform did not keep a length the spec gave, set last in the phase; and `mapLimit`, the bounded fan-out every parallel path reuses
    provision-input.js         ← Input validation for entity provisioning (the entry gate of `/genpage` and `/app-builder`; it applies `app-spec.js`'s own `primaryNameLengthProblem`, so a name-column length is refused here exactly as in `validateAppSpec`)
    dataverse-auth.js          ← Shared auth + HTTP helpers (`az account get-access-token`, memoized per process and replaced on a 401 — a synchronous `az` call, which a caller running other probes beside the request opts out of with `refreshOn401: false`; responses decoded as UTF-8 once; an environment URL is used only as a Dataverse https origin, `dataverseOrigin`; a request path must stay under `/api/data/v9.2/` as both the client and the server resolve it, `webApiUrl`), plus the CLI arg contract (parseArgs/validateFlags) and the result printer (`emitResult`, whose `{ ascii: true }` option writes stdout JSON as pure ASCII for a script an agent reads off a console)
    process-runner.js          ← how every script starts az/pac/npm/npx/git: the executable is resolved to an absolute path on PATH, never the project folder, and started without a shell (a Windows batch shim runs through cmd.exe with its arguments checked); `killProcessTree` ends a stalled child AND everything it started (`taskkill /T /F` on Windows, where `pac.cmd` is cmd.exe → pac.exe, looked up with the System32 folder first on PATH so a PATH without it cannot leave the tree running, and with its window hidden; SIGKILL to the descendants `ps` lists elsewhere, each only once `ps -o lstart= -p` shows its pid still has the start time the listing saw — a descendant that exited meanwhile can have its pid given to an unrelated process; best effort, since two processes can share a one-second start time and the moment between that read and the signal is not covered), through the same audited calls; a child that has already exited is only asked to die, never ended by its pid, which may be another process's by then; synchronous, and it never throws
    cli-failure.js             ← how a CLI child failed (timeout / missing / failed), the Azure CLI budget (60 s, `POWER_PLATFORM_SKILLS_AZ_TIMEOUT_MS`), so a slow `az` is never reported as missing or signed out, and the budget of one `pac model genpage` call (3 min to list or download, 10 min to upload; `POWER_PLATFORM_SKILLS_PAC_TIMEOUT_MS`, 1 s to 60 min, sets all of them); `commandArg` writes an argument of a command a message tells you (or the agent) to paste so that, run from PowerShell (Windows PowerShell 5.1 or PowerShell 7) or a POSIX shell, the program gets it unchanged as one argument — bare when it is plain, single-quoted otherwise (a path from an untrusted checkout can hold `;`, `$(…)` or backticks) — or not at all when no spelling does that in every one of those shells: a quote or a line break, and the values Windows PowerShell changes on their way to a native program (a `"`, white space with a final `\`, the empty value) or both PowerShells read as the stop-parsing token (`--%`), all measured. `commandArgOrPlaceholder` then writes a placeholder and names the value outside the command
    nearest-name.js            ← pure single-edit "did you mean" matcher for closed vocabularies (CLI flags, FetchXML operators)
    lcid-cultures.js           ← pure: a Windows language id (LCID) → culture code and right-to-left, from a table of Windows' own culture data (what `pac` printed; the SDK reports numbers only, and no Intl API maps an LCID); a language id with no entry stays numeric and is reported, never guessed (`list-languages.js`)
    supported-dependencies.js  ← Single source of truth for runtime + dev deps versions
    feature-flags.js           ← Default-OFF feature flag probe + Custom API script backstop
    sdk-build.js               ← app-builder build engine (idempotent; incl. the pages phase)
    app-components.js          ← shared current/published app layers, component inventory, hidden-table pin/proof, Main-form resolution/membership and push directives
    solution-membership.js     ← one read of a solution's components (the types the build adds, plus its tables: a table held with all its subcomponents vouches for its views, forms and charts, which have no rows of their own), so a rebuild adds just what the solution is not PROVEN to hold; every doubt reads as "add"
    unsynced-edits.js          ← the SDK's own dirty tracking (`diffArtifact`) as the one proof that a reconcile has nothing to push; every doubt reads as "push"
    stages.js                  ← stage→phase-range mapping + PHASES/STAGES constants
    op-diff.js                 ← destructive-op diff + --allow-destructive / --non-interactive gating
    artifact-intent.js         ← pure App Spec → canonical SDK intent compiler (new form topology; no SDK calls)
    form-occupancy.js          ← pure row occupancy (own cells + columns reserved by row-spanning cells above), shared by build and verify
    form-timeline.js           ← pure: the shape Dynamics renders the Notes timeline in (control id, tall cell, spacer rows), what a deployed one lacks, and how to read it in the SDK tree and in published FormXML (shared by the compiler, the build's repair and verify)
    form-subgrid-placement.js  ← pure: where a form's sub-grid sections go (`tab`/`column`/`after`/`before`) — resolving a placement against the declared layout, the order of a column's sections and grids, finding a grid's section in either tree shape (shared by the spec gate, build, verify and the wireframe)
    form-order.js              ← the Main Form Set order: which Main form a table opens with, and which forms the build orders (shared by the spec gate, build and verify)
    form-container-match.js    ← how an authored tab/section is matched to a deployed one, and the moves that put them in the layout's order (shared by build and verify)
    ai-app-settings.js         ← single source of truth for the per-app AI feature contract
    interaction-mode.js        ← whether a human is reachable in this run (shared by both skills)
    page-plan.js               ← pure App Spec → plan-document projection used by write-page-plan.js
    page-structure.js          ← the one structural gate a generated page must pass (empty, truncated, prose, elided), shared by genpage-worker-output.js and promote-intent-pages.js
    griffel-styles.js          ← the ten shorthands Griffel's runtime drops, found as keys of a `makeStyles` style object (any depth, code only — the plugin's own lexer), with their longhands and where each sits (`within`: its slot, selectors and at-rules); shared by check-griffel-styles.js, `genpage-upload.js` (refuses a create, and an update's NEW keys — `griffelProblemsNotIn` tells them from the deployed page's) and the build's pages-phase scan (halts a new or changed page, warns for one it sends as it is), whose texts (`describeGriffelRefusal` / `describeGriffelWarning`) say what each way out does to the page; and the tests
    page-file-targets.js       ← the one page-filename rule (absolute/backslash/traversal/.tsx only/links/case collision, incl. with files already there), shared with check-page-files.js and the evals
    app-source-path.js         ← shared lexical + realpath confinement for app source reads/uploads and hashes
    page-ownership-records.js  ← local CREATE receipts and pre-delete ownership records, scoped to app/key/id
    source-literals.js         ← TSX lexer (code/comment/string/template/regex/JSX) — see "Known limits" below
    sdk-teardown.js            ← app-builder teardown engine (planTeardown is pure)
    sdk-http-client.js         ← az-token HttpClient for the vendored SDK
    spec-lint.js / app-spec.js ← App Spec guardrail lint + validation
    lookup-labels.js           ← a lookup column's default label, environment-facing half: the parent table's label read, the old-default repair's column-label read (one per child table) (the pure rule is `lookupLabelFor`, app-spec.js); `lookup-cell-labels.js` repairs the form cell
    view-filter-labels.js      ← resolves a view filter's Choice labels against the environment's option metadata; the build's pre-flight (skipped for a spec with a localized label) and views-phase gate, the dry run, verify and lint all use it
    spec-shape.js              ← shared structural normalization for both authoring gates
    surface-resolver.js        ← pure: resolve personas[].jobs[].surfaces[] to the spec artifacts that satisfy them
    role-privileges.js         ← pure: declared persona privileges + subset comparison against a deployed role
                                  (also the oracle for `roleGrants[]`, which is additive rather than converged)
    odata.js                   ← OData literal escaping helpers
    genpage-cli.js             ← pac model genpage upload/list/download wrapper, plus a page's own name read from its row (pac stores `"` as `\"`; `unescapePacName`); an uncertain create reports its candidates and stops without adopting one as an UPDATE. Every pac call is time-bounded (`runPac`: the process tree is ended when the budget runs out and the result is `{ status: null, timedOut: true }`); a timed-out list or download is retried once, a timed-out UPDATE is not (it may have landed, and a re-send can record the prompt twice or overwrite a later change: it is reported with the check to run — `genpage-base.js check` for the upload script, a `pac model genpage download` of the page and then a re-run of the build for the build, which keeps no base marker), nor is an UPDATE that failed (a non-zero exit, or an exit 0 with no Page ID: it too may have landed, and is reported the same way; only a refusal of pac's own arguments or input files is an ordinary error), and a timed-out CREATE is an uncertain create that is never run again (a Page ID pac had printed names the page it created, stalled before publish or placement; a create that fails after the Page ID is reported the same way and never sent again; every error that follows a create whose outcome is not known to be nothing carries `transient: false`, so the build's own retry of a halt that reads as transient cannot re-send it, while a create that failed outright and left nothing in the listing is retried, as in 2.13.0); `runPac` also takes an AbortSignal and the client's `abort()` ends the pac runs it has in flight, each with its whole process tree (through the same `killProcessTree` as a timeout), and keeps a call that is paused between two attempts from starting another (an attempt that was ended is not followed by another pause either, and an ended upload is never sent again; a verify pass ends the page reads it gave up on this way)
    genpage-base.js            ← base-marker hash, compare, and read/write (sibling dotfile of the code file; no environment URL)
    safe-fs.js                 ← confined output writes and deletes: the named directory's final component must not be a link or junction (readlink, not a path-text compare, so an 8.3 name and a share root stay usable); write via exclusive temp + rename. Shared by genpage markers and later callers
    hydrate-spec.js            ← reconstruct an App Spec from a deployed app (edit flow)
    verify-spec.js             ← spec-vs-deployed reconciliation core (checks are ordered tasks on a pool of 4)
    build-journal.js           ← durable JSONL build journal (resume diagnostics)
    form-preview.js            ← form wireframe renderer
    app-preview.js             ← whole-app design renderer (data model + sitemap + forms + page-intents + design)
    app-spec-doc.js            ← pure App Spec → readable Markdown design doc (model-app-plan.md)
    schema-facts.js            ← pure data-model provisioning fact extractor for evals
    pageref-resolver.js        ← PAGEREF_<key> → GenPageId nav resolver
    page-manifest.js           ← durable <app>_pagemanifest read/write
    sitemap-pages.js           ← pure GenPageId extractors + fail-closed fetchSitemap MEMBERSHIP reader + navigationProof (page ownership proof from either app layer) + cross-app scan (every other app's published AND saved navigation — the saved layer in one request — and the apps that were never published, listed in one more; a never-published app with no sitemap component is looked up by the name its sitemap carries)
    sitemap-merge.js           ← pure: re-attach an existing app's rewritten sitemap to its live nodes (ids + everything the spec cannot describe), keep designer nav edits the spec did not make
    deployed-baseline.js       ← `.maker-workspace/last-applied.json`: the spec last applied or downloaded, stamped with its environment + app and the dashboard/page ids deployed there, each page with the hash of the source the pages phase read and sent (what the build's Griffel check compares a later source with)
    ai-candidates.js           ← selects good-candidate tables for auto row-summary mode
    ai-prompt.js               ← generates tailored Copilot row-summary prompts
    _graph.js                  ← entity topological ordering (shared by build + teardown)
    system-solutions.js        ← built-in system solutions (Active/Default/Basic) — shared by download recovery + teardown skip
    phase-diff.js              ← pure spec-diff → changed build phases (advisory diff foundation)
    content-hash.js / hash.js  ← content-aware phase diff: fold on-disk .tsx/contentPath byte hashes into the diff (changed-only); `recordedContentSha` reads a page's recorded hash back (the build's pages phase compares it with the page's source to tell an unchanged page from a changed one)
    classify-changes.js        ← changed-only: classify a spec diff → fast (page-content) | full | noop + sticky debt
    apply-snapshot.js          ← changed-only: pure eligibility state machine (identity bind, debt, tombstone, generation CAS)
    apply-snapshot-store.js    ← changed-only: atomic snapshot write (writeFileAtomic, also the baseline's) + workspace lease + invalidate/claim/tombstone/delete + distrust marker
    apply-snapshot-index.js    ← changed-only: build result.created → snapshot artifact map
    workspace-paths.js         ← the `.maker-workspace` name + the guard that gates destructive --clear-workspace cleanup
    workspace-binding.js       ← a persistent `.maker-workspace` belongs to the environment it was first opened for (only `--apply` opens it; a dry run uses a temporary workspace): turns the SDK's `WORKSPACE_ORG_MISMATCH`, and a manifest the JSON parser rejects, into the build's halt (both environments as origins, the safe remedies — the teardown one only for another environment that was built in, with the folder printed on its own line; a folder that was only opened gets no build-there/teardown advice; a folder with no usable environment is moved aside, not deleted; never clears or re-binds) and into the dry run's warning
    changed-only-flow.js       ← changed-only: --changed-only orchestration (decide fast/full, live identity, snapshot lifecycle)
    projection.js              ← changed-only: pure post-apply verifiers (form placement / sitemap / page dual-hash)
    detect-browser.js          ← System Chromium/Edge/Chrome detection (used by the launcher)
    modelapps-hook-utils.js    ← Tracked-skill discovery + validator lookup for the hooks
    utf8-stream.js             ← reads a hook's stdin as UTF-8 without splitting a multibyte character across pipe chunks
    telemetry/                 ← Bundled 1DS telemetry: ikey.json (this plugin's per-region keys) + resolver.js + region/ (copy of power-pages' router) + lib/ (copy of shared/telemetry/lib)
  vendor/cds-maker-sdk.cjs     ← headless vendored SDK bundle (rebuilt via _vendor-build/)
  _vendor-build/               ← esbuild vendoring tooling (build.js + pinned deps)
  tests/                       ← node --test coverage for the scripts + hooks
hooks/                         ← Lifecycle hooks
  hooks.json                   ← Hook registrations (closed schema — documentation lives in README.md)
  README.md                    ← What hooks.json wires up, and why
  run-skill-posttool-validation.js ← Runs a skill's validate*.js after the Skill tool returns
  validate-icon-imports.js     ← PostToolUse: blocks unverified @fluentui/react-icons in generated .tsx
  validate-write-safety.js     ← PreToolUse: flags (non-blocking) out-of-cwd writes in model-apps sessions
  run-skill-pretool-telemetry.js   ← PreToolUse(Skill): emits skill_started
  run-user-prompt-telemetry.js ← UserPromptSubmit: emits skill_started for /model-apps:<skill>
skills/
  app-builder/
    SKILL.md                   ← intent → model-driven app (create + edit)
  genpage/
    SKILL.md                   ← Orchestrator skill (delegates to agents)
    edit-flow.md               ← Edit flow steps (loaded only on edit path)
    verify-flow.md             ← Playwright browser verification (loaded only when user opts in)
  report-issue/                ← Bug-report skill (bundled shared workflow)
  telemetry/                   ← /model-apps:telemetry on|off|status control skill
```

## Skills

| Skill | Description |
|-------|-------------|
| `/genpage` | Build and deploy generative pages for a model-driven Power App |
| `/app-builder` | Build and edit a whole model-driven app — tables, columns, relationships, adaptive forms, views, Choice-column charts, generative pages, app + sitemap, sample data, and admin-gated AI features — from a natural-language intent, via the vendored `cds-maker-sdk` |
| `/report-issue` | File a bug/issue about the model-apps plugin to the GitHub repository |
| `/telemetry` | Enable, disable, or check usage telemetry (`on \| off \| status`) |

## Agents

Agents are invoked by skills via the `Task` tool — they are not user-invocable.

| Agent | Invoked By | Description |
|-------|-----------|-------------|
| `genpage-planner` | `genpage` (create flow) | Validates prereqs, gathers requirements, detects entity/app existence, presents plan for approval, writes `genpage-plan.md` |
| `genpage-entity-builder` | `genpage` (create flow) | Provisions Dataverse tables, columns, relationships, choices, and sample data via `scripts/provision-entities.js` (the shared SDK-backed core). Bulk inserts use OData `$batch`. Writes a transactional log for recovery |
| `genpage-page-builder` | `genpage` (create flow) **and** `app-builder` (Phase 1.5) | Generates one complete `.tsx` page from a plan document and schema; runs in parallel with other builders for multi-page requests. `/app-builder` projects its App Spec into that plan format via `scripts/write-page-plan.js` and dispatches this same agent |
| `genpage-edit-planner` | `genpage` (edit flow) | Reads downloaded artifacts as data, gathers changes, presents an edit plan and writes the same approved change list. The plan references prompt.txt without embedding it; the orchestrator applies only verified Requested Changes. |
| `genpage-connector-builder` | `genpage` orchestrator (create **and** edit flows) | Performs connector discovery (connections, connection references, datasets, tables, operations, schema), creates Dataverse connection references, and writes the `## Connector Bindings` contract + `connectors.json`. The orchestrator forwards its output into the planner or edit-planner prompt. |
| `genpage-customapi-builder` | `genpage` orchestrator (create **and** edit flows) | **Single owner of the custom-api feature gate.** Discovers the Dataverse Custom APIs a page can bind to (Global + entity-bound Actions/Functions) plus their parameter kinds via `list-custom-apis.js`, and writes the `## Custom API Bindings` contract + `actions.json`. The orchestrator forwards its output into the planner or edit-planner prompt. |

## Key Concepts

### Genux Pages

Generative pages (genux) are React 17 + TypeScript single-file components that run inside model-driven Power Apps. They use Fluent UI V9 for styling and the DataAPI for Dataverse data access. Each page is a single `.tsx` file with `export default GeneratedComponent`.

### Griffel shorthands

Fluent UI V9 styles are Griffel's, and Griffel's runtime silently drops ten shorthands from a `makeStyles` style object — `all`, `borderColor`, `borderStyle`, `borderWidth`, `borderBlock`, `borderBlockStart`, `borderBlockEnd`, `borderInline`, `borderInlineStart`, `borderInlineEnd` (`UNSUPPORTED_CSS_PROPERTIES` in `@griffel/core`; it skips the key and logs a console error). A page that writes one type-checks, transpiles, deploys and renders without it (AB#6691124); the guard used to be a grep for `borderWidth`, one of the ten. `scripts/check-griffel-styles.js --file <page.tsx>` is the gate now (the rule is `lib/griffel-styles.js`): one JSON line, exit 0 clean / 3 problems / 1 usage or an unreadable file, each finding with its line, column and the longhands to write (and where it sits, `within`, and what it is set to, `value`, which `genpage-upload.js` compares with the deployed page).

- **What it reads.** Every key of every slot's style object at any depth (`':hover'`, `'@media …'`, nested selectors; quoted, spaced, computed-from-a-string and escaped keys; `{ borderColor }` shorthand members; objects written inline in a spread), in code only — the plugin's own lexer (`source-literals.js`) masks strings, comments, templates, regexes and JSX text, so the same names there, and in an inline `style={{ … }}`, are not keys. Slot names are not properties, and an `animationName`'s keyframes are serialized as written, so neither is reported. `makeStyles` is found under its own name, an `import { makeStyles as ms }` alias and a `Fluent.makeStyles` namespace import from `@fluentui/react-components`, `@griffel/react` or `@griffel/core`.
- **`makeResetStyles` is not checked, on purpose.** The work item names it, but `resolveStyleRules` is the only code that consults the unsupported set, and `makeResetStyles` goes through `resolveResetStyleRules`, which writes every key as given (`@griffel/core` 1.19 and 1.20: `makeStyles` drops `borderColor`, `makeResetStyles` emits `border-color:red;`). Refusing a page that works would be a false reject. To check it too, add `['makeResetStyles', { slots: false }]` to `STYLE_FUNCTIONS`.
- **A style object the check cannot see is a problem, never a pass.** `makeStyles(styles)`, a slot or a selector given a variable (`root: base`, `':hover': hover`), and a spread of anything but an object written inline (`...base`, `...helper()`, `...(a || { … })`) are each `unread-style-object` — the keys behind them may be any of the ten — and the gates treat it as they treat a call the check cannot read: refused on a create or a change, warned for a page sent again as it is. So is a getter that runs, whatever key it defines (`get root() { return … }`, `get color() { … }`, with or without a space after `get`): Griffel calls it as it reads the key and a spread as it copies its object — even one whose value a later definition then replaces — and its body is code the check does not read, which may return anything and rewrite or delete what Griffel reads after it; a getter that never runs (replaced in its own object before anything reads it, or inherited under an own key) is not reported, and nor is one whose object never reaches Griffel (in a value a later definition replaces, in a prototype a spread does not copy, in an animation's keyframes), though a spread or Griffel may run it — its own object is all it can change there. Code that writes shared state (`Object.prototype.borderWidth = …`, a property of an import, a variable) changes what every object holds, as a statement anywhere else in the page could; no check of the style objects can see it, and this one does not follow it, wherever it runs (a getter's body, a Proxy's trap or a `toString` Griffel calls on a fallback value, a template's substitution) — the gate checks what a page asks Griffel for, not code written to get past it. So is a computed key the check cannot read as one string (`[key]`, `['border' + 'Width']`), which may name one of the ten or a selector — unless it is a template whose text before its first `${` no one of the ten begins with (`--${name}`, `& .${className}`, `@media (min-width: ${width}px)`), in which case its start tells what it is, and the object under one that starts as Griffel's nested keys do (`:`, `[`, `>`, `&`, `@`) is read. What a spread or a prototype (`__proto__: { … }`) brings into an object is read at that object's level — slots beside slots — and the object is resolved as JavaScript resolves it: a key's last definition is the property, own keys hide inherited ones, a spread copies only its object's own keys, and a key is the string JavaScript makes of it (a CR LF or a lone CR written in a template is one LF, however the file ends its lines; U+2028 and U+2029 stay as written), so a definition that provably never reaches Griffel is not checked, while one that may (a spread a condition chooses, a key computed at run time) is. A function member ends where its body closes, not at a comma of its type parameters or return type. Read in full: an object literal, or object literals chosen by a condition (`c && { … }`, `c ? { … } : { … }`), through parentheses, a non-null `!` and `as`/`satisfies` with a type the check reads — `as const`, or a type name with or without type arguments (no page wrote a cast in `makeStyles` at all); a keyword or literal type (`number`, `null`, `this`, …) ends at its word, so a `<` after it is a comparison; any other type leaves the value unread, so a type is never read as a value — for a slot, a selector, a spread and the call's own argument alike. Each `<` is matched to its `>` once per page (matchAngles), so a page of casts is read in linear time. Past 64 levels of nesting a value is unread and an object is reported as nested too deep, so a pathological page gets an answer rather than an exhausted stack. A number is read as a key (`{ 0: { … } }` is a slot), and a slot called `animationName` is a slot: only an `animationName` inside a style object holds keyframes. Spread without reading, alone or chosen by a condition, only as a value import from a Griffel module that the page never declares again (a read after `=` or `typeof` is not a declaration; an arrow parameter is), and only by the helper's own export names (`STYLE_HELPERS`: Griffel's 23 `shorthands` helpers and Fluent's 17 `typographyStyles` entries — never a name they inherit, since `shorthands.constructor(x)` is `Object(x)` and hands x back unexpanded; and `shorthands.transition` only where it builds its own array — any number of arguments but one, none of them a spread, or one array, string or number written in place — since given one array it returns what that array's own `reduce` returns): Fluent's `shorthands.*(…)` (it writes the longhands) and `typographyStyles.*` (font longhands only). Measured before the rule: no page among the samples, eval fixtures and pages generated on real environments (59 distinct) wrote a style object anywhere but inline, computed a key or wrote a getter, and the only spreads were `shorthands.*(…)`.
- **Lexer uncertainty fails closed**, as in the navigation oracle: a call that reaches the first place the lexer guessed what a `/` or `<` is — or a `makeStyles(` in the text after it, which a wrong guess may have blanked — comes back as an `unverifiable-style-call` problem with the remedy; a call that never closes is `unbalanced-style-call`. No committed page hits a guess (`source-literals.test.js`), so this costs a realistic page nothing.
- **What the gates hold a page to.** The check is held against what a run is about to *ship*. A page deployed by an earlier release of the plugin — whose only guard was a grep for `borderWidth`, so `borderColor` under `:hover` passed — can already carry a key Griffel drops, and it renders without it today. Sending the same bytes again ships nothing new, and refusing them would stop a rebuild (or a one-line edit) over a defect the maker did not touch, with only one way through: edit the page, and writing the longhands *changes how it renders* (those declarations never applied; deleting the key keeps today's look). So every refusal and warning says both: *deleting a key changes nothing on screen; writing the longhands makes the style start to render*.
  - **The build** holds no copy of what is deployed (and never downloads a page just for this), so it decides from the page's standing, in the pre-write scan, for each page the run uploads. A page that does not exist yet (`keyToId` has no id for it: it is *created*) and a page that exists but whose source no longer hashes to the `__contentSha` that `last-applied.json` recorded for it at the last apply (*changed*) are held to the check — `pages-griffel-shorthand`, before anything is written. A page whose source hashes to that record (*unchanged*), or that has no record (*unrecorded*), is sent as it is, and the build warns (`opts.warn`) with file:line:column and both remedies. The record is `last-applied.json` read as `opts.baselineSpec` — written by every successful full or changed-only apply and by download, and returned only for this app **in this environment** (`readBaseline`; a snapshot written before it carried those stamps is no baseline), the same hashes the changed-only flow compares. For a page an apply sent, the hash recorded is of the source the pages phase *read and checked* (`created.pageSourceShas`), not of the file when the baseline is written as the build ends: a page a maker saved while the build ran reads as *changed* on the next build, where re-hashing the files would have recorded the edit as applied and let a new key through on a warning. What is sent is what was checked: every page, a navigation source or not, goes to pac from a staging copy of the bytes the scan read (`writeStagingFile`), never from its own file, so a save made after the check does not ship until a build has checked it. A page a changed-only run did not send keeps the record it had (with none, the hash of its file as it is). *Unrecorded* — a new, moved or cleared workspace, another environment, or an apply that predates the record — fails **open** on purpose: the release before this check built such pages with the CSS silently dropped, so refusing one now would regress a rebuild that used to complete. The cost is that an edit that adds a key to an unrecorded page is warned about, not stopped, until a build has recorded it; a page that does not exist is always held, record or not.
  - **`genpage-upload.js`** has the deployed page in hand (an update downloads it for its base check), so it refuses only what that page did not already have. A create is refused outright. For an update each key is told from the deployed page's by `griffelProblemsNotIn`: the same key set to the same value in the same place (`within`: its slot, selectors and at-rules), or failing that the same key and value anywhere that nothing else has claimed — so a renamed slot or a moved rule is not new — and each deployed key is spent once, so a second `borderColor` where there was one is. A key given **another value** is new too: Griffel drops the new value as it dropped the old one, so the page would show none of the change the maker meant (a value is compared as written — a quoted string by its text, whichever quotes, anything else as its source text with the white space collapsed — and nothing is evaluated, so `tokens.colorBrandStroke1` is that text). A file that *is* the deployed page (a BOM or final CRLF aside) is a re-send, and none of it is new. A key the deployed page already had goes ahead, with a `warnings` entry in the result; a new one is refused, and `--overwrite-deployed` is no way past that (it is about edits made to the deployed page, not about what the file writes). The deployed page can be unreadable only under `--overwrite-deployed`; nothing then says what it had, so the update goes ahead with a warning, as an unrecorded build page does.
  - **A call the checker cannot read** is not CSS Griffel drops, and is not said to be: the message says the check could not read the page's styles, quotes the call, and says what to change in the code (parenthesise the operand, end the statement, or move the call above it). It is never "already there" — what it hides cannot be compared with anything — unless the file is the deployed page itself. And the checker command a message suggests quotes a path that has white space.
  - **`check-griffel-styles.js`** has no notion of what is deployed: it reports every key it finds, a page's old ones included. For a page that already had one, deleting it is the edit that preserves the page (`/genpage`'s edit flow says so).
- **Who runs it.** Three places, so no path to an upload skips it. The orchestrators run it per page, before any transpile or upload, for quick feedback: `/genpage` (single page, multi-page, edit flow, and the §7.5 fix after a browser verify) and `/app-builder` (before `promote-intent-pages.js`, and for a page edited by hand before a rebuild). The page-builder worker has no shell, so its Step 2.6 gives it the rule and a Grep self-review instead. And the code **enforces** it where pages are uploaded, whatever the instructions did or did not get run: `genpage-upload.js` reads the code file first and refuses a create with `code: "griffel-shorthand"` (file, line, column, longhands and what each way out does in `error`, the same list in `problems`; before any listing or download; a file it cannot read is refused) and, for an update, any key the deployed page did not already have (above), and the build's pages phase halts as `pages-griffel-shorthand` in its pre-write scan, before any page is created or updated, for each page that run uploads that is new or changed (a changed-only run checks only the pages it selects; a full build checks every implemented page). Both send `pac` what they checked, never the file again: the upload script reads the code file once and uploads a copy of those bytes (an update lists the environment and downloads the deployed page first — seconds in which the file can be saved), and its base marker records their hash; the build sends every page from a staging copy of the bytes its scan read, and records their hash. `genpage-upload.js` refuses `--compiled-code-file`, since `pac` would deploy that JavaScript instead of the checked TypeScript. Both build their text with `describeGriffelRefusal` / `describeGriffelWarning`, so one defect has one wording. It is deliberately not part of `genpage-worker-output.js` / `promote-intent-pages.js`: their failure sends the page back for a full rewrite, and this one is fixed by a targeted edit. The one rule text is the same in `references/rules.md`, the genpage skill and edit flow, the page-builder agent and the app-builder skill, and `genpage-dispatch-contract.test.js` fails if any drops it or the command (and if `verify-flow.md` or the app-builder edit step drops the check).

### DataAPI

The DataAPI (`props.dataApi`) provides typed CRUD operations against Dataverse tables. It uses RuntimeTypes.ts (generated by `pac model genpage generate-types`) for type safety. Column names must be verified from the generated schema — never guessed.

### RuntimeTypes

TypeScript type definitions generated from Dataverse metadata. Contains entity types, enum registrations, and the `GeneratedComponentProps` interface. Generated via PAC CLI before code generation to ensure correct column names.

## Feature Flags

Unreleased functionality is gated behind committed, **default-OFF** feature flags so
the skill can merge ahead of its cross-repo dependencies. With a flag OFF, the
**deployed page behavior is identical to before the feature existed** — the guarantee
is about runtime/deploy output, not that every authoring artifact is byte-for-byte
unchanged. The mechanism lives in `scripts/lib/feature-flags.js` with the committed
values in `feature-flags.json` at the plugin root.

**A flag is flipped to `true` FIRST and removed in a LATER change, not both at once.**
Flipping is reversible in one line if the rollout turns out to be incomplete in some tenant;
deleting the gate in the same change that enables the feature leaves no way back except a
revert. Once a release has shipped with the flag on and no rollback was needed, remove it —
a permanently-on gate is dead weight that still has to be probed, branched on and reasoned
about at every call site.

Connector authoring no longer has a feature flag (the feature itself is in public preview). The
remaining flags are `custom-api` and `custom-telemetry`; both currently ship **OFF** while their
dependencies finish rolling out.

- **Source of truth:** `feature-flags.json` (e.g. `{ "custom-api": false }`). Flip a
  flag to `true` in a one-line PR once its dependencies are GA in PROD, then remove
  it in a follow-up that deletes its gates.
- **Precedence (highest first):** env var `GENPAGE_ENABLE_<FLAG>` (e.g.
  `GENPAGE_ENABLE_CUSTOM_API=1` for a single run) → committed
  `feature-flags.json` → default `false`
  (fail-closed). This mirrors the telemetry opt-out env-over-config convention.
- **LLM gate:** skill/agent markdown probes a flag with
  `node "${PLUGIN_ROOT}/scripts/lib/feature-flags.js" <flag>` (prints `enabled`/`disabled`,
  exits 0/1) and skips the gated workflow when disabled. `--list` prints every known
  flag's lifecycle **status** (experimental / in-progress / ga), effective state +
  source (env/file/default), summary, how to enable, plus config-validation warnings.
  Flags are catalogued with that metadata in the `FLAGS` map in `feature-flags.js`
  (the committed `feature-flags.json` carries only the on/off value). Unknown flags
  are disabled even when a matching env var is set.
- **Script backstop:** Custom API entrypoints call the shared
  `exitIfCustomApiDisabled()` helper (DRY — no inlined gate) and fail closed with
  exit 3 when OFF: `list-custom-apis.js`. Connector scripts have no feature-flag
  backstop because connector authoring is always on.
- **Validation:** `KNOWN_FLAGS` + `validateFlags()` warn on unknown keys / non-boolean
  values in the committed file (so a typo can't silently do nothing, or — after a flip
  to `true` — accidentally enable the wrong thing).

**Each gated feature has a SINGLE OWNER agent, and every entry point must go through it or the
shared helper.** Gate a feature at the same five places, so the rule is stated once here and
only the per-feature specifics are tabled below:

1. **Discovery** — the owner agent runs the probe first; planners/edit-planners delegate to it and
   never gate inline.
2. **Scripts** — each entry-point script calls the shared `exitIf<Feature>Disabled()` helper (DRY —
   never an inlined gate) and fails closed with exit 3 when OFF.
3. **Deploy** — the SKILL phase **re-probes** the flag and treats an absent/malformed bindings
   section as *no bindings*, so a plan authored while the flag was ON cannot deploy after it goes OFF.
4. **ALM** — solution packaging honours the flag (or documents why it needs no gate).
5. **Codegen** — `genpage-page-builder` emits feature code **only** when the plan carries an actual
   binding table, never on an absent/sentinel section.

| | `custom-api` | `custom-telemetry` |
|---|---|---|
| **Owner agent** | `genpage-customapi-builder` | none — codegen-only |
| **Plan section** | `## Custom API Bindings` | none — driven by the maker request, not the plan |
| **Gated scripts** | `list-custom-apis.js` | none |
| **Deploy phase** | SKILL Phase 4.6 | SKILL Phase 4.7 (probe only) |
| **ALM** | none needed — `config.json`'s `actionBindings` travels inside the page's `uxagentprojectfile` rows automatically (the Custom APIs themselves are a separate deployment prerequisite, bound by name) | none — telemetry rides the host runtime, nothing is packaged |
| **Emits** | `executeAction` / `executeFunction` / `listBoundActions` | `props.appInsights` calls (`trackEvent` / `trackMetric` / `trackTrace` / `trackException` / `trackDependency` / `startTrack` / `stopTrack`) |

At Phase 4.7 the `/genpage` orchestrator probes and passes the verbatim result as
`Telemetry: enabled|disabled` in every page-builder dispatch — **that dispatch value wins over
the plan.** Phase 4.5 passes a `Connectors: none|<n> binding(s)` line the same way, but note the
difference: that value is the **binding count**, not a flag state. An empty binding table yields
`none`, because the page-builder only needs to know how many bindings it may call.

`custom-telemetry` is the odd one out: it has no owner agent, no discovery script, no plan
section and no deploy or ALM step. It gates **code generation only** — steps 2-4 of the
checklist above are N/A, and its Phase 4.7 "deploy phase" is nothing but the re-probe that
produces the dispatch line. It also carries a second gate the other flags do not have: even
when `enabled`, `genpage-page-builder` instruments a page **only** when the maker explicitly
asked to measure or track something. `enabled` is permission, not instruction.

The two remaining flags currently ship **OFF**, each waiting on cross-repo dependencies:

- **`custom-api`** — the AIBuilder CoderAgent action prompt, the shared `pai-gen-ux-action-runtime`
  plus the UCI and Controls host runtimes, a pac CLI `model genpage upload --actions` verb to
  persist `actionBindings` into `config.json`, and the `GenUxPluginActionAllowList` ECS setting.
  Note the maker-facing name is "Custom API" while the shipped wire contract stays
  `actionBindings` / `executeAction` (see `references/custom-api.md`).
- **`custom-telemetry`** — the page telemetry facade in the UCI host runtime, the GenUX
  authoring control (power-platform-ux), the AIBuilder CoderAgent telemetry prompt, and the
  `GenUxEnableCustomTelemetry` ECS setting (see `references/page-telemetry.md`).

## TSX source lexer — known limits

**`scripts/lib/source-literals.js` — known limits.** It is a hand-rolled TSX lexer, not a
parser: the plugin ships dependency-free, so there is no TypeScript to call. It tracks
code / line comment / block comment / string / template / regex / JSX tag / JSX text, and
backs the page structural gate (`lib/page-structure.js`, shared by `promote-intent-pages.js` and
`genpage-worker-output.js`) plus the eval's effect scoper. It also
backs the navigation oracle (`pageref-resolver.js`), which finds each call AND parses its object
in the lexer's mask — executable code inside a template's `${…}` is visible, template text and
string bodies are not — and reads each value, and each quoted key (`"pageId"`), from the source at
the same offsets, so one lexer drives both. It also backs `findElisionMarker` — the one "was code
elided?" rule the page gate and the Layer 2 eval share (a `FIXME` comment, a `TODO` that opens a comment or takes
a colon, a comment opening with `...`, "omitted for brevity", or a bare `...` line; the same words
as UI copy — "Loading…", a `'TODO'` status value — are not elision). A template's `${…}` body is
code and is read by the same lexer (`lexInto`), JSX and comments included, never by a lighter
scanner. It is **one pass** over the file with an explicit stack of code and template frames, so
its time is linear in the file's length however deeply templates nest — a lexer that re-scanned
each `${…}` body per level of nesting took over a second on a 2.5 KB page. Keep it one pass; the
`performance:` tests and the 50,000-deep nesting test in `genpage-lexer-hardening.test.js` guard it.
Whether a `/` or `<` starts a regex or JSX is judged by the code before it: never by a
comment's last word; a keyword read back as a property name (`counts.new / total`) or a postfix
`!` / `++` ends an operand; and a `)` ends one too, unless it closes an `if` / `for` / `while`
head, after which a statement starts. A `/` or `<` after a `/` or a `>` is judged by what the lexer
itself read there: it knows where a regex it blanked ended, where an element it read ended and which `/` it
took for the division operator (`ends` in `lexInto`), so `/x/ / 2`, `<b/> / 2`, `a / /y/` and `a / <b>t</b>`
are read for certain (the output alone cannot tell them from a guess: a blanked regex keeps its delimiters, an
element its `>`). A regex literal ends after its **flags**, not at its closing slash: TypeScript's scanner takes every
identifier part after the closing `/` as part of the literal, a valid flag or not (`reScanSlashToken`; an unknown flag is
a checker error and no parse error), so `const n = /x/in / 2;` is one literal and a division, and a lexer that stopped
at the slash read the keyword `in`, a regex from the second slash, and the real regex after it as code (`regexFlagsEnd`
reads the flags, characters beyond ASCII and code points beyond the BMP included, and the end it records is the last
of them). Each of those rules was once broken, and each break refused
a complete page as truncated or hid a navigation call. The judgements that are a guess — a `/` after a `}`,
a `)` whose `(` is out of reach, a word that may be a keyword or a name — report themselves through
`onAmbiguity` (see the trust frontier below). A file may start with a **hashbang**: `#!` at offset 0 is trivia
to TypeScript's scanner, to the end of its line, and is blanked and heard as a comment (so a token in it is a
stray one, like one in any comment); after a byte order mark, white space or a line break a `#!` is an error to TypeScript.
A **closing tag** is scanned as TypeScript scans it (`parseJsxClosingElement`): `</`, trivia and comments, a name
(an identifier, which a `-` continues, then either `.` and more such names or one `:` and one more, as
`parseJsxElementName` and `parseJsxTagName` read them — the `.` and the `:` are tokens of their own, so trivia and comments
may stand around them: `</a :b>`, `</a/*c*/:b>`, `</a . b>`, and no `.` follows a namespace name), trivia and comments, then `>` — never by
searching for the next `>`, which a comment inside the tag can hold (`</A /* > navigateTo(…) */>` ends at the last `>`; a
search for the first one read the call in the comment as code). The comments in it are blanked and heard as comments, as in an opening tag; a closing tag that holds
anything else before its `>` is the `jsx-open` guess. The `/` and the `>` of a **self-closing tag** are two tokens as well
(`parseJsxOpeningOrSelfClosingElementOrOpeningFragment`), with trivia of every kind between them — white space, line breaks of every
kind, comments: `<B / >`, `<B /⏎>`, `<B x="1" / /* c */ >`, `<A x=<B / >/>` — and the `>` after them ends the element. Read as the end of an opening tag, that `>`
took the text after it for the element's text, and a closing tag in a string after it for the end of the element, and a call after it was read as the data it
is not. A `/` directly followed by another `/` or by `*` is the start of a comment, so the slash of a self-closing tag has a space or a line break between it and any comment that follows. Any new scan for a delimiter (`indexOf`, a regular
expression, a loop that stops at the first `>` or `}`) has to skip comments and strings the way TypeScript's scanner does, or report a guess.
`hasDefaultExport` also needs the export
itself to be complete (a function or class reaches its body; a bare name is one the module
declares or imports, not a token that merely appears — the `as` of `import * as React` is not a
binding), because a write cut inside its export line balances; any complete `export default` will
do, for overloads. And `endsMidStatement` catches the cuts that leave every bracket balanced: the
lexer reports when a file stops inside a string, template, comment or JSX element, and a file
may not end on a token that needs more (`a +`, `React.`, `=>`). Both gates run all three checks.
A backslash continues a string over one line terminator, and CR LF is **one** terminator
(`"a\<CR><LF>b"` is a complete string): the quoted-string scans pair the CR with its LF, where skipping
only the CR read the LF as a raw line break, refused the string and every quote after it, and
rejected a complete page saved with CRLF endings.

**The navigation oracle's rules** (`pageref-resolver.js`, on top of that lexer):
- A property value is a *literal* only when it is exactly ONE string literal — the next significant
  token after it is `,` or the object's `}`. `"PAGEREF_x".slice(8)`, `&& y`, `?? y`, a ternary, a
  member/call/index tail make it an expression: never rewritten, a token inside it is
  reported as malformed (`navMalformedRefs`). The same holds for `pageType`.
- The one thing allowed between the literal and that `,` or `}` is a TypeScript cast, in a **small closed
  grammar**: `literal ( as const | as Name | satisfies Name )*`, where `Name` is a plain or dotted ASCII
  identifier (`string`, `PageKey`, `Pages.Key`; `true` and `this` are only identifiers here) with no type
  arguments, suffixes, unions or literal types. The compiler erases such a cast, so the value is still the
  literal; the span of the target is the literal alone, so a rewrite keeps the cast
  (`"PAGEREF_x" as const` → `"<page id>" as const`), and `as const` round-trips through reverse resolution.
  The grammar is closed on purpose: a reader that tried to tell "more of the type" from "an expression" by
  shape was fooled (`as unknown as true < limit > [false][0]` is a comparison), and one that read a
  string-literal type hid a `PAGEREF_` token in a span the compiler erases and the report never saw. A name
  read whole, then the end of the value, leaves nothing to tell. Anything outside it — a generic, a union, an
  array or indexed type, a string-literal type, a function type — is not a literal even where TypeScript
  would erase it: the value is reported (`pageref-malformed`) and never rewritten, and the author writes the
  literal alone. A `PAGEREF_` token inside a type is therefore reported too — a token is allowed only as the
  double-quoted `pageId` literal of a `navigateTo` call whose options object is written inline, and
  nowhere else, a comment included (`PAGEREF_RULE`, which the build and the promotion gate quote). `as` and `satisfies` must be on the line of what they cast, as in TypeScript.
  `pageref-resolver.test.js` checks the grammar against TypeScript's own parser when
  `TYPESCRIPT_ORACLE_PATH` is set (unset, those tests skip): TypeScript must erase every accepted cast
  to the literal, and across every short sequence of casts and operators whatever the oracle calls a
  literal must be the bare literal to TypeScript — a mutation that lets an expression through fails it.
- A call is found by its callee's name — bare, member, optional, or named by a string literal
  (`Xrm.Navigation?.["navigateTo"]?.(…)`) — and is a navigation only when the object literal is the
  **whole** of its first argument, because rewriting a token inside anything else changes what the page
  does. **The plain form `navigateTo({ … })` is the one to write**: the parenthesised spellings exist because a
  generated page may use them, and they are accepted in one place only. A parenthesised **callee** must wrap
  exactly the callee (`navigateTo` or a member chain ending in it: `(Xrm.Navigation.navigateTo)(…)`) AND its `(`
  must follow a token on an **allowlist** — the start of the source; `;` `{` `(` `[` `,` `:`; a ternary `?`;
  an operator ending in `=` (`=` `==` `===` `!=` `!==` `+=` `<=` `>=` …); `=>`; `&&` `||` `??`; and the keyword
  `return` (`return (navigateTo)({ … })`) on the line of the `(` — a line break between them makes `return;` a
  statement of its own — as the whole word (white space, the start of the source or one of `;` `{` `)` `:` right
  before it, so no `xreturn` and no `\u0061return`; `}` is not one, because `\u{61}return` ends in it) and not
  after a `.` (a member named `return`). It is an
  allowlist because the tokens that END a callable operand are many and easy to miss — `!` (a non-null
  assertion), `>` (a generic instantiation), a back-tick (a tagged template), `}` (a function expression, which
  a block's `}` cannot be told from), a name or keyword, `)`, `]`, `.`, `?.`, `++` — and each makes
  `(navigateTo)(…)` an argument list: `factory!(navigateTo)(…)`, `factory<unknown>(navigateTo)(…)`,
  ``tag`x`(navigateTo)(…)`` and `function () { return f; }(navigateTo)(…)` hand the object to whatever
  `factory`, `tag` or the function returns. (A denylist of those tokens missed four of them.) `await` and
  `yield` are **not** on it, although `await (navigateTo)(…)` is a navigation in an async function: outside
  one — and in an arrow that is not async, inside an async function — `await` is a name, and the object goes
  to what `await(navigateTo)` returns; the lexer cannot tell where it stands (the oracle test
  in `pageref-resolver.test.js` holds TypeScript's reading and the execution of both). A callee group at
  the start of a template expression, after its opening back-tick, is refused for the same reason. A
  parenthesised **argument** is closed straight after the object (type-only casts may precede the `)`), and the
  argument must then end at a `,` or the call's `)`: `navigateTo(({ … }))` is a call;
  `navigateTo(({ … }).pageId.length === 14 ? a : b)`, `navigateTo({ … }.pageId)` and `navigateTo(({ … }, other))`
  hand the object to something else. Anything else — a cast on the callee, `.call`, an alias, an options object
  built in a variable — is not read, and its token is *stray*: `strayPageRefs` is the invariant that no
  `PAGEREF_` token ships unaccounted for, and the page build (`pages-stray-pageref`) and
  `promote-intent-pages.js` refuse a page that holds one.
- **No comment may hold a `PAGEREF_` token — there is no comment exemption.** A token is allowed only as the
  double-quoted `pageId` literal of an inline `navigateTo` options object (`PAGEREF_RULE`: "…and nowhere
  else — not in a comment"). An exemption for comments cannot be made sound without a parser: every form of
  it (any comment; a `//` comment; a `//` comment that starts its own line) was defeated, because *where the
  comments are* is exactly what a lexer with no parser can get wrong: the `/*` inside a misread regex opens a
  false block comment over a real call, and a line that starts with `//` can sit inside a template literal that
  holds code that runs:
  ``const message = `\n// ${navigateTo({pageType:"generative", pageId:"PAGEREF_detail"})}\n`;``.
  `// PAGEREF_detail is replaced at deploy` is refused like any other stray token; write the comment without
  the token (`// the link to the detail page is resolved at deploy`), and a comment that merely LOOKS like a
  call (`// navigateTo({pageType:"generative", pageId:"help-text"});`) is fine. The rule is the same for the
  stray scan, promotion, the page build and the eval scorer, so nothing about comments is left for them to
  disagree about.
- **The trust frontier.** The lexer has no parser, so some of what it decides is a guess: whether a `/` is a
  regex or a division, whether a `<` opens an element, how a JSX tag with type arguments is scanned. Where a
  guess is wrong it blanks real code or reads text as code, and from there on the mask is not what the page
  means. `blankNonCodePreservingTemplateExpressions(code, { onAmbiguity })` reports each such decision as data —
  `(at, kind)`, the offset of the `/`, `<` or construct — and the tokenization is **byte-identical** with or
  without a listener (the other gates, `page-structure.js` and the worker-output and icon checks, get exactly
  what they got; compare the two over every committed `.tsx` and fuzzed input when you touch it). The
  navigation reader takes the **earliest** offset as a *frontier*, and trusts nothing at or after it, **or in
  a call that reaches it**. A call's meaning is read from its whole options object (a later member — a
  getter, a computed key, a spread — can make `pageType` something else, and a guess can hide that member),
  so a call is trusted only when ALL of it lies before the frontier: the object from its `{` to the `}` the
  lexer found, and the call from its `(` to its `)`. Deciding by where the `pageId` lies rewrote
  `navigateTo({ pageType: "generative", pageId: "PAGEREF_detail", data: {valueOf(){return 12;}}/2, r: /\/*$/,
  get [typeKey]() { … } })`, whose getter the guess had hidden, and which navigates as another `pageType`.
  - every raw `PAGEREF_` token at or after the frontier is stray — a recognised call's included — and so is
    the token of a call that reaches it, though the token lies before it. The report names the frontier's
    kind, line and column (`describePageRefLocations`: "after the … ambiguity at …", or "in a call that
    reaches the … ambiguity at …") and says what to change (`pageRefAdvice`: parenthesise the operand or end
    the statement with `;`, move the navigation call above it, or avoid the construct);
  - `resolvePageRefs` rewrites no such call, and a target in one says so (`afterFrontier: true`: some part of
    its call lies at or after the frontier);
  - reverse resolution writes no token there (below), `verify-spec.js` counts no literal there as proof of
    an edge, and the eval scorer refuses one in either phase; promotion, the page build and the scorer
    refuse the tokens through `strayPageRefs`;
  - a refusal that names no token still names the guess: a declared call the lexer could not see is
    "declared-but-absent", which the author cannot act on, so the build's parity halt (`pages-nav-parity`)
    and promotion add `frontierNote(code)` — the kind, line and column of the page's first guess, what it
    could be, and what to change — when the page has one.

  **ASI and the restricted productions are read by the grammar, not guessed**, and each rule is cited at its
  branch in `readPosition`: ECMA-262 §12.10 / §12.10.1
  (<https://tc39.es/ecma262/#sec-automatic-semicolon-insertion>) and TypeScript's parser (`src/compiler/parser.ts`).
  After a line break a `!` is a prefix operator (a postfix non-null assertion needs no break before it:
  `parseMemberExpressionRest`), a `++` or `--` is a prefix increment (`parseUpdateExpression`), and `break`,
  `continue` and `return` end their statement (`canParseSemicolon`), so a `/` after any of them is a regex; on
  the line of an operand the first two are postfix and the `/` divides. A block comment that holds a line
  break is a line break to the grammar. What no grammar rule can settle is a `/` or `<` that follows an
  operand on a LATER line — `type Value = number` then `/re/.exec(x)`: the type context ends the statement
  at the break, an expression would go on — so that is the `newline` kind, as is a `/` after `throw` and a
  line break (an error in JavaScript, `throw;` to TypeScript). The words that start an expression before a
  `/` include `throw`, `default` (`export default /x/`) and `extends` (`class A extends /x/ {}`); a `/` after a
  character that begins nothing in code (`#`, `@`) is a guess (`fallback`), and a quote, a back-tick, a member dot and a name ending in a
  letter beyond ASCII end an operand. A regex or an element is no type, and ASI inserts nothing before a `/` or `<`
  that continues an expression, so after one the lexer read (`const n = /x/` LF `/ 2;`) a `/` divides on any line:
  the `newline` kind is for an operand that could be a type.

  **A `<` where an expression starts is read by TypeScript's own rule for `.tsx`**, from the next two or three
  tokens, not by looking ahead for a `=>`. `isParenthesizedArrowFunctionExpressionWorker` in TypeScript 5.8
  (`src/compiler/parser.ts`, the `LanguageVariant.JSX` branch) treats the `<` as a generic arrow's type
  parameters only when, after an optional `const`, the token after the first identifier is `extends` (and the
  token after that is not `=`, `>` or `/`), or `,` or `=`; otherwise it begins an element — so `<T,>(x: T) => x`,
  `<T extends X>(…) => …` and `<const T,>(…) => …` are arrows, and `<T>`, `<T extends>`, `<T extends/>` and
  `<div data-active={e}>` are elements. The `/` is the one that rule does not settle for the lexer: in a type the same
  `<T extends /` opens type parameters, whose constraint is read as an expression where it starts no type
  (`parseTypeParameter`), here a regex — `let f: <T extends />;…[/]/>(a: T) => T` is a function type — and the lexer
  does not always know which of the two it is in, so `extends` and a `/` is the guess `generic`, read as the element
  (`/=` is another token, and type parameters in both) — unless the regex that the `/` would begin does not end on its line,
  which no type-parameter list has (TS1161: TypeScript's scanner ends a regex at a line break, `regexEnd`), so `<Flag extends />`
  with no other `/` after it on the line is the element's for certain. A lexer that searched for a parameter list and a later `=>` took the
  JSX text `(a): Title` in `isVisible(<div data-active={e}>(a): Title</div>) ? (() => /navigateTo(…)/.test(t)) : …`
  for parameters, and read the regex after the next arrow as code: a call, whose token was rewritten. Three
  things go beyond the three-token rule, and a test checks each against TypeScript's parser when
  `TYPESCRIPT_ORACLE_PATH` is set. The tokens are read through the trivia TypeScript's scanner skips: comments, and
  white space and line breaks of every kind (`ts.isWhiteSpaceSingleLine` and `ts.isLineBreak`: the no-break space,
  U+0085, U+2028, the zero-width space U+200B, the byte order mark and the rest). JavaScript's `\s` is all of
  them but U+0085 and U+200B, so it is not used (`skipSpaceAndComments`): `<T extends` U+0085 `>text</T>` is an
  element, where a reader that took the character for a token read a generic arrow and the regex after the
  element as code. A test compares the set with TypeScript's over every UTF-16 code unit. The set lives in one
  place (`isTrivia`, `trimTrivia` and `WS` in `source-literals.js`), and the navigation resolver, the structure
  gate and the eval's navigation contract take it from there wherever they skip, trim or split on white space:
  a `\s` or a `trim()` that stands in for it reads a character as a token that TypeScript skips (or the other
  way round), so do not write one where code is read. A `(` that follows a character beyond ASCII that is neither
  trivia nor a letter, a mark or a digit of a name is a token the lexer does not know, so a `)` that closes it is no
  head on nothing but that: a guess (`identifier`).

  **The `>` that closes a cast's type arguments is certain where the cast is** (`castKeywordIsCertain`). After a same-line `as` or
  `satisfies` TypeScript parses a type, and in a type `Name<` is type arguments, so `x as A<B> / 2` divides and `x as A<B> < y`
  compares — whatever else is true of the page. The `as` must be the keyword of a cast: no line break before it (`x` LF `as A<B> / 2`
  is a name `as` after a statement), not a property (`a.as`), not cut short by an escape or a letter beyond ASCII, not after a reserved word that is no
  operand (`break as`, `typeof as`; `this`, `null`, `true` and `false` are operands), and the operand before it must end at a position that is itself no
  guess. The type is walked back from the `<` over a name, a qualified name, closed type arguments, indexes and array brackets, string and signed
  number literals, unions and intersections (a leading operator too) and the false branch of a conditional type; `keyof`, `typeof`, `unique`, `readonly` and parenthesised types are not
  walked, and a cast with one stays the `angle` guess. A `>` run — `>`, `>>`, `>>>` — right after what the lexer knows ended an operand (a cast's closer, an element or a regex it read, or the end of a
  cast's type) is an operator, not the end of type arguments, and an operand follows it, certain too: `x as A<B> > /re/.test(y)`, `<b/> >> 1`. The operand after
  that binary operator is a unary expression, so a `<` there opens an element and nothing else — `x as A<B> > <T extends X>text</T>` is an element, never read by the
  arrow rule as type parameters. A chain of casts
  is followed up to `CAST_CHAIN_LIMIT` links, past which the guess stands. The mapped type's `as` (`{ [K in keyof T as Foo<K>]: V }`) passes the same
  test, and nothing can follow it there but more of the type, so nothing is read from it.

  *(1) The rule is asked only where an assignment expression starts.* After a unary or binary operator the
  operand is parsed by `parseUpdateExpression`, which tries no arrow, so a `<` and a name begin an element
  whatever follows the name — `a === <T extends X>text</T>` is JSX — and **the token before the `<` decides
  whether the rule is asked** (`ANGLE_AFTER` in `source-literals.js`; the operator is the last token of the run of
  sign characters before the `<`, cut as TypeScript's scanner cuts it, so `a!== <T…>` is `!==` and
  `x! = <T,>…` is a non-null assertion and then `=`). Element, certain: `==` `===` `!=` `!==` `<=` `<<` (after a
  space or a line break) `&` `&&` `|` `||` `^` `??` `%` `/` `*` `**` `+` `-` `!` `~`, and `typeof` `delete` `instanceof`
  `void` `await` (a `/` here is one the lexer read as the division operator: after the end of a regex or of an element no
  `<` is an element, it compares) — but a `*` after `function` is the generator mark, so the `<` opens type parameters, for certain
  (`function* <T>(x: T) {}`: an element there was read as a tag, and the closing tag was then looked for in a string
  or a comment that came after), and a `*` after `yield` (a delegation in a generator, a product outside one) or after
  a word cut short by an escape, `#` or a letter beyond ASCII is a guess. The rule: `=` and the compound assignments
  (`<<=` included), `=>` `,` `;` `:` `?` `(` `[` `{` `)` `}`, and `return` `throw` `case` `default` `else` `do` `yield`
  `of` `new` `extends` (and `break` and `continue` before a line break). After `async` with the `<` on its line the `<` is never an
  element: `isParenthesizedArrowFunctionExpressionWorker` answers True for the generic shapes — an async arrow with type
  parameters — and False for the rest, and `async` is then a method named `async` that has type parameters (an
  object literal's or a class's: `async<T>(x: T) { … }`) or the identifier, before a comparison or the type arguments of a call.
  So the text after the `<` is code, for certain, whatever the rule reads of its head (`async <T>text</T>` and
  `async <div>x</div>` parse in no reading as an element); after a line break the guess stays (`keyword`). A `>` that does not end `=>` is the `angle` guess,
  reported before the table is asked, and a run of `>` before a `=` is cut as one the lexer cannot make: the parser
  takes the first `>` for the end of type arguments where it can (`x as A<B>>= y` is `x as A<B>`, then `>=`), so
  `>=`, `>>=` and `>>>=` are each a guess, whatever the operator would be if the first `>` were not a closer.
  A guess, `operator`: those three (`a >= <T extends X>…`, or the end of type arguments and an initialiser,
  `let x: A<number>= <T extends X>(y: T): T => y`, or a shift assignment, `a >>= <T extends X>(y: T): T => y`), a `<`
  after a space (`a < <T extends X>(y: T): T</T>`), `in`
  (`"a" in <T extends X>…`, or `for (k in <T extends X>(y: T): T => y)`) and `yield *`; `newline`: `void` before a line
  break (the operator, or a type that the break ended: `let x: void` LF `<T extends X>(y: T): T => y`). In each the
  element is read, and the guess is reported only where the text after the `<` leaves both readings open. A head
  that cannot be an element — `<T,>`, `<T = X>`, or a parameter list and an arrow whose element reading fails (3) —
  is type parameters whatever stands before it, so nothing is guessed (`elementReadingFails`): after a `<` that is
  how `f< <T extends X>(y: T) => T>()` is read, a function type for a type argument. A head with a
  constraint and no parameter list, `<T extends X>text</T>`, is an element in every program that parses, since an
  arrow wants its parameters (`const k = <T extends X>text</T>` is an error), so after `<`, `in`, `yield *`, `void`
  with a line break and the cut `>=` family it is read as one, with no guess (`headWithoutParameterList`) — which rests
  on the tag reading and the type-parameter reading of the head ending at the same `>`. They do, in every head the
  tag reading accepts, but for a string: an attribute's value is a JSX string, with no escapes, and a type parameter's
  default is a JavaScript string, with them, so `<T extends X="\">">(x)=>x` is an arrow whose default is `"\">"` and a tag
  that ends at the `>` after `"\"`. A head that holds a backslash is therefore not decided, and the guess stays (a
  comment, a nested `<`, a `=>` or a character beyond ASCII outside a string are not in a head the tag reading accepts,
  and the first `>` that no list of tag type arguments holds ends both). What stays
  a guess is the head with a constraint, a parameter list and a `:`:
  JSX text, or an arrow's return type, which compile wherever an arrow may stand (after a `<` only the text does,
  which the lexer does not tell). A token that is in
  none of the classes is a guess too, and the oracle test fails for a punctuator or keyword that is neither in the
  table nor on its list of tokens the table is never asked about, so a new one cannot arrive unclassified.

  *(2) `await` and `yield` are not identifiers in every function.* The rule asks whether the first name is an
  identifier (`isIdentifier()`), and it is not for `await` in an async function (or at the top level of a module) nor
  for `yield` in a generator: there `<await extends X>text</await>` is an element, where outside them it is a
  generic arrow. The lexer does not track the function, so a `<` followed by either word is a guess (`generic`),
  and the element is read. The name after `const` is not asked about, so `<const await extends X>` stays with the
  rule.

  *(3) A generic function type is certain wherever the element reading of its text cannot parse.* What the rule
  does not say is what a TYPE does. A `<Name>` followed by a parameter list and `=>` is a generic function type
  (`const a: <T>(x: Array<T>) => T = f`, `onPick: <K>(key: K) => void`, `type H<A> = <T>(x: T) => T`) or, in an
  expression, an element whose text holds an arrow — and in a `.tsx` file there is no other reading. So it is a
  type, for certain, wherever the element reading **provably fails to parse**: no page that compiles has an element
  there (`elementFailsAt`). The text from the `>` of `<Name>` to the arrow is read the way TypeScript reads the
  children of an element, token by token, and the reading fails on what TypeScript 5.8 rejects: a `>` in the
  text, at any depth — the arrow's own, a nested arrow's, the `>` that ends the tag of `Array<T>` (TS1382); a `}`
  (TS1381); an expression container that starts with a name, a number or a quoted name and a colon, `{ label: T }`
  and `{ 'a': T }` (TS1005), or with a name, a `?` and a colon, `{ a?: T }` (TS1109); one that starts with two names
  side by side, `{ readonly a: T }` (no expression is two names, but `typeof a`, `a in b`, `a as T` and `async a => a`
  are, and are not read as one), with a bracket and a colon, `{ [k: string]: T }` and `{ [K in keyof T]: T }`, or
  with a call and a colon, `{ m(x: T): T }` and `{ new (x: T): T }` (TS1005) — and a quoted name is a string, which
  is called as a name is: `{ 'm'(x: T): T }` and `{ "m"(): T }` fail at the same token, the string read with its
  escapes (`{ 'a\'b'(x: T): T }`), which a JSX string has none of; and a container that holds nothing a
  `}` could hide — no brace, quote, back-tick, backslash or `<`, and no `/` but a comment's — which is skipped to its first `}`, because
  an expression ends there and TypeScript reports an error inside anything else before it reaches a later name
  (`{ a }`, `{}`, `{ a, b }`, `{ ...a }`, `{ a + b }`). A comment is trivia to TypeScript's scanner wherever one may stand between two
  tokens, so it is passed over whole where a container is read, and a `}` in it ends nothing: `{ /* c */ readonly a: T }` is read as `{ readonly a: T }` is
  (`skipTagTrivia`; a comment that the window ends in leaves the container unread). In a tag's own head, a `<` and a character that cannot start
  a tag name, `Array<{ a: T }>` and `Array<(y: T) => void>`, or a tag name and a token that is no attribute,
  `Record<string, T>`, `Array<T[]>` and `Array<T | U>`, or a `<` after an attribute, `<T extends A<B>>`, is TS1003,
  and a `{` where an attribute may start that is no spread, `<T extends { a: string }>`, is TS1005. A tag's own type
  arguments are read when they are only names, dots and commas, so `Array<Array<T>>` is decided too. Nothing else is
  claimed, and a test checks each class against TypeScript's
  parser, and a differential fuzz checks every verdict against it (`tests/jsx-element-reading.test.js`; its
  alphabets hold white space of every kind TypeScript skips — which an ASCII-only fuzz cannot reach — and the
  test asserts that each kind was put next to an `=` and into the heads of valid function types, and that quoted method signatures were put into their object types). **Where the reading
  cannot show the failure** — the arrow lies inside a tag or an attribute string (a JSX attribute string has no
  escapes, so `<Wrapper>(<Child x="\" y=") =>" />)</Wrapper>` is one JsxElement with no diagnostic; where the list's
  first token is the `<`, as there, it holds no parameters and the element is certain — see `parameterListMayStart` below); the text holds
  a closing tag, a fragment, a comment, a spread, a name with a `-` or a `:`, or an attribute string that does not
  follow its `=` at once (TypeScript scans `x = "a\"` as a JavaScript string, with escapes); a character beyond
  ASCII where a token could start or end in a tag — TypeScript's scanner skips trivia between a tag's tokens, and
  before an attribute value that does not follow its `=` at once, so `x=` U+00A0 `"a"` is an attribute with a value,
  and such a character may hide a tag name, a value or a colon; or a container holds a brace, a quote, a
  back-tick, a `/`, a backslash or a `<` and is none of the shapes above (`{ a = {} }`, an optional or generic quoted
  method `{ 'a'?(y: T): T }` and `{ 'a'<U>(y: U): T }`, a call signature with type parameters, `{ <U>(y: U): T }`) — it is read as the
  TYPE it almost always is, and the guess `generic` is reported at the `<`: the page is complete to the structure
  gate, and a navigation call after it is refused, naming the kind. A constraint makes it the rule's
  (`<K extends keyof V>(…) => …` is a generic, for certain). A certain function type is read as code, so a page
  that holds one is as complete to the structure gate as any other. What stays two-way, with the element read,
  is a `:` after the parameter list — a call signature
  (`interface I { <T>(x: T): T }`) and JSX text (`<span>(required): Name</span>`) both compile — and a parameter
  list too long to scan: the guess `generic`, and everything after it not trusted. The list is matched as JavaScript reads it, with comments, strings and
  templates skipped (a template whole only where each substitution closes, and holds no `<` after something that may start an expression, where JSX —
  whose quotes are no strings — may stand: `templateSkipIsWhole`). In a type that reading is exact until something in the
  list may start an expression — an initializer's `=` (any `=` but the `=>` of a function type and the `=` of `==`, `===`, `!=` and `!==`, which stand only in an expression already entered), a `[` other than `[]`, a decorator's `@`, an import
  type's `import`, an accessor's body in a type literal (`get`/`set` before an accessor's name — a `[`, a name, a string or a number; before a `:`, a `?`,
  a `(` or a `<` each is a name and starts nothing; TypeScript parses a block after an accessor and only its checker objects), a type parameter's
  constraint (`extends` before what starts no type — a `/`, `class`, `super`, `delete`, an operator, a `#` or an `@` — for a constraint that starts a
  type is read as one, and a conditional type's `extends` is always followed by a type) (`expressionMayStart`) — and a type holds no `/` outside a comment, a string or a
  template, so a `/` before one leaves only the element (`<p>(see: /docs)</p>`, `<span>({done}/{total})</span>`). After one the scan is still exact while
  it reads no `/`, `<` or back-tick (no regex, no JSX, no template: every quote is a string and every parenthesis code), so `<span>(@username)</span>` and
  `<p>(x = 5)</p>` are read as before; once it reads one, the expression may hold a `)` of its own — a regex's, JSX text's, a nested template's — and
  with no arrow after the `)` it found, the list is the guess (`interface I { <T>(a = /[)]/); m: '</T>…' }` ended the list at the `)` in the regex's class).
  Three things are read exactly instead, because TypeScript reads each one way (`initializerReading` and the scan's loop), and none of them needs a parser:
  the first token of the first default, where it is a `/` — nothing is there to divide, so it begins a regex, which ends at its own `/` on its line (`(a = /x/)`,
  `(a = /[)]/)`; a line break before the closing `/` is TS1161, and no program that parses has it) — or a `<` that the arrow rule above leaves an element, which is read to its
  closing tag (`(a = <b>c</b>)`); a `/` after a name or a number, which divides in an expression (`(a = b/(c))`, `(score = 3/5)`), unless a `{` has been read, which may open a function body, where a
  statement starts and a `/` after a `)` may begin a regex; and a template with no `${`, which holds only its own text. The `=` must be an initializer's, so no `<` stands before it (it may be a
  type parameter's `=` default, where a `<` opens a function type); an `=` that `</` follows is no default in any signature or function type (TS1109, TS1110), so `(Tip: use <code>=</code>)` is the
  element's alone. A second default, a regex or an element that is not a default's first token (`(a = f(/x/))`, `(a = <b>c</b> / 2)`), a `/` after a bracket, a `)` or a comment of either kind, and a template
  with a `${` stay the guess. A comment is trivia to TypeScript, so after one the last token is the one before it, and a `/` after one is a regex or a division by that token: a block comment ends in a `/`, which is no
  word (`1 + /* c */ /[)]/`), and a line comment ends in whatever its text does — a name, a number, a `$` — which is no operand: in `a = 1 + // fallback` and a line break, `/[)]/)` is a regex whose `)` is not the
  one that ends the list, and a scan that took the `/` for a division ended the list there and read the string of the type after it as code. The scan of a list does not find its comments one by one, so a `/` after a comment of either
  kind is the guess there (`slashFollowsAnOperand`); the reading of the text after a signature opens its comments itself and judges a `/` by the token before them (below) — the same misread hid the closing tag of
  `interface I { <T>(x); m(a = 1 + // c` LF `/[</T>;navigateTo(…)//]/): void }` in a regex, and read what followed as code. The character before a line break is in a line
  comment only if a `//` stands before it on its line (`lineMayEndInComment`), so a `/` after a name or a number on a line that holds no `//` still divides (on a line of its own after a comment's line, too); the `//` of a
  string, which a line may begin inside of, is not told from a comment, nor is a line that begins beyond the window: each is the guess too, the safe side.
  A list that can hold no parameters is
  the element's alone, with a `:` after it or without: its first token a number, a string, a template, a `#` or an operator
  (`<li>(1): First</li>`) is TS1003 in every signature and function type (`parameterListMayStart`; a character beyond ASCII and a
  `.`, which may start `...`, are not judged); so is one whose parameters fail before a default, a pattern or a modifier, or at the second token of
  their type — a name followed by anything but `?`, `:`, `=`, `,` or `)`, a keyword that is no parameter's name, a later parameter that starts as no
  parameter does, or a type whose second token is a name (`<span>(press [/] to search)</span>`, `(see docs)`, `(optional, <a>…</a>)`,
  `(get the <b>app</b>)`, `(extends <code>Base</code>)`, `(Note: see the <a href="/x">docs</a>)`, `(Note: <a href="/x">docs</a>)`: TS1005/TS1003;
  `parameterSyntaxFails`). A keyword there is read as TypeScript reads it: the thirteen modifiers it takes before a parameter's name (`public`,
  `readonly`, `async`, `in`, `out`, …) end the reading, the thirty-three words that are no parameter's name (`extends`, `import`, `new`,
  `class`, …) fail the list — but `default`, a modifier before what it can head (`interface`, `class`, `function`, `abstract`, `async` or a
  decorator), ends the reading there — and every other keyword (`get`, `set`, `of`, `type`, `this`, …) is a name. A type is read to its second token
  (`typeStartFails`): a name after a type reference is TS1005 unless the first word takes a type (`keyof`, `typeof`, `readonly`, `unique`, `infer`,
  `asserts`) or the second is a conditional's `extends`; `this` and `is` on one line are a type predicate, which TypeScript reads in any type, and the
  type after `is` is read the same way (`this field` fails, `this is string` does not); a type reference's first type argument is read the same way
  (`see<a href` fails at `href`); a name after a generic function type's first type parameter is TS1005 unless the first word is a modifier, which
  TypeScript parses there whatever it is (`<private x>`); and a type that is one name and a `,` lets the next parameter be read as the first was
  (`(Type: string, <a href="/x">docs</a>)` fails at the `<`). Four more types fail at their own second token, each measured over every keyword after it: a number
  literal and a name that is not a conditional's `extends` (`(Size: 10 MB max, <a href="/x">upgrade</a>)`: TS1005; the number may be negative or have a fraction, and white space or a comment follows it);
  an object type whose first member is a name and then a `.` or an operator no member has (`(Name: {user.name}, <a href={profile}>edit</a>)`: TS1131, a line break and a comment
  before it as well; a `.` and a digit is a numeric name, so it is no failure); `new`, which a `(` or a `<` must follow (`(Mode: new <a href="/x">beta</a>)`, and after the `<` the type parameters are read
  as above); and `import`, which a `(` must follow (`(Hint: import <a href="/x">docs</a>)`). Tests measure the classes over every keyword TypeScript has, every keyword before every
  other one, and every two-word type start and second parameter with the endings that could complete it. One place settles even those: **directly after the head of a type alias, `type Name =` at a
  statement start** (after any `export` and `declare`), where `type Name` — two names side by side — cannot be
  an expression, so a `<` is a function type's type parameters, for certain, whatever follows it
  (`afterTypeAliasHead`; a head with type parameters, `type Fn<A> =`, is not read, and its right side is decided
  by the shape alone).

  *(4) A parameter list that neither `=>` nor `:` follows is a call signature or JSX text, and what comes after it
  tells which.* `<T>(x)` is the text of an element that starts with a parenthesis in an expression
  (`<b>(optional)</b>`) and, where a type holds members, a call or construct signature with no return type
  (`interface I { <T>(x) }`, `type L = { <T>(x); m: { a: string } }`, `interface C { new <T>(x) }`); TypeScript parses
  both with no diagnostic. A lexer that read the signature as an element read the rest of the type as text up to a
  closing tag that it found in a string or a comment after it — `const s = '</T>;navigateTo({ … });//'` — and the text
  of a call in that string was then a real call, whose token was rewritten. So the two are told apart
  (`callSignatureOrElement`), by two things. *A signature ends its member where TypeScript wants it to*: after the
  `)` comes a `,`, `;` or `}`, the end of the source, or a token on a later line (`parseTypeMemberSemicolon`,
  `canParseSemicolon`; a line break is a line feed, a carriage return, U+2028 or U+2029, in a comment too, and not
  U+0085, U+00A0 or the like). Where anything else follows on the line — a name, a `<`, a `(`, a `{`, a comment with
  no line break — no signature stands there and it is an element, for certain, and so it is where a closing tag
  follows (`</` is one token in a `.tsx` file): `<b>(optional)</b>`, `<p>({t("label")})</p>`,
  `<span>(total: {count})</span>` are decided by that token alone. **Where no member of a type can begin at the head, there
  is no signature at all**: right after an `=` or a compound assignment (`+=`, `||=`, …), an `=>`, a `(`, a `[` or a `:`
  (`NO_MEMBER_BEFORE`) a call or construct signature, a method and an index signature cannot stand, and a function type needs the `=>` that
  this shape lacks, so `<Name>(…)` that neither `=>` nor `:` follows is an element, for certain, whatever its text holds (`const help = <p>(optional);` and
  a line break, then `foo( // note` and a line break, then `/docs</p>;`). After a `,`, a `;`, a `{`, a `}`, a `)`, a `?` or a keyword a member may begin — `return <T>(x)` is a method named `return`,
  `m? <T>(x)` is a method with an optional mark, and TypeScript parses both, so neither a keyword nor a `?` is in the set — and the text is read as
  follows. (The set was measured against TypeScript's parser over every place a token can stand: no program has type parameters with no
  arrow and no `:` after a token of it, and a program has them after each token the rule reads that is left out: `source-literals.test.js`.) *Where the member may end there, the text after
  `<Name>` is read as the children of an element up to its closing tag*, as (3) reads it up to an arrow (`readChildren`
  with a closing name): the nested elements are followed, each closed by its own closing tag, a nested tag's attribute
  containers and spreads are skipped, and an expression container that holds strings and balanced braces is skipped
  exactly, the strings read with their escapes (`{t("label")}`, `{"a}"}`, `{{ color: "red" }}`). A **failure** — a `}`
  or a `>` in the text, a closing tag that is not the open element's, a `</*` (TS1003 to an element; the `<` and the
  comment of the next signature's type parameters to a type) — means no page that parses has an element there, so it is
  a signature and code, for certain: the `}` that ends the type is the usual one. **A closing tag reached decides nothing by
  itself**: a type holds `</T>` in a string, and the text through it is the same in both programs — `interface I { <T>(x: "</T>") }`
  is a call signature and `const e = <T>(x: "</T>;` is an element, with no diagnostic in either. So the same text, from the
  `)` on, is scanned as the type would scan it, as code: a string, a template, a comment or a regex that the text opens and that
  is not closed before the closing tag hides it (`hideable`), and where none is open at the closing tag, the tag is code in the type
  reading too, no type reads `</` as code, and the element is the only reading: certain (`<b>(optional)</b>`, `<span>(total: {count})</span>`,
  `<p>({t("label")})</p>`, nested children). What opens one: a back-tick (a template with a `${`, which opens code that is not read, may), a `//` or a `/*`,
  a `/` that no word or number stands before and that so may start a regex (a keyword that an expression follows is no such word: `return`, `typeof`, and
  `implements`, whose heritage clause takes one, as in a computed name `[class C implements /…/ {}]`; and a `/` after the comments that the text opened is judged by the token before them, which a type reads as trivia:
  `b // c` and a line break, then `/d`, divides, and `1 + // c` and a line break, then `/[</T>…]/`, does not — and a `)` or `]` before the `/` divides, since a regex follows a `)` only after the head of a statement, which has a block, whose braces
  are not read: `slashFollowsAnOperand`), and a quote that starts a string closing on its line and
  covers the closing tag (a quote right after a word that is no keyword, an apostrophe or an inch mark, opens none; a keyword is a reserved word, a contextual one,
  or a modifier, which TypeScript takes before a quoted member name: `public'…'`). A nested tag is an element's or, to a type, something else: the type arguments of a name (`Array<b>`), a comparison (`a<b>c`), the type parameters of
  a signature or of a method (`<b>(y): T`, `<U extends A>(y): T`, `m<b>(y)`), where the text after its `>` is code and what looks like its closing tag is inside a string, a template or a comment that the text opened before it. So a
  string, a template or a comment that the text of a nested element opens — and the run of comments that a `/` is judged by — and that is still open at the closing tag of that element is read **both ways** (`scanChildren`, run
  twice by `readChildren`): as an element, where it ends there and the code goes on after the closing tag (`m(a = <b>'x</b> + '</T>…')`: the string that holds the closing tag begins after the element; `m(a = <b>x // y</b> +` and a line
  break, then `/[</T>…]/`: the regex does), and as code, where it goes on past it (`m: Array<b> = '</b>' + '</T>…'`, `<b>(y): '</b>'; q: '</T>…'`): the closing tag of the head is hidden if either reading hides it. A tag that no type has
  (`tagMayBeCode`) is an element to every reading: there is only the first, the region is cut at its closing tag, and its text is JSX text, which opens nothing (`jsxText`: a URL, an apostrophe or a back-tick in the text of a link is
  text). That is a tag in which every word has a value (`<a href="/x">`; and `<button type="button">`, `<label for="x">`, `<Link as="span">`, where the word that has a value is an attribute's even when it spells a keyword: the only `=`
  in a list of type parameters is a default, which its name owns; and one whose attributes stand each on a line of their own, as a formatter lays a tag out, has every `=` on the line of its word), an attribute that `extends` does not introduce (`<b d>`), a dotted name with an attribute, and a fragment where no empty list can stand. **Every other tag may be code,
  by the words in it**: a keyword of TypeScript's, other than `extends`, in a word that has no value is a modifier of a type parameter (`<public U>`, `<in out U>`, `<readonly U>`: `parseTypeParameter` takes them all, and only the checker
  objects), an operator of a comparison or a tagged template with type arguments (`x <a in b> y`, `x <typeof U> …`, `x <new b> …`, `x <A.B as T> …`), or a type operator in type arguments (`Array<keyof X>`) — and so is a dotted or dashed word
  that has one — **or by the lines it takes**: a line break between a word and its `=` (`href` and a line break, then `="/x"`), or before a `get` or a `set` that has a value (`get="/x"`). A line break ends a member of a type, so where the tag stands
  in one, `m: A = x <a` ends its initializer at the break, `href` is a member of its own, and `="/x"> '</a>' + '</T>…'` is that member's initializer, with the string that holds the closing tag in it (a name and a `=` on one line are no member,
  TS1131, but `get` and `set` begin one before the parser looks at what follows, so `get="/x"` on a line of its own is a property with an initializer: `isTypeMemberStart`). And a fragment is an empty list of type parameters or arguments right after a name,
  a `)`, a `]`, a `}`, a `!`, a quote, a number or a `.` (`f(x)<> || …`, `a[0]<>`, `'s'<>`, `{}<>`, `1.<>`: the parser accepts an empty list there and the checker
  rejects it) or right before a `(` or a back-tick (`<>(y)`, `m<>(y)`, `` x <>`t` ``), and after a line break when the line before it may end in a comment, whose last character may be any (`f // note:` and a line break, then `<> || …` is `f<> || …`:
  `lineMayEndInComment`) (`fragmentMayBeList`); after a `,`, a `;`, an operator or nothing, on a line that has no comment before it, it is a fragment, for certain. The keywords are TypeScript's own list (`TS_KEYWORDS`), and the
  sweeps draw their tags from every keyword it has, joined to names, dots, dashes and values, **including the tags the rule calls no code** — a rule that is wrong about a word can only be met that way (`nestedTagPages`; the sweep of
  the valued keywords keeps the precision of the rest). A tag that a type may have and an element may too (`<b>`, `<U extends A>`, `<A.B>`, `<public U>`) is read both ways, and a tag that is not read leaves it possible; two such places
  in one text are taken for hidden, as the second reading depends on the choice made at the first. A place is a region that the text of a nested element opened and that is still open at that element's closing tag, **told by where the region starts**,
  not by where it ends: two `//` comments on one line, each opened in the text of an element of its own (`m(a = <b>//</b> - <i>//</i> ??` and a template that starts on the next line), end at the same line break and are two places (`skipFrom`; `nestedPairPages`). A tag inside a string, a template or a
  comment that the type's own text opens is no element's to the type (`'a <b>' + \`</b>\` + '</T>…'` holds `<b>` and `</b>` in two literals): it opens and closes in the element reading and counts for nothing in the depth
  that those rules use (a ghost, `realDepth`); and a tag inside a region that the text of a nested element opened is a ghost in the reading where that element is code, which pairs the quotes after it as a type does
  (`m: Array<b> = '<>' + '</>' + '</b></T>…'`: the element reading cuts the second string at `</>`, the type reads three strings), so where one stands the text is read both ways too (`tagged`). Where the text up to such an opener cannot be the members of a type, though — two names on a line,
  a name and a `/`, a sign, a `.` or a quote (`<p>(a), see https://x.y</p>`, `<p>(a); well-known // c </p>`, `<p>(a), it's // c </p>`: TS1005 in a type), a `:` after a type
  (`Docs: https://x.y`), a `/` where a type must follow a `:` (`https://x.y` and a line break, then `/b`: TS1110), a template or a regex where a member would begin (``<p>(a); `x ${y} </p>``: TS1131;
  after a member's colon a template is a type and stays possible) —
  no type holds the closing tag in anything, nothing that follows can hide it, and it is an element (`typeMembersFail`, asked at each opener from where it stopped, through the comments between them). It is a small
  reader of where a member is — its start, a name, a `:`, a type, an initializer; the next line of a name and the next line of a type are different places (`PROSE_*`) — and what it reads is TypeScript's: a number is one name by
  the scanner's rule (`1e-5`, `.5`, `0x1F`; a hex, octal or binary literal ends at its last digit, so `0x1Fin` is `0x1F` and the operator `in`, where a word right after a decimal literal is TS1351), a keyword is a name where a member begins
  unless it may be a modifier (which a `:` after it makes a name too), a word after a `.`, `typeof` or `infer` is a name, whatever it spells (`A.new`, `typeof new`, `infer keyof`; but `typeof import` is an import type, whose `(` has no line-break rule: `typeof import` and a line break, then `('./m')`, is a type, `typeofImport`), after `keyof`, `readonly` or `unique` it is a type, of which a
  keyword is the name (`keyof new`), and a word that starts a type a word may follow (`asserts b`) is not read; a word written with an escape (`a\u0061b` is ONE name) is not read; a `(…)` or `[…]` that closes is one thing (a signature's parameters,
  a computed name, a parenthesised type, an array), `<T>` is type parameters only where a parameter list follows, a type goes on with `.`, `|` and `&`, and two operands of an initializer side by side are an error unless a keyword is one
  of them. A name or a keyword that a `(` follows opens a parameter list, whose first parameters are read as a head's are (`parameterListMayStart`, `parameterSyntaxFails`): a `/` right after the `(` and its comments is TS1138, a number,
  a string or an operator there is TS1003, and two names are TS1005 — so `foo( // note` and a line break, then `/docs` is no type's, and neither is `m(see docs) // note` (the `(` of an import type is no parameter list, and is not read as one: `typeof import`). A name, a number, a string or a signature with a `=` right after it on its line
  is no member (TS1131; TS1005 after a signature): the initializer that a property signature may have — the parser accepts it and the checker rejects it — follows a `?`, a type, a computed name, `get` or `set`, or a line break
  (`m? = 1`, `m: A = 1`, `[m] = 1`), and `m = 1` is TS1131. A `[`, a name and a `,` or a `:` are an index signature for certain (`isUnambiguouslyIndexSignature`) — unless `get` or `set` stands before the `[`, which then begins an accessor's computed name,
  an expression that a regex may hold (`get [a, /[</T>…]/]()`: `parseContextualModifier` takes the keyword first, whatever its line) — and neither a parameter nor a type starts with a `/` (TS1138, TS1110):
  `[ a, // note` and a line break, then `/path` is no type's, where `[ // note` and a `/` is a computed name that a regex may follow, and neither is `Total: [ // note` and a line break, then `/docs`, where the `[` after a `:` opens a tuple. Each place has the characters that fail there, **measured over every ASCII character against TypeScript's parser**, and the sweeps in `tests/source-literals.test.js` put every claim back to TypeScript. What it cannot
  follow — a `[` that nothing closes on its line, an initializer after a `?`, a type or a computed name, a conditional type, an expression, a decorator, a `{` — leaves the rest undecided, and the closing tag may be hidden there. Where one is open at the closing tag, or the reading cannot say (a container or a tag it
  does not read, a window of 2,000 characters, a spent budget), both programs compile and the text up to the decision
  is the same: the guess `generic`, and the element is read. A type of *k* signatures reads the rest of the type for
  each, up to the window, so a pass has a budget of characters to read (`READING_BUDGET_PER_CHAR` for each character of
  the source and a base for short ones); where it is spent a reading says nothing, which is the guess, and the pass
  stays linear. A `:` after the list is not settled this way: it is the guess above (`interface I { <T>(x: T): T }` and
  `<span>(required): Name</span>`). The fuzz of this reading puts the same text into a type and into an element and
  asks TypeScript which parse (`signaturePrograms` in `tests/helpers/jsx-element-fuzz.js`).
  The kinds, each with the raw example that makes the reading a guess (`AMBIGUITY_KINDS`; a test refuses a kind
  that has no wording in the report):

  | kind | what the lexer cannot tell | example |
  |---|---|---|
  | `brace` | `}` ends a block (a `/` then starts a regex) or an object/function expression (it divides) | `{valueOf(){return 12;}}/2; … /x/` |
  | `paren` | `)` ends an `if`/`for`/`while` head (a regex follows) or an operand, and its `(` is beyond the 2,000-character look-behind | `if (true /* …2,100 chars… */) /re/.test(x)` |
  | `keyword` | a word that is a keyword in some places and a name in others (`of` `await` `yield` `let` `async` `static` `get` `set` `as` `satisfies` `type` `from` `declare` `abstract` `readonly` `keyof` `infer` `is` `asserts` `override` `accessor` `using` `out` `module` `namespace` `global` `unique` `implements`) | `const of = 12; of/2; … /x/` |
  | `angle` | a `>` closes type arguments, a JSX tag or a comparison; the `>` of `=>`, the closer of a cast the lexer proves is one (`castKeywordIsCertain`) and the end of an element or a regex it read are certain, and so is a `>` run after one of those — and any other `>` before a `/` or a `<` is reported | `total as Types . Alias<number> / 2`, `f<A> / x / 2` |
  | `identifier` | an identifier written with a `\u` escape, or a word cut short by `#` or a non-ASCII letter; a `)` whose `(` follows a character beyond ASCII that is neither trivia nor part of a name (a token the lexer does not know, so "no head" would rest on nothing) | `Al\u0069as<number> / 2` |
  | `operator` | a `/` after `...` or after a run of three signs; the second `<` of a shift, before a name; a `<` that opens a generic with a constraint right after `>=`, `>>=` or `>>>=` (the first `>` may end type arguments), after a `<`, after `in` or after `yield *`, where the text after it leaves both readings open — the element of a comparison, a test or a product, or the type parameters of an arrow: a parameter list that a `:` follows, or a head the reading cannot settle | `[.../a/.exec(s)]`, `a+++/x/`, `1<<n`, `a >= <T extends X>(y: T): T</T>`, `let x: A<number>= <T extends X>(y: T): T => y`, `a >>= <T extends X>(y: T): T => y`, `a < <T extends X>(y: T): T</T>`, `for (k in <T extends X>(y: T): T => y)`, `yield* <T extends X>(y: T): T => y` |
  | `newline` | a `/` or `<` on a later line than the operand before it (name, literal, `)` `]` `}` `>`), a `/` after `throw` and a line break, or a `<` that opens a generic with a constraint after `void` and a line break: ASI may have ended the statement there | `type Value = number` LF `/re/.exec(x)`, `let x: void` LF `<T extends X>(y: T): T => y` |
  | `jsx-type-arguments` | a JSX element whose type arguments hold a string, template, object or function type: TypeScript's rules apply there and the tag is scanned by a JSX attribute's | `<Component<"quote\"\<LF>/*"> …/>` |
  | `jsx-attribute` | a JSX attribute value whose quote does not follow its `=` at once (white space or a comment stands between them) and whose text holds a backslash: TypeScript scans it as a JavaScript string, with escapes, and other compilers as a JSX string, with none, so the two end it at different quotes and read different calls after it | `<C x= '\'/>;navigateTo(…);//' />` |
  | `jsx-open` | a `<` where an element may start that is not read as one (a space after it, or a name beyond ASCII: in code, in JSX text and as an attribute's value), or a closing tag that holds anything but trivia, comments and a name (dotted, or namespaced) before its `>`, so that where it ends is not known | `< div>x</div>`, `<A x=< B/>>…</A>`, `<A></A b>` |
  | `generic` | `<Name>` and a parameter list followed by `:`, which is the call signature of a type or JSX text that starts with a parenthesis (both compile; a list that can hold no parameters, `<li>(1): First</li>`, is the element's alone); or one followed by no return type, where a string, a template, a comment or a regex after it could hold its closing tag, or its text cannot be read to the end of an element, or the pass's budget of readings is spent (a signature with no return type is otherwise code, for certain, and JSX text that starts with a parenthesis an element: `callSignatureOrElement`); or a parameter list too long to scan (2,000 characters), or one with no arrow after it in which something may start an expression (`expressionMayStart`: an `=`, a `[` other than `[]`, an `@`, `import`, `extends` before what starts no type, and `get` or `set` before an accessor's name) and a `/`, a `<` or a back-tick follows that the scan does not read exactly (a first default's regex or element, a `/` after a name or a number that is no line comment's last word, a template with no `${`: `initializerReading`), or a template whose substitutions do not close or hold a `<` after an expression start (`templateSkipIsWhole`), or `<T extends` and a `/` that begins a regex which its line ends (an element where an expression starts, type parameters with a regex constraint in a type) — except directly after `type Name =`, where it is certainly a type. A `=>` after the list is a generic function type for certain when the element reading of the text up to the arrow cannot parse (`elementFailsAt`: a `>` or `}` in the text, a container that starts with a name, a number or a quoted name and a colon, two names, a bracket, a call or a quoted name and a call and a colon, a `<` and a character that cannot start a tag, …), and a guess, read as a type, when it may: the arrow inside the attribute string of a nested tag (TypeScript scans a JSX attribute string with no escapes), a closing tag, a fragment, a spread, a comment, an attribute value that does not follow its `=` at once, a character beyond ASCII where a token could start or end in a tag, or a container that holds a brace, a quote, a back-tick, a `/`, a backslash or a `<` and none of the shapes `elementFailsAt` knows; a `<` followed by `await` or `yield`, which are identifiers or not by the function around them | `interface I { <T>(x: T): T }`, `interface I { <T>(x); m: '</T>' }`, `<span>(required): Name</span>`, `<p>(a), b/c</p>`, `const f: <T>({ a = {} }: P) => T = g`, `const f: <T>(x: { 'a'?(y: T): T }) => T = g`, `async function p() { <await extends X>t</await> }` |
  | `fallback` | a quote with no closing quote on its line, read as an ordinary character; a `/` after a character that begins nothing in code | `const a = 'unterminated`, `# / 2 / 3` |

  Names alone in a tag's type arguments (`<DataGridRow<Row>`, `<Select<string | number>`) are certain: the scan
  and TypeScript agree, and the shipped samples use them. **When you change the lexer, a new judgement that
  rests on a heuristic must report itself, or be documented as certain in the table above `readPosition` in
  `source-literals.js`** — the aim is to stop finding these one class at a time. An identifier written with a
  `\u` escape in code is a frontier wherever it is, so a call spelled `navigate\u0054o(…)` is refused (escapes
  inside a quoted key are inside a string and are certain). Both defences are independent: the frontier
  and the residual net below. `tests/ambiguity-sweep.test.js` holds
  the soundness claim as a property — *a page that is not refused does what the original did, with each token
  its page id* — over a construct per kind × payloads, with line breaks around `!`, `++` and `--`, the
  restricted keywords, type aliases with no semicolon, templates whose lines start with `//` and hold a
  `${}`, and calls with a guess inside the options object and a getter, computed key or spread after it:
  plain-JavaScript pages are run, original and resolved, and compared (a call counts as navigation by its
  `pageType` at run time); with `TYPESCRIPT_ORACLE_PATH` set, TypeScript pages are checked against which calls
  the TypeScript parser says are real (unset, that part skips). With the oracle set, a second sweep puts **every
  punctuator and keyword TypeScript has** before a `<` — adjacent, after a space, after a line break — in front of an
  element, two generic arrows and four function types (plain, and with a type argument, an object type and a
  comma between type arguments in the parameter list), in about fifty frames (an operand, a head, a statement, a type
  …), each followed on the line by a regex that holds the text of a navigation call and then a real call
  (`tests/helpers/angle-frames.js`): wherever TypeScript parses the program and reads a regex, the token in it
  is never rewritten and the page is never accepted. And `source-literals.test.js` checks the table of tokens
  before a `<` (`ANGLE_AFTER`) against TypeScript: every token is classified or listed as one the table is
  never asked about, each class is how TypeScript reads a `<` after it, and the lexer never reads an element's
  text as code without reporting a guess. Further sweeps run against TypeScript's parser with every trivia
  character TypeScript skips between a token and a `<` or a `/`, in the head of a statement and before its regex,
  and with a `*` before a `<` (a generator mark, a delegation and a product), with a string in the head of a
  function after each token that leaves a head undecided (a backslash in it), an element as an attribute's value, a comment in a
  closing tag and a closing tag's name with a `:` or a `.` and trivia around it, a self-closing tag with trivia of every kind between its `/` and
  its `>` (in a plain tag, one with attributes, an attribute's value, a fragment, nested children), a hashbang, a regex with flags (valid, unknown, and beyond ASCII), a `/` or
  `<` after a regex, an element or a division the lexer read, a `/` or `<` after every shape of cast (and a `>` run after its closer), a comment in an object
  type, `async` before a `<`, and a
  call or construct signature with no return type and JSX text that starts with a parenthesis (each in every place a type
  with members or an element stands, followed by every ending that ends a member or goes on to another, and by a decoy —
  a string, a template, a comment or a regex that holds the closing tag of its head and the text of a call, and a type that
  holds the closing tag in a string as the text of a call signature's parameter, in a member name quoted after `public`, or in
  a regex after `implements`), a label and a list that no signature has (`(Size: 10 MB max, <a>…</a>)`, an object type that is none,
  `new`, `import`, an `=` and a closing tag: TypeScript rejects each in a call signature, a construct signature and a function type,
  and the element is certain) and a parameter list whose default is a regex, an element, a template or a division (read exactly where the
  scan can say where the list ends, a guess for the rest, and never a misread token), a regex or a division after a line comment (one that ends in a name, a number, a `$`,
  a letter beyond ASCII or a sign, and every line break TypeScript has, in the scan of a list, behind `>=`, `in` and the other tokens that leave a `<` undecided, and in the reading of the text after a signature), JSX text of several lines with a `//` in one and a
  `/` at the start of a later one (a URL on a line of its own, a label and a URL, a name and a note, with `;`, `,` and a line break after the `)`: read for certain wherever no type has the text, and the lines of every class that no type has —
  a bracket, a quote, a tag, an operator, a number, a modifier, a postfix, a line that goes on over a line break or after a type — are certain too; a closing tag in the comment, in an element and as the members of a type, a guess; and
  the same lines in an expression of a type, where a regex after the comment may hold the closing tag), a member named by a number with a signed exponent (`1e-5` is one name), the text of a nested element in a default value or
  a computed name (what it opens ends with it, unless the element may be code), a literal that holds the tags of an element (in one string, or `<b>` in one and `</b>` in the next), the text of a nested element drawn from quotes, back-ticks,
  comment openers and tags, a nested tag that no element has (type arguments, a comparison, the type parameters of a signature or a method, in front of a string that holds its closing tag: read both ways, and
  the closing tag of the head hidden) — drawn from every keyword TypeScript has, joined to names, dots, dashes and values, spread over lines (a line break of every kind before an `=` or a valued `get`; a formatter's layout), with fragments after every operand, after a comment of every ending and before a parenthesis, **the tags that the rule calls no code included**
  (`nestedTagPages`) — two or three nested elements whose texts open regions that end at one place (`nestedPairPages`), a keyword that is the name of an attribute with a value (certain: nothing is refused),
  strings and elements that hold fragments, tags with attributes and closing tags after such a tag (the quotes pair differently in the two readings), links and other tags with attributes in a
  hint (certain: their text is text), and a parenthesised callee after `return`
  — each read token by token against TypeScript's parser — and every claim of `typeMembersFail` that a text is no type's members, put to TypeScript's parser (every text of up to three tokens of a vocabulary of the pieces of a
  member and what is rejected there, seeded draws of more, runs of valid members mutated at one or two places, every keyword of TypeScript in each place a word may stand, after each of the words that start a type or a member, after a `.`,
  `typeof`, `infer`, `keyof`, `readonly` and `unique`, and a number of every spelling with a word, a quote or a `/` right after it, a `/` after every operand that ends in a bracket, import types and accessor names of every spelling, and tuples: `tests/helpers/type-member-texts.js`) — and a differential fuzz
  (`tests/jsx-element-reading.test.js`; `JSX_FUZZ_SCALE` multiplies it) checks every verdict of the element
  reading of a head and of a function type against it, and puts the text after a parameter list that neither an arrow
  nor a colon follows into a type and into an element, where TypeScript says which parse: a type that parses is never
  read as an element for certain, nor an element that parses as code, and an element with nothing a closing tag could
  hide in is never a guess. Where `BABEL_ORACLE_PATH` points at an `@babel/parser`
  package, the sweep of JSX attribute strings that follow trivia also parses each page with Babel — which reads
  such a string with no escapes, as TypeScript does not — and the lexer must have named the guess
  (`jsx-attribute`) on every page the two read differently.
  **`tests/valid-page-differential.test.js` is the regression net for what the rules refuse.** Its pages are
  programs TypeScript parses (`tests/helpers/valid-pages.js`: generic arrows and function expressions in every
  position, casts and `satisfies` with every operator and operand after them, instantiation expressions, elements whose
  text starts with a parenthesis, interfaces and type literals with every member form (call and construct signatures with
  and without a return type), generic function types in every position and with every kind of
  parameter list, and the statements an everyday page is made of (regexes with flags, tags with trivia between their `/` and `>`, closing tags with a spaced or dotted name, a comment in an object type), each with a navigation call after it and read
  again with the text of a call in data after it (a regex, a string of closing tags). The data is never
  rewritten; every page that is refused names its guess and belongs to a **recorded class** — the cases where two
  programs share the text up to the decision and compile differently, shown by a pair in the test — and a page
  in no class, or a class that no page meets, fails it. Where `BASE_LIB_PATH` points at the `scripts/lib` of an
  earlier lexer, the pages are read by that one too, and the test counts what each resolves and the data each
  rewrites. What that asks of the base depends on it: one from before the lexer was hardened misreads shapes the
  corpus holds, and the net must see at least 100 of its decoys rewritten (and none of this lexer's); the previous
  release reads every page of the corpus right, so it rewrites none and nothing is demanded of its count — the
  comparison is then the pages this lexer resolves and the base does not (at least 100), and the pages it refuses that
  the base resolves, of which there must be none: a page that 2.13.0 resolved and this lexer refuses is a regression, and the test fails on it. When a change makes the lexer refuse a shape it resolved, this is where it shows: add the shape to
  the generator, and fix the rule. **The oracle tests skip themselves where the variable is not set** (`TYPESCRIPT_ORACLE_PATH`, `BABEL_ORACLE_PATH`, `BASE_LIB_PATH`) **and fail where it is set and the package or the lexer cannot be loaded**
  (`tests/helpers/typescript-oracle.js` throws), so a wrong path or a renamed file cannot turn the job green without running them. The CI job `test-model-apps-lexer-oracles` (`.github/workflows/model-apps-script-tests.yml`) installs
  TypeScript 5.8.3 and `@babel/parser` 7.29.7 under `$RUNNER_TEMP` — outside the plugin, which ships no dependency, with `--ignore-scripts`, at exact versions — fetches the lexer of the 2.13.0 release by its commit hash, sets
  the three variables, and runs `node scripts/run-tests.js`. A new TypeScript release can change what a sweep judges: raise the pin in the same change, and run the sweeps.
- **The residual net does not depend on the lexer, and has no exemption.** After forward resolution the
  RESOLVED source is read again — the copy can read differently from the source, because each token became a
  longer page id, which can move a `(` out of the look-behind — as plain text: any `PAGEREF_` token left in it
  is refused (`pages-stray-pageref`), a comment's included. Nothing is exempt, so there is nothing for the
  lexer to be wrong about; the net can fire only on what the checks before it missed (a dangling target, a
  call that reaches the frontier, a page id that itself holds token-shaped text). The page build checks
  `residual` after resolution — after the targets were minted, because the copy needs their ids, and before
  any page is updated with it.
- **Reverse resolution never changes quoting, writes only what the forward path takes, and reports every id it
  leaves.** It turns a deployed id back into
  `"PAGEREF_<key>"` only for a **double-quoted** literal — what a build writes — in a call the checker trusts
  (wholly before the frontier). A single-quoted or back-ticked id, and one in a call that is not trusted, is left as
  the id. **It then reads what it would write the way the forward path reads it.** A token is usually longer than
  the id it replaces (`"<36 characters>"` against `"PAGEREF_<a long key>"`), so the code after it moves, and
  the lexer's bounded windows — the 2,000 characters it looks back for the `(` of an `if` head — can end before the
  construct they look for: a head read for certain with ids is a `paren` guess with tokens, after which promotion
  and the build refuse every call, with nothing said at the download. So the result is analysed (`rebuildRefusal`)
  and every token written must be the canonical `pageId` of a call the forward path trusts — a `pageref` with the
  key written, wholly before any frontier the result has — and must not sit inside another call's `pageId` value
  that is not a literal (`pageId: pick(navigateTo({ … "PAGEREF_x" }))`), which the forward path reports as malformed
  and the build halts on. If one is not, **nothing is written for the page**: it keeps its
  ids (a half-tokenised page is refused all the same), and every known id in it is reported with
  `why: 'would-not-rebuild'` and, when a frontier is to blame, `frontier: { kind, line, column }` in the columns of
  the page as it is. A token the page already held is not this check's business; only what reverse resolution
  writes decides. The invariant — *whatever reverse resolution writes, the forward path accepts unchanged, and
  resolving it gives the page back* — is tested across the look-behind boundary (`pageref-resolver.test.js`), over
  every sweep program with a short and a long key (`ambiguity-sweep.test.js`), and through a download. Then the
  RESULT is scanned as text for every known page id (whole, case-insensitive), and each one
  still in it is reported in `reverseResolveNavIdsReport` → `left: [{ id, key, line, column, why }]`, whether
  a recognised call holds it or not: `why` is `'quote'` (a recognised literal in other quotes), `'frontier'`
  (at or after the frontier, or in a call that reaches it; with `frontier: { kind, line, column }`, and
  `reaches: true` for the second), `'text'` (anything else: a record id, a string, a comment, an object
  built in a variable) or `'would-not-rebuild'` (above). The list comes from the text and not from the recognised
  calls because a frontier can
  hide a call, whose id then stays with nothing recognised to report it. `download-model-app.js` writes a
  `WARNING:` to stderr whenever any remains and returns `navIdsLeft`: a navigation call that keeps an id is a
  hardcoded page id, which a rebuild refuses; an id that is data can stay. What this closes: a single-quoted id
  inside a double-quoted string that the lexer had read as code became `"PAGEREF_detail"` and ended the string.
- Targets come back in source order (resolve and reverse build their output in one left-to-right pass).
- It is **linear in time and memory**: the brackets are matched once per source (a table, both directions), and
  each call's object is scanned for its own members only, jumping nested groups — never re-copied or
  re-scanned per call. A chain of n nested calls once cost n². A malformed value holds a **range of indexes**
  into the source's one sorted token list (`tokenFrom`, `tokenTo`), never a copy of its tokens — copying each
  value's tokens would grow as n²/2 (n = 8,192: 33.5 million entries, 256 MiB), so a value holds two indexes
  into the shared list — and `navMalformedRefs` reads each token once. The `performance:` tests in
  `genpage-lexer-hardening.test.js` guard the ratios and the size of what a result keeps alive. Keep any new
  scan on those tables.

Judge changes to it by **both** error directions, and weight them correctly:
- a false **accept** promotes prose as a page, and promotion is sticky — the page is then
  marked implemented and skipped on retry;
- a false **reject** blocks a real user mid-build, which is worse, because their page was
  fine.

`scripts/tests/source-literals.test.js` carries a **false-positive corpus** over every
committed `.tsx` in `samples/` and `evals/model-apps/genpage/fixtures/` (enumerated via
`git ls-files`, so it does not race the transient fixture dirs `capture-fixture.test.js`
creates). Two lexer bugs were invisible to hand-written cases and caught only by that
corpus — keep it, and add to it rather than around it. The worker-output gate
(`genpage-worker-output.test.js`) and the icon hook (`validate-icon-imports.test.js`, samples
only) run the same kind of corpus: every committed page must pass them, because a false reject
there throws away a good page or blocks a pattern the page builder was told to copy.

Residual limits, accepted deliberately:
- **`<` disambiguation is TypeScript's own rule for `.tsx`, which is structural, not semantic.** Where an
  assignment expression starts, a `<` opens a generic arrow's type parameters only by the rule above (a `,`, an
  `=`, or `extends` and a token that is not `=`, `>` or `/`); anything else is an element — and after a unary or
  binary operator it is an element whatever follows (the token before the `<` decides: `ANGLE_AFTER`). A page
  that wants a generic arrow writes `<T,>(x: T) => x` — as TypeScript requires in `.tsx`. A generic function type,
  `<T>(x: T) => T`, is a type wherever it stands when the element reading of its text cannot parse (`elementFailsAt`:
  a `>` or `}` in the text, a container that starts with a name, a number or a quoted name and a colon, two names, a
  bracket, a call or a quoted name and a call and a colon, a `<` and a character that cannot start a tag, …), which covers a type argument, an
  object type with named members, an index signature, `{ readonly a: T }`, a destructured parameter and a comma
  between type arguments in its parameter list. **What the reading cannot show to fail is a guess**: a parameter
  list whose object type holds a brace, a quote, a back-tick, a `/`, a backslash or a `<` in a shape the reading
  does not know (`{ a = {} }`, an optional or generic quoted method `{ 'a'?(y: T): T }`, a generic call signature `{ <U>(x: U): T }`),
  a tag name with a `-` (`Array<a-b>`), or white space beyond ASCII inside a tag, outside the
  right side of `type Name =`, is read as
  the type it almost always is and the guess `generic` is reported at the `<`: the page is complete, and a navigation
  call after it is refused, naming the guess (put the call before it, or give the member a name and a colon
  first). A shape the rule cannot settle at all (a signature followed by `:`) is the `generic` kind, never a certain
  one — except directly after `type Name =`, where nothing but a type can stand. A navigation call after one is
  refused naming the guess, and a page that holds a call signature in an interface is read as an element whose text
  runs on, which the structure gate refuses as truncated, as it always has; JSX text that starts with a parenthesis
  and a colon is read right, and only the call after it is refused. Put the call before the construct. A call or
  construct signature with no return type is not that shape: it is code, for certain, and JSX text that starts with a
  parenthesis an element, wherever what follows the `)` or the text read to its closing tag shows it (4); only a type
  that holds its own closing tag in a string, a template, a comment or a regex, or text the reading cannot take to an end, is the guess.
- **Valid pages that are refused, each in a recorded class** (`valid-page-differential.test.js`): the lexer refuses
  a page only where two programs share the text up to the decision and compile differently. A `>` that is not the end of `=>`, closes no cast the lexer can prove and ends no element or regex it read,
  before a `/` or a `<` (`angle`: `a > /re/` is a comparison and a regex, `f<A> / x / 2` is type arguments and a division, and only a type parser tells whether what stands between
  the `<` and the `>` is a list of types); a generic call signature with a return type in an object type, which is JSX
  text that starts with a parenthesis and a colon in an expression, a call signature with no return type whose type holds
  its own closing tag in a string, a template or a comment, which is an element that ends there, and JSX text that starts with a parenthesis and then holds a `//`, a `/*` or a
  template after a separator, which is the comment or the template of a type's members that holds the closing tag, and JSX text that starts with a parenthesis and holds a default whose
  regex, element, division or template the scan does not read (`(a = f(/x/))`, `(a = <b>c</b> / 2)`, ``(a = `${b}`)``: the `)` it finds may be one of that expression's), and a `/` that a line comment stands before
  (`(a = 1 + // c` and a line break, then `/[)]/)`: the comment may end in a word, and the regex is no division), and JSX text of lines with a `//` in one and a `/` at the
  start of a later one, where the members of a type could hold the closing tag in the comment (the next bullet), and
  `<T extends />` with another `/` after it on its line (`generic`); and a `/` on a later line than its operand
  (`newline`). A `/` or `<` that follows a `/` is no class of its own: where the lexer read the regex (flags included), the element or
  the division itself, it knows what ended there (`ends` in `lexInto`), and the page resolves — and so does a cast it proves (`castKeywordIsCertain`). What stays the `angle` guess among casts is a limit of the walk back over the type, not a place where two programs share
  their text — TypeScript reads each of these one way: a type that begins with `keyof`, `typeof`, `unique` or `readonly`, or is parenthesised; a `>>` that touches a cast's closer (`x as A<B>> / 2`); a chain past `CAST_CHAIN_LIMIT`; and, as the `newline` guess, a line break after a cast type that ends in a name or a `]`.
  A head with a constraint and a parameter list that a `:` follows, after the cut `>=`, `>>=` or `>>>=`,
  is the same kind of place (`operator`: `a >= <T extends X>(y: T): T</T>` is an element,
  `let x: A<number>= <T extends X>(y: T): T => y` an arrow); no generated page has it, so a pair of programs stands
  for it. Each says which it is and what to change in the report, and nearly every other page that compiles resolves.
- **JSX text of lines, one with a `//` in it and a later one that starts with a `/`, is read for certain wherever no type has the text, and is a guess where the reading of a type's members cannot show it.** `<p>(a);` and a line break,
  then `https://contoso.com`, a line break and `/docs`, is the text of an element, and the same text could be the members of a type — a name, a `:`, a comment, then a `/` where a type must be, which no type has (TS1110). The lexer
  reads the text after a signature's `)` as those members (`typeMembersFail`, above), and a `/` after the comments that the text opened follows the token before them (`slashFollowsAnOperand`), so a URL, a label and a URL, a name
  and a note, and every line of the classes below resolve: a line that begins with a bracket, a quote, a tag or an operator (`(x) // c`, `[x] https://x.y`, `'x': https://x.y`, `<b/> // c`, `|| // c`), has a number for its name
  or ends in a postfix (`1.5 https://x.y`, `x! // c`, `f() // c`, `x = 1 https://x.y`), starts with a modifier (`readonly https://x.y`, `in // note`, `default // note`: where a member begins these are names, and a `/` after one is
  TS1131), or goes on over a line break or after a type (`m` and a line break, then `&"x"`; `a: A.B, // c`). The base took the last word of the comment for the token before the `/`, which is right for these pages and wrong for others
  (the misreads that this lexer refuses). **Where a member of a type may begin at the head, a few lines are still refused after the guess `generic`, though only the element compiles, and the base may have resolved them**: a line
  whose last token before the `//` is a `[` that nothing closes on its line (`[ // note` and a line break, then `/path`), an initializer's `=` after a `?`, a type, a computed name or in a parameter list (`m? = // note`,
  `m: A = // note`, `f(a = // note`, `[ x = // note`), or an operator in such an expression (`m: A = b + // note`). A regex may begin at the `/` of the next line, and the closing tag may be inside it; both programs compile
  (the pairs of `valid-page-differential.test.js` are the proof), and 2.13.0 takes the word that ends the comment for the token before the `/`, which resolves the page of the element and rewrites the data of the other. Their
  shape cannot be told apart without a parser: `return <p>(a);` and a line break, then `[ // note`, is the same text in an interface that has a method named `return` and in a function that returns an element, and `return <T>(x)` and
  `m? <T>(x)` are why no place that a member may begin is excluded (above). A line that ends in a `[` after a `:` or a type is none of them, since a tuple's or an indexed access's first type is no `/` (`Total: [ // note`: TS1110), and is certain
  in every place. A rule over the regex that the next line's `/` would open could win more of these pages back — where it ends at the closing tag's own `/` (or the regex does not close on its line) and what follows the tag cannot be an operand, only the element compiles — and no rule reads that
  today. After an `=`, a `(`, a `[`, a `:` or an `=>` the same lines are certain (`NO_MEMBER_BEFORE`),
  and these lines are certain in every place: a name, a keyword or a signature and a `(` that is not closed on its line (`foo( // note`, `if ( // c`, `m(see docs) // c`: TS1138 at the `/`, TS1003 or TS1005 in the list), and a name, a number, a
  string or a signature with a `=` right after it on its line (`x = // note`, `1.5 = 1 // c`: TS1131). What the reading does not follow — a conditional type, an expression, a decorator, a `{` — leaves the type undecided, and the
  page is refused only where an opener after it (a `/` after an operator or an open bracket, a quote, a back-tick) could hold the closing tag. `valid-page-differential.test.js` with `BASE_LIB_PATH` fails on a page of its corpus that
  2.13.0 resolves and this lexer refuses, and it reports how many pages of the member-position lines 2.13.0 resolves (they are not in that corpus: they are the class above). Put the call before the text, or write the line with one name before the `//`.
- **Shapes of type parameters among a type's members are read as the element they are not, exactly as 2.13.0 reads them** (the same output in 2.13.0 and in this lexer, measured over every keyword TypeScript has): type parameters
  that start with a modifier — a variance modifier (`<in U>(y): void`, `<out U>`, `<in out U>`, also with no return type) or one that only the checker rejects (`<public U>(y): void`, `private`, `protected`, `readonly`, `static`, `abstract`,
  `async`, `declare`, `override`, `accessor`) — and an empty list (`<>(y): void`, `new <>(y)`), wherever a member of an interface or an object type begins (a page is refused at an earlier call signature that was guessed at; a `const` is refused too, a guess); and a method named by `delete`,
  `typeof`, `void` or `instanceof` (`delete<U>(y): void`, `typeof<U>()`), where the other 79 keywords are refused. A `<` after a keyword, or a head that starts with a reserved word, a modifier or `>`, is the tag of an element to the lexer, and a type reads it
  as type parameters. Where a string, a template or a comment later in the same type holds a closing tag with the name, a `;` and the text of a navigation call (`<in U>(y): void; a: '</U>;navigateTo(…);//'`), the element ends there and the call
  in the string is read as code and rewritten. Where nothing closes the element, the rest of the page is its text, which is read as code where it has a container: a typed API interface (`interface Api { list<T>(): Promise<T[]>; delete<T>(url: string): Promise<T>; }`)
  followed by a handler builds as it should, since the handler's container is code in the swallowed text, and a `{…navigateTo({ pageType: "generative", pageId: "PAGEREF_x" })…}` in a later comment or template literal is
  rewritten, though it is data — so this is not a failure that is always loud. No page in the committed corpus has these shapes, and telling a type's members from an expression is what the lexer has no parser to do. (The same words in a
  *nested* tag, `m(p = x <a in b> '</a>' + '</T>…')` or `m: Array<keyof X>`, are read both ways: `tagMayBeCode`.)
- **A JSX attribute string after trivia is a guess when its text holds a backslash** (`jsx-attribute`): TypeScript
  reads a quote that does not follow its `=` at once as a JavaScript string, with escapes, and Babel and esbuild
  read a JSX string with none, so a page whose string ends at different quotes in the two does different things
  with the text after it, and neither reading is trusted. Write the value right after the `=`. An element or a
  fragment written as an attribute's value (`x=<B/>`, `x= <B>…</B>`) is not part of this: it is read exactly, as
  TypeScript's `parseJsxAttribute` does (`parseJsxElementOrSelfClosingElementOrFragment` for the value), so the
  `>` that ends it is the one that ends it and no token inside it is taken for code; only a `<` there that cannot
  start an element (`x=< B/>`) is the `jsx-open` guess.
- **Look-ahead is bounded** (`LOOKAHEAD`, 2,000 chars) so a stray `<` or `)` cannot walk the file. Where a window
  runs out the decision is a guess and reports itself (`paren`, `angle` or `generic`): a navigation call after
  it is not trusted, and its token is reported rather than rewritten.

None of these shapes occurs in the committed corpus (the test over it asserts that no page reports a guess),
and each fails *loudly* (exit 3, retryable) rather than silently. If you hit one, widen the tests first.

## Hooks & Validators

Hooks are registered centrally in `hooks/hooks.json` (auto-loaded by the plugin
host). Validators **fail open** on any internal error (exit 0). The icon validator
blocks only on a real generated-page violation (exit 2); the write-safety guard is
non-blocking by design (exit 1), so hook bugs do not break `/genpage` or
`/app-builder` authoring.

- **PostToolUse(Skill)** — `run-skill-posttool-validation.js` runs a skill's
  `skills/<skill>/scripts/validate*.js` when present. Tracked skills are discovered
  from `skills/*/SKILL.md` by `scripts/lib/modelapps-hook-utils.js`, so both
  `/genpage` and `/app-builder` are tracked automatically (the telemetry control
  skill is excluded from tracking).
- **PostToolUse(Write|Edit|MultiEdit)** — `validate-icon-imports.js` validates
  `@fluentui/react-icons` named imports in generated `.tsx` pages against
  `references/verified-icons.txt`, automating the page-builder's manual grep. It is
  gated to generated-page output (the file's `export default GeneratedComponent`
  marker) or plugin-specific sibling markers (`genpage-plan.md` or
  `model-app-plan.md`). It intentionally does **not** use `app-spec.json` as a
  sibling marker because this validator blocks (exit 2) and `app-spec.json` is too
  generic for a globally installed hook.
- **PreToolUse(Write|Edit|MultiEdit)** — `validate-write-safety.js` **flags
  (non-blocking, exit 1)** writes outside the cwd, and only during an active
  model-apps authoring session (`genpage-plan.md`, `model-app-plan.md`, or
  `app-spec.json` at/under cwd). It accepts `app-spec.json` because a false
  positive only warns; it never blocks and is a clean no-op in unrelated projects.
  Silence with `MODEL_APPS_SKIP_WRITE_GUARD=1`.
- **Master kill-switch** — `MODEL_APPS_DISABLE_HOOKS=1` (or `true`) disables **all**
  model-apps hooks (validators + telemetry emit); checked before any stdin/work.
  Both escape hatches are documented in `README.md`.

## Telemetry

This plugin ships 1DS telemetry for `skill_started`. The canonical library is the
repo-root `shared/telemetry/`; `scripts/lib/telemetry/lib` is a **physical copy**
(never a symlink) so installed plugins don't depend on symlink handling. Edit
`shared/telemetry/lib/` first, then refresh this plugin's copy in the same change.

- **Posture:** the committed `ikey.json` ships **live** (`disabled: false`) with this
  plugin's own per-region keys (`internal`, `us`, `eu`, `gov`, `high`, `dod`, `mooncake`;
  US and EU share the public key and differ only by collector) and the Power Apps client
  `event` stream. `disabled: true` is the repo-wide hard-off (no POST,
  no local log). **Provision a fresh key; never copy another plugin's `ikey.json`**
  (CI-enforced: `node scripts/validate-telemetry-ikeys.js`).
- **Wire shape:** these tenants ingest **only** the `event` stream, into the Power Apps client
  `event` table; any other stream name is accepted by the collector and then dropped. So
  `resolver.js` also exports `formatEnvelope`, which mirrors mobile-apps' envelope:
  `app_Name: "powerappsclient"`, `event_Name`, `session_Id`, `tenantId`, and every other
  allowlisted field as a `customDimensions` JSON string. `clientType` is
  **`ModelAppsAIPlugin`** (never mobile's `PowerAppsNative`), so rows are separable from
  real client traffic. Query the tenant's `event` table (raw 1DS copies prefix columns with `data_`):
  `event | where clientType == "ModelAppsAIPlugin" | extend d = parse_json(customDimensions)
  | summarize count() by tostring(d.pluginName), tostring(d.skillName)`.
  The local mirror keeps the flat, readable shape.
- **Routing:** `scripts/lib/telemetry/resolver.js` picks the key + collector per event.
  `region/` beside it is a **verbatim copy** of power-pages' Artemis geo + cloud-stamp
  router — refresh it by copying, and keep model-apps logic in `resolver.js`
  (`telemetry-lib-copy.test.js` fails on drift, and the workflow runs on a power-pages
  `region/` change). A public-cloud
  org routes to `us`/`eu` by its geo (cached per org for 24 h under
  `~/.power-platform-skills/region-cache/`, shared with power-pages — it stores only the
  region, never a key); if its geo can't be determined (Artemis down, unknown geo) it sends
  nothing rather than defaulting to US, and only a signed-out PAC (no org) takes the `us`
  default. Unlike power-pages, a sovereign or internal PAC cloud (`UsGov`,
  `UsGovHigh`, `UsGovDod`, `China`, `Tip1`/`Tip2`/`Test`/`Preprod`) routes from the stamp
  alone, with no Artemis call and **never** a fallback to the public collector; an
  unrecognized cloud, or a signed-in org with no cloud line, sends nothing. `ikey.json`
  deliberately has no top-level static key, so a `null` resolution really does mean no
  transmission.
- **Emission:** `hooks/run-skill-pretool-telemetry.js` (PreToolUse Skill) and
  `hooks/run-user-prompt-telemetry.js` (UserPromptSubmit `/model-apps:<skill>`).
- **Privacy:** telemetry is default-on: events can include Dataverse organization and Entra tenant GUIDs
  when PAC is signed in, never the signed-in user's Entra object ID, and the local diagnostic mirror
  (`~/.power-platform-skills/telemetry/model-apps/sessions/<id>/events.jsonl`) retains the same
  fields — it is still written after a user opts out of transmission via
  `/model-apps:telemetry off`. CI/automation opt out via
  `POWER_PLATFORM_SKILLS_TELEMETRY_MODEL_APPS_OPTOUT=1` (highest precedence).
- **Fail closed:** telemetry never changes a script's exit code; emission is
  fire-and-forget via a detached dispatcher child. See `shared/telemetry/README.md`.

## Development Standards

- **React 17 + TypeScript** — all generated code
- **Fluent UI V9** — `@fluentui/react-components` exclusively (DatePicker from `@fluentui/react-datepicker-compat`, TimePicker from `@fluentui/react-timepicker-compat`)
- **Single file architecture** — all components, utilities, styles in one `.tsx` file
- **No external libraries** — only React, Fluent UI V9, approved Fluent icons, D3.js for charts
- **Type-safe DataAPI** — use RuntimeTypes when Dataverse entities are involved
- **Responsive design** — flexbox, relative units, never `100vh`/`100vw`
- **Accessibility** — WCAG AA, ARIA labels, keyboard navigation, semantic HTML
- **Complete code** — no placeholders, TODOs, or ellipses in final output

## CLI argument contract

**Every `scripts/*.js` entry point declares its flags and validates them up front.** `parseArgs`
accepts any `--name`, and an unrecognised flag is both dropped silently *and* swallows the token
after it — so a typo does not fail, it quietly changes what the command does. Measured on
`build-model-app.js` before this was enforced: `--stage ui` planned 3 steps, while `--stagee ui`
planned all 9 and still exited 0, turning a scoped UI apply into a full data-model apply.

So a `main()` starts with:

```js
const argv = process.argv.slice(2);
const { positional, flags } = parseArgs(argv);
const flagError = validateFlags(argv, {
  known: ['env', 'spec', 'stage', 'apply'],   // every flag this CLI accepts
  needValue: ['env', 'spec', 'stage'],        // those that must carry a value
  hints: { stage: 'one of: data, ui, app, publish' }, // optional, for closed value sets
});
if (flagError) { process.stderr.write(`✗ ${flagError}\n${USAGE}\n`); process.exit(1); }
```

`validateFlags` (`scripts/lib/dataverse-auth.js`) rejects unknown flags with a single-edit "did you
mean" (via `scripts/lib/nearest-name.js`, shared with the FetchXML operator lint), and rejects a
value-bearing flag passed bare or empty. It reads flag names from `argv` rather than the parsed
object, because `--__proto__` goes through the inherited setter and never becomes an own property.

Two consequences worth knowing:

- Because `validateFlags` guarantees a `needValue` flag is either absent or a non-empty string,
  `typeof flags.x === 'string' ? flags.x : undefined` is redundant afterwards — read `flags.x`.
- `needValue` must be a subset of `known`; `validateFlags` throws if it is not, which catches a
  rename applied to one list and not the other.

When a CLI test harness cans `parseArgs` to a fixed result, use
`scripts/tests/helpers/fake-auth.js` → `validateFlagsFromParsed` so the harness exercises the **real**
validator instead of a hand-written copy that can drift from it.

**Testing a CLI end to end:** `scripts/tests/helpers/cli-harness.js` → `loadCli(scriptPath, { requires, argv })`
loads an entry point with injectable module stubs and a shadowed `process`, so a test can drive
`main()` and assert the **wire calls** it makes (the Dataverse requests, the `AddSolutionComponent`
component types, the temp-workspace cleanup) rather than regex-matching the source. It uses
`vm.compileFunction`, **not** `vm.runInNewContext`: a new VM context is a separate realm with its own
`Array`/`Object` prototypes, so every array the script builds would fail `assert.deepStrictEqual`
against a host array with "same structure but not reference-equal".

**Testing a CLI in a real process:** what only a process shows — its exit status, the one line on stdout and
that line's bytes — is tested by spawning the script. `scripts/tests/helpers/fake-org-languages-preload.js`
(`node --require …`) swaps the az-token client for a fake Dataverse that answers the three language reads, so
`list-languages.test.js` runs the real script, bundle and `emitResult` with no Azure CLI and no network; with
the PATH emptied, the same test shows what a machine with no `az` gets.

## Dataverse Access From Scripts

**Default: go through the vendored SDK.** Anything the SDK models — tables, columns, relationships,
views, charts, forms, commands, dashboards, app modules, sitemaps, solutions, roles, settings — is
read and written through `createMakerSdk`. That is not style: the SDK persists metadata under
`<app-folder>/.maker-workspace/` for reuse and edits, resolves artifact identity the same way the
build does, and owns retry/pagination behaviour. A read that bypasses it can disagree with the write
about which artifact it is talking about.

Two escape hatches exist, and both are deliberate. The maker SDK models the *maker* surface; parts of
Dataverse simply are not in it.

| Hatch | Use for | Examples in tree |
|---|---|---|
| `dataverseRequest()` in `lib/dataverse-auth.js` (and the `dataverse-request.js` CLI) | Dataverse surfaces the SDK does not model at all | `WhoAmI` (`check-auth.js`), `customapis` (`list-custom-apis.js`), `connectionreferences` (`create-connection-reference.js`), solution-component adds (`add-page-to-solution.js`) |
| The raw `httpClient` from `createAzHttpClient` | A surface the SDK *does* touch but whose response it **projects away** | **No caller today.** The one that existed — `entityPrivileges` in `verify-model-app.js` — is gone: the SDK had no privilege READ at all, and `fetchEntityMetadata` drops `Privileges` permanently by design, so the check composed its own `EntityDefinitions(...)?$select=Privileges` request. The vendored bundle now carries `getEntityPrivileges`, and the reader takes it. The hatch stays documented because the *category* recurs; opening it again needs the same justification |

**Prefer `dataverseRequest()` over the raw client.** It already handles the API path, auth, headers
and timeouts. Reach for `httpClient` only when you must share the exact client instance the SDK is
using — and check first that the SDK has not since grown the method, as it did for entity privileges.

When you do go direct, all four of these apply:

1. **Comment WHY the SDK cannot serve it** — name the SDK method you would otherwise call and what it
   drops or lacks. "Deliberately not `sdk.fetchEntityMetadata`" is the difference between a
   documented exception and something a later reader "simplifies" back into a silent bug. Say what
   would retire the hatch, so the note is actionable rather than permanent.
2. **Absolute URL including `/api/data/v9.2`** when using the raw `httpClient`. It is the transport
   the SDK drives, so it takes full request URLs and validates them with `new URL(url)` for its
   same-origin guard — a relative path throws there rather than resolving against the org.
3. **GUIDs unquoted.** Record ids and `_x_value` lookups are `Edm.Guid`; `id eq '<guid>'` fails with
   *"A binary operator with incompatible types was detected"*. See `references/troubleshooting.md`.
4. **Test the reader itself, not only an injected stub.** The `entityPrivileges` URL bug shipped
   because every test injected a fake reader into `verifySpec`, so the real one was never executed —
   and `verify-spec` catches per-entity read failures, so it would have failed silently on every live
   run rather than crashing. Drive at least one test through the real seam: the client's request path
   for a raw read, or the **real vendored bundle** for one that goes through the SDK. A hand-written
   SDK stub proves only that the mapping is self-consistent with itself.


- Keep SKILL.md under 500 lines
- Use short, descriptive `name` field (e.g., `genpage`)
- Write descriptions in third person ("Creates X" not "This skill guides you through creating X")
- Use progressive disclosure: SKILL.md for workflow, reference files for details
- Link to references inline: `See [troubleshooting.md](../../references/troubleshooting.md)`
- Immediately after the frontmatter of every user-invocable skill, run
  `node "${PLUGIN_ROOT}/scripts/check-version.js"` and show any output before
  proceeding. The check is best-effort and must never block skill execution.

## Building & Testing

**One-command regression gate (run before every commit)** — from `plugins/model-apps/`:

```bash
# Plugin unit suite only (node:test):
node scripts/run-tests.js

# Plugin suite + the vendored SDK's Jest suite (Node 20):
NODE20_BIN=/path/to/node20/bin node scripts/run-tests.js --with-sdk /path/to/power-platform-ux
```

- `run-tests.js` runs the full `scripts/tests/*.test.js` suite and prints a combined PASS/FAIL.
  **CI runs this same command** (`.github/workflows/model-apps-script-tests.yml`) on any PR into `main`
  touching `plugins/model-apps/**`, `evals/model-apps/**`, `shared/telemetry/**`, `shared/skills/**` or the
  workflow itself, across ubuntu × windows × macos and Node 20 × 22.
  Keep `POWER_PLATFORM_SKILLS_TELEMETRY_MODEL_APPS_OPTOUT: "1"` on any new job that could run a
  telemetry-emitting hook or script.
- The SDK's Jest suite needs **Node 20** (its `canvas` native module is built for the Node-20 ABI).
  Set `NODE20_BIN` to a Node-20 bin dir; without it the SDK suite is skipped (plugin suite still runs).
- Evals: `node --test evals/model-apps/tests/*.test.js evals/model-apps/app-builder/tests/*.test.js
  evals/model-apps/genpage/tests/*.test.js` (the first directory holds the cross-runner coverage
  contract), plus the runners (`node evals/model-apps/genpage/run-layer-{1,2}.js --tier smoke`,
  `node evals/model-apps/app-builder/run-app-builder.js`). See `## Eval Suite` below. CI's
  `test-model-apps-evals` job runs the eval tests AND all three runners (genpage Layer 1/2, app-builder)
  over every committed fixture — offline, no org — so a fixture that falls out of step fails the PR.

**The vendored SDK lives in a separate repo.** The Dataverse mechanics are in
`power-platform-ux` (Azure DevOps `msazure/OneAgile`), package `packages/cds-maker-sdk`. This plugin
ships a **self-contained bundle** at `scripts/vendor/cds-maker-sdk.cjs` (NOT the SDK source). The SDK
owns deterministic wire formats (create/query/delete, AI settings/row-summaries, `seedRecordGraph`,
`enrichDefaultViews`, artifact resolve/cascade); the plugin owns judgment (spec validation, candidate
selection, prompt authoring). To change SDK behavior:

```bash
# 1. Build the SDK (emits lib/) — from <ppux>/packages/cds-maker-sdk:
npm run build          # tsc/ppux-build
npm test               # Jest (Node 20)
npm run lint           # ppux-lint (Node 20)

# 2. Rebuild the vendored bundle here (reads the SDK's lib/, so build the SDK first) — from repo root:
node plugins/model-apps/scripts/_vendor-build/build.js --sdk /path/to/power-platform-ux
# → rewrites scripts/vendor/cds-maker-sdk.cjs (~540 KB). COMMIT the rebuilt bundle.
```

Only the SDK `src/` is committed in the SDK repo (`lib/` is gitignored). A type-only/whitespace SDK
edit produces a byte-identical `lib/*.js`, so the bundle only needs rebuilding when SDK **runtime**
changes. **Never patch the bundle** to work around an SDK defect: the next re-vendor silently
reverts it and the hash in `PROVENANCE.json` stops matching. Fix it upstream and re-vendor.

**Vendored-SDK contract invariants (regression net).** When you bump the SDK and re-vendor, the
skill relies on behaviors that must survive. The test files below lock them — run all against every
rebuilt bundle.

**Re-vendor from a COMMIT, and check the recorded provenance.** `scripts/_vendor-build/build.js`
writes `scripts/vendor/PROVENANCE.json` next to the bundle: the upstream SHA and subject, whether
that package had uncommitted changes, and the bundle's own sha256. Two things make this
load-bearing rather than bookkeeping. First, the bundler consumes the SDK's **gitignored `lib/`**,
a build output that can be arbitrarily older than `src/` — a bundle shipped in this repo was once
built from a stale `lib/` several commits behind its nominal source, and nothing could reveal it.
The bundler now **refuses** (exit 3) when `lib/index.js` predates the newest `.ts` under `src/`.
Second, "built from master" is not provenance, because master moves; the SHA is what lets a
reviewer reproduce the artifact. Re-running the build on the same inputs must reproduce the same
sha256 — check it.

⚠ **`subject` is the subject of the commit the bundle was BUILT FROM — normally master's HEAD — and
is usually unrelated to the change you are taking up.** It is recorded verbatim from git on purpose
(`sanitize-subject.js` only strips merge-tool prefixes), because editorialising it would break the
one thing provenance is for. So expect it to read like `Revert 'fix: scheduled trigger skips its
first run…'` while the uptake is about AI settings: master simply moved on after the commit that
carried the fix. Do not "correct" it, and do not read it as a description of the uptake — name the
change in `CHANGELOG.md` instead, which is where a reader looks for what actually arrived.

`scripts/tests/sdk-surface-contract.test.js` — the **method-presence** guard. Asserts every SDK
method the engines call (`SKILL_SDK_SURFACE`, kept in sync with the `provision.*` / `sdk.*` call
sites by a source-scan test that also covers `artifact-intent.js`) is a function on the real vendored
bundle. A re-vendored SDK that **renames or removes** a method the skill uses fails HERE, listing the
exact names — instead of silently at build time (the mock-based `sdk-build`/`sdk-teardown` suites
can't catch that, since the mock mimics the old interface). The skill drives Dataverse through the
SDK's **generic** surface (`createArtifact`/`addElement`/`updateElement`/`removeElement`/`getArtifact`/
`fetchArtifact`/`pushArtifact`), NOT per-artifact mutators — a bundle that drops the generic surface
fails here. Update `SKILL_SDK_SURFACE` **and** migrate the call sites together.

`scripts/tests/hardening2-real-bundle.test.js` — **compiler↔adapter integration** against the real
bundle: it drives `artifact-intent.js` + the generic surface exactly as the engine does and asserts
the real wire output — **parity** with a pre-swap golden (`fixtures/parity-golden.json`, via the pure
`wire-facts.js` normalizer), multi-tab/section create, **metadata-derived control classIds** (a Lookup
and a String field get DIFFERENT classids — the adapter defaults them from attribute type, T4, so the
plugin must NOT precompute classId), sub-grid relationship/target/view serialization, `/bag/c` events
**merge** (exactly one `<events>` root on a rebuild), field removal, and the **412 → failed
`PushResult`** signal `requireSuccessfulPush` halts on. The mock-based `sdk-build.test.js` covers the
engine ORCHESTRATION (call order, idempotency, phase selection); this covers what a mock cannot.

`scripts/tests/form-timeline-real-bundle.test.js` (with `form-timeline.test.js` for the pure helpers) — the
**Notes timeline shape** against the real bundle: the exact FormXML a new host serializes to (control id
`notescontrol`, a 23-row `auto="false"` cell, 22 empty rows, no duplicate control id), the engine's own
create path, the in-place repair of an old-shape host on an explicit and an auto layout (same section,
position and cell identity; ONE atomic write; a second rebuild mutates nothing), hosts of the heights and
widths platform forms use (a 5-, 20- or 23-row cell, with or without `auto`, a widened timeline) that a
rebuild leaves alone and verify accepts, a host that only lacks rows being topped up to its own height, the
refusals (a maker's own timeline, a taken `notescontrol`, a host with a field added), the **order** of the
repair (a field the layout dropped, or one a `fieldOptions` anchor moves, leaves the old host first so the
SAME build repairs it), and what `--verify` reports — with the advice that follows from the reason — before
the build as well as after it: for each way a field leaves the host or stays in it (including a chain of
anchors, one anchored to another that is itself moved out, up to three links), the advice has to be what a
real build then does (`verify before the build and the build itself agree about …`); for a field the form has
twice (sections the build orders differently from the published form, a pruned field removed one cell per
run, a chain of anchors that reaches one, a taken `notescontrol` beside one) verify says it cannot tell, and
what the build then does is pinned both ways. `form-timeline.test.js` pins each rule of
verify's `notesHostInspector` on its own, one inspector asked about every cell as `--verify` does. It shares
`tests/helpers/form-store-harness.js` — the SDK over a stub HTTP client that stores one systemform row.

`scripts/tests/form-subgrid-placement-real-bundle.test.js` (with `form-subgrid-placement.test.js` for the pure
rules, the spec gate and lint parity, verify over hand-built FormXML, the wireframe and the plan) — **sub-grid
placement** against the real bundle: where a new grid lands (between sections, before/after an anchor, in a tab
or form-column, on an auto layout), a rebuild moving the grid an older build appended into place (one move OF
THE GRID, the same section id, a second rebuild writes nothing), **the form the released builds deployed**
(`notes: true` + a grid behind the Notes section, the old timeline: every placement ends in the order a new
form gets, the timeline repaired in place, nothing but the grid moving), a section between a grid and its
anchor left behind the grid, the refusals (a grid in a maker's section — reported only when it is NOT already
in place — an anchor the deployed form lacks),
the sweep of an emptied same-named section still resolving after a grid shifted the indices, **fields the
build adds** landing in their own section whichever way a grid shifts the sections around it (an auto
form, a section the spec adds, a grid added in front, leaving the column, moving down past it or up from
behind it — each of which wrote the field into the grid's section or halted the forms phase when the field
was queued by index — and a section that is gone altogether), and a recorded
**regression guard** — a spec without placement keys writes exactly what it always did.

`scripts/tests/vendor-sdk-smoke.test.js` — the **behavior/return-shape** `CONTRACT:` tests (drive the
public `createMakerSdk` factory — the `MakerSdk`/`AppAdapter` classes are no longer bundle exports):
- **Raw OData filters pass through, single-encoded** — the skill builds raw `$filter` strings
  (quoted string literals via `lib/odata.js`, and **unquoted GUID literals** like `objectid eq <guid>`);
  a query builder may transport-encode them but must not double-encode.
- **Name-based methods accept logical/unique/schema names verbatim** — `deleteTable`, `setEntityIcon`,
  `resolveArtifact({uniqueName}/{name}/{entity})`, `createRelationship({referencedEntity,…})`. A GUID
  normalizer must apply to GUID params ONLY, never to these names.
- **Sitemap free-text (titles/URLs/descriptions) is XML-escaped, not rejected** — a "safe DOM factory"
  must escape attribute/text VALUES while only validating element/attribute NAMES.
- **`deleteAppCascade` returns a structured `{ success, deleted, failures }` result** — teardown
  reads `failures` to report orphaned sitemap/genpage rows instead of claiming a clean delete; the
  bundle must keep returning the result (not void) after a re-vendor.
- **`seedRecordGraph` returns `{ createdIds: { <entityLogical>: [ids] } }`** and dedups only on an
  explicit **`matchOn`** key (it NEVER falls back to the primary display name — `buildSeedGroup`
  supplies `matchOn` from a single-column alternate key or the primary name, validated non-empty).

`scripts/tests/sdk-async-surface.test.js` — the **sync-vs-async** guard, and the one to read first
after a re-vendor. The SDK's generic surface is asynchronous (`addElement`, `findElements`,
`getArtifact`, `moveElement`, `queryTree`, `removeElement`, `updateElement` all return Promises; a
read may revalidate against the server before serving its cached copy). Forgetting an `await` does
**not** throw: a Promise is truthy, so the engine's `|| {}` fallbacks stay dormant and the pure
helpers silently receive a Promise — `hasSubgrid` → `false` (duplicate sub-grid), `formFieldLogicals`
→ `[]` (every field looks missing), `findFieldCellPointer` → `null` (a removal never happens). Wrong
artifacts, 2xx statuses, green build. The test therefore does two things: a **source scan** that
fails on any un-awaited `provision.*`/`sdk.*` call to those methods (annotate a deliberate one with
`sdk-async-ok`), and a **dynamic check** that the real bundle still returns Promises for exactly that
list — so if a future SDK makes one synchronous again, the scan can't go on enforcing a dead rule.
The same file pins the **throwaway-workspace lifecycle**: a CLI that makes a temporary workspace must
build its SDK behind a `try` whose own `catch` or `finally` removes it (`cleanup()`, `dispose()` or
`rmSync`), or a failed construction strands the directory. The guard is read from the syntax tree — the
first `try` after the `mkdtempSync`, in the same function — so a cleanup elsewhere in the file neither
satisfies the check nor breaks it by changing shape.

`scripts/tests/workspace-projection-real-bundle.test.js` — the **upgrade** guard. The SDK refuses to
push a workspace copy stamped with another parser version (`ARTIFACT_PROJECTION_STALE`), so a
re-vendor that raises it makes every existing `.maker-workspace` old. Upgrades stay invisible only
because a plain fetch re-reads a clean old copy (and the build fetches every existing form, view,
chart and app before editing it); a copy still holding an interrupted build's edits is kept by that
fetch and refused at push — or, when the environment's copy has moved since, refused by the fetch
itself (`LOCAL_EDITS_WOULD_BE_LOST`, measured live on a 2.10.0 workspace). For the push refusal the
runner names the manual reset every workspace-reset remedy shares
(`RESET_WORKSPACE` in `lib/entity-provision.js`): stop other builds and teardowns on the workspace
first — the changed-only snapshot holds their leases and a running teardown's registration — then
delete everything except `last-applied.json` (the navigation baseline) and `destructive-approval.json`
(the approved removals), which no re-run can rebuild. The build does not reset the copy itself: the
SDK's per-artifact lock is per process, so an overwrite could discard another build's newer edits on
the same workspace. For the fetch refusal it asks for a review first: the environment has changed
since the copy was fetched (maybe by a maker — the same copy is what a concurrent-edit halt keeps as
a fence), so rebuilding the same spec over a cleared copy could overwrite that change. The halt says
to look at the change in Maker and put into the spec what should stay, then make the same reset. (A
re-download is no substitute: it captures no forms, views or charts, and an interrupted first build
can leave them before the app exists.)

**Newer SDK behaviors the plugin adopts, and the options it leaves off.** Each is pinned against the
real bundle, because a stub stays green through a change in the SDK's own rule.

- **A workspace belongs to its environment** (`scripts/tests/workspace-binding.test.js`). The SDK
  records the environment a workspace was first opened for in `manifest.json` and refuses an SDK for
  another one (`WORKSPACE_ORG_MISMATCH`) before it reads or writes anything. Only `build-model-app.js
  --apply` opens the app folder's workspace — a dry run, download, `ai-preflight`, the `provision-*`
  scripts, teardown and the standalone verify use throwaway ones (verify reads only the build's
  `last-applied.json` from the folder, so it verifies any environment the app is deployed to and never
  binds a fresh folder). A **dry run** works in a temporary workspace inside the SDK's own temp dir and
  creates nothing in the app folder: opening binds, a preview builds nothing, and a folder it bound to the
  environment it only previewed would refuse the environment the operator meant (a preview against the wrong
  environment followed by `--apply` against the right one would be told the folder's saved copies belong to
  the wrong environment, though nothing was built there). Every read its live plan makes goes to the server, so it
  needs nothing from the folder; the folder is only ASKED, read-only, which environment it belongs to
  (`getManifest`: it parses `manifest.json` and no saved copy, and writes nothing), and the answer is a `⚠` note on stderr
  (`workspaceBindingNote`) that an `--apply` would stop — the preview itself, its plan and its "No spec
  changes since the last apply" advisory (read from the baseline file) are unchanged. The question is not
  a listing of the app artifacts, which would check the environment just as well: listing also parses every
  artifact's saved copy and its metadata (to flag unpushed edits), so a corrupt copy in a folder that belongs to
  THIS environment would throw the very error a corrupt manifest does and be reported as one. A saved copy says
  nothing about which environment a folder belongs to, so the preview reads none and says nothing about one (an
  `--apply` opens such a folder; the SDK meets the copy when it first reads it). A link or a file where
  the folder should be is still refused; a folder that is not there is just one with no workspace. So
  `--apply` is the one entry point that turns the refusal into a halt (`lib/workspace-binding.js`) that
  names both environments as normalized origins, never the manifest's own text, and offers the safe ways
  forward: build against the environment the workspace belongs to, use a fresh app folder or `--workspace`,
  or — only when that environment's copy of the app is no longer wanted — tear it down there with
  `--clear-workspace`. The folder is printed on a line of its own and the teardown command takes it as
  `--workspace <the folder printed above>`, never embedding it: teardown otherwise clears the folder beside
  its spec, and no quoting of a path is right for every shell (inside double quotes PowerShell and POSIX
  shells still expand `$name`, so a folder named `$work` would point the delete elsewhere; single quotes are
  not quotes in cmd.exe), so the halt says to pass it quoted for the operator's shell. Teardown already keeps
  the folder while a page-ownership receipt remains. A folder that holds **nothing a build saved** (the SDK's
  manifest listing no artifact, and its empty folders, and nothing else — `holdsNothing`, which is
  conservative: any other file, a link or an unreadable entry counts as saved work) was only ever opened —
  by a preview of an earlier release, or a build that stopped at its start — so nothing was built in that
  environment: the halt says so and offers neither `--env <that environment>` nor the teardown, only
  deleting or moving the folder aside, or a fresh one. A manifest the JSON parser rejects would otherwise
  surface the parser's own "Unterminated string in JSON at position N" (no path, no advice); it is
  recognized by the error's name (enough, because opening a workspace and asking it for its manifest parse
  `manifest.json` and no other file, so a corrupt saved copy is never mistaken for it), never echoed (newer
  Node versions quote the file), and halts with
  `WORKSPACE_MANIFEST_INVALID` as the folder with no usable environment does: move it aside — rename it, do
  not delete it, it can hold page-ownership receipts and the baseline a new workspace does not get back.
  The plugin never clears or re-binds a workspace for the operator — the SDK's own remedy,
  `cleanWorkspace()`, discards its saved copies, metadata and baselines, a copy can hold an edit that was
  never pushed, and the receipts and baseline beside them are the other environment's — and no script
  calls it. Not even an empty folder is cleared automatically: the check and the act cannot be made atomic
  against a build that starts in that folder in between.
  The SDK compares the recorded URL as text (trimmed, lower-cased, trailing slashes dropped), and `--env`
  takes an https origin and nothing more, so the one spelling of the SAME environment a run of this
  plugin can meet is the `.api.` host (`<org>.api.crm.dynamics.com` against `<org>.crm.dynamics.com`).
  The plugin does not fold the two — the baseline, the receipts, the token and the HTTP client's origin
  guard all key on the host it was given — so the halt says it is the same environment (never "another"),
  gives the host to re-run with, and offers no teardown, whose target would be the run's own
  environment. A manifest another tool wrote with a path or port has no `--env` that matches it, and
  gets a fresh folder only. Handing the SDK one canonical origin would prevent neither: it already
  ignores case and trailing slashes, and the plugin does not treat the two hosts as one.
- **`createRecovery: 'adoptExact'` on the SDK that creates forms** (`provisionSdk` in
  `build-model-app.js`; `scripts/tests/form-create-recovery-real-bundle.test.js`). The HTTP client
  re-sends a create whose answer never arrived, and the platform answers the re-send 412 because the SDK
  mints the `formid`. The SDK then reads that row and adopts it only when it is exactly what was sent (a
  Main form with the same XML, name, table and type). The push comes back `{ saved: false,
  adoptedExisting: true }` having written nothing, so `pushFailed` (`lib/entity-provision.js`) does not
  count it as a failure and `reportPartialPush` says it happened. An inexact row, a Quick Create form,
  or an environment that answers a replayed create with 404 halts as before, now with the SDK's reason.
  The header-carrying `sdk` does not get the option: it does no workspace I/O, so it never creates forms.
- **An AI override saved but not yet published is `pendingPublish`** — see the AI features note above.
- **A form or dashboard is read and written only as its own kind**
  (`scripts/tests/form-kind-real-bundle.test.js`, `form-order-real-bundle.test.js`,
  `sdk-resend-layers.test.js`). A fetch and a push decode the row's `type` from a read they already make — a
  fetch's `RetrieveUnpublished()` selects it, a push's content-token read selects `formid,type` — so the check
  costs no request; only a delete adds one, a `RetrieveUnpublished()?$select=type` before it, because it reads
  nothing else first (forms are types 2, 6, 7 and 11, dashboards 0 and 10). The SDK refuses
  another kind — or a row whose type it cannot read — with `SYSTEMFORM_KIND_MISMATCH`, naming the kind it
  found; a push that would change an existing form's type is refused `FORM_TYPE_IMMUTABLE` (the plugin never
  changes one). A fetch refused this way stores nothing, a push of a copy whose row has since become another
  kind writes nothing, and teardown reports a refused delete as the step's failure in the SDK's own words,
  never as "already gone". Download's dashboard read reports a sitemap dashboard subarea whose id is a form as
  unreadable and leaves it out, so the usual gate stops the run. The plugin's own pin checks
  (`resolveExistingFormId`, `findPinnedDashboard` — a type-0 dashboard only) refuse a wrong pin first, so this
  is the second line. A fake Dataverse that stores a systemform must answer its `type` (a Main form is 2),
  honour `$select` and leave a null column out, as the platform does; otherwise every form read in a
  real-bundle test is refused.
- **An app header write is never unconditional** (`scripts/tests/app-ai-description-real-bundle.test.js`). The
  SDK writes the app's name, description and routing description conditioned on the appmodule ROW token a
  fetch read, even when its own read of the header fails (the header PATCH carries `name`, `description` and,
  when one is supplied, `aiappdescription`; the icon is written at create only). A header edit made while it
  holds no token throws `APP_UPDATE_NO_ETAG` before anything is written (it used to send the PATCH plain,
  which overwrote another writer's header). The plugin edits an existing app's header only on a copy it has
  just fetched, and only the routing description — every other header field is written at create — so the
  build does not meet the refusal; a sitemap-only push needs no header token at all. If a push ever did meet
  it, `pushAppHeader` resets the copy and the next run, whose fetch reads a token, saves the edit.
- **Metadata reads ask for the latest definition** (`scripts/tests/sdk-uptake-contract.test.js`). The SDK
  sends `Consistency: Strong` on every read of `/EntityDefinitions`, `/RelationshipDefinitions` and
  `/GlobalOptionSetDefinitions` — about 10% more per read — and on no other path, so a read right after a
  metadata write is not served the definition from before it, and a schema write drops the cached
  definitions of the tables it changes. The plugin's HTTP client passes a caller's headers through, so the
  header reaches the wire. The plugin still carries three waits for the metadata-read lag (`lib/entity-provision.js`),
  and keeps them, because it is not measured whether `Consistency: Strong` leaves any lag: `readTextColumnLength`
  asks a 404 on the attribute of a table created a moment ago again, twice; `confirmInlineColumns` re-reads the
  attribute collection of a new table, up to three reads in all; and the relationship create, told that its
  name is already used while no read shows the row, reads twice more (about 2 s, then 4 s) before it calls the
  name unidentified. They are candidates for removal once a live run shows no lag. The `MetadataCache` 400 that
  a form, view or chart lookup on a table that does not exist yet answers (`resolveExistingFormId`, the plan's
  artifact probes) is the platform's answer to a read of a DATA table, which the header does not cover, so
  that handling stays. See the localized-label note above for the one effect that is not measured.
- **The environment's languages come from the SDK, with `pac` behind it** (`scripts/list-languages.js`,
  `lib/lcid-cultures.js`; `scripts/tests/list-languages*.test.js`, `lcid-cultures.test.js`).
  `getEnvironmentLanguages()` makes three reads — the organization's base language,
  `RetrieveProvisionedLanguages()`, `RetrieveAvailableLanguages()` — and touches no workspace; it is strict, so
  a response of an unexpected shape throws `ENVIRONMENT_LANGUAGES_UNREADABLE` and a failed read throws its own
  error. The planner runs the script first: it prints one pure-ASCII JSON line with the base language and, for
  each provisioned language, its LCID, culture code and whether it is right-to-left. The SDK returns numbers
  only, so the host maps them with a table of Windows' own culture data; an LCID the table does not name stays
  numeric (the base language's too), is reported in `unmapped` and on stderr, and is never given a guessed
  code or direction. The script writes nothing and emits no telemetry of its own.
  **The script is the preferred source, not the only one.** It reads through the `az` token, and `/genpage` has
  never needed `az` for a page over existing entities or mock data (README). So when it exits non-zero — for
  any reason; most often the Azure CLI is missing, signed out, slow or signed in to another tenant — the
  planner runs `pac model list-languages --environment '<envUrl>'`, as before the script existed but pinned to the
  environment the script was given (without the flag `pac` answers for the active organization of its auth
  profile, which is not necessarily the environment the plan is for), records both in the log and says so,
  and stops only when `pac` fails too. The rule is the exit status, on purpose: the SDK reports a missing
  Azure CLI, a slow one, a token another tenant refuses and a dropped connection alike, as `CONNECTION_ERROR`
  (`statusCode` 0, or the HTTP status of a refusal), so no code tells them apart, and a rule keyed on the
  wording of the sign-in advice (`Run 'az login' first`) is met by a signed-out Azure CLI only. The planner's section
  and README's promise are pinned (`list-languages.test.js`), and so is the eval grader's reading of a run
  that fell back (`phase-expectations.test.js`). The plugin's own `readProvisionedLanguages` /
  `readOrgLanguageCode` (`lib/dataverse-auth.js`) stay: they run before an SDK exists (it needs the language
  at construction) and fail soft, where the SDK's read is strict.
- **Two layers re-send, and for a delete they add** (`scripts/tests/sdk-resend-layers.test.js` pins every
  count). The HTTP client re-sends a 429 or 5xx — except a record delete and a `$batch`, which it
  re-sends only for a deadlock (SQL 1205) or a 429. The SDK re-sends a deadlock for reads, an app
  create, `PublishXml`, a form create, a view create, a publisher create and each delete of a row-summary
  removal (there a 400 quoting SQL's "chosen as the deadlock victim" counts too), and re-sends an app delete
  that hits the platform's search-index insert race (HTTP 400, SQL 547). An app delete's two triggers are
  different answers, so a race, then a deadlock, then success is three change sets, and a persistent one of
  either is four — never four times four. A POST's deadlock is a 500 the client has already re-sent by
  the time the SDK sees it, so one deadlock costs one re-send in all; only a deadlock that never
  clears multiplies, bounded by the client's six attempts times the SDK's four (24 requests, measured
  on a form create and on a view create). A row-summary delete is re-sent by both layers for a 500/1205
  (one that clears costs one re-send; one that never clears at most 16, the client's four times the SDK's
  four), and by the SDK alone for the 400 form.
- **A row-summary removal settles each delete** (`sdk-resend-layers.test.js`). The skill row is deleted first
  and alone, then the AI model — only when it is the one `configureRowSummary` made (named
  `<table> row summary`, built from the row-summary template). A delete is re-sent while it loses a deadlock
  and, when its answer is lost or odd, settled by reading the row back. A model another component still
  uses is refused by the platform (HTTP 400, `0x8004f01f`) and KEPT, which is success — a removal that threw
  there would fail the teardown step of every table whose model another component had adopted. What the SDK
  cannot settle throws `ROW_SUMMARY_REMOVAL_UNCONFIRMED` (the skill-row delete's outcome unknown, naming our
  model) or `ROW_SUMMARY_MODEL_NOT_DELETED` (the model left behind, naming it). Teardown's `aiSummary` step
  still treats every failure as best-effort and does not surface these: the table delete that follows reports the
  dependency if the skill row is still there, and a model left behind blocks only a later row summary for the
  same table, by name.
- **Changes in the same SDK release that no plugin path reaches** — read these again before a plugin change
  reaches one. `importSolution` now requires `overwriteUnmanagedCustomizations` and `publishWorkflows` (no
  script calls it, and `sdk-surface-contract.test.js` fails on a new call until it is listed). An Active
  business process flow is edited in one conditional change set and stays Active — it needs a client with
  `postRaw`, which the plugin's has — where the build only creates a flow and toggles the state of one that
  exists, never editing a definition.
- **Changes in the same release that the plugin's paths DO reach, with the outcome unchanged** — re-test them
  when one of these paths changes.
  - *Commands.* A command push writes only its own table's bar, with every check before the first write. The
    build's commands phase is a command push (`createArtifact('command')`, then `pushArtifact`), so the
    anchoring, the table-id resolution and the write plan run on it; the requests are the same two as before
    — one table-id read, one write — and a bar that exists is skipped (`command-real-bundle.test.js`).
  - *The app-publish token bracket costs requests.* Every `publishArtifact('app')` — the app shell's rewrite of
    an existing app, the pages finalizer, the AI re-confirm step, and phase 8's envelope when it carries the
    app; a typical build makes two or three — now reads the app before the publish and again after it, to adopt
    the tokens the publish rotated when its content is unchanged and its whole token vector was proven, so
    that a header edit right after needs no fetch. That is about **10 more GETs per app publish** (probed over
    a fake app after a fetch: none on the previous bundle, ten on this one). The adoption is of no use to the
    plugin, which fetches before every header or sitemap write because a pages run changes the live app
    between its first fetch and its finalize, and no step of it was a workaround for a token a publish
    rotated. The bracket is best-effort: a failed or throttled read leaves the app un-rebaselined and never
    fails the publish.
  - *Component proofs.* An app create or update that pins tables reads the app's current layer at most once for
    its component proofs — the table proof and, when the spec lists the Main forms of a table, the Main-form
    proof — where each proof read it for itself (one read fewer when both run; the same requests when only the
    table proof does). An ambiguous layer fails closed (`APP_COMPONENTS_UNVERIFIED`): a create unwinds, an
    update reports a warning. The plugin's own component reader (`lib/app-components.js`) is separate.
  - *Unchanged pushes.* A push of an unchanged artifact skips its artifact write and reads less from the
    workspace. The plugin's own push-if-changed decision (`hasUnsyncedEdits`, which asks the SDK's
    `diffArtifact`: the copy against its snapshot) is made before the push and is unaffected
    (`rebuild-writes-real-bundle.test.js`).
- **Left off: `readConcurrency`**, which overlaps a generative page's file downloads and
  `fetchSolution`'s component reads. The plugin makes neither read — pages go through
  `pac model genpage`, and no script fetches a solution — so the option would change nothing.
  `scripts/tests/sdk-opt-ins.test.js` fails if a script starts to make one of those reads without
  passing it.
- **Left off: creating the app as a draft (`published: false`), so the final publish covers it.** A
  draft is not in the published projection: a by-id read answers 404 and `findArtifact('app')` finds
  nothing, and so does every other probe the build, verify and download make — the collision gate, the
  sitemap and navigation reads of the pages phase, the app-shell "does it exist" check. A failed first
  build would leave an app its own re-run cannot see, the pages phase right after the create would find
  no sitemap, and a build without `--publish` would never publish it. The app is created published;
  `scripts/tests/sdk-opt-ins.test.js` pins the SDK facts this rests on and that no script passes
  `published: false`.


`scripts/tests/rebuild-writes-real-bundle.test.js` — the **unchanged-rebuild write budget**, against the
real bundle and a stateful fake Dataverse (`tests/helpers/fake-dataverse.js`), so what is asserted is
what reaches the wire, not which SDK methods a mock saw. Every test name starts `rebuild writes:`. A
first build followed by a rebuild of the same spec with `--publish` must send only the phase-8
envelope; every change a reconcile makes (a view column or description, a form field, a form event
handler) must still be pushed exactly once and end up published; an edit an interrupted run left in the
workspace must still be pushed; an edit a maker made in Dataverse since the last build (a column
removed from a view, a description changed, a field removed from a form) must be pushed back — the
workspace copy is clean but the server has moved — and one made while the workspace also holds an
interrupted run's edit must halt (`LOCAL_EDITS_WOULD_BE_LOST`) without writing; without `--publish`
each reconcile must still publish its artifact; a build that halts after a reconcile must hand its
caller the publish it owes; a phase-8 publish that fails or cannot be confirmed must name the views and
forms it carried — the enriched default views of a table included — on the publish phase and on the halt
path alike, and on the publish phase also the views, forms and charts the run created (a first
build's, a rebuild's additions, a chart whose description it wrote), each once and never as a bare id,
with the requests it sends unchanged, while a first build that halts owes nothing for what it merely
created; a build killed between its reconcile and its publish must leave a re-run able to publish
the edit without writing it again; and the membership read must happen once, follow paging, and fall
back to adding everything when it fails. A table the solution holds with ALL its subcomponents must
vouch for its views, forms and charts (which have no rows of their own, so the rebuild adds none of
them), and nothing else: a table held at 1 or 2, another table's components, a failed table lookup and
a table the maker has since re-segmented must all still get their adds. The fake models only views,
charts, forms, solution components and `PublishXml` — it throws, naming the request, on anything else
— and it answers the way the platform does: table rows carry the `rootcomponentbehavior` and the
table's MetadataId, the subcomponents of a whole table get no row, and `$select` is honoured.
`unpublished()` answers "was every saved change published?".
`rebuild-writes-reuse-sites.test.js` and the `rebuild writes:` tests in `sdk-build.test.js` pin each
reuse site (rule, flow, role, app, sitemap, page manifest) the same way; `solution-membership.test.js`
and `unsynced-edits.test.js` pin the two helpers' contracts, every doubt of which reads as "add" and
"push".

**Live end-to-end (app-builder — writes to a real Dataverse env; optional).** All build/verify/
teardown scripts are **dry-run by default**; add `--apply` to write.

```bash
az account set --subscription <sub-id>
node scripts/check-auth.js --env <envUrl>       # az token + WhoAmI preflight (pac optional; --require-pac for genpage)
node scripts/build-model-app.js   --env <envUrl> --spec @<dir>/app-spec.json [--sample-data --publish] --apply --verify
node scripts/verify-model-app.js  --env <envUrl> --spec @<dir>/app-spec.json
node scripts/teardown-model-app.js --env <envUrl> --spec @<dir>/app-spec.json --apply --allow-destructive
```

AI features are **admin-gated** — preflight readiness with `node scripts/ai-preflight.js --env <envUrl>`.
Prefer a scratch env; always tear down probes so nothing app-owned is left behind. Teardown needs
`--allow-destructive` as well as `--apply` — with `--apply` alone it refuses and exits — and it
deliberately leaves **one** thing behind: the **publisher**. A publisher can own other solutions in
the environment, so deleting it is not this app's decision to make — the same fail-safe reasoning
that keeps an `external` web resource. Expect a clean environment afterwards *except* for
`<prefix>publisher`; if a probe must restore the environment exactly, remove that row yourself after
confirming it owns no other solution.

**`appmodulecomponent` rows also survive an app delete, and teardown deliberately leaves them** — this
has been re-reported as a teardown bug and re-measured. The platform exposes no way to remove them:
the table registers only `Retrieve`/`RetrieveMultiple` (a direct `DELETE` 400s), and
`RemoveAppComponents` returns `204` while removing nothing as long as the app exists, then `404`s once
it is gone. Calling it would report a cleanup that never happened. The rows are unreachable metadata
that a rebuild neither adopts nor trips over; revisit only if the platform adds a delete or a cascade.

**After modifying the plugin also:** run `claude --debug` to confirm the plugin loads, exercise the
skill (`/genpage` or `/app-builder`), and for genpage verify Playwright browser checks
(navigate/snapshot/click/screenshot).

**Hooks + telemetry:** the lifecycle hooks and telemetry hooks are covered by the plugin unit suite
(`node scripts/run-tests.js`); `resolver.test.js` pins the shipped `ikey.json` (live, a real key and the
right collector per region) and the sovereign/unknown-cloud routing. A test also forbids shipping a
placeholder key enabled. Run `node scripts/validate-telemetry-ikeys.js` from the repo root after
touching `ikey.json`.

## Eval Suite

The plugin has a 3-layer eval suite under `evals/model-apps/genpage/`. Two
layers are automated (TAP v13 runners); Layer 3 is manual.

- **Comprehensive guide:** `evals/model-apps/genpage/EVAL_GUIDE.md` — what
  we evaluate, the 3 layers, tiers (smoke/full/stress), fixture types
  (synthetic vs real captures), runner output, capture flow, cadence,
  diagnosing failures, adding evals and assertions.
- **Eval definitions:** `evals/model-apps/genpage/evals.json` — prompts,
  answers, and expectations.
- **Fixtures:** `evals/model-apps/genpage/fixtures/<eval-id>-<slug>/` —
  one folder per captured or synthetic run. Each contains the `.tsx`,
  `workflow-log.md`, `genpage-plan.md`, and (when applicable)
  `genpage-entity-creation-log.md` (older captures: `entity-creation-log.md`)
  and `RuntimeTypes.ts`.
- **Coverage contract:** `evals/model-apps/tests/eval-coverage-contract.test.js`
  fails when a prompt has no fixture, an expectation has no check, or a check
  skips on every fixture, unless `eval-coverage-baseline.json` beside it records
  the gap and why; it also pins the guides' counts to the registries.

Run on every PR that touches the skill, agents, rules, or evals:

```bash
node evals/model-apps/genpage/run-layer-1.js --tier smoke
node evals/model-apps/genpage/run-layer-2.js --tier smoke
```

### /app-builder — offline structural harness

A data-driven, **offline** eval harness at `evals/model-apps/app-builder/`
(sibling of `genpage/`). Grades **structural per-stage facts** — not `.tsx`
snapshots — using the plugin's own pure primitives. No live env required.

- `evals.json` + `fixtures/<n>-<slug>/app-spec.json` — data-driven cases
- `lib/facts.js` — per-stage fact computation (`schema-facts.js` + `app-spec.js` primitives)
- `lib/assertions.js` — assertion text → check function registry
- `run-app-builder.js` — TAP v13 runner (run from the repo root)
- `EVAL_GUIDE.md` — grading guide (see [`evals/model-apps/app-builder/EVAL_GUIDE.md`](../../evals/model-apps/app-builder/EVAL_GUIDE.md))

Per-stage oracles: `author` (validate + lint), `plan` (`planFor`), `data`
(`schema-facts.js` normalized tables/columns/relationships), `ui` (view/chart/form
intent facts), `app` (sitemap facts + nav graph), `security`, `verify` (`verifySpec`
reconcile, fail-closed), `process`, `generate-pages`, `teardown`, `round-trip` and
`changed-only` — see the guide's stage → oracle table for what each grades.

```bash
# From repo root:
node evals/model-apps/app-builder/run-app-builder.js
```

### Skill routing — agent in the loop

`evals/model-apps/routing/` grades which skill a real agent CLI (Copilot CLI or
Claude Code, headless, isolated, only this plugin loaded) invokes for each of 20
prompts: `/genpage` vs `/app-builder` from both sides of their boundary, plus
negatives that share vocabulary (Power Pages, canvas, Power Automate, a generic
React dashboard). The offline harnesses above grade what a run *produced* and
cannot see a mis-route; both skill descriptions point at each other, so a
wording change to one can start stealing the other's prompts. The agent is
killed at its first plugin-skill call, so a trial costs about one model turn;
cases are graded as a pass rate over N trials. **Run it when you edit a skill
`description:`.** It is not a per-PR gate (cost, non-determinism):
`model-apps-agent-evals.yml` runs it on a same-repo PR labelled
`run-agent-evals`, weekly, and on demand, using Copilot CLI authenticated by the
workflow's own `GITHUB_TOKEN` (`copilot-requests: write`) — no secret to
provision. Its parsing/grading/process code is unit-tested on every PR against a
fake agent. See [`evals/model-apps/routing/EVAL_GUIDE.md`](../../evals/model-apps/routing/EVAL_GUIDE.md).

```bash
node evals/model-apps/routing/run-routing.js --dry-run                 # what would launch
node evals/model-apps/routing/run-routing.js --tier smoke --runs 1     # quick local check
node evals/model-apps/routing/run-routing.js --out after.json --compare before.json
```
