SolidProfessor Research Library ← Research library
Feasibility · Report 02

Bringing instructors in through LTI

A feasibility read on letting instructors — known and anonymous — enter SolidProfessor through the existing LTI integration. Organized into what's true regardless of role, then the known-instructor case, then the anonymous case. It draws on the knowledge base and a fresh code review. It is the trio-facing companion to Report 03 (the proposed backend changes), the file-by-file engineering spec.

StatusFeasibility — not a spec
Backend HEADcc6a47a28 (2026-09-09)
Frontend HEAD9df3b28e8
ReviewedAug 7, 2026 · re-reviewed Sept 10, 2026
Authored byElizabeth & Claude, referencing research by Favour

How to read this — a document for the product trio

This research is written for the trio (PM, design, and engineering): it lays out what exists in our code today and what needs to be considered for instructor provisioning to be built. Everyone should read the bottom line and scope. Beyond that:

Start with the bottom line just below. The numbered parts further down are collapsed by default; click any heading to expand it, or use the links above to jump straight to yours.

Bottom line

Feasible, and mostly a backend change. Both the known and the anonymous (LAUSD / Schoology) cases are viable and bounded — and the pivotal anonymous question is now confirmed against production data.

General — true or needed regardless of role

Everything in this part applies whether the instructor is known or anonymous. The known- and anonymous-specific deltas are in Parts 2 and 3.

Size at a glanceKnown instructor is Medium; anonymous (LAUSD/Schoology) is a viable, bounded delta; roster/whole-school are Large and out of scope.
Known instructor · access + authoring
Medium

Well-mapped. Spans a backend access half (Report 03 is effectively the spec; the plumbing already accepts an Instructor role) and a frontend authoring half. Decomposes into ~6–8 small PRs.

Anonymous instructor · LAUSD / Schoology
Viable · low-end

A bounded delta on top of the known-instructor work: a small provisioning branch, PII hygiene on the launch record, and two Schoology deep-link fixes. The pivotal roles question is confirmed against production data (see Part 3).

Roster import (NRPS) / whole-school
Large

Net-new capability, zero implementation today. Explicitly out of scope for v1.

These are scope, not velocity. The pod that would take this on would be working in LMS/LTI for the first time, so a real learning-curve ramp-up should be added to any calendar estimate (see the sizing caveat in Part 3).

How it works — the standard, the code, and the docsLTI 1.3 hands us identity + role on every launch; the code recognizes the person but throws the role away.

