Skip to content

Add AI-assisted triage to the admin Review center - #1711

Merged
Paul Lizer (paullizer) merged 31 commits into
microsoft:paullizer-react-v2-uifrom
paullizer:paullizer-admin-review-ai-assist
Oct 8, 2026
Merged

Paul Lizer (paullizer) merged 31 commits into
microsoft:paullizer-react-v2-uifrom
paullizer:paullizer-admin-review-ai-assist

Conversation

@paullizer

@paullizer Paul Lizer (paullizer) commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Depends on #1709 and #1710 (layers 1-2 of 3). Until they merge, this diff includes their commits; review only the commits after ac6e713.

Summary

  • AI assist for the V2 admin Review center (off by default). Ask AI in the feedback and violation editors analyzes one record and fills the unsaved draft (changed fields marked, Undo); the reviewer still saves. Triage with AI on the workbenches sends the checked records 10 per request, each user's records together, with progress, Cancel and per-record outcomes (records without a suggestion stay checked). A new AI suggestions page per section lists stored suggestions with a per-row diff (e.g. Status New → Resolved · Warn user · Notes …), the model's reason and confidence, and the full text each saves that its user can read, under Visible to the user. Any eligible reviewer can approve one, approve selected, Approve all ready, edit the notification text inline, or dismiss.
  • The model never writes or acts. Answers are validated server-side (strict schema, unknown handles refused, one correction round) and policy is enforced by the server: never Escalate, no warn/suspend/block for AI-generated content, never weaker than an applied remediation. Approving goes through the existing save: a warning is sent on approval (the confirmation states how many users will be warned, and how many reviews save text a user can read), Suspend/Block still create a second-reviewer approval request, are excluded from Approve all and select-all, and the queue never re-requests a restriction the violation already records.
  • Data minimization and isolation. Records are loaded on the server by id; the model sees request-local handles (r1…) with no record/user/conversation ids, names or emails, emails/GUIDs in text replaced, text truncated, plus a server-computed prior-violation count and optional org guidance. One model call never covers two users' records: groups that don't fit in the request's time come back deferred and the browser resends them. Text a user can read (feedback analysis notes/action/response, violation notes/notification) is refused, never cut, when too long, and refused when it repeats 40+ characters of another record in the request. A content-filter refusal for a group is retried per record. Telemetry is content-free; 60 requests per 10 minutes per reviewer, one at a time.
  • Persisted, auditable suggestions. Triage stores ai_suggestion (pending/applied/dismissed, shown as stale) using conditional writes and a fingerprint of the reviewable fields plus, for violations, the full request/warning state (not _etag, which changes when the suggestion is stored). Lists and record reads return etag + fingerprint; editors send both, so a save across a stored/applied/dismissed suggestion still goes ahead on the fresh read (all guards, write conditional on that version), while a real edit, claim, new request or acknowledgment still gets 409 record_changed. Bulk update accepts suggestion_id (refused with suggestion_stale / suggestion_not_pending), new bulk op dismiss_suggestion, list/ids filter ai=pending, /ids returns owners; a suggestion operation that can't read its record fails alone with operation_failed. Applies and dismissals are credited in the admin activity log. Feedback reviews also gain a Theme with a list filter and a dashboard breakdown.

