Skip to content

feat: standardize the account retirement pipeline as /api/user/v2/ - #39330

Draft
Abdul-Muqadim-Arbisoft wants to merge 107 commits into
openedx:masterfrom
edly-io:feat/api-adr-user-v2-phase-d1
Draft

Abdul-Muqadim-Arbisoft wants to merge 107 commits into
openedx:masterfrom
edly-io:feat/api-adr-user-v2-phase-d1

Conversation

@Abdul-Muqadim-Arbisoft

@Abdul-Muqadim-Arbisoft Abdul-Muqadim-Arbisoft commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part of [API] User under the umbrella #38137, following docs/decisions/ and OEP-69.

This PR moves the eleven operations that the retirement pipeline driver calls onto /api/user/v2/. They are the queue reads, the record read, the move, the cancellation, the two erasure steps, the two batch disposals, and the partner-report claim and queueing. The work extends the unreleased v2 package from #39297. Every v1 address keeps resolving unchanged.

Why a new version. Some of the eleven v1 operations answer errors as bare JSON strings, and the driver reads those strings. An in-tree v1 test also asserts that an exception string is returned verbatim. Changing these in place would break a pinned contract on the pipeline that erases learner data.

Stack and merge order: #39297 → #39314 → #39330 (this PR, on #39314) → #39334 and #39331 (siblings, both on this PR).