Three sources: the 1EdTech LTI 1.3 spec, the current code (freshly reviewed at today's HEAD), and the knowledge base. Here they are side by side on the questions that matter.

QuestionLTI 1.3 standardWhat the code does today
Who is this person?Signed sub claim, stable within its issuer (iss).Works Matches on sub. The same user.id works for instructors and students — no instructor-specific parameter needed.
Which school?Vendor custom parameter — the lms_school_id.Works, but unscoped The mapping row is stored correctly; the lookup at launch filters on lms_school_id alone and ignores the deployment that validated the launch. Since that value isn't globally unique, two customers can share one. See finding F4.
Which course?Standard context.id claim.Works Used for class enrollment when a class is in the target URL.
Student or instructor?Signed roles claim (…membership#Instructor vs #Learner).Ignored The launch path never reads the roles claim. A new resource-launch user is defaulted to Student.
Name / email?Optional, privacy-dependent identity claims.Works Drives known vs anonymous: if the JWT carries email, the deployment is flagged accepts_pii; otherwise a synthetic …@lms.solidprofessor.com identity is generated.

So the platform already recognizes whoever launches, and the standard already hands over the role for free. The one missing link for instructors is that the role is thrown away. That is the core of the change, and it is small. Everything else in the estimate is the safety net around it — because an LMS role becomes a SolidProfessor permission boundary the moment it is trusted.

Confirmed in the code review

  • Confirmed New LTI users default to Student and the roles claim is unread — FindOrCreateUserForLtiContext.php:170–177. (ASE-1221 later added a flag-gated path that preserves a migrated member's existing status and role on first launch, but a genuinely new user still defaults to Student.)
  • Confirmed Existing privileged roles (Instructor / District Admin / Super Admin) are preserved, not downgraded, on launch — …:104.
  • Confirmed ProvisionUserToSchool already accepts Role::INSTRUCTOR end-to-end and even creates an instructor's default class — ProvisionUserToSchool.php:138–140. The plumbing exists; only the two LTI callers pass Student for new users.
  • Confirmed NRPS is a dormant flag: accepts_names_roles_services is stored, but no app code fetches or processes a roster (only the vendor library references it).
Scope: three requests that sound the same"Add instructors" can mean JIT-at-launch (recommended), roster import, or whole-school — wildly different sizes.

Three outcomes are easy to conflate, and they are wildly different sizes, so naming the target is the single biggest driver of the estimate. The recommendation below is for the first one.

The requestWhat it needsSize
A · One instructor, at launch
Recognize / create / promote the instructor the moment they click SolidProfessor in the LMS.
Consume the signed roles claim already received. Same shape as how students provision today. No new standard, no roster call.Medium — recommended v1
B · One course roster, before launch
Pull a course's instructors & students ahead of time.
Names & Role Provisioning Services (NRPS). There is a dormant flag for it but zero implementation.Large — net new
C · A whole school / district, in advance
Sync organizations, courses, users, enrollments.
OneRoster / SIS / LMS admin API. A separate integration track entirely.Large — separate program

Recommended scope for a first release

Option A — just-in-time instructor provisioning at launch, supporting both known and anonymous modes, with instructors treated as free (no seat, no payment path). It reuses the exact mechanics students already run through, it is the request a customer actually makes ("let my teachers in"), and it does not commit to building NRPS or a rostering pipeline. B and C can layer on later without rework — LTI stays the login/launch experience underneath them.

What "just-in-time" does and does not mean — worth reading before a customer conversation

"JIT" is easy to misread as "no setup needed." It isn't. There are two layers on two different clocks, and JIT only touches the second:

  • The integration is always ahead of time. client_id, deployment_id, and the lms_school_id school mapping are configured once per school and are the prerequisite for any launch to succeed — student or instructor. JIT does not remove this, and this feature does not change it.
  • The individual account is just-in-time. JIT refers only to the person who launched: their SolidProfessor account is created / matched / role-assigned at the moment they click the tool, rather than being pre-created one by one or bulk-imported.

It is exactly how students provision today: the integration is set up once, then each student is created at launch. Instructor JIT applies that same pattern to the instructor role.

The one real bootstrap limit: a working integration can only be stood up by an instructor/district-admin in the customer-facing Instructor Tools — there is no complete CS-side path in Platform Admin today. Admin has a deployment form (client_id, deployment_id) but no school-mapping UI, and the mapping is required for a launch to succeed. CS's only route to do it directly is to log in as an instructor/DA, which requires the customer's agreement. So this feature still depends on at least one willing, already-provisioned instructor/DA existing first — JIT does not bootstrap that. Its payoff is every teacher after that: one admin sets the school up once, and the rest get instructor access simply by launching. Config is once-per-school; the provisioning benefit is once-per-teacher.

Findings from the code review (F1–F4)Four things the review surfaced — two would surprise a developer; each feeds a change in Report 03's inventory.

These are the things the code review surfaced that shape the work. Some are prerequisites, and each maps to a change in Report 03 (the proposed backend changes). A fifth, anonymous-specific finding lives in Part 3.

F1 · Deep linking is instructor-only — but the gate lives in the LMS, not the code. Nuance

The instructor-only restriction on deep linking is enforced upstream in the LMS, not in our code: an instructor logs into the LMS, only instructors/admins can select the SolidProfessor tool, and (if Deep Linking was enabled in the app config at install) that instructor is taken to the deep-linking window. A student never reaches it. In our backend, the deep-link action's docblock claims it validates "instructor or above," but no such check exists in the code (DoLtiDeepLinkLaunch.php) — it only checks whether an account exists by lti_id.

Not a live hole, since the LMS enforces it. But if an instructor is ever auto-provisioned from a deep-link launch, there's no backend role check to lean on — one would be added as part of the work.

F2 · Integration setup is instructor-owned, not staff-owned. Confirmed

Instructors enter client_id, deployment_id, and the school mapping themselves, in the 2.0 instructor tool. The customer-facing self-serve flow lives in platform-frontend at /instructor-tools/lms-integrations/ (LTIToolSetupForm.vue, LTISchoolMappingForm.vue), gated by the ltiSelfServiceModule flag and view_instructor_tools + create_*_lms_integration permissions. platform-admin has a deployment form (client_id, deployment_id) but no school-mapping UI at all, so it cannot complete a launch-ready integration. There is no full CS-side setup path today; CS would have to log in as an instructor/DA (customer willingness confirmed) to use the self-service screens.

Setup is instructor-owned — worth surfacing before a customer conversation, since it shapes onboarding and drives the CS setup question for the anonymous case in Part 3.

F3 · Identifier handling is inconsistent across launch paths. Latent bug

It is a three-way split: resource launch stores the Schoology sub truncated at :: (FindOrCreateUserForLtiContext.php:74), while deep-link matching and account linking (LtiConnectController.php) use the full sub. The same Schoology instructor can look like two different people across paths.

Higher-severity for instructors: one recognized on one path but not another gets a duplicate account or loses their work. A single shared normalization helper is a prerequisite, not polish.

F4 · School lookup crosses tenant boundaries. Prerequisite

The mapping itself is stored correctly on the pivot lti_deployment_school_mappings (deployment_id, school_id, lms_school_id). But the lookup takes only the custom parameters — never the validated deployment — and resolves the school by lms_school_id alone, then firstOr returns the first match (FindSchoolByLmsId.php:27–34; both callers pass only the custom claims). The deployment_id side of the pivot is never used to disambiguate.

Why that's not harmless: lms_school_id is the LMS's own identifier — a Canvas sub-account ID, a Schoology building ID, or (Blackboard/Moodle) an arbitrary label the admin invents. It is not globally unique, and it is owned by the LMS, so two different customers can legitimately hold the same value (MAIN, 1). That is exactly why a global uniqueness constraint on the field would be wrong — it would reject a valid second customer.

The fix is the filter, not a field constraint: scope the lookup by (deployment, lms_school_id), resolving within the launch's own deployment. A composite (deployment_id, lms_school_id) constraint is an optional secondary guard, never a global constraint on the LMS value.

The UI reality — config is instructor-owned, no new config fieldsThe setup forms don't change; the real UI work is making launch/landing pages role-aware.

A natural assumption is that this needs UI changes because instructors input client_id, deployment_id, and the school-mapping ID in the 2.0 instructor tool. That half holds; the conclusion makes the UI smaller, not bigger.

Integration config ≠ per-instructor provisioning

Those fields configure the school's connection to the LMS — entered once per integration, by whoever sets it up. They are not touched when an individual instructor walks in. Instructor provisioning happens at launch time, driven by the signed roles claim — no form to fill in. So the self-serve config forms do not change for this feature.

What UI actually does need attention

The real frontend surface is the launch-time pages that currently assume "student":

  • The payment / inactive-user redirect — the resource-launch path pushes inactive, non-anonymous users toward /academic-enrollment/payment, keyed on status only, not role (DoLtiResourceLinkLaunch.php:64). Largely handled by provisioning new instructors active so they never reach it; a deactivated instructor hitting the existing inactive dead-end (and contacting CS) is acceptable. Worth a quick check that a valid instructor can't be sent down the payment path.
  • The anonymous-LMS landing pageanon-lms.vue ("You're almost there!") is student-worded ("ask your instructor for help").
  • The account-link / connect screen (lms-connect.vue) — today's fallback for an unrecognized instructor. With auto-provisioning, a decision is needed on when (if ever) this still appears.

Net: the UI work is real but modest, and it isn't in the config forms. No new config fields — instead, making the existing launch/landing pages role-aware. Worth flagging to a developer up front so no one goes looking to bolt an "instructor" field onto the setup form.

"Free" means Active — the permission gateVerified: Instructor role grants class/assignment creation, but an inactive instructor resolves to zero permissions.

Verified: the Instructor role grants creation — but only when the instructor is active

Confirmed. Role::INSTRUCTOR grants both create_classes (permission-matrix.php:276) and create_assignments (permission-matrix.php:286), seeded from committed config into the Spatie tables. Effective permissions resolve from the role on the user's active AccountUser, so a JIT-provisioned instructor resolves the identical set as any other instructor.

The gate that matters: FilterInactivePermissions.php:36–38 wipes all permissions to empty for an inactive academic instructor — no create, not even viewing classes. And known-LMS provisioning currently creates new users inactive. So "instructors are free" has to mean provisioned active, not merely fee-exempt: skip the payment path and activate, or the entire instructor experience is dead on arrival.

The activation rule has a known-instructor twist — a launch must activate net-new accounts only, never reactivate a deactivated existing one. That is covered in Part 2 (it only arises via email matching, which is a known-instructor mechanism).

What would be built regardless of roleThe shared work items — role mapping, activation, tenant-scoping, identifier, deep-link check, cleanup, tests.

These apply to both known and anonymous instructors. The role-mapping itself is tiny — a role resolver + an enum and a change to one caller. What earns the "Medium" is the safety net that turns an LMS claim into a trustworthy permission boundary: provisioning net-new instructors active, deployment-scoped tenant safety (F4), shared sub normalization (F3), a real signed-role check on deep linking (F1), removing the dd() debug calls, role fixtures and regression tests across Canvas and Schoology behind a feature flag, and role-aware launch pages on the frontend.

The file-by-file plan for all of this is Report 03's inventory (touch points #01–17). This section is the decision-level summary; Report 03 is the build spec.

Known instructor

A known instructor arrives with a real name + email. They can hold a normal account and log in directly. This is the recommended first case — buildable now, Medium. It builds on all the shared work in Part 1.

The authoring flow, and the two halves of the featureAccess gets them in; authoring lets them work in the LMS. Both core — the LTI session is already full-scope.

How a known instructor builds and integrates a class today — and the dependency worth naming: creation and integration happen in two different sessions.

StepWhere it happens today
1 · Build class + assignmentsDirect SP login A separate SolidProfessor session, outside the LMS.
2–4 · Open the SP tool in the LMS → deep-link windowIn the LMS Requires the deployment to allow deep linking and the instructor's SP account to be linked — an unlinked instructor is bounced to /lms-connect to log in first (which links the account).
5 · Select the class + assignmentsSelect-only The deep-link window is a picker. The Create control is explicitly hidden in LMS mode (GroupingListHeader.vue, v-if="!lms") — so the class must already exist from step 1.
6 · "Integrate to LMS"In the LMS Only works inside a live deep-link launch — it hard-requires the launch_cache_key, so it cannot run from a plain direct session.

What JIT provisioning changes (the access half): it removes the bootstrap/link detour at steps 2–4 — the first launch recognizes the Instructor role and creates/links the account just-in-time, instead of bouncing to /lms-connect. It does not, by itself, move the creation step (1) into the LMS.

The good news: the LTI session is already a full instructor session

Despite a docblock calling it "scoped," the token minted on launch (CreateScopedAuthenticationTokenForLtiContext.php) carries the full api scope — identical to a direct login, just tagged lti with a 2-hour expiry — and no middleware blocks an LTI-originated instructor from class/assignment-creation routes. The deep-link pages simply run under a chrome-less layout and never surface those controls.

So closing the gap — create and integrate in one LMS-native motion — is primarily frontend work: expose the create controls in LMS mode (the create modal is already imported in the grouping layout) and thread launch_cache_key through creation. Not a new backend auth or session model.

Two halves of one feature — both core to the value:

  • Access (JIT provisioning) — get the instructor in from the LMS, recognized as Instructor. The backend half.
  • Authoring-in-the-LMS — let them build and integrate classes without leaving the LMS. The frontend half — and not optional. The point of provisioning instructors through the LMS is a fully LMS-native lifecycle; if authoring stays behind a direct SP login, the exact friction the feature set out to remove is left sitting in the middle of the workflow. A known instructor can already log into SP directly, so access-alone gives them little — the native workflow is what makes it worth doing.

Sequencing is a choice; dropping authoring is not

The two halves can be phased — access first, authoring as a fast-follow. But that is a delivery decision, not optionality: access-alone is an incomplete product, not a shippable end state. Both halves are counted in the Medium estimate.

Email match & the deactivation ruleA launch activates net-new accounts only; an inactive email-match fails to CS, an active one proceeds.

Email matching is a known-instructor mechanism (anonymous instructors have no email and always create a new record). It intersects the standing business rule directly.

Deactivation stays a CS control lever

Per the standing business rule — instructors and district admins are free while Active, and CS deactivation is the deliberate lever to revoke access — a launch must never flip an existing account's status.

  • New instructor (email not found) → create, provision Active + Instructor. Free access.
  • Existing match, already Active → recognize / promote per the role claim, proceed.
  • Existing match, Inactive (CS deactivated them) → do not touch status. The launch dead-ends exactly as an inactive student's does today, and the instructor contacts CS — the current, accepted path. (Upgrading that generic dead-end to an instructor-specific message is optional polish, not required.)

Otherwise a deliberately-offboarded user could re-grant themselves access just by launching from the LMS. Applies to both Instructor and District Admin.

Known-instructor build & sizeMedium. The shared work in Part 1, plus two known-specific items.

The known-instructor v1 is the shared work in Part 1 plus two known-specific additions: (1) an optional instructor-specific "contact CS" message on a blocked launch — not required, since inactive users already dead-end today; and (2) authoring-in-the-LMS — the frontend half (expose the create controls in LMS mode and thread launch_cache_key through creation), which is not optional. Overall Medium — a backend access half plus a frontend authoring half, ~6–8 small PRs.

The backend file changes are enumerated in Report 03's inventory; the frontend authoring work is the trio-facing half described here.

Anonymous instructor

The driving case is a large, privacy-strict district (LAUSD) on Schoology whose bar is: no teacher PII — especially no district-domain email — stored anywhere in our database, while teachers still get instructor access and can do their work through the LMS. This is a net-new idea, so this part is the first worked-through analysis, grounded in a fresh code review.

Verdict: a viable path — a bounded extension of LTI, not a rebuild

An anonymous instructor can be provisioned with no PII, can create classes/assignments, deep-link content back, and see reporting. The "anonymous ⇒ student" assumption is localized (three spots in one file), identity generation is already no-PII and role-agnostic, and anonymous accounts are already created active. The real work is PII hygiene on the launch record and two Schoology-specific deep-link fixes — all bounded and understood.

This lands closer to the "last 10% of LTI" than the "hardest 10%." The pivotal question — does the role arrive when Schoology sends no PII — is now confirmed against production data (real masked Schoology instructor launches already exist, including on the Fairfax County deployment), so no live test is needed. What remains is code work (the deep-link fixes below) plus a couple of product/customer confirmations.

The pivotal question — does the role survive a masked Schoology launch? Confirmed from production: real masked Schoology launches carry the role, including Instructor.

In masked mode Schoology suppresses name/email. If it also suppressed the role, we couldn't tell teacher from student and the whole idea would collapse. It doesn't — confirmed three ways:

  • The standard/library require it. The LTI library treats roles as mandatory — it rejects any launch missing it (AbstractMessageValidator.php:31–33, "Missing Roles Claim"). PII masking is decided independently, purely on the presence of an email claim (Lti13Service.php:212). Role and PII are orthogonal.
  • A vendor certification fixture ("Launch Instructor No PII") carries roles: [...#Instructor] with no email/name — role-without-PII is a valid launch.
  • Production data Real Schoology launches confirm it directly. Every launch write persists the full payload, so masked launches are inspectable in prod. Across real Schoology deployments, 100% of launches carry the roles claim — including ones with no email and no name. Concretely: the real Fairfax County Schoology deployment alone holds 1,588 launches that carry an Instructor role with no email and no name, and a separate fully-anonymous Schoology deployment (an internal test rig) shows the same pattern. A sampled masked instructor launch contains only iss / sub / roles / custom / context / deployment_id-type claims and the role value […#Administrator, …#Instructor] — with no email or name claim present at all.

So the pivotal question is answered from production, not a hypothetical test — the masked-Schoology launch has effectively already happened, many times, and the data is in prod. This also settles a related item: the Schoology sub is opaque — across ~214k production launches, zero are email-shaped (it's a ~43-char building::guid), so it isn't a hidden PII vector. Note separately: even though the role arrives today, the app ignores it (role defaults to Student) — consuming it is the core known-instructor change, and the anonymous case rides on it.

Does it clear the privacy bar?The real surface isn't the account — it's the launch record, which persists the whole payload. That's the item to close.

The account identity is already clean: the anonymous branch builds a synthetic email from internal ids only — CreateAnonymousEmailAddress.php:14, format lti-{school_id}-{lms_user_id}@solidprofessor.com (our own domain, no external email).

But the identity isn't the main risk. The surfaces to close:

  • Highest priority The launch record persists the entire payload. Every validated launch writes the whole launch blob to the DB — UpdateOrCreateLtiLaunch.php:47 ('request' => $launchData). In any launch that does carry PII, name/email land here verbatim. For a "nothing in our database" bar, this table is the thing to fix — whitelist non-PII keys.
  • Guard The accepts_pii auto-flip. A deployment is flagged PII-accepting simply if a launch contains an email claim (Lti13Service.php:212). A stray email from Schoology would silently un-mask the deployment — guard against it.
  • Cleanup The three dd() calls (Lti13Service.php:154/176/191) dump the exception (and nearby payload) into the HTTP response — a PII exposure in a non-masked launch, and a violation of the repo's own no-debug rule regardless.
  • Confirmed The LTI sub is opaque. The code doesn't sanitize it, but across ~214k production Schoology launches zero are email-shaped — it's a ~43-char building::guid, not an SIS email/username.

Clean already: deep-link content items carry no user identity, and grade sync sends only the opaque sub and explicitly excludes instructors from the synced set — no instructor identity ever leaves for the LMS.

Can an anonymous instructor actually do the job?Yes — permissions are role-based; the anonymous filter only strips user-management. Class creation, deep-link, and reporting all survive.

Confirmed against the permission pipeline. An anonymous + active + Role::INSTRUCTOR account keeps what it needs:

  • The anonymous-school filter (FilterAcademicPermissions.php:12–19) strips only three permissions — create_users, add_users_to_class, add_existing_user_to_class — keyed on the school's onboarding strategy, not role.
  • create_classes, create_assignments, view_instructor_tools, view_reporting, and trigger_lms_grade_sync are all preserved. So class/assignment creation, deep linking, reporting, and grade sync all work.
  • The student-only launch middleware (anon-lms redirect, LMS warning) gate on hasRole('student'), so an instructor is never trapped by them.
  • Deep linking has no PII/anonymity gate — it finds the JIT-provisioned account by lti_id and proceeds (subject to the Schoology fixes below).

Confirms your read (C): feature-gating resolves from the DB role, not identity — so an anonymous account with Role::INSTRUCTOR gets the instructor experience; indistinguishability from a student was only ever an admin-visibility question.

Your "Anonymous Instructor" idea (D) — small, and it solves the visibility/deactivation problem

The anonymous display name is two hard-coded fields ("Anonymous" / "User") in one file, so branching to "Anonymous Instructor" for the instructor role is trivial. Combined with the DB role being Instructor, that gives CS a way to find and deactivate a specific anonymous teacher in admin — turning the operational gap from an unknown into a distinct label + normal deactivation.

Narrow limitations to accept: an anonymous-school instructor can't manually add/create users (expected — students arrive via LMS launch), and can't edit member/roster details through one form (MemberDetailsForm.vue:278). Neither blocks the core teach-and-integrate job; both are worth confirming as acceptable.

Two Schoology deep-link blockers — hard requirements for LAUSD's flowCustom-param drop and the :: identifier mismatch both break the create → deep-link → integrate path, and anonymous users can't self-repair.

Because LAUSD's teachers will deep-link, two items we'd otherwise call "prerequisites" become hard requirements for this specific flow:

  • Custom-param drop. Our deep-linked resources carry only title/url/lineitem (CreateDeepLinks.php:46–55) — never lms_school_id. Schoology does not auto-carry app/district custom params onto the resource-link launches created from a deep link, and the later launch hard-requires lms_school_id (FindSchoolByLmsId.php:23–25) or it throws. The vendor resource supports attaching custom params; our code just never calls it. Fixable, but required.
  • The :: identifier mismatch. Resource launches store lti_id truncated at ::; deep-link and connect use the full sub. A Schoology instructor provisioned on a resource launch won't match on a deep-link launch → treated as unknown → bounced to login. And an anonymous user can't log in to self-repair. So deep-link recognition simply breaks for them without the shared identifier-normalization fix (F3).

Both are already in the shared prerequisites (Part 1 → "What would be built regardless of role"), but for the LAUSD/Schoology anonymous flow they are non-negotiable, not nice-to-haves.

Accepted trade-offs — talking points for the LAUSD conversationNo support/email contact, and no anonymous-to-known conversion — both are existing anonymous behavior, not new risk.
  • We can't email or directly support an anonymous teacher. With no email on file, SP can't send them anything or identify them outside the LMS. The district has already indicated this is acceptable — worth stating plainly in the conversation so it's a known, agreed trade-off.
  • An anonymous account can't later convert to a known one. This is simply how anonymous works for any anonymous school today — not a new limitation introduced here. Acceptable from our side.
  • Deactivation is how access is revoked. Same free-while-active rule as known instructors; CS deactivates the "Anonymous Instructor" record to remove access.
The anonymous delta & sizeOn top of the known-instructor work: a small provisioning branch, PII hygiene on the launch record, and the two Schoology deep-link fixes.

Anonymous is not a separate build — it rides on the known-instructor work (consuming the role claim) plus a bounded delta:

Delta on top of known-instructorShape
Branch the anonymous provisioning path to honor Instructor + name it "Anonymous Instructor"Small Three spots, one file; identity + active already handled.
PII hygiene: whitelist the launch-record payload; guard the accepts_pii auto-flip; remove the dd() callsRequired The actual "nothing in our DB" work.
Schoology deep-link fixes: attach lms_school_id to deep-linked resources; shared sub normalizationRequired Already shared prerequisites; hard-required here.
Validate the deep-link → resource-launch round-trip on SchoologyValidate The roles-in-masked question is already confirmed from prod; this only checks whether lms_school_id carries forward. The fix (attach it to deep-linked resources) removes the risk regardless.

For the "3 sprints vs 12 sprints" question: firmly toward the low end on scope — a handful of small, well-scoped PRs on top of the known-instructor work, not a multi-quarter build. The pivotal masked-roles question is settled from production data. The one residual is the Schoology deep-link custom-param carry-forward — and its fix is cheap and already in scope. Nothing found suggests a hidden "hardest 10%."

Caveat: scope is not velocity — factor in the pod's LTI learning curve

The estimate above is scope (how much needs to change), not velocity (how fast a given team moves through it). The pod that would take this on would be delving fully into LMS/LTI for the first time — the deepest current expertise sits elsewhere. LTI is an unusually intricate area (signed launches, per-LMS quirks, deep linking, the identity edge cases in this very report), so a real ramp-up should be built into any calendar estimate. The work is small; a team new to LTI will move through it more slowly than the PR count alone implies.

What still needs a human (not answerable from code)One customer confirmation and two small product calls — the technical unknowns are now settled from prod.

The masked-roles question and the sub-opacity question are both now answered from production data, so the live test is no longer needed. What remains:

  • Customer confirmation: the exact privacy bar — "no district-domain email" vs "no email at all." (Schoology's sub being an opaque non-email id is already confirmed from prod.)
  • Product calls: that the narrow "can't add users / can't edit member details" limitations for anonymous-school instructors are acceptable, and that auto-creating an instructor default class on first launch is desired.
  • One engineering validation (during the build, not before): that lms_school_id carries forward on Schoology resource-links created via deep linking — mooted by the fix to attach it.

Decisions & recommended path

What has to be settled before a developer starts, and how the work would be sequenced.

Open product decisionsThree confirmed; three open; one hard guardrail.
DecisionStatus
Scope of "add instructors"Leaning settled Recommendation: Option A (JIT at launch). Confirm B/C are explicitly out of v1.
Instructor licensing / entitlementConfirmed Instructors are free / unlimited — never a seat, never the payment path. "Free" must also mean provisioned active: an inactive instructor resolves to zero permissions.
Deactivation is a CS control leverStanding business rule Free while Active; CS deactivation revokes access. A launch activates net-new accounts only — an email match never flips status. Inactive match → dead-ends as inactive users do today (instructor contacts CS); active match → proceeds. Both Instructor and District Admin.
Known vs anonymousBoth — anonymous researched (Part 3) Known is buildable now; anonymous is a viable, bounded delta whose pivotal question is confirmed from production data. Only a couple of minor product/customer confirmations remain.
Context role vs account roleOpen LTI "Instructor" is per-course; the SolidProfessor Instructor role is synced on the account. Confirm a launch-time promotion is meant to persist across that account's mapped schools.
Teaching Assistant handlingOpen Recommendation: leave TAs out of the scope of this work unless product discovery deems otherwise. Today TA access is a separate internal Platform Admin flow — an LTI claim should not quietly bypass it.
Do not map LMS Admin → SP District/Super AdminGuardrail Different organizational scope; must stay behind an explicit SP admin process, never an LMS claim.
Recommended sequencingConfirm the privacy bar → security prerequisites → build known → build anonymous delta.
  1. Confirm the exact privacy bar with the customer — "no district-domain email" vs "no email at all." The technical unknowns (masked launch carries the role; sub is opaque) are already settled from production data (Part 3); this is the remaining requirements question.
  2. Land the security prerequisites (F3, F4) as their own small PRs — scope the school lookup by (deployment, lms_school_id), shared sub normalization, and the dd() cleanup. These stand on their own merit and shrink the feature's blast radius.
  3. Build known-instructor JIT provisioning + authoring behind a flag — role resolver, provision/promote active, role-aware payment skip, deep-link role gate, the contact-CS outcome, and LMS-native authoring; tested against both Canvas and Schoology.
  4. Build the anonymous delta — the small provisioning branch ("Anonymous Instructor" role + name), launch-record PII hygiene (whitelist the payload, guard the accepts_pii flip, remove the dd() calls), and the two Schoology deep-link fixes (attach lms_school_id, shared sub normalization). Detailed in Part 3.
Scope boundary: this is a feasibility read, not an implementation spec. It confirms the approach is sound, sizes it honestly, and names what must be decided and de-risked first. The engineering spec is Report 03 (the proposed backend changes); the findings above feed its inventory. Nothing here has been built, and no code has been changed.