Notes for reviewers

  • New settings on Security > Access & Roles > Permissions (V2 and classic): enable_admin_review_ai_assistant (default off) and admin_review_ai_guidance (≤ 2,000 chars). Bootstrap exposes only the boolean; the guidance is stripped by sanitize_settings_for_user() and never sent to the Review center.
  • Endpoints POST /api/admin/review/feedback/assist and POST /api/admin/review/safety/assist use the same section decorators as their record routes, plus the toggle (403 review_assistant_disabled). While the toggle is off, bulk suggestion operations are refused per operation with the same code; other operations in the request still run.
  • Code-review fixes (commits 354a7681, 46793704, b8dccb97): per-user model calls with deferred + browser resend and owner-grouped chunks; the copied-text backstop; full user-visible text in the queue; fingerprint-tolerant saves across suggestion writes; per-operation failure handling in bulk suggestion ops. One Layer 2 assertion (test_review_center_bulk_and_dashboards.py, exact /ids body) now includes the additive owners field.
  • CodeQL fix (commit f635cd63): js/trivial-conditional flagged while (!settled) in runTriage (lib/reviewSuggestions.ts): every settled = true was followed by break, so the condition was always true. Not a behaviour bug; the retry rule is now the loop condition (triageRetryWait + REVIEW_TRIAGE_MAX_WAITS = 3), with new node tests for the wait cap, the limiter and too-long-wait exceptions, a waited-out 503, and Cancel before and during a wait.
  • Merged Layer 2's CodeQL fix ac6e7139 (classic admin-safety-violations.js action select and its UI test only) in 2690fd0e.
  • Known limitations: suggestions are only as good as the model and guidance; records about many different users take more model calls; the copied-text check can refuse a suggestion that shares a long passage with another record by chance (one rewrite, then no suggestion); Approve all ready covers the current page; a triage runs in the reviewer's tab; the classic admin pages don't show AI suggestions; no live Azure OpenAI call was made in validation (fake invoker plus the real WorkflowAssistModel over a fake client).

Linked issue

No tracking issue: the requester declined one for this work. Stacked on #1709 and #1710.

Release Notes & Latest Features

  • New Feature
  • Bug Fix
  • UI Enhancement
  • Breaking Change
  • Internal only

Is this visible to end users?

  • Yes
  • No

Is this admin-facing (Admin Settings, governance, deployment, config)?

  • Yes
  • No

Should this become a Latest Feature card?

  • Yes
  • No
  • Already added

Screenshot needed for the card?

  • Yes
  • No
  • Attached

Version bump

  • application/single_app/config.py VERSION third segment bumped, or not needed because this is docs-only (0.261.298 → 0.261.299; unchanged for the review and CodeQL fixes, same unreleased PR)
  • deployers/version.txt bumped, or not needed because deployers/ was not changed

Testing / validation