28 commits, 27 files, +7860 / −42 (this PR's own range):

commits what
from feat: standardize the account retirement pipeline as /api/user/v2/ to fix: append the component Studio's document had stopped carrying (16) the feature and its review fixes. The last one is undone by fix: keep the user retirement model out of Studio's Authoring API document
from fix: keep the user retirement model out of Studio's Authoring API document to the last commit, test: read the retirement permissions from their declaration in every run order (12) corrections from the 2026-11-04 review

Operations

superseded (v1) replacement (v2)
GET /api/user/v1/accounts/{username}/retirement_status/ GET /api/user/v2/retirements/{username}/
GET …/accounts/retirement_queue/, GET …/accounts/retirements_by_status_and_date/ GET /api/user/v2/retirements/ (filters state, created_after, created_before, cool_off_days)
PATCH …/accounts/update_retirement_status/ PATCH /api/user/v2/retirements/{username}/
POST …/accounts/cancel_retirement/ DELETE /api/user/v2/retirements/{username}/
POST …/accounts/retire/, POST …/accounts/retire_misc/ POST /api/user/v2/retirements/{username}/erasures/ with scope lms_core / lms_misc
POST …/accounts/retirement_cleanup/ POST /api/user/v2/retirement_cleanups/
POST …/accounts/retirement_partner_report/ POST /api/user/v2/retirement_partner_reports/
PUT …/accounts/retirement_partner_report/ PUT /api/user/v2/retirement_partner_reports/{username}/
POST …/accounts/retirement_partner_report_cleanup/ POST /api/user/v2/retirement_partner_report_cleanups/

replace_usernames and deactivate_logout are not in this PR. They follow in #39334.

Declared differences a caller can see

The record differences are RECORD_DIFFERENCES, and the filter differences are QUEUE_FILTER_DIFFERENCES, both in test_retirement_parity.py. Each test is named in the evidence comment.

Every operation

  • Refusals use the RFC 9457 envelope. No internal exception text reaches a body. A query parameter the operation does not read is refused 400. A token issued to a restricted application is refused 403, by each view itself.
  • A method that no operation of the address serves is answered 405 with Allow. The v1 disposals answer the same request 403.
  • If more than one retirement record was requested under an account name, every operation that resolves a record by that name refuses it 400 user/ambiguous-account-name rather than acting on the first record.

The collection and its records

  • One paged, sortable collection replaces the queue and the by-date list. state takes step names, either repeated or as one comma-separated list.
  • created_before is exclusive, where the end_date it replaces included the whole day named.
  • cool_off_days above 36,500 (a century) is refused 400. Up to about 739,000 days, v1 answers such a value with no rows, and beyond that it fails with a 500. Within a century, both versions answer the same rows.
  • A record carries the step as its name (current_state, last_state), not as an object.
  • ?fields= reaches the top level only. A name written as a path, such as user.email, is refused 400.
  • A refused step is 409 user/retirement-state-conflict, and a missing record is 404. new_state and response in a move are trimmed, while v1 stores response verbatim. No in-tree caller pads either.
  • Whether a retirement admits pipeline work is judged in version 2, on the record it resolved, by the rule get_retirement_for_retirement_action applies. A parity test holds both versions to the same 13 refused steps and 9 admitted steps. user_api/models.py is unchanged.
  • The two queue reads let the platform's language-cookie sync store one preference row, as it does on every authenticated request. The reads write nothing of their own.

Batch disposals and the partner report

  • Both disposals report a result for each name (deleted / already_absent). Like v1, they put no limit on how many names one request carries, and they compare each name exactly as sent. A name with spaces around it matches no record and is reported already_absent, under the name as sent.
  • A refusal of one item is listed under that item's position, for example usernames[1], and detail is written in words.
  • Refusals are all-or-nothing. An unfinished record or an unclaimed entry refuses the whole batch with 409, and nothing is deleted.
  • The claim marks exactly the entries it returns. limit takes 1 to 100, and a larger value is refused 400. Omitting it takes every waiting entry, as before. v1 has no limit. The placeholder organization outdated_entry is never named to partners, as on v1.

Published documents

  • The 11 v1 operations are marked deprecated: true, and 10 of them publish the body their handler reads.
  • Studio mounts the same v1 module, so its /api-docs/ marks the 11 operations at those 10 addresses. Studio's Authoring API document carries no user-API component, as on master. Studio's /api-docs/ still publishes PatchedUserRetirementStatus, generated from its own v1 update_retirement_status operation and identical to the definition the LMS appends.
  • The two choice sets are published as RetirementErasureScopeEnum and RetirementBatchResultOutcomeEnum. These names exist only on this unreleased branch.

ADRs applied

The evidence for each box is in the compliance matrix in the evidence comment.

  • 0025 serializers — serializer_class on every view, with request and response declared separately where they differ
  • 0026 permissions — the two v1 capability classes are imported and mapped per action; restricted-application refusal on every view
  • 0027 schema / docs — every action publishes every status it produces; the v1 operations publish the bodies their handlers read
  • 0028 viewsets — a generic viewset for the collection, and viewsets for the record resource and each disposal
  • 0029 errors — RFC 9457 throughout; one domain type (user/retirement-state-conflict), registered for this area's own exception
  • 0030 GET idempotence — the reads write nothing of their own; the platform's language-cookie write is declared and pinned
  • 0031 merged endpoints — queue plus by-date list become one collection; retire plus retire_misc become one erasure with scope
  • 0032 pagination — met for the collection; the partner-report claim answers an array, because the entries it returns are the entries it marks (see deviations)
  • 0033 filtering — FilterSet, a strict ordering backend, a deterministic default order
  • 0034 authentication — no view declares authentication_classes
  • 0035 MFE config — n/a: the pipeline serves no frontend configuration
  • 0036 nested JSON — heavy record by default; ?view=minimal and ?fields= refuse unknown values and dotted names
  • 0037 versioning — extends the unreleased v2; user_api/models.py unchanged
  • 0038 URL structure — snake_case plural collections; the member key is the username
  • OEP-66 queryset scoping — ScopedQuerysetMixin with FullScopePolicy() on the records and both disposals
  • OEP-69 conventions — envelope, strict parameters, fields/view

In-place edits

file why
openedx/core/djangoapps/user_api/accounts/utils.py gains get_orgs_for_user(), moved unchanged from AccountRetirementPartnerReportView._get_orgs_for_user, so that v2 does not call a private method of the v1 view
openedx/core/djangoapps/user_api/accounts/views.py that static method now returns the helper's result. Its name, docstring and return value are the same, and the legacy tests are untouched
lms/envs/common.py APPEND_COMPONENTS carries PatchedUserRetirementStatus; ENUM_NAME_OVERRIDES pins the two choice-set names
cms/lib/spectacular.py the 10 superseded retirement paths are added to Studio's SUPERSEDED_PATHS
cms/lib/tests/test_spectacular.py, lms/lib/tests/test_spectacular.py tests for the above
cms/tests.py, lms/tests.py additions only (+15/−0 each): every served document resolves every $ref
openedx/core/djangolib/testing/utils.py, …/tests/test_utils.py unresolved_references() and its tests
user_api/schema.py, user_api/schema_components.py (new; from earlier in the stack) the retirement rows, and the shared component definition

Error-format decision

Versioned. The v1 bodies, bare strings included, are unchanged.

Deprecations

The 11 operations are marked deprecated: true, with no header and no note in code. They are tracked on #39128. This area's entry is still to be posted, and its text is drafted. Every replacement is LMS-only, so a driver that calls Studio must be pointed at the LMS before removal.

Deliberate deviations

  • The partner-report claim answers an array. The entries it returns are the entries it marks, and nothing can release one, so a page boundary would strand entries. Send ?limit= to drain a large queue in steps.
  • v2 still imports two v1 view classes (AccountRetirementView, LMSAccountRetirementView). Listeners select on them as the signal senders, and they also hold the public erasure helpers. The DEPR removal must move both first.
  • Hygiene H7 WARN ×4 on RetirementViewSet, RetirementPartnerReportViewSet, RetirementCleanupViewSet and RetirementPartnerReportCleanupViewSet. Their permissions come from access mixins, which the heuristic does not see.
  • The versions gate against feat: standardize the user preferences and email opt-in API #39314 lists v2 files as edited in place. They belong to the same unreleased version. Against master, the gate passes.

For an operator

  • POST /api/user/v2/retirement_partner_reports/ without limit takes the whole queue in one request and one transaction. The in-tree driver sends no limit, so on a large backlog, configure it to send one.
  • SPECTACULAR_SETTINGS['APPEND_COMPONENTS'] and ['ENUM_NAME_OVERRIDES'] are load-bearing. A deployment settings patch that assigns either key, instead of merging into it, drops the pinned names.

Known pre-existing behaviour, left as it is

Evidence

The full tables are in the evidence comment.

  • Gates (PR base feat: standardize the user preferences and email opt-in API #39314; d178206575 for the comparison against master):
  • Every served document (LMS /api-docs/ and /lms-api/, Studio /api-docs/ and /authoring-api/) has 0 unresolved $ref, checked by test_every_reference_in_each_served_document_resolves.
  • Repo CI lint: 0 findings added in every step. commitlint passes on every commit.
  • Test runs at this head (c59981f3d1), MongoDB attached, serial: LMS (user_api/, profile_images/, user_authn/, lms/lib/tests/, lms/tests.py, openedx/core/tests/test_apidocs.py) 2779 passed, 1 skipped, 0 failed; Studio (cms/lib/tests/, cms/tests.py, user_api/) 209 passed, 1866 skipped, 0 failed.
  • test_retirement_views.py passes in file order, in reversed order and under four random seeds (256 passed each time), and each of its 28 classes passes alone. The whole user_api/v2/ suite: 1458 passed, also under a random order.
  • Merged with origin/master d2dbc6a593, conflict-free (wider targets, as in feat: standardize the user accounts, profile image, verification and name-change API #39297; the tree differs from this head by the C1803 line and the last commit's four tests): LMS 3526 passed, 8 skipped, 0 failed; Studio 381 passed, 2250 skipped, 0 failed. Every master test id is still collected.

Follow-ups

The user directory, the stored-preference listings and the time-zone
reference list were the least conforming endpoints of the User API: the
only gate was the deprecated X-Edx-Api-Key header, which ignores
request.user entirely, so any holder of the key could read every user's
email address and full preference mapping. The listings were addressed by
database id, two of them put the parent key in the path instead of the
query string, the time-zone list returned an unbounded array, the stored
preference row embedded its owner's entire record (one extra query per
row), and the forum-role listing created the role it was asked about on
every GET.

Each of those is a contract change, so they land on a new version and
/api/user/v1/ is untouched and keeps serving them.

What callers get instead:

  GET /api/user/v2/users/                              (also ?preference_key=,
                                                        ?forum_role=&course_key=)
  GET /api/user/v2/users/{username}/
  GET /api/user/v2/user_preferences/
  GET /api/user/v2/user_preferences/{username},{key}/
  GET /api/user/v2/time_zones/

Authenticated staff callers rather than an API key, the standard error
envelope, the standard pagination envelope on all three lists, filter
sets and a deterministic ordering, and the ?view= and ?fields= response
presets. The forum-role filter no longer creates a role, and an
unparsable course key is a validation error rather than a 500.

Being refused is part of the contract, so the refusals are explicit. A
deactivated staff account is turned away on both paths rather than only
one: the session authenticator already rejected it, but IsAdminUser reads
only is_staff and the JWT authenticator does not read is_active, so such
an account could read the whole directory until its token expired. These
addresses require an active user.

An unrecognised query parameter is a 400 rather than silence. A client
porting from a superseded address by changing the version segment alone
would otherwise pass a filter this version does not define, have it
ignored, and receive every row believing the page was filtered. The
allowlist is derived from the paginator, the ordering backend, the filter
set and the query serializer, so it cannot drift away from the filters it
describes.

Shared building blocks are used rather than rewritten:
StandardizedErrorMixin, DefaultPagination, IterablePaginationMixin,
ScopedQuerysetMixin with FullScopePolicy, MinimalViewMixin and project.
The two local classes each supply one predicate the library does not,
and each docstring says which.

The tests pin behaviour rather than structure: every allowed and every
refused caller with the error type asserted, value-level parity with each
superseded address, query counts that stay constant across 1, 5 and 25
rows where the superseded listing pays per row, SQL captures proving no
GET writes, and the URL literals each name reverses to.
The LMS drf-spectacular pre-processing hook is an allowlist and admitted
only /api/enrollment/vN/, so everything under /api/user/ generated,
routed and tested while being invisible in the published document. The
new v2 addresses would have shipped undocumented and the schema gate
would have reported the prefix missing with no clue as to why.

The filter now admits exactly what this change owns: the v2 addresses,
matched by their view package rather than by path prefix, plus the seven
legacy paths they supersede, so each superseded address appears beside
its replacement. It is deliberately not a /api/user/vN/ prefix. Admitting
the whole prefix pulled thirty-eight further v1 operations into the
document -- undocumented request bodies and internal docstrings published
as client-facing text -- none of which this change owns or documents.

SCHEMA_PATH_PREFIX becomes a two-branch pattern, stripped only to derive
tags and operationIds. Every existing enrollment operationId was compared
before and after and is unchanged, and so is every operationId, path and
component of the service-wide /api-docs document: a generated client
derives a module per operationId and a model per component, so a rename
there deletes a module as surely as a removal does. The title follows the
contents, and the three comments that named the old title follow it.

Deprecation is marked where it belongs, outside the frozen modules: the
existing post-processing hook gains a tuple of the seven exact legacy
paths this change supersedes. Exact paths, not a prefix, so an address
whose replacement nobody has written yet can never be marked -- the hook
test includes neighbouring user addresses, the unversioned twins and
another area's paths and asserts they are left alone.

POSTPROCESSING_HOOKS is unchanged: setting it replaces drf-spectacular's
defaults, so the enum hook stays restated and stays first.
@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor Author

Phase D1 — evidence tables

Moved out of the pull request description, which GitHub caps at 65,536 characters.

Compliance matrix

Decision Status How it is met, or the concrete reason it is excluded Evidence (test / gate)
0025 serializers met serializer_class on every view; get_serializer supplied where the base class has none; help_text on every field; a write whose body differs from its response declares both. The serializer-name index was run before any name was written — no implicit ref_name collides with the five names the version 1 serializers hold, so none needs pinning test_a_retirement_record_publishes_the_step_as_a_name, test_the_erasure_publishes_the_sets_of_data_it_can_erase, test_a_batch_publishes_what_it_can_report_about_each_name_it_is_given, test_both_batch_addresses_publish_the_same_exchange
0026 permissions met The coarse check is on the view; the capability is chosen per action from one mapping, and an action the mapping does not name reaches no capability at all. The two capability classes this surface uses — one for the pipeline's work, one for cancelling a request — are imported from the module that already defines them, so the two versions cannot drift apart. (The area's third class belongs to replace_usernames and is not used here.) The four views take their permission classes from a shared base rather than restating them, which is why the hygiene gate reports four H7 warnings; the resolved tuple is asserted exactly the access matrix over all seven addresses this version mounts (an earlier version said nine, the number of (method, address) pairs, not of addresses) (test_every_admitted_caller_is_served, test_every_other_caller_is_forbidden, test_an_anonymous_caller_is_unauthenticated), test_an_action_the_mapping_does_not_name_reaches_no_capability, test_each_named_action_carries_the_capability_it_is_mapped_to, test_the_classes_of_a_view_must_all_admit_a_caller, test_a_rule_added_to_the_records_is_asked_of_their_disposal, test_the_classes_each_view_resolves_are_the_ones_the_shared_layer_declares
0027 schema / docs met Every action declares a summary, a description, its parameters and each status it can answer with. The prefix was already admitted by the pre-processing filter, and the post-processing hooks are untouched 119 passing tests across v2/tests/test_schema.py and lms/lib/tests/test_spectacular.py; test_published_text_is_written_for_the_clients_that_read_it
0028 viewsets met The collection uses a generic viewset so the filter backends, the paginator and the serializer machinery are the library's rather than hand-written; no model mixin is mixed in, so every write goes through the pipeline's own model layer. The partner reports are a plain viewset over a service data path. The two batch disposals are plain viewsets registered with the router, each serving create alone: one operation over a set of records named in its body, with an invariant covering the whole set and no member address of its own. They were built as plain views; routing them makes the disposal and the collection whose records it deletes resolve one permission declaration and one selection, which is the defect that change exists to remove. Selections carry exactly the relations the serializers read test_retirement_query_counts.py: the collection and the claim cost the same at 1, 5 and 25 rows, beside the measured legacy baselines, which grow per row; test_each_address_is_mounted_on_the_view_that_serves_it (view identity and routed actions, table-driven), test_neither_disposal_is_served_by_the_collection_whose_records_it_deletes, test_neither_disposal_has_a_member_address
0029 errors met StandardizedErrorMixin first in the MRO with the three mixins this version already carries. One new domain error type is registered, for this area's own exception; a refused step is translated at the service boundary. The specific reason is logged with the records it names; the response carries one stable sentence assert_error_envelope on every refusal the endpoints produce, including 409 with its per-record errors; test_a_step_that_fails_partway_through_says_nothing_about_why; test_another_area_refusing_a_conflict_is_classified_as_it_was_before
0030 GET idempotence met, one declared exception The two reads and both HEADs make no change of their own, with every filter applied. The one write that happens on them is the platform's own language sync, which stores the caller's language preference on any authenticated request and is not this surface's: it writes one row, on the first such request only, and is asserted to do so. The entry-claiming operation remains a POST, which is where a read-with-a-write belongs RetirementReadsAreNotWritesTests — five runtime SQL captures, because a grep of handler bodies cannot see through a service module; RetirementReadsWithTheLanguageCookieTests::test_the_row_is_written_once_and_not_once_a_request
0031 merged endpoints met Two merges: the queue and the by-date list differ only by which filter is applied, which is the filtering layer's job and not two addresses'; the two erasure steps differ only in which set of data they clear, which is a mode with its values enumerated in the schema. Cancelling and disposing are not merged although both delete: they carry different capabilities and act on retirements at opposite ends of the pipeline, and one verb cannot carry two permission models test_several_steps_can_be_asked_for_at_once; both scope branches asserted separately; test_neither_scope_takes_a_step_belonging_to_the_other; the two capabilities proved distinct by the access matrix
0032 pagination deviation, declared The collection is paginated with the version's paginator, 10 by default and 100 at most, and publishes the whole seven-member envelope. The partner-report claim is not paginated: the entries it answers with are exactly the entries it marks as being processed, and nothing in the pipeline can release an entry, so a page would hand the caller the first entries while marking all of them. An optional limit lets the caller bound its own batch; there is no default and no maximum, so omitting it takes the whole waiting queue — which is what the operation it replaces does, and what a drain has to do if the entries it does not take are to stay takeable. The response is therefore bounded by the depth of a queue the pipeline itself fills, not by a page size. Measured at 37 entries in one body in the parity fixtures test_the_queue_answers_with_a_page, test_the_claim_answers_with_an_array_rather_than_a_page, test_at_most_the_number_of_entries_asked_for_is_taken, test_taking_no_entries_at_all_is_refused, RetirementPageSizeTests
0033 filtering met DjangoFilterBackend with a FilterSet and a strict ordering backend that refuses a field it does not offer; four optional filters; a deterministic default order that is the order the pipeline works the queue in, completed with a tiebreak so a page is stable test_the_page_can_be_sorted_by_every_field_it_offers, test_a_sort_field_the_page_does_not_offer_is_refused, the four filter tests and their refusals, test_the_queue_publishes_the_filters_it_narrows_by, and RetirementQueueOrderTests, which reads the order the database is asked for — the tie-break column is the identifier the rows are already stored by, so the order asked for is the only evidence available that ties are settled
0034 authentication met No view of this pass declares authentication_classes; all four take the platform default pair test_no_retirement_view_declares_its_own_authentication; hygiene rule H4
0035 MFE config excluded No endpoint in the retirement pipeline serves front-end or site configuration, and none returns configuration bundled with user context —
0036 nested JSON met The record is nested and stays heavy by default, so a migrating client changes addresses rather than parsing. ?view=minimal reduces it to which account it is for and where it stands — carrying none of the account owner's own data, which is exactly what the one in-tree consumer of the queue reads — and ?fields= selects properties. An unknown preset or property is refused rather than answered empty. Selection reaches the top level only: a name written as a path, such as user.email, is read as user and the whole embedded account comes back. That is the shaping helper's defined behaviour rather than this surface's, it is now stated in the published description of the parameter, and it is declared below as a payload-minimisation limitation rather than changed, because refusing a dotted name would change an answer that three stacked PRs already publish test_the_minimal_preset_carries_no_data_of_the_account_owner (collection and member), test_a_preset_the_page_does_not_answer_to_is_refused, test_a_property_a_row_cannot_carry_is_refused, test_a_property_asked_for_as_a_path_answers_the_whole_of_its_first_part, test_selecting_properties_publishes_how_far_a_name_reaches
0037 versioning met, less two clauses; both recorded New modules beside the existing ones in the one unpublished version of this area. Deviation 1: the superseded addresses carry the schema flag but no docstring marker, which decision 4 also asks for — the reason is the FC-0118 review direction that a superseded view is left untouched in code, and the follow-up form, if a reviewer asks for one, is the OEP-21 single line and nothing else. Deviation 2: two lines inside this unpublished version that earlier phases of the stack added are edited here — the shared fields parameter's published description gains a sentence (see 0036). Editing a version in place is what the decision forbids; it is admissible only because this version is unpublished, and it is named here rather than left for a reader to find. Four files outside the version change, each a strictly opt-in addition or a documentation correction, listed below. The superseded views carry no notice of any kind and are absent from the diff versions gate PASS against the merge-base with the legacy surface protected; each of the 22 protected paths additionally checked by hand at zero changes; git diff of the superseded modules is empty
0038 URL structure met Addresses come from one table. Names are snake_case and version-free. Nothing is renamed or dropped, and the member key is the name the account was registered under — never a database identifier. No fixed word sits where a member's name goes, because a user may register any name url_snapshot.py check against this PR's branch point: 7 new routes, 0 address removals, 0 name removals, 0 rule findings. RetirementRouteTests (including /retirements/cleanups/ resolving as a member named cleanups), LegacyRetirementAddressTests. The run against master is a declared FAIL on an earlier phase's route — see the gate report
OEP-66 queryset scoping met Every action of the retirement records — the list, the member read, the move, the cancellation and the erasure — and both batch disposals resolve the records they act on through ScopedQuerysetMixin. Both partner-report operations resolve through the policy the view declares; the member PUT is the one that does not go through its own get_queryset(), because the record it queues is a retirement record while that view's selection is of queue entries, so it applies the declared policy to the retirement records and the line above it says so. The policy is the library's FullScopePolicy(): any caller holding the pipeline's capability may see every record today, and that is what the policy expresses. The layer exists so a narrower rule can replace it without touching the filtering, and the user filters live in filter_queryset where they can only narrow. Extending the layer from the two list reads to every operation could not change an answer by construction — FullScopePolicy.scope() returns the selection unchanged — and both whole documents are byte-identical across that change RetirementVisibilityTests, PartnerReportVisibilityTests and PartnerQueueRecordVisibilityTests — a policy put in place of the configured one narrows both selections and is handed the caller and the whole of each, so removing the layer turns them red; the last of the three covers the member PUT specifically, and passing the unscoped selector there instead of the policy turns two of its four tests red; test_every_admitted_caller_sees_the_same_rows; test_a_record_the_policy_leaves_out_is_absent_from_its_member_address and its three siblings for the move, the cancellation and the erasure; hygiene rule H3
OEP-69 conventions met One-line module docstrings matching the siblings; class docstrings describing behaviour and the one non-obvious choice; schema text written for the clients that read it; fields and view as the shaping parameter names hygiene gate PASS; test_published_text_is_written_for_the_clients_that_read_it

Authorization

The permission model is carried forward as it stands; tightening it inside a
standardization PR is out of scope. The two capability classes this surface
uses are imported from the module that already defines them rather than
re-expressed, so the two versions cannot drift apart, and a later hardening of
either applies to both at once.

Operation Legacy check Conforming check
the queue, one record, the move, the erasures, both batch disposals, the partner-report claim and queueing IsAuthenticated + CanRetireUser the same two classes, imported; CanRetireUser chosen per action
the cancellation IsAuthenticated + CanCancelUserRetirement the same two classes, imported; CanCancelUserRetirement chosen for that action alone
@request_requires_username (on the move, the erasures and the partner-report queueing) 404 when the body carries no username the name is in the address, so a request without one is a different address and the router answers 404. The decorator's condition cannot arise
parser_classes = (JSONParser,) a body in another media type is refused the same narrowing is kept rather than widened: a media type the endpoint cannot read is refused at 415 with the standard envelope

Deny-path tests

Nine caller fixtures, named by what each is entitled to: anonymous; another
learner; the learner whose account is being retired; staff holding neither
capability; staff who may request a retirement; staff who may cancel one;
the pipeline driver; the caller entitled to replace usernames; a superuser.

Every address runs all three sides of the matrix — each admitted caller served,
every other caller refused with 403, and an anonymous caller refused with
401. Three rows are the proof that privilege is not flattened by the merges:
the pipeline driver is refused on the cancellation, the staff caller who
may request a retirement is refused on the cancellation, and the caller
entitled to replace usernames reaches nothing here. A test also asserts
that an action absent from the per-action mapping reaches no capability, so an
action added later cannot inherit the pipeline's.

Beyond the matrix: DRF's conjunction of the classes and its ordering of 401
before 403 are asserted by test rather than assumed; the visibility layer is
proved to narrow nothing for any admitted caller; and a rule added to the
shared declaration is proved to be asked of the batch disposal as well as of
the collection, which is the defect the last commit removes.

Gate report

run_gates.sh, --settings test, base the merge-base 87cd3811a4, with a
MongoDB service attached and --old-tests widened to all three superseded
packages.

  versions             PASS
  hygiene              PASS   (0 FAIL, 5 WARN)
  schema               PASS
  schema compare       PASS   (0 BREAKING, 85 WARN, 69 INFO)
  urls                 FAIL   (2 FAIL, 1 WARN) — declared, see below
  old-tests            PASS   (496 passed)
  tests                PASS   (1244 passed)
GATES FAILED: 1
  • schema compare ran — and it is not this PR's compatibility evidence.
    The 0 BREAKING, 85 WARN, 69 INFO above compares the narrow
    SPECTACULAR_SETTINGS document against the merge-base. That base
    document holds 19 paths, every one of them /api/enrollment/…, and not one
    /api/user/ path
    , so every operation this PR alters is an addition
    there, and an addition cannot be reported as breaking. The verdict is
    truthful about what it compared and says nothing about the surface this PR
    changes. (It is at least a real run: an earlier run of this branch reported a
    verdict for a comparison that had produced no input: the script's default
    settings module needs a deployment configuration file the container lacks,
    and --settings test fixes it.) Re-run with the same tool against
    this PR's branch point, on both documents, it is FAIL on each:
    1 BREAKING on the narrow document and 20 BREAKING, 35 WARN, 22 INFO
    on the service-wide /api-docs one. The single narrow finding is the requestBody.required gained on
    POST /api/user/v1/preferences/email_opt_in/, declared above. The twenty
    service-wide findings are that one, the seven other gains, the one loss, and
    the nine changed version 1 success responses set out above — none of which
    the quoted PASS could have seen. The tool reports any change to a published
    request or response as breaking, whether or not the earlier value was ever
    true; which of the twenty are corrections and which are contract changes is
    the subject of that section. Read 0 BREAKING as the narrow document's
    verdict against the merge-base, and nothing more.
  • hygiene PASS — 0 FAIL, 5 WARN, and the five are expected. One is
    pre-existing (H3 on an earlier phase's v2/paginators.py:6). Four are new
    and are the point of the last commit
    : the four retirement views no longer
    declare permission_classes in their own body but inherit it from the layer
    they share, which ADR 0026 states in terms is fine — "A view inheriting a
    base class that declares permissions is fine. A view with no declaration
    anywhere in its MRO is public."
    H7 can only see a class body. The base
    classes are _RetirementRecordAccessMixin and _PartnerReportAccessMixin,
    and test_the_classes_each_view_resolves_are_the_ones_the_shared_layer_declares
    asserts the exact tuple each of the four views resolves.
  • urls FAIL (2 FAIL, 1 WARN), declared and not this PR's. All three
    findings are on one route an earlier phase of this stack added: the
    version-level JSON-404 fallback, which has no trailing slash, puts an
    identifier directly under the version, and carries no URL name — all three
    correct for a regex fallback that is not an address and that nothing may
    reverse() to. head routes: 3061, base routes: 3036; nothing is
    removed
    . Run against this PR's own branch point, where that route is not
    new, the same gate is PASS (0 FAIL, 0 WARN) over the 7 routes this PR
    adds. Not to be read as passing against master.
  • old-tests PASS — 496 passed, the three superseded packages, equal to the
    count before this PR's first line.
  • tests PASS — 1244 passed over user_api/v2/. Widened to the packages
    this stack touches (user_api/v2/, profile_images/, enrollments/v2/ and
    the schema-hook tests): 1443 passed, 0 failed under lms.envs.test.
    Studio collection: 0 collection errors, and that is the whole of the
    claim.
    On the path set this PR's own test tier uses, cms.envs.test
    collects 2237 tests with 0 collection errors — the only figure here that
    can be set against that tier's baseline. Two other collections were run and
    are given for completeness: the four packages above collect 1443, and
    widened to the three superseded packages, 2010. Neither path set matches
    a baseline measured anywhere in this work, so neither number is comparable
    with anything; only the absence of collection errors is load-bearing, and it
    holds on all three. The new modules run there with no failures.
  • The Studio URL dump is BLOCKED, not passing and not failing. Dumping
    Studio's URL configuration in this image raises
    ModuleNotFoundError: openedx_learning.urls — on the merge-base and on
    this head alike
    , so it is a property of the image and not of this change,
    and it produces no verdict either way. No-Studio-impact is therefore argued
    by inspection instead: the conforming version's URL module is included at
    exactly one place, lms/urls.py:145, and the cms/* diff of this branch is
    empty. Studio collection did run, with the result above.
  • Nothing was reported SKIPPED.
  • Locally, beside the gates: ruff clean; pylint reports nothing on any line
    this PR adds.

…image APIs as /api/user/v2/

The account surface was the largest non-conforming part of the User API and the
one most callers depend on: frontend-platform's account fetch, the account and
profile MFEs, and both mobile apps all read it. Ten of its operations answered
in shapes nothing documented, refused malformed input with server faults, and
addressed one account's records through a permission that read a username from
somewhere other than the address.

Every change here is a contract change, so they land on a new version:
/api/user/v1/ is untouched and keeps serving every one of its callers.

What this version answers:

  GET    /api/user/v2/accounts/                     (?username=, ?email=,
                                                     ?lms_user_id=, ?view=,
                                                     ?fields=, ?ordering=)
  GET    /api/user/v2/accounts/me/
  GET    /api/user/v2/accounts/{username}/
  PATCH  /api/user/v2/accounts/{username}/
  POST   /api/user/v2/accounts/{username}/deactivation/
  POST   /api/user/v2/email_searches/
  POST   /api/user/v2/name_changes/
  POST   /api/user/v2/name_changes/{username}/confirmations/
  GET    /api/user/v2/accounts/{username}/verification_status/
  GET    /api/user/v2/accounts/{username}/verifications/
  POST   /api/user/v2/accounts/{username}/profile_image/
  DELETE /api/user/v2/accounts/{username}/profile_image/

A refusal is part of the contract, so the refusals are explicit. The request
body is measured against a serializer before the account service reads it, so a
wrongly typed property is a validation envelope rather than a server fault; a
failure inside the service is a server fault rather than a validation envelope.
A permission reads the username from the address and from nowhere else, and
compares it exactly, because the handler behind it selects the record by that
same value. An account that is no longer active is refused on both halves of the
default authentication pair rather than only one.

Lists answer in the standard envelope, bounded and ordered by username; a filter
that matches nothing answers an empty page rather than reporting the address as
missing, and a status that has never been established answers "none" rather than
reporting the account as missing. An email search is bounded at a hundred
addresses, which bounds its answer.

The schema describes what the code does: the superseded addresses publish the
media types they actually read, every operation publishes the codes it really
answers, and the response components carry the properties the bodies really
hold, with presets modelled rather than implied.

The tests pin behaviour rather than structure. Every allowed and every refused
caller is named with the error type asserted; value-level parity is checked
against each superseded address; query counts are measured on the earlier
endpoint first and asserted not to grow with the collection; SQL captures prove
no read writes, including writes deferred to commit; and each guard was checked
by breaking what it covers and watching the right tests fail.

The profile-image endpoints are addressed under the User API but served by a
view that belongs to the profile_images app, so the conforming version lives in
that app's own new version package and is mounted into the user version two
URLconf exactly as the earlier view is imported today. The app's existing
modules are untouched, and the image validation they own is called rather than
copied.

An upload is read before anything is stored, so a truncated file, metadata that
cannot be parsed, or a header declaring more pixels than the site will ever draw
are answered as the caller's error rather than as a server fault, while a
failure to store a well-formed image stays a server fault. The media types the
address reads are published, and the body is published as required.

A caller may set and clear the image on the account the address names and on no
other, staff included, since clearing an image is a change to an account rather
than a moderation tool. The permission reads the username from the address and
compares it exactly, because that is the value the stored names are built from,
so the account a caller is admitted for is always the account written to.

The tests drive real uploads: each refusal with its error type, parity against
the earlier address for the bodies both answer, and a check that a caller
holding another spelling of the owner's name is refused and stores nothing.
This package is shared with the CMS, and the CI shard that runs it under
cms.envs.test could not import this module at all: it read
settings.ACCOUNT_VISIBILITY_CONFIGURATION, an LMS setting, in the module body.
Collection failed, which failed the shard, which cancelled the rest of the
matrix.

Every test here that reads these sets is already @skip_unless_lms, but a module
body runs before any skip can apply, so the setting is now read through the same
guard user_api/accounts/tests/test_views.py uses for it, and the field sets fall
back to empty when it is absent.

Under cms.envs.test this module now collects, and the failures left in these two
packages are the same 44 as on master, in the same tests -- an artefact of
running two of the shard's sixty-nine paths rather than anything in this branch.
Under lms.envs.test nothing changes.
…uth class

Four views declare their own authentication_classes so that a caller who has
not yet confirmed their email address can still reach their own account, its
picture, its preferences and its email choice: the session class the platform
configures refuses such a caller, so that half is replaced.

For the token half they named DefaultJwtAuthentication. That class exists only
to be named in DEFAULT_AUTHENTICATION_CLASSES -- its own module says it is only
meant for that -- and the using_default_auth_classes attribute it records exists
so an operator can find the endpoints that are on the defaults. A view that
declares its own classes is not one of those, so recording the attribute filed
a false signal and polluted the query the attribute serves. Nothing on master
declares the class: two files name it, both definitional, against the library
class used throughout the rest of the platform.

The four views now name JwtAuthentication, the class the platform's own pair is
built on. Two of these addresses, the account and its picture, were added
earlier on this branch and are corrected in place here.

The guards assert the attribute is absent, each alongside a control request to
a sibling address that declares no classes and must still record it, so a pin
cannot pass merely because a patch target was mistyped.
An audit across the three phases on this branch found four defects, each
introduced by an earlier phase and each visible to a caller or to a generated
client. They are corrected together here, as the JWT class was, rather than left
to differ between sibling addresses of one version.

The profile image published a username with no pattern, the only one of the
eleven username path parameters in this version without it, so a generated
client validated the name before the request on nine addresses of the resource
and not on the tenth. The parameter helpers and the username pattern move from
views.py into view_utils.py, which that module already imports from, and the
parameter is built with the helper. The helper being private to one module was
the cause, and a further module would have hit it again.

The stored-preference resource pinned one operation id inline, which applies to
every document, so the service-wide document published that operation beside a
collection read carrying a different prefix, and a client generated from it
derived the two from different modules. The pin moves into the per-document
table beside the entries that already do this, so the resource's operations
share their leading tokens in each document. One id changes, in the service-wide
document only, to a name that document has never published.

The username filter accepted a single exact name on two collections and a
comma-separated list on a third, so a caller who learned the list form on the
account collection was answered a 200 with an empty page rather than told the
filter does not take a list. All three now accept the list, through one shared
selector, matching the member address of that resource, which already reads a
comma as a separator.

The email search was the one collection in this version reading accounts
directly without declaring a scoping seam, while the two collections over the
same table declare one. It now declares the same policy through a queryset the
shared mixin can wrap. No caller gains or loses access and no row changes: the
policy is a pass-through today, and the seam is what was missing.
…er them

Both documents already published these four addresses with a 200 and no body
before their bodies were described. No handler answers that: each answers 204.
The corrected code is now pinned in both documents and each handler is driven
on its success path in the same test, so the published code and the answered
one can only move together.

Replaces the pin that covered two of the four in one document.
…here too

Studio mounts the user API at the same addresses the LMS does and publishes
its own unauthenticated document at /api-docs/, built from its own settings,
so none of the marking applied in the LMS reached it: all twenty-five of those
operations were published there as current while the LMS published them as
superseded. A client generated from Studio's document therefore offered a
caller an address the LMS document steers them off.

The list of superseded addresses and the hook that reads it are shared rather
than restated, so the two services cannot drift apart. Measured on the two
generated documents: Studio marks 25 of 25, as the LMS does, and nothing else
in Studio's document changes -- no path, operation, operationId or component
is added or lost. The narrow Authoring API document Studio also serves carries
none of these addresses, so the hook cannot reach it.
…ettings

The documentation settings replaced the appended components wholesale rather
than adding the security schemes to them, so any component name the service
settings append was dropped from the settings the published reference is
generated from. Nothing showed it: the name simply stops being in the
reference, and in every client built from it, while every count stays where it
was.

They now add to what they inherit. The test loads those settings in a process
of its own -- loading them here would turn every feature flag on and add
Studio's applications to the settings this process already runs under -- and
checks that a component the service settings append arrives alongside the
security schemes.
Asking for a page of nothing, of fewer than nothing, or of something that is
not a whole number of rows was answered 200 with the default page of ten, on
every collection of this version that pages its rows. The framework drops a
size it cannot honour and falls back without telling the caller, so the page
reads as the one that was asked for; the links on it carry the value back, so
a client that follows them keeps sending a number nothing reads.

The shared paginator now reads the value itself and refuses one no page can
have, the way this version already refuses a query parameter it does not
read, a sort field it does not offer and a boolean naming neither side. A
number above what a page carries is still answered with a full page, which is
what it has always meant, and a value carrying nothing still leaves the size
alone.
The standard error body carries one line for a person to read beside the list
of what each parameter or property was refused for. Where the refusal named
parameters, that line was produced by printing the list itself, so a caller
read back the brackets, the quoting and the class names that write a data
structure out instead of a sentence. Everything that puts the line in front
of a person showed it: the message of the exception a generated client
raises, a line in an integration log, the notification a page puts up.

The line is now written from the same values as a sentence, naming each
parameter and what it was refused for; a refusal that names nothing in
particular is spelled on its own. The list itself is untouched, and a refusal
that never had a list -- an address that matches nothing, a caller that may
not read, a method the address does not serve -- keeps the line it had.
…ved it to

A member address of the account collection was answered by matching the name it
carried against the name each record that came back is stored under, by a
reduced form of that name failing that, and anything matching neither was
dropped. The database settles which stored records a name resolves to, and the
name it resolves to need not be written the way the address wrote it: where it
resolved to a record neither comparison matched, the list came back empty, the
member handler indexed the first element of it, and an authenticated caller was
answered 500. The superseded address answers 200 with that record, so the
replacement was worse than the address it stands in for, and a client or MFE
that pads or transliterates a name turned a working read into a server fault.

A member address is now answered from the records the lookup resolved it to:
the one stored under exactly the name given when there is one, and otherwise
the single record that came back. An address the lookup resolves to several
records, none of them stored under the name given, names no one of them, so it
is refused with 400 rather than answered with whichever came back first. A
lookup that comes back with nothing is reported as an absent address rather
than as a fault.

The page of a list keeps the order its names were asked in, which is what the
matching is for; a record it resolved to under a name none of them is written
as is now carried at the end of the page rather than left out of it in silence.

Tests: a member address the lookup resolves to a record stored differently, one
it resolves to nothing, one it resolves to several records with and without an
exact match among them, and a page carrying a record resolved under another
name. Each fails against the previous code, the first three with the 500 this
corrects.
The account collection selects by the email address of an owner with a lookup
that takes one record and refuses to answer at all where there is more than
one. Nothing in the user model keeps one address to one account, so a staff
caller looking up an address two accounts hold was answered 500, with nothing
in the answer to say why. The superseded address fails the same way, so this
was carried forward rather than introduced.

The selector now names every account holding the address, and the page carries
them all. A collection is what this address answers, and a page of the accounts
holding an address is already what the search over addresses beside it answers
with, so the page is the answer here whether the address is held once or
several times. An address no account holds still leaves the page empty.

Tests: the selector asked for an address two accounts hold, and the collection
read by a staff caller for the same address, asserting both accounts in the
page. Both fail against the previous code, the second with the 500 this
corrects.
…source

The accounts are one collection however many addresses reach into them, and
six operations that read or change a row of it were served by views standing
apart from it: the identity verification summary and attempts, the preference
document and one preference of it, and setting and clearing the image, each
declaring a flat list of classes of its own, none of them reading what the
account resource admits a caller by. A rule declared for that resource
therefore did not reach them. Measured with a rule admitting nobody added to
the resource: the account read answered 403 while the summary, the attempts
and the preferences answered 200, one preference and an upload reached their
handlers, and clearing the image answered 204 and cleared the stored image --
a change to an account carried out while every read of the same account was
refused. Deactivating an account read nothing of the resource either.

The classes the resource admits a caller by are now declared once and carried
by every address that works on one of its accounts, which applies them first
and the classes of its own address after. The same measurement now refuses all
eight, and the stored image is left alone. The declaration is read from the
shared tuple rather than from the collection's own attribute because that
attribute is added to in place by the first request to reach a view that reads
a token, which would otherwise make admission depend on which addresses a
process had served already.

The account an address names is resolved through the resource as well, by one
rule: the record stored under exactly the name given, or the single record the
name came back with, and otherwise a refusal. Three operations resolved the
record they write to with a lookup that takes one record and fails outright on
more, each catching only the not-found case, while the class in front of them
had settled the question on the name as text. Making a password unusable,
confirming a profile name and recording an image can therefore no longer land
on a record nobody addressed: each is refused with 400 and writes nothing.
Recording an image is looked up again by the writing helper, which this
version does not own, so the refusal is raised where that lookup comes back
with more than one record.

The preference addresses keep their own two-tier rule -- a read of somebody
else's is a privilege, a change of it belongs to the owner -- and declare it
through the hook the shared gate calls, so both tiers still apply and the
resource's rules apply before them.

One test outside this version's own modules moves with it: the preference
guard harness handed a bare request to classes that are given the wrapped one
by the view, and the classes carried in from the resource read the request
through it, so the harness now authenticates the request the way the test
client does.

Tests: every read and every change of one account measured twice, once as they
stand and once with a rule admitting nobody added to the resource, with the
stored image asserted untouched after the refusal; a deactivation refused with
the privilege for it held, so that the refusal measured is the resource's; and
each of the three write targets, refused when the address names no one account
and landing on the account stored under the name when it does. Removing the
shared gate from the four views, or emptying the shared declaration, turns the
refusal tests red.
The answer to an OPTIONS request is built from the parsers and the serializer
declared on the view, which belong to the view rather than to the address
being asked about, and two addresses of this version were described wrongly
because of it.

The preference document of one account is read and changed as a merge patch
and a body in any other media type is refused, which is what the published
document says of it. The description of that address named the three media
types the platform reads bodies in everywhere else and left out the one it
takes, because the parsers follow the action being served and the action
recorded for this question is not the change. A caller negotiating from the
description -- the one mechanism offered for it -- therefore sent a body in a
media type the address refuses and was answered 415, with the two published
descriptions of one address contradicting each other. The parsers now follow
the methods the address serves, and the description reports the ones it would
read a body with.

Confirming a requested profile name carries out the change already asked for
and reads no body, which is what the published operation says. The description
of that address offered a body with a required name in it, built from the
view's serializer, so a client or a form driven by it asked the operator for a
name before confirming the change and sent a body the address ignores. An
action that reads no body is now declared as such and no body is described for
it. The address that asks for a name still describes the one it takes.

Tests: the media types named by each preference address, a change sent in
every media type the document address names being carried out, no body
described for a confirmation, and the body still described for asking. Each
fails against the previous code, the negotiated change with the 415 this
corrects, and each half of the description turns its own tests red when taken
back off.
…ently

An address can have been held and left more than once, so more than one
retirement of it can sit inside the cooling-off period. This version reports
the most recent, being the only one a caller could still undo; the superseded
version reads the same window with a lookup that takes a single record and so
fails outright on the second. The window boundaries are the same in both.

The selection is on a path declared to answer as the superseded one answers,
and this is the one input they answer differently for, so the selector now
says so where the next reader of it will be.
Abdul-Muqadim-Arbisoft and others added 24 commits November 5, 2026 19:10
The two disposals deleted rows of the retirement and partner-report
collections from un-routed views standing outside them, each with its
own flat list of permission classes and reading neither the records nor
the rules its collection reads. A restriction added to the collection
therefore did not reach the operation that deletes those same rows,
which is measurable: the same rule, in the same place, leaves the
collection refusing a caller the disposal still serves.

Both disposals are now registered on the same router, at addresses
unchanged character for character, and take their rules and their
records from the mixins their own resource uses. Neither declares
either for itself. One ordering is load-bearing and is pinned by a test:
the mixin carrying the queryset must sit after the one that declares a
policy, because that one declares an empty policy of its own and
shadows anything behind it.

The visibility policy now reaches every action rather than the list and
the queue drain alone, so a member address resolves the record from the
selection the policy left, as the class already said it did. Nothing a
caller is answered changes: the policy this deployment runs returns the
selection unchanged, and both published documents are byte-identical.
The refusal of an ambiguous name and the absence reported for a record
outside the selection are each held by a mutation rather than observed.

One answer does change, and it is the one the collection already gives:
a caller without the capability who sends a method the disposal does
not serve is now told which methods it serves, instead of being
refused. The address serves one method, and that is already published.
The record published two properties the handler has no way to answer:
it is built from the queue entry and from the enrolments of the account
it names, and neither carries an identifier the deployment knows the
user by or a per-organization reporting configuration. Both are
removed, the class docstring no longer promises them, and a test holds
the published property set equal to the set a claim really answers, so
the two cannot drift apart again.

Selecting properties reaches the top level only: a name written as a
path, such as `user.email`, is read as `user` and the whole embedded
account comes back. That is the shaping helper's own behaviour, shared
with addresses already published, so it is stated in the parameter's
description and pinned by a test rather than changed under a caller
who is relying on it.

One test literal named a step this deployment does not run, so the
refusal it asserted could only ever have come from the media type it
sends. It now names a step that is configured, which is what makes the
assertion say what it claims to.
… own settings

Two defects, both of which a test could not have caught as the tests
stood.

The gate that decides whether a retirement admits pipeline work was
asked about the name a caller sent rather than about the record that
name had already resolved to, by a model-layer call that reads the
whole table. Where the records a view may act on are narrower than the
table -- which the class invites, and says is applied by every operation
it serves -- the row judged and the row worked on are different rows,
and an erasure destroys an account the gate exists to refuse. The step
check is now a method on the record, with the predicate, the message
and the exception it always had, and the classmethod calls it after
resolving a name, so the superseded version behaves exactly as before.
The queue write that takes a record from another collection resolves it
through the same narrowing rather than from the whole table.

Two smaller losses of the superseded behaviour go with them: the delete
that disposes of finished retirements states the step again, because a
record moved off it between the redaction and the delete is one the
caller was never answered about; and the name a second erasure falls
back to is the name the account was registered under rather than the
one it holds by then, which is a hash of it.

The hold this schema takes on one generation setting no longer runs
through the library's shared patch table, which restores and forgets
every patch in it rather than its own. A document generated under
settings of its own -- its title, its version and the address to write
to about it -- lost all of them the moment a body of this kind was
built, and the service answered with the fallback title and version
while the file checked in beside it kept the real ones. A setting
nothing had asked for is left unasked for afterwards, so a later change
to a deployment's settings still reaches it.
… the pin table

The documentation settings carried a second copy of the retirement-status
component the service settings already append, which two places had to keep
identical, and the settings they are built from were asked for it under a key
that replaced the service's own. They now name the one definition the service
holds and merge the component names under it, so a name appended on either side
reaches the published reference, and the check of what those settings append
covers both names rather than one.

The table of route stand-ins the derived operation names are read from gained
the two reads of the retirement queue, so the check that every pinned name
replaces a derived one can see the names those two addresses pin.
…ondition once

Two definitions of the name for a request that names more than one account
stood in the same module, one shadowing the other, and the name was registered
with the shared catalog twice. There is one of each now, describing both the
address several accounts answer to and the name several records were requested
under.

A refusal that arrives carrying the records at fault wrote its own line about
the request as a whole -- that a set of records was left alone and nothing
deleted -- and that line was being replaced by one written from the list of
records, which cannot be read to say it. A caller of a batch disposal read
which record was at fault and no longer read that the rest were left alone.
That line is now kept, and the records stay in the list beside it; a refusal
whose line was printed from its list is written out as a sentence as before.
…d hold the records to it

The parameter that selects which properties a response carries refuses a name
written as a path, because reading it as the property it starts with hands a
caller the whole of a property it asked one piece of. Its published
description still said such a name is read as its first part and the whole of
that property returned, so the document promised the behaviour the handler
had stopped having.

The retirement records are held to the refusal as the rest of this version is:
asking for one property of the embedded account by path is refused with the
names that can be selected, rather than answering with the account whole,
which is personal data of the account being retired.
… answers

Which representations an answer may be given in is settled as a request
enters, before the record it names is read or written, so every address of the
retirement pipeline already refused a request asking for one it cannot answer
in and none of them published that refusal. Each operation publishes it now,
and a sweep sends every method of every one of those addresses such a request:
each answers the refusal, the covered methods are read from the module that
routes them so an address mounted later is covered too, and the records the
batch disposals delete, the data the erasure destroys and the request the
cancellation undoes are all still there afterwards.

The retirement queue joins the collections the page-size refusal is measured
on, which is tied to the views that share the paginator. The check of what
each list publishes about a page past its end carried two sets of expectations
at once, the second quietly replacing the first; there is one set now, and it
covers the queue.

The write of one stored preference, which answers a request carrying no body,
is where a body that needs nothing is read from; the change of a whole
preference document is published as needing one because its shape names a
member it cannot be sent without, and both are held here.
…o a maintainer

A request asking an address what it serves is answered with the description
its view class carries, so that description is published text. The four
addresses of the retirement pipeline described themselves to somebody reading
the module: why the resource is modelled as it is, which layer resolves what,
and in one case a word for the machinery that narrows a page. Each now says
what a caller can do there, in a paragraph or two, and the rest is written
beside the class where nothing serves it.

Asking the queue and the batch disposal what they serve is answered with
exactly the text each class carries, so the descriptions above are measured as
published rather than assumed to be, and the sweep over every mounted address
counts the four this pipeline adds.
…ddresses too

Studio's name pin covered the addresses this version replaced before the
retirement pipeline joined them; it now covers those as well, each under the
name both documents already carry -- checked against the other service's
document, where none of them is named differently.
The repository's pylint reports four attributes of one retirement test set
outside the constructor. They are declared empty where the rest of the case
is set up, so the names exist before the helper that pairs two records
assigns them, and the helper is unchanged.
The partial update of a version 1 retirement was the only reference to the
retirement-status model, and that operation now publishes the object its
handler really reads, so nothing generates the model any more. The service
serving the newer version of this API appends the model to every document it
builds; the service serving only the older one did not, so a client generated
from its document of everything it serves lost that class.

The definition now sits beside the rest of this API's schema behaviour and is
read from there by both services and by the published reference. It is kept in
a module that imports nothing, because a settings module reads it while the
settings are still being assembled, which is too early for any module that
reads a setting of its own.
…ument

Studio's SPECTACULAR_SETTINGS build the Authoring API document
(/authoring-api/schema/ and the committed docs/cms-openapi.yaml), whose
pre-processing hook keeps only the contentstore and authoring addresses.
Appending PatchedUserRetirementStatus there left two references, to
RetirementUser and RetirementState, that the document never defines, and a
client generator refuses such a document.

Studio's service-wide /api-docs/ needs no append either: Studio's schema
class describes the version 1 retirement update as master does, so that
operation still generates the model, identical to the definition the LMS
appends. The settings key and its import are removed, and docs_settings no
longer re-adds the components the LMS settings already carry.

Each service's module tests now request both documents the service serves
and assert that no reference is left unresolved, through a small walker in
the shared testing utilities. Studio's component test now pins the operation
that generates the model there rather than an append.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both version 2 batch disposals refused a body naming more than 100
accounts. Version 1 sets no such bound, and the in-tree drivers rely on
that: scripts/user_retirement/retirement_partner_report.py takes every
waiting partner report entry and then disposes of all of them in one call,
as retirement_archive_and_cleanup.py does when --batch_size is not given.
Past 100 entries the disposal answered 400 after the reports had gone out,
and the entries stayed taken, so the next claim returned none of them.

The cap and its constant are removed, and the published description of
`usernames` now says that one request may name any number of accounts.
Each disposal is one statement over the matched identifiers, as on
version 1.

A parity test drives both versions with 101 names: 101 finished
retirements disposed of through each version's cleanup, and 101 partner
report entries taken and then disposed of through each version's claim and
cleanup, leaving nothing behind on either. The views test that pinned the
refusal now pins an answer for every one of 101 names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The names in a batch disposal's body went through DRF's default
trim_whitespace, so " name " deleted the record of "name" and reported it
as "name". Version 1 compares the string it is sent, refuses that request
and deletes nothing, and the published description already says the names
are compared exactly as spelled and reported as given.

The names are now read untrimmed. A padded name matches no record, so it
is reported as absent under the name as sent and nothing is deleted, which
leaves the same records behind as version 1 does. The answer stays the
version 2 one: an absent name is reported rather than refused.

Parity tests send a name with spaces around it to each version of both
disposals and check that every record is kept on both, and that version 2
reports the name as it was sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…un order

The test asserted that the capability the disposal asks for came last in
the classes it resolves. The restricted-application guard is appended
after it unless EnsureJWTAuthSettingsMiddleware has already copied the
guard onto the view's class attribute, which it does on the view's first
request in the process. The test therefore passed only when another test
in the same worker had requested the disposal first: run alone it failed.

It now reads the classes from the view's own declaration, as the test
beside it does, and pins the whole resolved list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A batch disposal's `usernames` list is refused item by item. The shared
readable-error layer now lists each refused item under a name of its own
(`usernames[1]`), so a disposal sent {"usernames": ["ok", null]} no longer
answers "usernames: {1: [ErrorDetail(string='This field may not be null.', code='null')]}".

The batch disposal tests, for both disposals, pin the item-named entries
and the sentence, and check that no refusal of a bad body carries
"ErrorDetail(" or "string=".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`cool_off_days` on the retirement list had a lower bound only. A count of
days reaching back past the first year of the calendar overflowed
datetime arithmetic and answered an undeclared 500. The version 1 queue
read fails the same way (OverflowError, an HTML 500). The claim's `limit`
had no upper bound either, and a value too large for the database's LIMIT
answered 500 as well.

`cool_off_days` now takes 0 to 36500 days, a century, which reaches back
further than any retirement a deployment holds; a larger value is refused
with 400 on the parameter. `limit` takes 1 to 100, the rows a page of any
collection of this version holds; omitting it still takes every entry
waiting, which is what the pipeline does. Both parameters publish their
bounds, read from the field that enforces them.

This is a declared difference from version 1 for `cool_off_days`, added to
the parity harness and measured on both versions: past a century version 1
answered (with no rows, since no retirement is that old) and past the
first year of the calendar it failed, where version 2 refuses both. A
century answers the same rows on both. `limit` has no version 1
counterpart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d helper

The version 2 retirement service read the organizations to report a
retired account to by calling a private static method of the version 1
partner-report view, and read two of that view's class attributes for
the keys of the record it returns. That tied the newer version to a
private name of the module it supersedes.

The loop now lives in accounts/utils.py as get_orgs_for_user(), moved
unchanged, and both versions call it. The version 1 view keeps
_get_orgs_for_user() as a static method that returns the helper's
result, so anything calling or patching it, including its own tests,
sees the same set as before. Version 2 writes the two record keys out
like the others in the same record.

Two version 2 tests now cover the placeholder organization the helper
leaves out, once for the queue and once for the report a partner reads;
both fail if the helper stops leaving it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Version 2 gates each pipeline operation on the record it has already
resolved rather than on the name, so that the record judged is the record
acted on. To do that it had extracted the state check of
UserRetirementStatus.get_retirement_for_retirement_action() into a new
model method and pointed the classmethod at it, which edited the model
module version 1 and several other apps read.

The model module is back to what it was before this version existed. The
rule -- no work at a step the pipeline requires, nor at a step recording a
finished part of it -- is written in the version 2 service, applied to the
resolved record, and answered with the same conflict as before. The log
line now names the step the retirement stands at instead of carrying the
model's exception text.

A parity test creates a retirement at every configured step and asserts
that both gates refuse exactly the same thirteen steps and admit the other
nine; it fails if either half of the rule is dropped from version 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ting

The move of a retirement is sent as PATCH but is read whole: a body
missing the step to move to or the note about the step being left is
refused. To publish it that way, the schema mixin both services use
overrode a private method of drf-spectacular for every operation and,
for this body, set COMPONENT_SPLIT_PATCH on the process-wide
spectacular settings object and put it back afterwards. A release that
renames the private method would break /api-docs/ in both services, and
a document generated concurrently could read the held value.

The serializer now says what it is: its partial attribute is always
false, so neither DRF nor the schema generator, which copies DRF's PATCH
handling by setting that attribute, can read the body as a set of
changes. The override, the settings helper and the marker attribute are
removed; no generation setting is written any more.

The published body is unchanged: the two schema tests that pin it
(component RetirementUpdate, both values required, no Patched variant)
pass as before, and a new test builds the serializer with partial=True
and asserts the missing value is still refused. All three fail if the
attribute is not overridden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The LMS settings pinned the two sets of choices version 2 publishes as
ScopeEnum and OutcomeEnum. The pin keeps them stable, but those plain
names are then held by the user API for the whole service: the next
scope or outcome choice field anywhere in the LMS would be published
under a prefixed name instead, and a test of it would have to know why.

They are now RetirementErasureScopeEnum and
RetirementBatchResultOutcomeEnum, the names the generator itself gives
them once another field shares their field name. Neither the old nor the
new names are in any document generated from master (both LMS documents
and both Studio documents checked), so no published component name is
lost; version 2 is unreleased.

The tests that pin the names follow them; the collision test fails if
the settings go back to the plain names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comments this work added to the retirement settings, schema, URL,
serializer, service and view modules explained in whole paragraphs what
a line or two can say. Each is cut to the point it makes. Comments only:
no code, docstring or help text changes, so every module's syntax tree is
unchanged and the served documents are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… run order

Four tests asserted that the capability an operation asks for came last in
the classes the view resolves. The restricted-application guard is appended
after it unless EnsureJWTAuthSettingsMiddleware has already copied the
guard onto the view's class attribute, which it does on the view's first
request in the process. Each test therefore passed only when another test
in the same worker had requested the view first: run alone, by class, or in
any other order, it failed.

They now read the classes from the view's own declaration, as the test of
the disposal's actions does, and pin the whole resolved list, with what
each class answers where the test asks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor Author

Rebased onto the rewritten #39314, plus this round's corrections (28 commits, up from 16).

This branch was rewritten and force-pushed. The description is replaced and an evidence comment follows.

What changed in the history. Each correction now sits in the PR whose code it changes. Before, some corrections to #39297's code (for example, the JWT class on the account and image views) existed only in #39314. Merging #39297 alone would have left them out. Now each PR is complete once the PRs before it merge (order: #39297 → #39314 → #39330 → #39334 / #39331). The corrections from the review round of 2026-11-04 are added as commits at the end of each PR. Commits that changed a lower PR's code moved down, with their authors and messages kept. Some fixes were folded into earlier commits of the same PR so that every commit passes the suites.

How to review the rewrite.

git fetch origin pull/39330/head:pr-39330 ed83beab3d
git range-diff cab976a910..ed83beab3d pr-39314..pr-39330

The new commits of the 2026-11-04 round are listed in the description's commit table. Each one names its subject, and each behaviour change comes with a test that fails without it.

@Abdul-Muqadim-Arbisoft

Copy link
Copy Markdown
Contributor Author

Evidence for #39330

Each test is named as module::Class::test as it exists at this head. Modules are under openedx/core/djangoapps/user_api/v2/tests/ unless a path is given. _BatchTestsMixin tests run in both RetirementCleanupTests and PartnerReportCleanupTests.

Declared differences → tests

declared item test
unwired method → 405 (v1 disposal 403) test_retirement_views.py::UnservedMethodTests::test_an_address_that_serves_one_method_refuses_the_same_caller_instead
ambiguous name → 400 test_retirement_views.py::RetirementMemberVisibilityTests::test_a_name_more_than_one_record_is_held_under_is_still_refused; RetirementErrorCatalogTests::test_the_refusal_of_an_ambiguous_name_is_named_for_this_area
state repeated or comma-separated test_retirement_views.py::RetirementListTests::test_several_steps_can_be_asked_for_at_once, ::test_several_steps_can_be_asked_for_by_repeating_the_parameter
cool_off_days capped at a century test_retirement_views.py::RetirementListTests::test_a_cooling_off_period_of_a_century_is_taken, ::test_a_cooling_off_period_longer_than_a_century_is_refused; test_retirement_parity.py::QueueFilterParityTests::test_a_cooling_off_period_of_a_century_answers_the_same_rows_on_both_versions, ::test_the_declared_difference_is_a_cooling_off_period_longer_than_a_century
dotted ?fields= refused test_retirement_views.py::RetirementDetailTests::test_a_property_asked_for_as_a_path_is_refused
refused step 409; nothing moves test_retirement_views.py::RetirementUpdateTests::test_an_earlier_step_is_refused_and_the_retirement_does_not_move
move read whole test_retirement_views.py::RetirementUpdateTests::test_the_move_is_read_whole_even_when_built_to_read_a_set_of_changes
pipeline-work gate equal to v1 test_retirement_parity.py::ActionGateParityTests::test_both_versions_refuse_work_at_the_same_steps
language-cookie write on the reads test_retirement_views.py::RetirementReadsWithTheLanguageCookieTests::test_reading_the_queue_stores_the_language_the_cookie_carries
no cap on names; a whole queue in one request test_retirement_views.py::_BatchTestsMixin::test_a_body_naming_more_accounts_than_a_page_holds_is_answered_for_every_name; test_retirement_parity.py::BatchDisposalParityTests::test_a_whole_queue_of_finished_retirements_is_disposed_of_in_one_request_on_both_versions, ::test_a_whole_queue_of_partner_reports_is_taken_and_then_disposed_of_on_both_versions
names compared exactly as sent test_retirement_parity.py::BatchDisposalParityTests::test_a_name_sent_with_spaces_around_it_disposes_of_no_retirement_on_either_version, ::test_a_name_sent_with_spaces_around_it_disposes_of_no_partner_report_on_either_version
item refusals named by position, in words test_retirement_views.py::_BatchTestsMixin::test_each_refused_name_is_listed_by_its_position, ::test_no_refusal_of_a_body_is_written_out_as_program_text
all-or-nothing 409 test_retirement_views.py::RetirementCleanupTests::test_an_unfinished_record_refuses_the_whole_batch_and_nothing_is_deleted; PartnerReportCleanupTests::test_an_entry_nobody_has_taken_refuses_the_whole_batch_and_nothing_is_deleted
claim limit 1–100 test_retirement_views.py::PartnerReportClaimTests::test_a_claim_held_to_a_page_of_entries_is_taken, ::test_a_claim_held_to_more_than_a_page_of_entries_is_refused
outdated_entry never named test_retirement_views.py::PartnerReportClaimTests::test_the_placeholder_organization_is_not_named_to_partners; PartnerReportQueueTests::test_an_account_enrolled_only_under_the_placeholder_organization_is_not_queued
no dangling $ref in any served document cms/tests.py::CmsModuleTests::test_every_reference_in_each_served_document_resolves; lms/tests.py::LmsModuleTests::test_every_reference_in_each_served_document_resolves
Studio generates the identical retirement model cms/lib/tests/test_spectacular.py::SupersededUserOperationTest::test_the_retirement_status_model_is_published_as_the_other_service_appends_it

RECORD_DIFFERENCES and QUEUE_FILTER_DIFFERENCES in test_retirement_parity.py, verbatim in substance:

  • current_state and last_state: the step is the name of the step, not an object carrying its order and its id.
  • created_before: the bound is exclusive, where the end_date it replaces included the whole day named. Name the day after to ask for the same rows.
  • cool_off_days: a period longer than a century is refused, where the queue read answered it, and failed with a server error once the period reached back past the first year of the calendar.

The trimming of new_state/response is declared from reading the code. No test pins it.

Compliance matrix

decision status evidence
0025 met RetirementUpdateTests, _BatchTestsMixin
0026 met: v1's CanRetireUser and CanCancelUserRetirement imported and mapped per action; NotJwtRestrictedApplication resolved last RetirementRecordAccessReachTests, PartnerReportAccessReachTests; test_restricted_applications.py::EveryOperationCarriesTheRefusalTests
0027 met test_schema.py; lms/lib/tests/test_spectacular.py
0028 met test_urls.py
0029 met (one domain type, registered for this area's exception only) RetirementErrorCatalogTests::test_another_area_refusing_a_conflict_is_classified_as_it_was_before
0030 met with the declared platform write RetirementReadsAreNotWritesTests, RetirementReadsWithTheLanguageCookieTests
0031 met RetirementErasureTests (both scopes)
0032 met for the collection; the claim is a declared deviation RetirementPageSizeTests; PartnerReportClaimTests
0033 met RetirementListTests, RetirementQueueOrderTests
0034 met (no view declares authentication) test_retirement_views.py access matrix
0036 met RetirementDetailTests::test_a_property_asked_for_as_a_path_is_refused
0037 / 0038 met versions gate PASS vs master; urls gate PASS
OEP-66 met RetirementVisibilityTests, PartnerReportVisibilityTests

Gate report

run_gates.sh (d4e6de0), same settings and sets as #39297. The run used a tree that differs from this head in two test files only: the C1803 assertion inherited from #39297, and the last commit, test: read the retirement permissions from their declaration in every run order, which changes four tests in test_retirement_views.py.

gate vs #39314 vs d178206575
versions FAIL 11 (same unreleased version, stacked base) PASS
hygiene PASS (0 FAIL, 4 WARN). H7 on RetirementViewSet, RetirementPartnerReportViewSet, RetirementCleanupViewSet, RetirementPartnerReportCleanupViewSet; their permissions come from access mixins FAIL (1 FAIL, 5 WARN): the inherited H3 plus the above
schema (/lms-api/) 0 BREAKING, 43 WARN, 22 INFO 0 / 84 / 69
urls PASS, 3051 → 3058 PASS, 3034 → 3058
old-tests 1258 passed, 4 skipped (D1 adds tests to lms/tests.py and djangolib/testing/tests/test_utils.py) —
tests 1554 passed —

There are 3 schema WARNs of the form "new write operation documents no requestBody", all on bodiless actions: POST /api/user/v1/accounts/retirement_partner_report/, POST /api/user/v2/retirement_partner_reports/ and PUT /api/user/v2/retirement_partner_reports/{username}/. The remaining 40 WARNs are generation warnings on views newly included in the document.

Repo CI lint: ruff, xsslint, pii_check, check_keywords, lint-imports ("lms and cms are independent" KEPT), mypy, verify-dunder-init and pylint add 0 findings against #39314. commitlint passes on every commit.

Test runs at this head (c59981f3d1), MongoDB attached:

run result
LMS, serial: user_api/, profile_images/, user_authn/, lms/lib/tests/, lms/tests.py, openedx/core/tests/test_apidocs.py 2779 passed, 1 skipped, 0 failed
Studio, with the learning-app stub: cms/lib/tests/, cms/tests.py, user_api/ 209 passed, 1866 skipped, 0 failed
test_retirement_views.py: file order, reversed order, -p randomly seeds 1, 2, 3 and 12345 256 passed in each run
each of the file's 28 classes alone; each of the five permission tests alone 28/28 and 5/5 pass
user_api/v2/, in file order and with -p randomly seed 7 1458 passed, both runs
merged with origin/master d2dbc6a593, wider targets as in #39297 (tree: this head minus the C1803 line and the last commit's four tests) LMS 3526 passed, 8 skipped, 0 failed; Studio 381 passed, 2250 skipped, 0 failed; 0 master test ids lost

Before the last commit, RetirementAccessModelTests failed when run alone, because four tests assumed the JWT middleware had already appended its class to the permission list, which it does only on the view's first request in the process. The last commit reads each list from the view's own declaration and pins the whole list. Under pytest -n 4 the LMS targets also hit the profile-image test-isolation issue described in #39297, which CI does not meet because it runs serially.

Tests removed or changed by the corrections

  • test_a_body_naming_more_accounts_than_a_batch_carries_is_refused (both disposals) is replaced by …_than_a_page_holds_is_answered_for_every_name, because the cap is removed.
  • Studio's test_the_component_no_operation_refers_to_any_more_is_still_published_here is rewritten as test_the_retirement_status_model_is_published_as_the_other_service_appends_it.
  • test: read the retirement permissions from their declaration in every run order makes the retirement permission tests read the classes from each view's declaration, so they no longer pass only when an earlier request in the same process has already run.
  • user_api/models.py is back to master's content. The pipeline-work rule lives in v2, and a parity test holds it to v1.

This branch has not been deployed

No deployments
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.

1 participant