You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Parent epic: #54. Coordinates with broad course-management parity epic #59.
PM dependency status
BLOCKED — owner decision and integrated foundations required; do not engineer while needs grooming remains. The issue is otherwise specified, but its default masked member identifier is not authorized. #288 owns that shared product decision; this issue must consume it without inventing an email/name mask or reusing the event-provider helper.
Outcome
Authorized operators can inspect target-native course-registration and Cohort-enrollment state, compare bounded campaign/Cohort metrics, and request short-lived CSV exports through private Studio and /api/v1/admin/ adapters over the same read-only courses query/export services. The surfaces use stable UUID resources, apply the authorized queryset before lookup or counting, mask personal data by default, and expose full personal fields only through an additional capability.
This issue adds no registration or Enrollment mutation. Every safe GET is side-effect free; export creation is the sole command and creates only one durable operation/artifact.
_docs/architecture/app-boundaries.md: courses owns queries/exports; Studio and API are adapters; accounts owns normalized identity/profile reads; core owns shared operation/audit primitives.
#224/#51 are transitive structural inputs through the accepted #242/#244 handoffs; #231/#234 are transitive through #247/#248; #230 is transitive through #244; and #248 plus the ordinary #49 interface are transitive through #243. They remain required provenance but are not duplicate direct blockers. An open dependency or a bounded slice is usable only when its owning tester and PM explicitly accepted the exact named interface, it is integrated and on-call green on the same current-main lineage, and its evidence envelope remains valid.
#64 is a coordination boundary: this issue registers every export artifact and its opaque subject UUIDs with the shared retention/deletion hook, while #64 owns the wider rights/tombstone/restore workflow. #133 aggregate-only registration totals are not a row source and are never joined into these management resources or exports. #46 is event-only and is not a dependency.
#246 is downstream and must consume the exact accepted member_label, field policy, and operation/result presentation from #245; it may not finalize or test a competing mask. #59 consumes accepted #245 and #246 as source/completeness rows and is not a prerequisite for either, so no dependency cycle is introduced.
After #288 owner approval, PM must replace every provisional masked-identifier phrase with the selected literal grammar, record the authorized decision, and remove decision and needs grooming only when this issue is unconditional. The engineer handoff then records exact prerequisite merge SHAs, the selected #288 contract and implementation provenance, adopted-source pin, capability/policy/registry and OpenAPI digests, target migration leaves/fingerprint, and the final field/export matrix digest. Any prerequisite change to fields, identities, authorization hooks, operations, routes, schema, or the selected grammar invalidates the frozen plan.
Courses-owned query boundary
Add courses-owned query/export services usable without importing studio, api, Django request objects, templates, or transport serializers. Studio/API pass an actor/principal context and a validated query object. The service:
constructs the function-authorized and Course/Cohort-object-scoped queryset before lookup, filtering, counting, metrics, serialization, or export;
obtains normalized-identity/profile scalar reads only from accepted accounts services; derives member_label only from an already-authorized immutable MemberProfile UUID through the accepted Decide the shared masked member identifier for private management surfaces #288 presentation boundary; and never implements another lowercase/email resolver, email/name mask, provider-identifier helper, or fallback identity;
performs no model save, signal, callback, job dispatch, network call, account/profile projection write, registration conversion, score recomputation, or preference update on reads;
returns transport-neutral typed results with an exact allowlist, masking disposition, page metadata, and stable safe error code;
derives all metrics from the same authorized filtered target-native rows used by lists; and
creates export operations through the shared durable-operation/idempotency/audit primitives, with the worker rerunning the same frozen authorized query specification before writing the artifact.
Direct model queries in Studio/API adapters fail repository/parity tests.
Capability, role, and scope contract
Register these exact domain capabilities and matching Django permissions/scopes:
Capability key
Purpose
Default effective access
courses.registration.read
masked registration list/detail/exact lookup
course_operator within assigned Course/Cohort; support_operator exact lookup only; auditor global masked read; site_admin
courses.registration.view_pii
unmask the separately listed registration fields
additional grant only; never implied by read, staff status, support, auditor, or object assignment
course_operator in scope, auditor, site_admin; no support browse
courses.registration.export
request/download a bounded registration CSV
additional grant plus courses.registration.read; object scope still applies
courses.enrollment.read
masked Enrollment list/detail/exact lookup
same role/object rules as registration read
courses.enrollment.view_pii
unmask the separately listed Enrollment fields
additional grant only
courses.enrollment.metrics.read
authorized Cohort Enrollment counts
course_operator in scope, auditor, site_admin; no support browse
courses.enrollment.export
request/download a bounded Enrollment CSV
additional grant plus courses.enrollment.read; object scope still applies
course_operator above is the specification-06 function group, not a Course/Cohort assignment role. Except for the explicit global site_admin and masked auditor cases, access requires both the #32 function permission and an active in-scope #52 assignment whose accepted role allowlist contains the exact capability; neither the group nor the assignment grants access alone. #245 does not rename or infer #52 roles (owner, instructor, grader, support, communications), and PM must freeze their accepted per-capability mapping before engineering. Removal, expiry, disablement, or scope drift takes effect on the next request and before an export worker or download recheck.
site_admin remains subject to the same service, field declarations, high-risk policy, and audit; generic is_staff, superuser, group-name, compatibility token, public Person, and Django-admin checks are not authorization. Admin API bearer scopes use these exact keys. A service principal receives only explicitly granted keys and object assignments and cannot use a human session or broader role name as evidence.
support_operator may resolve one exact registration/Enrollment UUID or an exact identity lookup authorized by its support scope; it cannot page through a Cohort, run metrics, or export. Exact identity lookup additionally requires the corresponding view_pii capability and uses the accounts identity service. Substring/prefix email search is not offered. member_label is presentation only: it is never a route, lookup/search/filter, authorization, idempotency, correlation, or uniqueness input, and identical labels do not broaden a result.
Routes and adapter parity
Studio
GET /studio/courses/registrations/
GET /studio/courses/registrations/<uuid>/
GET /studio/courses/registration-metrics/
POST /studio/courses/registration-exports/
GET /studio/courses/enrollments/
GET /studio/courses/enrollments/<uuid>/
GET /studio/courses/enrollment-metrics/
POST /studio/courses/enrollment-exports/
GET /studio/courses/exports/<uuid>/
GET /studio/courses/exports/<uuid>/download/
The Courses navigation labels are exactly Registrations and Enrollments. Each list page contains a filter region, exact result count, table, pagination, and an Export CSV action only when allowed. Detail headings are Registration details and Enrollment details. Masked fields show Hidden rather than an empty value; genuine absence shows Not provided. Metrics pages use Registration metrics and Enrollment metrics. Export status uses Pending, Running, Ready, Failed, Expired, and Cancelled; failure copy is safe and actionable, and Ready shows the expiry time and download action.
Admin API
GET /api/v1/admin/course-registrations
GET /api/v1/admin/course-registrations/<uuid>
GET /api/v1/admin/course-registration-metrics
POST /api/v1/admin/course-registration-exports
GET /api/v1/admin/course-enrollments
GET /api/v1/admin/course-enrollments/<uuid>
GET /api/v1/admin/course-enrollment-metrics
POST /api/v1/admin/course-enrollment-exports
shared authorized GET /api/v1/admin/operations/<uuid> for status
GET /api/v1/admin/course-data-exports/<uuid>/download for CSV
Every route uses UUID path identifiers. Legacy numeric IDs/slugs may appear only as read-only metadata; no target Studio/admin route resolves by them. POST export returns 202 Accepted with the existing operation envelope and Location for the UUID operation. The generated OpenAPI 3.1 document declares every route, operation ID, exact bearer scope, request/filter/sort/page fields, response schema, CSV media type, and 400/401/403/404/409/413/422/429 states. No undocumented query parameter is accepted.
Studio and API use identical capability entries, services, object/field policy, filters, ordering, counts, masking, export columns, and result/error meaning. CI compares normalized results for the same actor and fixture; HTML presentation differences are not field/result drift.
Resource and field matrix
All timestamps are timezone-aware RFC 3339 in JSON and human-readable with explicit timezone in Studio. All UUIDs serialize canonically. revision is the accepted current resource revision needed by #246; reads do not increment it.
CourseRegistration list/detail
Always present when authorized:
uuid, revision, created_at, accepted lifecycle/eligibility state, quarantine-present boolean and bounded reason code when policy allows it;
Present only with courses.registration.view_pii and an in-scope object:
full accepted MemberProfile UUID, normalized verified-email snapshot, and accepted account UUID;
certificate/display name, country code, derived region, organization, work status, professional role, and seniority from the immutable minimized snapshot;
course-specific comment;
optional marketing granted/not-granted value, notice/policy version, and recorded timestamp; and
preserved historical accepted_newsletter evidence as explicitly legacy evidence, never relabelled as current consent.
Never serialized: About/bio, ambitions, why-joined, profile/social URLs, Slack state/secret, account credential/token/session/alias internals, mutable campaign-pointer values presented as historical target, raw reconciliation payloads, reversible identity digests, audit bodies, delivery/provider bodies, or another learner's work.
Never serialized: submissions, answers, review text, GitHub/commit URLs, complaint bodies, provider/email payloads, account/profile free text, another Cohort's state, or mutable fields not in the accepted Enrollment query schema.
A field not available in the final accepted prerequisite schema is removed explicitly during PM revalidation before engineering; engineers do not invent it. Studio/API/export matrices must remain identical after that freeze.
member_label remains provisional until the #288 owner response. Approval selects exactly one literal grammar; it does not authorize a second account/email mask. The selected value is computed at presentation/export time and is not stored on CourseRegistration, Enrollment, MemberProfile, an operation, or an export row source. It serializes as the exact visible string or null in API/CSV; Studio displays Hidden only when the consuming field is unavailable or unauthorized and never substitutes PII. Invalid, absent, quarantined, absorbed/cyclic, inactive, deleted, unauthorized, or nonexistent identities expose no label and preserve the generic result. Collisions remain identical and are distinguished only by separately authorized resource UUIDs. Ordinary logs, metrics, traces, errors, and job context never record the label; any permitted append-only audit target-label snapshot follows the exact #288 boundary and denied/unknown targets receive none.
Filters, lookup, ordering, pagination, and counts
Unknown, repeated scalar, malformed, mixed-scope, or overlong filter values return 400 invalid_query with allowlisted field errors and no partial fallback.
created_from inclusive and created_before exclusive RFC 3339 timestamps; and
exact identity (account UUID or email normalized/resolved by accounts), permitted only with courses.registration.view_pii and exact-lookup support scope.
display_on_leaderboard, display_public_profile, and disable_learning_in_public exact booleans;
enrolled_from inclusive and enrolled_before exclusive RFC 3339 timestamps; and
exact identity under the same courses.enrollment.view_pii/support rule.
Default stable ordering is -created_at,-uuid for registrations and -enrollment_date,-uuid for Enrollments. The only selectable sort keys are either timestamp direction and uuid in the same direction. Metrics default to stable Course/Cohort/campaign UUID order. Export always uses the corresponding list's selected stable order.
Use bounded page-number pagination: page defaults to 1; page_size defaults to 20 and accepts 1–100. Responses contain items, page, page_size, exact total_count, and nullable next_page/previous_page. Counts and pages are computed after authorization and filters. An authorized out-of-range page is 200 with empty items and the exact count. No count or timing response may reveal out-of-scope objects.
Metrics
Registration metrics accept the registration scope/date filters above plus group_by=cohort|campaign (default cohort) and return, per authorized group:
total target-native registrations;
eligible and quarantined counts;
converted and unconverted counts; and
invariant-failure count only as a bounded operational count, never raw row values.
Enrollment metrics accept Course/Cohort/date/state filters and return, per authorized Cohort:
total Enrollments;
counts by accepted lifecycle state;
leaderboard-visible, public-profile-visible, learning-in-public-disabled, and certificate issued/revoked/present counts; and
invariant-failure count only.
Metric groups use the same page contract and maximum size. Zero is returned only for an authorized, complete query over a real scope; unknown/out-of-scope objects do not become zero. #133 baseline overlays never contribute.
Export contract
Registration and Enrollment export requests use the corresponding exact filters/sort plus:
include_pii, default false; true additionally requires the resource's view_pii capability;
The service previews the exact authorized filtered row count and selected column class. If the frozen count is greater than 10,000, return 422 export_too_large and require narrower filters; never truncate or split silently. A request body over the global bound returns 413. Changed-payload key reuse returns 409; exact replay returns the original operation. Export is cancellable only while pending; once row materialization starts it runs to a terminal result and a cancellation request reports the actual boundary.
CSV is UTF-8, comma-delimited, RFC-4180 compatible with one header row and deterministic row/column ordering. Every text cell, including identifiers that begin with =, +, -, @, tab, or carriage return after leading whitespace, passes the shared formula-neutralization helper. CR/LF/control-character, quoting, Unicode, and delimiter canaries round-trip as data, never formulas or extra rows. Masked exports use only the Always-present matrices. PII exports add only the corresponding gated fields; the Never-serialized fields never appear in either form.
The worker writes to the approved encrypted, private operational-artifact storage through a repository abstraction; local/test storage remains under the project-local configured private boundary. No public ACL, stable public URL, source filename, raw filter query, email, or token appears in the object key, operation JSON, logs, metrics, or audit. Store a redacted manifest containing export UUID, operation UUID, capability, policy version, selected column class, safe filter digest, row count, content checksum/size, actor/principal reference, created/completed/expires timestamps, and opaque contained subject UUID links required for #64 deletion propagation—not row values.
A successful artifact expires exactly 24 hours after completion. The authenticated same-origin Studio/API download streams through authorization; there is no bearer token, signed URL, identifier, or filter in a redirect/query string. Every download rechecks active principal, export capability, object scope, and view_pii when applicable. Expired, cancelled, failed, deleted-for-rights, or out-of-scope artifacts return generic 404 and no bytes. Cleanup removes bytes and marks the operation result expired; exact counts/checksum metadata follows the approved one-year minimized audit retention, without preserving row content. Subject deletion immediately revokes and removes any live artifact linked by its opaque subject UUID manifest rather than waiting 24 hours.
Every successful or denied unmasked list, detail, or exact-identity access records a minimized courses.registration.pii_viewed or courses.enrollment.pii_viewed audit event with actor/principal, target type/UUID or safe filtered-scope digest, field-class names, outcome, request/correlation ID, and timestamp. It never records returned values. Masked ordinary reads use bounded access metrics and do not create one audit row per listed object.
Audit separate request, start, completion/failure/cancellation, download allowed/denied, expiry, and privacy deletion. Record action, export/operation UUID, capability, safe filter digest, PII column-class boolean, row count/size/checksum, actor/principal, timestamps, outcome, and bounded reason code. Never record raw rows, field values, email, comment, name, certificate URL, consent value, filter values, filenames/paths, download links, authorization/cookies, request/response bodies, or provider data. Metrics expose only bounded export counts/bytes/duration/outcome by safe operation class—not Course/Cohort/actor/email dimensions.
Migration and evidence boundary
The selected #288 label is derived and requires no alias column, persistent digest, secret, uniqueness constraint, data migration, or backfill. Any #245-owned migration is limited to durable export operation/artifact state strictly required by this contract; it may not rewrite prerequisite registration, Enrollment, account, profile, campaign, consent, score, certificate, or legacy-source rows. Repository verification uses synthetic .invalid data and local/fake storage only. Protected-source reconciliation, production mapping/migration, privacy-right execution, provider/storage administration, deployment, and cutover remain with their owning issues and authorized HUMAN gates.
Forward/backward and migration-drift evidence must cover every #245-owned migration plus compatibility with the exact accepted prerequisite leaves. Counts/checksums/fingerprints are aggregate and non-sensitive; no row payload, email, name, profile value, token, source path, or production identifier enters an issue report or artifact.
Authentication, authorization, cache, and error behavior
Signed-out Studio requests follow the accepted OIDC/login path with a same-host path-only return; browser back after sign-out/revocation shows no cached page. Missing/invalid/expired/revoked API credentials return 401 with the standard safe envelope and WWW-Authenticate; Studio/API never mix session and bearer principals.
An authenticated principal lacking the function capability receives 403. Asking for include_pii or exact identity lookup without the field capability receives 403 before query execution.
Once function access exists, missing and out-of-scope registration, Enrollment, export, Course, Cohort, campaign, or operation resources are indistinguishable generic 404 after applying the authorized queryset. No response, count, latency assertion, or audit text discloses existence.
Invalid filters/page/sort are 400; stale/idempotency conflict is 409; size is 413/422; throttle is 429; worker/storage/database failure is a redacted error/failed operation, never an empty list or zero metric.
Every Studio, admin API, CSV, denial, redirect target, and error response is Cache-Control: private, no-store; Studio HTML is noindex, nofollow, X-Robots-Tag: noindex, nofollow, absent from sitemap/search, and uses zero-TTL edge behavior. CSV sets safe attachment headers, nosniff, and a generic UUID-based filename.
Logs, traces, analytics, OpenAPI examples, fixtures, screenshots, issue reports, operations, and errors use synthetic .invalid identities and contain no real/reversible learner or registration data.
Studio UX and states
Use the established private Studio shell. Desktop and narrow layouts provide semantic headings, landmarks, labelled filter controls, a captioned responsive table or equivalent reflow, keyboard-complete pagination, visible focus, 44 CSS px pointer targets, announced result/export status, readable timezone dates, and no color-only state.
Required states for both resources:
empty authorized scope: No registrations match these filters. / No enrollments match these filters. with filters retained and no fake zero/error;
populated masked list, PII-authorized list, detail with real absence versus Hidden;
invalid filter with linked error summary and preserved safe inputs;
signed-out, denied capability, out-of-scope/missing generic denial, and revoked-session browser-back state;
zero/one/many metrics, including a complete authorized zero;
export confirmation showing exact scope, selected column class, and count; cancel; pending/running; ready with expiry; safe failed; too-large; expired; and denied download.
Do not display raw comment, email, name, organization, certificate URL, or consent value in screenshots. PII-field behavior is proven with synthetic canary values and screenshots may show a visibly redacted/masked fixture, not real data.
Acceptance criteria
An authorized Decide the shared masked member identifier for private management surfaces #288 owner response is recorded; PM substitutes the selected exact member_label grammar, removes the decision/grooming labels, and records its implementation provenance plus the exact accepted prerequisite SHAs/pin/schema/migration/capability/OpenAPI fingerprints. The final field/export matrix is revalidated without an invented prerequisite field.
Courses-owned query/export services apply function, object, and field authorization before lookup/filter/count/serialization/export and have no read side effects; adapters perform no direct model query.
All eight exact capabilities/scopes, role defaults, Course/Cohort assignments, support exact-lookup restriction, the selected nullable member_label default, and separately gated full MemberProfile/account/email fields behave identically for Studio/API/CSV; the label is never an input and no is_staff/superuser/Person/legacy-token bypass exists.
Exact UUID routes, resource fields, filters, stable ordering, page contract, authorized counts, metrics, and 400/401/403/404/409/413/422/429 semantics match this issue and have OpenAPI/registry parity.
Registration and Enrollment lists/details preserve immutable target and cohort isolation; identity resolution uses accounts; no Preserve public course signup totals without registration PII #133 rows, another Cohort, another learner's work, forbidden profile values, or mutable campaign pointer leaks into a result.
Export preview/request/replay/cancellation/worker/download/expiry/deletion is durable, bounded at 10,000 rows, explicitly confirmed and reauthenticated, formula-safe, encrypted/private, exactly 24-hour-lived, authorization-rechecked, and fully audited without raw values.
CSV masked/PII column sets exactly match the field matrix; every dangerous formula/control/Unicode/quote/delimiter canary remains one inert cell/row and no Never-serialized value appears.
Empty, complete zero, invalid, missing/out-of-scope, storage/database failure, too-large, pending/running/ready/failed/expired/cancelled, signed-out/revoked, and denied-field states are deterministic, private/no-store/noindex, accessible, and never misreported as successful empty/zero.
Studio pages use the private shell, correct copy/navigation, responsive/reflowing tables and forms, keyboard/focus/status behavior, and independent desktop/mobile/denied/export screenshots with synthetic redacted data.
Focused Django/service/API/OpenAPI/browser/job/storage/security/adoption tests plus the graph-selected full verification pass, with artifact/log/audit canary scans and zero network/email/provider/production-data side effects.
Required Django, API, job, and repository scenarios
Role matrix: site admin; in-scope/out-of-scope Course operator; support exact lookup versus browse/metrics/export; auditor masked read; read without PII; PII without read; export without PII/read; composed roles; inactive/disabled principal; removed object assignment; expired/revoked token/session; legacy token/is_staff/Person non-authority.
Registrations: same learner in two Cohorts, two campaigns for one Cohort, campaign repoint, eligible/quarantined/invalid identities, converted/unconverted, privacy/marketing present/absent/legacy evidence, every PII and excluded field canary, and no Preserve public course signup totals without registration PII #133 contribution.
Enrollments: sibling/unrelated Cohorts, different visibility/learning/certificate/score/preference states, provenance present/absent, generated versus explicit display, historical aliases, unavailable/colliding identity, and no submission/review content or cross-Cohort selection.
Exact UUID/detail/identity lookup; foreign/missing equality; every filter boundary; inclusive/exclusive timestamps; unknown/repeated/malformed/overlong values; stable tie ordering; page 1/last/out-of-range; page sizes 1/20/100/101; exact authorized total counts under concurrent foreign writes.
Metrics zero/one/many, each group and status, partial authorization, campaign repoint, quarantine/conversion, Enrollment state/certificate/visibility, invariant-failure count, database error, and aggregate-only Preserve public course signup totals without registration PII #133 baseline exclusion.
API schema/registry/Studio parity for routes, operation IDs, scopes, object/field policy, filters, sorts, pages, result/error envelopes, CSV media/headers, and undocumented parameter rejection.
Export 0/1/10,000/10,001 rows; masked/PII; exact replay/changed replay; stale scope/role before worker and before download; pending cancel/running cancel; worker retry/crash; database/storage failure; checksum/size mismatch; formula/control/Unicode/quote/newline canaries; multiple authorized downloads; 24-hour boundary; cleanup retry; subject deletion; no bytes after revocation/expiry.
Cache/SEO/header tests for every success/redirect/denial/error/download; CSRF on Studio POST; Bearer-only API; rate/body/time limits; generic existence behavior; log/metric/audit/OpenAPI/operation/artifact-name canary scans; no model-write, account/profile projection, conversion, email/job beyond the export operation, network, provider, workflow, or production fixture.
Use uv/Make and the versioned change-selective verification plan. Export worker/storage tests use repository fakes and the approved development boundary; they never create a public object or access production storage/data.
Independent browser and artifact evidence
At approximately 1440×900 and 390×844, with synthetic .invalid fixtures, the independent tester captures and reads:
Courses navigation plus masked Registration list/detail, filters, pagination, and empty state;
PII-authorized versus no-PII behavior using visibly synthetic/masked canaries, without retaining unnecessary raw values;
masked Enrollment list/detail for two sibling Cohorts with distinct preference/certificate state and a direct foreign-Cohort generic denial;
Registration and Enrollment metrics at zero and populated states;
export confirmation with exact count/column class, pending/running, Ready with expiry/download, too-large, safe failure, expired, cancelled, and denied download; and
320 CSS px reflow/200% zoom, keyboard-only filters/pagination/export/cancel/download, visible focus, status announcement, reduced motion, and JavaScript-disabled list/filter/detail/export-request flows.
CSV is non-HTML evidence: store only a synthetic export under .tmp/, inspect media/disposition/cache headers, exact columns/order/row count, formula neutralization, checksum, expiry behavior, and absence of forbidden fields; then remove it through the tested cleanup path. Every screenshot must show the expected page rather than debug/404 leakage or a broken layout and contain no real or unnecessary synthetic PII, token, URL secret, comment, consent payload, provider value, or production data.
No public registration/profile/dashboard/course page, learner API, CourseInterest, waitlist/capacity, communication audience/send/template/delivery/provider, scoring/certificate mutation, curriculum, complaint, peer-review, or unrelated Move all course operations into Studio and the admin API #59 surface.
No numeric-ID or email-in-path compatibility admin API, copied staff token, direct adapter/model query, broad serializer/model dump, mass assignment, arbitrary SQL/filter/sort, Django-admin ordinary workflow, unrestricted view-as, or public/signed download URL.
No unbounded/synchronous request-time CSV, arbitrary upload/path/filename, spreadsheet format, permanent export archive, production/protected data, real email/profile/comment/certificate/consent value, network/provider/email call, deployment, workflow dispatch, commit, merge, or push inside this issue's engineering/testing gates.
Delivery convention
Follow _docs/PROCESS.md. Engineering remains dependency-blocked even though grooming is complete. After a frozen uncommitted implementation, an independent tester recomputes the verification plan and inspects all required screenshots/artifacts; PM then performs final acceptance. Only after both pass may the engineer commit with Closes #245 and Refs #54; no pull request.
Parent epic: #54. Coordinates with broad course-management parity epic #59.
PM dependency status
BLOCKED — owner decision and integrated foundations required; do not engineer while
needs groomingremains. The issue is otherwise specified, but its default masked member identifier is not authorized. #288 owns that shared product decision; this issue must consume it without inventing an email/name mask or reusing the event-provider helper.Outcome
Authorized operators can inspect target-native course-registration and Cohort-enrollment state, compare bounded campaign/Cohort metrics, and request short-lived CSV exports through private Studio and
/api/v1/admin/adapters over the same read-onlycoursesquery/export services. The surfaces use stable UUID resources, apply the authorized queryset before lookup or counting, mask personal data by default, and expose full personal fields only through an additional capability.This issue adds no registration or Enrollment mutation. Every safe GET is side-effect free; export creation is the sole command and creates only one durable operation/artifact.
Normative authority
_docs/PROCESS.md_docs/architecture/app-boundaries.md:coursesowns queries/exports; Studio and API are adapters;accountsowns normalized identity/profile reads;coreowns shared operation/audit primitives.Dependencies and freeze order
Do not engineer until a fresh base contains these accepted and integrated direct inputs:
MemberProfileUUID, and confirmed-profile scalar read contracts.member_labelgrammar. The decision alone implements no helper, route, read, export, schema, or permission.CourseRegistrationUUID/revision, immutable target/evidence components, eligibility/quarantine state, and protected legacy mapping. Decide pre-cohort CourseInterest identity and retention #286 and Contract legacy course-registration schema after the rollback window #287 own separate CourseInterest and legacy-contraction outcomes; neither is implemented or exposed here.#224/#51 are transitive structural inputs through the accepted #242/#244 handoffs; #231/#234 are transitive through #247/#248; #230 is transitive through #244; and #248 plus the ordinary #49 interface are transitive through #243. They remain required provenance but are not duplicate direct blockers. An open dependency or a bounded slice is usable only when its owning tester and PM explicitly accepted the exact named interface, it is integrated and on-call green on the same current-main lineage, and its evidence envelope remains valid.
#64 is a coordination boundary: this issue registers every export artifact and its opaque subject UUIDs with the shared retention/deletion hook, while #64 owns the wider rights/tombstone/restore workflow. #133 aggregate-only registration totals are not a row source and are never joined into these management resources or exports. #46 is event-only and is not a dependency.
#246 is downstream and must consume the exact accepted
member_label, field policy, and operation/result presentation from #245; it may not finalize or test a competing mask. #59 consumes accepted #245 and #246 as source/completeness rows and is not a prerequisite for either, so no dependency cycle is introduced.After #288 owner approval, PM must replace every provisional masked-identifier phrase with the selected literal grammar, record the authorized decision, and remove
decisionandneeds groomingonly when this issue is unconditional. The engineer handoff then records exact prerequisite merge SHAs, the selected #288 contract and implementation provenance, adopted-source pin, capability/policy/registry and OpenAPI digests, target migration leaves/fingerprint, and the final field/export matrix digest. Any prerequisite change to fields, identities, authorization hooks, operations, routes, schema, or the selected grammar invalidates the frozen plan.Courses-owned query boundary
Add courses-owned query/export services usable without importing
studio,api, Django request objects, templates, or transport serializers. Studio/API pass an actor/principal context and a validated query object. The service:accountsservices; derivesmember_labelonly from an already-authorized immutableMemberProfileUUID through the accepted Decide the shared masked member identifier for private management surfaces #288 presentation boundary; and never implements another lowercase/email resolver, email/name mask, provider-identifier helper, or fallback identity;Direct model queries in Studio/API adapters fail repository/parity tests.
Capability, role, and scope contract
Register these exact domain capabilities and matching Django permissions/scopes:
courses.registration.readcourse_operatorwithin assigned Course/Cohort;support_operatorexact lookup only;auditorglobal masked read;site_admincourses.registration.view_piicourses.registration.metrics.readcourse_operatorin scope,auditor,site_admin; no support browsecourses.registration.exportcourses.registration.read; object scope still appliescourses.enrollment.readcourses.enrollment.view_piicourses.enrollment.metrics.readcourse_operatorin scope,auditor,site_admin; no support browsecourses.enrollment.exportcourses.enrollment.read; object scope still appliescourse_operatorabove is the specification-06 function group, not a Course/Cohort assignment role. Except for the explicit globalsite_adminand maskedauditorcases, access requires both the #32 function permission and an active in-scope #52 assignment whose accepted role allowlist contains the exact capability; neither the group nor the assignment grants access alone. #245 does not rename or infer #52 roles (owner,instructor,grader,support,communications), and PM must freeze their accepted per-capability mapping before engineering. Removal, expiry, disablement, or scope drift takes effect on the next request and before an export worker or download recheck.site_adminremains subject to the same service, field declarations, high-risk policy, and audit; genericis_staff, superuser, group-name, compatibility token, publicPerson, and Django-admin checks are not authorization. Admin API bearer scopes use these exact keys. A service principal receives only explicitly granted keys and object assignments and cannot use a human session or broader role name as evidence.support_operatormay resolve one exact registration/Enrollment UUID or an exact identity lookup authorized by its support scope; it cannot page through a Cohort, run metrics, or export. Exact identity lookup additionally requires the correspondingview_piicapability and uses the accounts identity service. Substring/prefix email search is not offered.member_labelis presentation only: it is never a route, lookup/search/filter, authorization, idempotency, correlation, or uniqueness input, and identical labels do not broaden a result.Routes and adapter parity
Studio
GET /studio/courses/registrations/GET /studio/courses/registrations/<uuid>/GET /studio/courses/registration-metrics/POST /studio/courses/registration-exports/GET /studio/courses/enrollments/GET /studio/courses/enrollments/<uuid>/GET /studio/courses/enrollment-metrics/POST /studio/courses/enrollment-exports/GET /studio/courses/exports/<uuid>/GET /studio/courses/exports/<uuid>/download/The Courses navigation labels are exactly Registrations and Enrollments. Each list page contains a filter region, exact result count, table, pagination, and an Export CSV action only when allowed. Detail headings are Registration details and Enrollment details. Masked fields show
Hiddenrather than an empty value; genuine absence showsNot provided. Metrics pages use Registration metrics and Enrollment metrics. Export status uses Pending, Running, Ready, Failed, Expired, and Cancelled; failure copy is safe and actionable, and Ready shows the expiry time and download action.Admin API
GET /api/v1/admin/course-registrationsGET /api/v1/admin/course-registrations/<uuid>GET /api/v1/admin/course-registration-metricsPOST /api/v1/admin/course-registration-exportsGET /api/v1/admin/course-enrollmentsGET /api/v1/admin/course-enrollments/<uuid>GET /api/v1/admin/course-enrollment-metricsPOST /api/v1/admin/course-enrollment-exportsGET /api/v1/admin/operations/<uuid>for statusGET /api/v1/admin/course-data-exports/<uuid>/downloadfor CSVEvery route uses UUID path identifiers. Legacy numeric IDs/slugs may appear only as read-only metadata; no target Studio/admin route resolves by them. POST export returns
202 Acceptedwith the existing operation envelope andLocationfor the UUID operation. The generated OpenAPI 3.1 document declares every route, operation ID, exact bearer scope, request/filter/sort/page fields, response schema, CSV media type, and 400/401/403/404/409/413/422/429 states. No undocumented query parameter is accepted.Studio and API use identical capability entries, services, object/field policy, filters, ordering, counts, masking, export columns, and result/error meaning. CI compares normalized results for the same actor and fixture; HTML presentation differences are not field/result drift.
Resource and field matrix
All timestamps are timezone-aware RFC 3339 in JSON and human-readable with explicit timezone in Studio. All UUIDs serialize canonically.
revisionis the accepted current resource revision needed by #246; reads do not increment it.CourseRegistrationlist/detailAlways present when authorized:
uuid,revision,created_at, accepted lifecycle/eligibility state, quarantine-present boolean and bounded reason code when policy allows it;member_label, derived only from the already-authorized immutable profile UUID under the owner-selected Decide the shared masked member identifier for private management surfaces #288 grammar.Present only with
courses.registration.view_piiand an in-scope object:accepted_newsletterevidence as explicitly legacy evidence, never relabelled as current consent.Never serialized: About/bio, ambitions, why-joined, profile/social URLs, Slack state/secret, account credential/token/session/alias internals, mutable campaign-pointer values presented as historical target, raw reconciliation payloads, reversible identity digests, audit bodies, delivery/provider bodies, or another learner's work.
Enrollmentlist/detailAlways present when authorized:
uuid,revision,enrollment_date, accepted lifecycle state;member_label, derived only from the already-authorized immutable profile UUID under the owner-selected Decide the shared masked member identifier for private management surfaces #288 grammar; Course/Cohort UUID, slug, title; source registration UUID when provenance exists;display_on_leaderboard,display_public_profile,disable_learning_in_public, andposition_on_leaderboard;Present only with
courses.enrollment.view_piiand an in-scope object:Never serialized: submissions, answers, review text, GitHub/commit URLs, complaint bodies, provider/email payloads, account/profile free text, another Cohort's state, or mutable fields not in the accepted Enrollment query schema.
A field not available in the final accepted prerequisite schema is removed explicitly during PM revalidation before engineering; engineers do not invent it. Studio/API/export matrices must remain identical after that freeze.
member_labelremains provisional until the #288 owner response. Approval selects exactly one literal grammar; it does not authorize a second account/email mask. The selected value is computed at presentation/export time and is not stored on CourseRegistration, Enrollment, MemberProfile, an operation, or an export row source. It serializes as the exact visible string ornullin API/CSV; Studio displaysHiddenonly when the consuming field is unavailable or unauthorized and never substitutes PII. Invalid, absent, quarantined, absorbed/cyclic, inactive, deleted, unauthorized, or nonexistent identities expose no label and preserve the generic result. Collisions remain identical and are distinguished only by separately authorized resource UUIDs. Ordinary logs, metrics, traces, errors, and job context never record the label; any permitted append-only audit target-label snapshot follows the exact #288 boundary and denied/unknown targets receive none.Filters, lookup, ordering, pagination, and counts
Unknown, repeated scalar, malformed, mixed-scope, or overlong filter values return
400 invalid_querywith allowlisted field errors and no partial fallback.Registration allowlist:
uuid,course_uuid,cohort_uuid,campaign_uuid, acceptedstate,converted=true|false,quarantined=true|false;created_frominclusive andcreated_beforeexclusive RFC 3339 timestamps; andidentity(account UUID or email normalized/resolved by accounts), permitted only withcourses.registration.view_piiand exact-lookup support scope.Enrollment allowlist:
uuid,course_uuid,cohort_uuid,registration_uuid, acceptedstate,certificate_state;display_on_leaderboard,display_public_profile, anddisable_learning_in_publicexact booleans;enrolled_frominclusive andenrolled_beforeexclusive RFC 3339 timestamps; andidentityunder the samecourses.enrollment.view_pii/support rule.Default stable ordering is
-created_at,-uuidfor registrations and-enrollment_date,-uuidfor Enrollments. The only selectable sort keys are either timestamp direction anduuidin the same direction. Metrics default to stable Course/Cohort/campaign UUID order. Export always uses the corresponding list's selected stable order.Use bounded page-number pagination:
pagedefaults to 1;page_sizedefaults to 20 and accepts 1–100. Responses containitems,page,page_size, exacttotal_count, and nullablenext_page/previous_page. Counts and pages are computed after authorization and filters. An authorized out-of-range page is200with empty items and the exact count. No count or timing response may reveal out-of-scope objects.Metrics
Registration metrics accept the registration scope/date filters above plus
group_by=cohort|campaign(defaultcohort) and return, per authorized group:Enrollment metrics accept Course/Cohort/date/state filters and return, per authorized Cohort:
Metric groups use the same page contract and maximum size. Zero is returned only for an authorized, complete query over a real scope; unknown/out-of-scope objects do not become zero. #133 baseline overlays never contribute.
Export contract
Registration and Enrollment export requests use the corresponding exact filters/sort plus:
include_pii, defaultfalse;trueadditionally requires the resource'sview_piicapability;confirmed=true, bounded reason code/text under the accepted Complete Studio production identity, role, audit export, and high-risk controls #32 high-risk policy;Idempotency-Key; andThe service previews the exact authorized filtered row count and selected column class. If the frozen count is greater than 10,000, return
422 export_too_largeand require narrower filters; never truncate or split silently. A request body over the global bound returns413. Changed-payload key reuse returns409; exact replay returns the original operation. Export is cancellable only while pending; once row materialization starts it runs to a terminal result and a cancellation request reports the actual boundary.CSV is UTF-8, comma-delimited, RFC-4180 compatible with one header row and deterministic row/column ordering. Every text cell, including identifiers that begin with
=,+,-,@, tab, or carriage return after leading whitespace, passes the shared formula-neutralization helper. CR/LF/control-character, quoting, Unicode, and delimiter canaries round-trip as data, never formulas or extra rows. Masked exports use only the Always-present matrices. PII exports add only the corresponding gated fields; the Never-serialized fields never appear in either form.The worker writes to the approved encrypted, private operational-artifact storage through a repository abstraction; local/test storage remains under the project-local configured private boundary. No public ACL, stable public URL, source filename, raw filter query, email, or token appears in the object key, operation JSON, logs, metrics, or audit. Store a redacted manifest containing export UUID, operation UUID, capability, policy version, selected column class, safe filter digest, row count, content checksum/size, actor/principal reference, created/completed/expires timestamps, and opaque contained subject UUID links required for #64 deletion propagation—not row values.
A successful artifact expires exactly 24 hours after completion. The authenticated same-origin Studio/API download streams through authorization; there is no bearer token, signed URL, identifier, or filter in a redirect/query string. Every download rechecks active principal, export capability, object scope, and
view_piiwhen applicable. Expired, cancelled, failed, deleted-for-rights, or out-of-scope artifacts return generic404and no bytes. Cleanup removes bytes and marks the operation result expired; exact counts/checksum metadata follows the approved one-year minimized audit retention, without preserving row content. Subject deletion immediately revokes and removes any live artifact linked by its opaque subject UUID manifest rather than waiting 24 hours.Every successful or denied unmasked list, detail, or exact-identity access records a minimized
courses.registration.pii_viewedorcourses.enrollment.pii_viewedaudit event with actor/principal, target type/UUID or safe filtered-scope digest, field-class names, outcome, request/correlation ID, and timestamp. It never records returned values. Masked ordinary reads use bounded access metrics and do not create one audit row per listed object.Audit separate request, start, completion/failure/cancellation, download allowed/denied, expiry, and privacy deletion. Record action, export/operation UUID, capability, safe filter digest, PII column-class boolean, row count/size/checksum, actor/principal, timestamps, outcome, and bounded reason code. Never record raw rows, field values, email, comment, name, certificate URL, consent value, filter values, filenames/paths, download links, authorization/cookies, request/response bodies, or provider data. Metrics expose only bounded export counts/bytes/duration/outcome by safe operation class—not Course/Cohort/actor/email dimensions.
Migration and evidence boundary
The selected #288 label is derived and requires no alias column, persistent digest, secret, uniqueness constraint, data migration, or backfill. Any #245-owned migration is limited to durable export operation/artifact state strictly required by this contract; it may not rewrite prerequisite registration, Enrollment, account, profile, campaign, consent, score, certificate, or legacy-source rows. Repository verification uses synthetic
.invaliddata and local/fake storage only. Protected-source reconciliation, production mapping/migration, privacy-right execution, provider/storage administration, deployment, and cutover remain with their owning issues and authorized HUMAN gates.Forward/backward and migration-drift evidence must cover every #245-owned migration plus compatibility with the exact accepted prerequisite leaves. Counts/checksums/fingerprints are aggregate and non-sensitive; no row payload, email, name, profile value, token, source path, or production identifier enters an issue report or artifact.
Authentication, authorization, cache, and error behavior
401with the standard safe envelope andWWW-Authenticate; Studio/API never mix session and bearer principals.403. Asking forinclude_piior exact identity lookup without the field capability receives403before query execution.404after applying the authorized queryset. No response, count, latency assertion, or audit text discloses existence.400; stale/idempotency conflict is409; size is413/422; throttle is429; worker/storage/database failure is a redacted error/failed operation, never an empty list or zero metric.Cache-Control: private, no-store; Studio HTML isnoindex, nofollow,X-Robots-Tag: noindex, nofollow, absent from sitemap/search, and uses zero-TTL edge behavior. CSV sets safe attachment headers,nosniff, and a generic UUID-based filename..invalididentities and contain no real/reversible learner or registration data.Studio UX and states
Use the established private Studio shell. Desktop and narrow layouts provide semantic headings, landmarks, labelled filter controls, a captioned responsive table or equivalent reflow, keyboard-complete pagination, visible focus, 44 CSS px pointer targets, announced result/export status, readable timezone dates, and no color-only state.
Required states for both resources:
No registrations match these filters./No enrollments match these filters.with filters retained and no fake zero/error;Do not display raw comment, email, name, organization, certificate URL, or consent value in screenshots. PII-field behavior is proven with synthetic canary values and screenshots may show a visibly redacted/masked fixture, not real data.
Acceptance criteria
member_labelgrammar, removes the decision/grooming labels, and records its implementation provenance plus the exact accepted prerequisite SHAs/pin/schema/migration/capability/OpenAPI fingerprints. The final field/export matrix is revalidated without an invented prerequisite field.member_labeldefault, and separately gated full MemberProfile/account/email fields behave identically for Studio/API/CSV; the label is never an input and nois_staff/superuser/Person/legacy-token bypass exists.Required Django, API, job, and repository scenarios
is_staff/Person non-authority.Use
uv/Make and the versioned change-selective verification plan. Export worker/storage tests use repository fakes and the approved development boundary; they never create a public object or access production storage/data.Independent browser and artifact evidence
At approximately 1440×900 and 390×844, with synthetic
.invalidfixtures, the independent tester captures and reads:CSV is non-HTML evidence: store only a synthetic export under
.tmp/, inspect media/disposition/cache headers, exact columns/order/row count, formula neutralization, checksum, expiry behavior, and absence of forbidden fields; then remove it through the tested cleanup path. Every screenshot must show the expected page rather than debug/404 leakage or a broken layout and contain no real or unnecessary synthetic PII, token, URL secret, comment, consent payload, provider value, or production data.Explicit non-goals
Delivery convention
Follow
_docs/PROCESS.md. Engineering remains dependency-blocked even though grooming is complete. After a frozen uncommitted implementation, an independent tester recomputes the verification plan and inspects all required screenshots/artifacts; PM then performs final acceptance. Only after both pass may the engineer commit withCloses #245andRefs #54; no pull request.