The full app import fails on this machine (pyOpenSSL), so routes were exercised through Layer 1's functional_tests/test_support/safety_review_harness.py and fresh-process offline import probes with a fake model invoker. All results below are for head 2690fd0e.

  • python -m pytest functional_tests/test_review_assist_core.py functional_tests/test_review_assist_routes.py functional_tests/route_tests/test_review_assist_policy.py functional_tests/route_tests/test_route_blueprint_policy_inventory.py functional_tests/route_tests/test_route_unauthenticated_policy_contract.py functional_tests/route_tests/test_route_policy_test_coverage.py functional_tests/test_review_center_bulk_and_dashboards.py functional_tests/test_safety_remediation_approval_state.py functional_tests/test_approvals_dashboard_stats.py functional_tests/test_safety_warning_send_claim.py functional_tests/test_v2_admin_review_pages.py -p no:cacheprovider -q → passed, 132 passed (core 47; assist routes 14, normal and -O Python; assist policy 6; blueprint inventory 14; unauthenticated contract 9; policy coverage 3; Layer 1/2 regressions 39).
  • node functional_tests/test_v2_review_assist_logic.mjs → passed (includes the new triage retry tests; mutating the wait cap or the limiter exception makes it fail)
  • node functional_tests/test_v2_review_center_logic.mjs → passed
  • npm run typecheck (in application/v2_ui) → passed
  • npm run build (in application/v2_ui) → passed (only the existing chunk-size warning)
  • python -m pytest ui_tests/test_v2_review_center_ai_assist.py ui_tests/test_v2_review_center.py ui_tests/test_v2_approvals_dashboard.py ui_tests/test_v2_approvals_and_terms_pages.py ui_tests/test_classic_safety_review_and_access_restricted.py -p no:cacheprovider -q → passed, 35 passed (11 AI-assist tests; 7 classic safety tests including Layer 2's forged-option test; strict fixtures fail on unexpected requests)
  • python functional_tests/test_docs_app_surface_coverage.py → passed, 7/7
  • python functional_tests/test_docs_site_quality.py → passed, 6/6
  • python scripts/check_xss_sinks.py --base-sha 6570decdb --head-sha HEAD <changed files> → passed (38 files); python scripts/check_broken_access_control.py --base-sha 6570decdb --head-sha HEAD <changed files> → passed (20 files)
  • git diff --check 6570decdb HEAD → passed
  • GitHub checks on 2690fd0e: CodeQL success (no warnings or errors; 2 notices from Layer 1's ab55a8f8: a cyclic import at functions_authentication.py:766 and mixed tuple lengths in get_pending_safety_warnings), plus Analyze (python, javascript-typescript, actions), syntax, swagger-route, xss-sink, broken-access-control, malicious-PR review, branch-flow and CLA all passing.
  • Pre-existing failures, unrelated and also on the base: functional_tests/test_v2_admin_security_parity.py::test_every_v1_security_field_is_claimed (chat_content_settings_present), functional_tests/test_v2_admin_settings_schema.py::test_defaults_match_their_field_type (m365_trusted_download_hosts), functional_tests/test_logging_tag_standardization.py (workflow/editor-assist tags; [REVIEW_ASSIST] is standard and documented), functional_tests/test_docs_link_integrity.py (2/5; existing release-notes and explanation/ links, none added here), functional_tests/test_docs_release_notes_integrity.py (generated release-notes pages stale since before this stack).
  • Not run (listed as pre-existing failures on the base): test_profile_and_admin_review_tabs (3), test_v2_stats_parity, test_v2_conversation_deep_link, bell UI teardown 404s, 12 agent/MCP editor UI tests, and the test_v2_sidebar_conversation_scroll.py phone-drawer flake.

Documentation

  • Release notes updated, or not needed (docs/explanation/release_notes.md, v0.261.299)
  • Feature documentation updated, or not needed (docs/explanation/features/ADMIN_REVIEW_AI_ASSISTANT.md; guides admin-review-center.md, admin-review-feedback.md, admin-review-safety-violations.md; docs/admin/security.md; docs/_data/features.yml; docs/_data/app_surface.yml; docs/reference/logging-tags.md)
  • Fix documentation updated, or not needed (not needed)

Security checklist

  • New Flask routes include @swagger_route(security=get_auth_security()) (decorator order asserted by functional_tests/route_tests/test_review_assist_policy.py)
  • Settings sent to non-admin frontends use sanitize_settings_for_user() (bootstrap sends only the boolean; the guidance is stripped)
  • Browser JavaScript is served from local SimpleChat static assets only; no CDN-hosted JS
  • No secrets, keys, connection strings, or local-only artifacts are included

Paul Lizer (paullizer) and others added 25 commits October 7, 2026 22:46
…restricted screen

Layer 1 of 3 of the Admin Review Center stack.

- Warn user executes when the safety reviewer saves the review, without an
  approval request, and is audited in the activity log. Saving again does not
  resend it. Suspend and Block still need a second eligible reviewer.
- Executed warnings must be acknowledged: new user-scoped routes
  GET /api/safety/warnings/pending and
  POST /api/safety/warnings/<id>/acknowledge (owner-only, idempotent,
  ETag-conditional), a V2 dialog fed by a bootstrap pending count, and the
  acknowledgment state in the admin review and the user's Violations tab.
- Escalate can no longer be chosen; legacy records keep it, labelled
  "Escalated (legacy)". escalate_count stays in the stats JSON.
- Approved suspensions and blocks store the notice the user was sent in
  settings.access.notice.
- user_required answers restricted users with a structured 403
  (error: access_restricted) for API calls and redirects pages to
  /v2/access-restricted or /access-restricted. New login-only routes serve
  those pages and GET /api/v2/access-restriction (caller's own data only),
  exempt from the Terms of Use gate.
- Version 0.261.296.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the fix and feature notes, updates the safety review and approval
request guides and the Content Safety settings page, records the release
notes for 0.261.296, lists the new logging tags, and regenerates the docs
app surface inventory for the classic Access restricted page.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ser test

Render the real classic templates and scripts behind a closed API: no Escalate choice, the legacy label, the second-reviewer help text, the warning acknowledgment line, Blocked statistics, and the localized restore time on the classic Access restricted page.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…restricted branch

The base now uses 0.261.296, so this branch moves to 0.261.297: config.py, its tests, docs and a new release-notes section above the base's 0.261.296 entry. Both route-inventory coverage lines are kept.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A reviewer can warn about the same violation again. The V2 store hid any warning whose violation it had already acknowledged, so a newer warning never showed in a tab that stayed open. It now keys each warning on the violation and its send time.

The acknowledge route also takes the issued_at of the warning the user read. When the violation now holds a newer warning, it answers 409 safety_warning_replaced and records nothing, and the dialog reads the pending warnings again to show the newer one. A body without issued_at keeps the previous behaviour.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Two overlapping saves with Warn user, such as a double-click or two reviewers, both passed the already-sent check, so each sent a notification and only one was recorded. A save now claims the violation with a write conditional on the ETag it read (action_request_status 'sending') before anything is sent; the other save sends nothing and gets 409 safety_warning_in_progress. The outcome is written on the claimed version. While a claim is fresh, other saves and deletes wait, and 'sending' never counts as a warning to acknowledge. A claim older than five minutes reads as a failed send and can be retried once. A send whose claim was lost is audited and reported as safety_warning_not_recorded instead of overwriting another writer. The classic Save Review button is disabled while its request is in flight.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds list search, filters and server-resolved names, select-all-matching ids, dashboard windows on the feedback and safety stats, bulk review endpoints and an approvals summary. Denied and expired remediation requests now unlock their violation, re-saving an applied suspension or block no longer requests it again, and review writes are ETag-conditional. Exports take the list filters. Version 0.261.298.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…hboard

Extracts the Approvals rail into CategoryRailPage and the Control Center tiles, charts and data tables into DashboardParts. Approvals gains a Dashboard category and a Safety remediation category, Group requests now lists group requests only, and its list reads status, type and who-it-is-for filters from the address.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
One Review center entry opens a rail of each section's dashboard, workbench and queues. Workbenches list records beside their detail, with search, address filters, multi-select and bulk actions; records open in full-page editors with Back and a dirty guard. Unchecked chat content moves to its own page with sequential bulk recheck. Old routes redirect.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Covers bulk caps and per-item results, select-all-matching ids, stats windows with legacy fields kept, list search by display name, the approvals summary's visibility, denied and expired requests unlocking violations, unchanged restrictions not requested twice, and ETag-conditional review writes. Moves the V2 page source checks to the Review center files and adds the new routes to the policy inventories.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Covers rail access per role with the not-available states and old-route redirects, dashboard figures opening filtered workbenches, click and Shift+click selection with per-record bulk reports, select-all-matching and the counted delete confirmation, the editor's dirty guard, save return and conflict reload, the violation editor's notice and suspension presets, sequential unchecked chat rechecks, and the Approvals Dashboard and Safety remediation categories.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the feature and fix notes, a Review center guide, and updates the feedback, safety violation, chat recheck and approval request guides, the release notes for 0.261.298 and the logging tag inventory.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
POST /api/admin/review/feedback/assist and /api/admin/review/safety/assist analyze one record for the editor's draft or triage up to ten, storing each suggestion on its record as ai_suggestion. Records are read on the server by id and shown to the model under request-local handles with no user ids, emails or names. The reply is validated against a strict schema with one correction round, and policy is enforced on the server: no Escalate, no remediation for AI-generated findings, no weakening of an applied remediation. A content-filter refusal is retried one record at a time. Suggestions carry a fingerprint of the reviewable fields, so a pending one reads stale only when the record itself changes.

Bulk update operations accept suggestion_id to apply a pending suggestion through the normal save, credited in the audit log, and dismiss_suggestion dismisses one. Lists and ids take ai=pending; feedback gains a theme field, filter and dashboard breakdown. Users never see suggestions or themes about themselves. New settings enable_admin_review_ai_assistant (off) and admin_review_ai_guidance live with the reviewer permissions; the guidance never reaches browsers.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Core tests cover request checks, identity-free views, the correction round, handle checks, policy, content-filter isolation, locked records, the limiter refund and the suggestion lifecycle. Route probes run the real routes offline with a scripted model and a real limiter. A policy test pins the decorators, the toggle check and the section's own container.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A Suspend or Block save created its approval request and then wrote the
request onto the violation without checking what had changed meanwhile.
A warning claimed during that window was overwritten and later recorded
as executed over the pending request, and two overlapping restriction
saves left the first approval approvable but unlinked.

- Record a new request only on the violation's remediation state as it
  was read, and refuse a fresh warning claim. When the write is refused,
  withdraw the approval (withdraw_approval_request) so nothing stays
  approvable, and return 409 record_changed.
- The approval executor refuses a request its violation no longer
  awaits (safety_log_awaits_request).
- A save or archive that names an etag is written on that version or
  refused with record_changed, never retried onto a newer one. Without
  an etag, a retry merges only the fields the request changed. This
  covers safety and feedback PATCH, archive and bulk update/archive.
- Archive refuses a violation whose warning is being sent.
- Re-request messages name the "Request this ... again" control.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The classic safety review never sent reissue, so after a request was
denied, expired or failed, saving the same Suspend or Block returned
"No new suspension was requested" and dropped the new date and message.

- Classic review: add a "Request this suspension/block again" control,
  shown when the violation already records that action and no request
  is waiting. It sends reissue: true. The help text describes where the
  last request stands, the notification and restore time are only sent
  when something will be requested, and the restore time must be in
  the future.
- Review center editor: the same rule and wording through
  offersRestrictionReissue and existingRestrictionText.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… center

The feedback and safety editors get an Ask AI panel that analyzes the record, fills the unsaved draft with the suggested review, marks each changed field and undoes it; a suggestion stored by triage saves through the bulk route with its suggestion id. The workbenches offer Triage with AI for checked records, ten per request with progress, waits for the rate limit and Cancel, and report each record's outcome. Each section gets an AI suggestions queue listing what every suggestion changes and why, flagging stale and locked rows, with inline notification edits, Approve selected, Approve all ready (suspensions and blocks excluded and never ticked by select all) and Dismiss, confirming how many users are warned and how many requests are created. The Feedback dashboard adds a themes breakdown and the workbench a Theme filter.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ullizer/simplechat into paullizer-admin-review-ai-assist

# Conflicts:
#	application/single_app/route_backend_feedback.py
#	application/single_app/route_backend_safety.py
#	functional_tests/test_support/safety_review_harness.py
…n from the queue

A suggestion that repeats the suspension or block its violation already records
is labelled as already on the violation, still left out of Approve all, and
approving it updates the review only; the queue never sends reissue. The
confirmation and the report count these separately from new requests, and a
record_changed, suggestion_stale or suggestion_not_pending refusal reads in the
reviewer's terms on its row.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ssist is off

Bulk update operations that carry a suggestion_id and dismiss_suggestion
operations are refused per operation with review_assistant_disabled (403)
while enable_admin_review_ai_assistant is off; the rest of the request runs.
Stored suggestions stay on their records and are offered again when the
assistant is turned back on.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Like the other bulk actions, a Triage with AI run leaves checked only the
records that got no suggestion, including any it never sent after a cancel
or stop, so they can be tried again or reviewed by hand.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The guidance is shown on the admin settings page, so 'never sent to
browsers' was not accurate; it is sent to the model with each request and
never to the Review center.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the feature note, Review center, feedback and safety guide sections,
the Security settings rows and troubleshooting, the features catalog entry,
the regenerated docs inventory, the REVIEW_ASSIST logging tag and the
0.261.299 release notes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread application/single_app/static/js/admin/admin-safety-violations.js Fixed
Comment thread application/v2_ui/src/lib/reviewSuggestions.ts Fixed
Comment thread application/single_app/functions_authentication.py
Comment thread application/single_app/route_backend_safety.py
…s' saves working across suggestion writes

Cross-record prompt injection: a triage request's records are grouped by the
user each one is about and every group is its own model call, with its own
handles, so text one user wrote can't steer what the model writes for another
user's record. The first group always runs; groups that can't start in time,
or whose call fails after others were answered, come back 'deferred' for the
browser to send again. As a backstop, text a record's user can read (feedback
analysis notes, action taken and response; a violation's notes and
notification) is refused when it repeats 40+ characters of another record in
the request and not of its own, and is refused rather than cut when too long.
The prompt says which fields the user reads. /ids now returns each record's
owner so the browser can keep a user's records together.

Suggestion writes vs open editors: the lists and record reads return a
fingerprint of the reviewable fields, which now also covers every remediation
state field (request id and status, send claim, warning notification, issue
and acknowledgment). A save whose etag is stale but whose fingerprint still
matches goes ahead on the fresh read, with every guard and a write conditional
on that version; a real change, claim, new request or acknowledgment is still
refused with 409 record_changed.

Bulk suggestion operations: a read failure, or any unexpected error, fails
only that operation with operation_failed (500) and is logged; the rest of the
request still runs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Paul Lizer (paullizer) and others added 5 commits October 8, 2026 04:43
…r, and save editors with their fingerprint

The AI suggestions queue shows, under 'Visible to the user', the whole text
each suggestion saves that its record's user can read, marks cleared fields,
labels the editable notification the same way, and the approval confirmation
counts reviews that save such text. Dismissals name only the suggestion id.

Triage chunks keep each user's records together, using the owners the page
and 'Select all matching' report, and records the server answers 'deferred'
are sent again next, with a guard against resending a chunk that made no
progress. The editors and queue approvals send the record's fingerprint with
its etag.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… suggestion writes

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…is always false

CodeQL (js/trivial-conditional) flagged `while (!settled)` in runTriage: every
path that set `settled = true` broke out at once, and the only `continue`
(after a rate-limit wait) left it false, so the condition was always true and
the loop really ran until a `break`, bounded only by `waits < 3`. Not a logic
bug, but it hid the retry rule.

`triageRetryWait` now decides whether an answer is worth waiting out (a 429,
or a 503 other than the limiter's own, with a wait within the limit), and the
send loop's condition is that decision plus the REVIEW_TRIAGE_MAX_WAITS (3)
cap. Behaviour is unchanged. New node tests pin the cap (one try and three
waits, then stop), the limiter and too-long-wait exceptions, a waited-out
503, Cancel during a wait and before the first send, and that a full run
leaves nothing unprocessed; mutating the cap or the limiter exception fails
them.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CodeQL flagged js/xss-through-dom in the classic safety review: the
action select's value, which is DOM text, was written into data-action
attributes on the notification and restore-time fields.

The select is now read only through readSelectedAction(), which returns
the matching constant from ACTION_VALUES (None, WarnUser, SuspendUser,
BlockUser and the legacy Escalate). An empty or unknown value reads as
None, as an empty one did before. The remediation fields and the save
both use it, so only those constants reach the data attributes and the
request. Every value the select can hold is in the list, so the review
behaves the same.

A browser test checks that a forged option reaches neither the data
attributes nor the save, while a chosen action is kept as it is.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@paullizer
Paul Lizer (paullizer) merged commit 1316cd9 into microsoft:paullizer-react-v2-ui Oct 8, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants