Skip to content

ADR for shared Assets and how course files are referenced/served from openedx_content [FC-0138] - #822

Open
bradenmacdonald wants to merge 36 commits into
mainfrom
braden/serving-assets
Open

bradenmacdonald wants to merge 36 commits into
mainfrom
braden/serving-assets

Conversation

@bradenmacdonald

@bradenmacdonald bradenmacdonald commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

This is an ADR for representing Course Files in openedx_content.

Builds on #812 and #818 (merged).

View the rendered ADRs here:

Private ref: FAL-4383


AI disclosure: Claude helped me with research, writing, and formatting the RST, but I wrote most of the content myself.

@bradenmacdonald
bradenmacdonald added this pull request to stack #823 September 19, 2026 00:11
@openedx-webhooks openedx-webhooks added open-source-contribution PR author is not from Axim or 2U core contributor PR author is a Core Contributor (who may or may not have write access to this repo). labels Sep 19, 2026
@openedx-webhooks

openedx-webhooks commented Sep 19, 2026 •

Copy link
Copy Markdown

Thanks for the pull request, @bradenmacdonald!

This repository is currently maintained by @axim-engineering.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

Comment thread docs/openedx_content/decisions/0013-course-static-assets.rst Outdated
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Waiting on Author in Contributions Sep 22, 2026
@mphilbrick211 mphilbrick211 added the FC Relates to an Axim Funded Contribution project label Sep 22, 2026
Comment thread docs/openedx_content/decisions/0013-course-static-assets.rst Outdated
@bradenmacdonald
bradenmacdonald force-pushed the braden/serving-assets branch 2 times, most recently from 6fe58fa to ee4a005 Compare September 22, 2026 23:47
@bradenmacdonald
bradenmacdonald marked this pull request as ready for review September 22, 2026 23:48
@bradenmacdonald
bradenmacdonald marked this pull request as draft September 23, 2026 21:03
Comment thread docs/openedx_content/decisions/0013-course-static-assets.rst Outdated
@bradenmacdonald bradenmacdonald changed the title ADR for reusable asset "Uploads" and how course assets are referenced/served [FC-0138]- #812 ADR for reusable "File components" and how course assets are referenced/served [FC-0138] Sep 23, 2026
@bradenmacdonald

Copy link
Copy Markdown
Contributor Author

For those following this so far, I have made major revisions to everything, primarily renaming it from "Upload" to "File component" and adding a "Consequences" section.

Naturally, the ``FileComponentMetadata`` table will enforce that ``legacy_path`` is unique per learning package.

2. File components can be referenced using new ``oex-asset:`` URL
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just pushed this new ADR 14.

Decision 1 is the only part that is relevant to our short-term project of "move static assets into openedx_content and serve them using the contentstore API". The rest is trying to paint a picture of the long-term functionality we'll get when we also migrate courseware itself to openedx_content.

The reason I'm sketching this all out now is so we can decide if we want all this, or if we'll instead go with the dumb, simple approach that does the bare minimum for contentstore compatibility but drops versioning, tracking, linking, etc.

@bradenmacdonald
bradenmacdonald marked this pull request as ready for review September 26, 2026 01:10
@bradenmacdonald

Copy link
Copy Markdown
Contributor Author

OK, I still have some TODOs and things to clean up, but I'd like to get more input before focusing on the fine details. This is ready for review, @ChrisChV @ormsbee @kdmccormick.

CC @xitij2000 and @Kelketek on ADR 14 in case you have any thoughts on this after working on the XBlock Asset Picker UI.

When copying a Component with references to shared File components to a new Learning Package, each referenced File component is handled as follows:

- First, if the destination Learning Package already has a File component that is a downstream copy of the same upstream File component (see decision 6), that existing copy is reused, even if its ``component_code`` or content differs. The copy is not updated as a side effect of pasting, since that would change every other component that uses it; if the source uses a newer upstream version, the author can sync the File component as usual. Matching on upstream prevents repeated pastes or imports of the same library content from creating a new copy of the File component each time.
- Otherwise, if the destination Learning Package already has a File component with identical ``component_code`` and media asset file hash(es), it is reused and no File components need to be copied. Identical bytes don't prove that it's the same asset, but reusing a File component with the same code and content is harmless.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit worried about this one. If this occurs, the File Component in the destination Learning Package might already be in use by another component. If an author updates the asset, it will be updated in both the existing component and the copied component. However, the author is unaware of this; as far as they are concerned, they are only changing the asset in the existing component.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but we will presumably have something in the UI that shows authors where each File component is being used, so they would see a list of which changes will happen when they update it.

There is actually something like that in the "Files" page already, but it's currently not 100% accurate.

11. Image metadata will be in separate models
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

As mentioned in the :class:`Media` docstring, we can use a separate model called ``ImageMedia`` to capture metadata like image dimensions (assuming it can be purely derived from the image byte data itself). We can also use a separate model like ``ImageComponentVersion`` to store *editable* metadata about an image, such as its default alt text and whether or not the image is purely decorative; saving edits of such metadata would create a new version of the File component.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that a File component that bundles an image with its thumbnail will have separate ImageMedia entries for each thumbnail size and the original asset?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I don't think thumbnails should be considered part of the asset, nor bundled with the image. But if you did upload a pair of images with a regular and "retina" @2x resolution versions, each of those would have its own ImageMedia entry. The ImageMedia is 1:1 with each actual image file that we store (de-duplicated by hash), so it doesn't matter how they are logically organized. (Two different-resolution versions in one component or two separate components each with a different resolution.)

As for how thumbnails are stored, see the draft proposal from @ChrisChV at #837 which is more or less a separate system for storing thumbnails, not storing them as versioned file components.


This is an appealing alternative, especially when you consider that the number of use cases that require multiple files per File component is likely very small. Videos require multiple files but are rarely if ever stored in Course Files (instead hosted on video-specific services like YouTube or edX.org's video platform). This is rejected primarily to support the use case of HTML "interactive" files that in turn reference image, JavaScript, and/or CSS files. Examples of this can be seen in the `Studio Advanced course <https://github.com/HarvardX/studio-advanced/tree/5cf820c29e3439c9f45cb88e5cf22fd4ab269739/course/static>`_.

TODO: can we quantify how much this feature would be used?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think images are probably a big user of this and I expect would be very common as assets. I think embracing this would allow uploading the original asset and using a web-friendly resized version in a course and potential a mobile version on mobile.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, for the PDF XBlock I'd worked on a feature to convert a .docx or other file type to pdf. It would be great that could be represented via this mechanism.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradenmacdonald I think we need to identify which assets are authored and which are derived/generated. This is because adding an asset to a File Component means a new version of that File Component will be created. That is correct for authored assets, but creating a new version for derived assets is not ideal. I’ve explored this further in the thumbnails ADR (#837). Thumbnails are derived/generated and disposable; it doesn't make much sense to version-control them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good points guys. If we are auto-converting .docx to .pdf or something, I think that it should be treated differently than if an author uploads both a .docx and a .pdf.

Derived files, like thumbnails and auto-converted docs:

  • Are safe to delete
  • Can be (re-)created on demand
  • Are versioned differently: based on when the original file or the code that produces them changes (e.g. if we upgrade "pdflib" to a new version, we may want to re-create all the .pdf auto-conversions)

I think it makes sense to store such things in completely different models, not re-using the PublishableEntity machinery that we're using for the original file components themselves.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is another use-case for images, which is to allow editing while retaining the original. I think the versioning ability would probably be a better fit for that though. i.e. if a the authoring MFE or a plugin allows editing image it could save that as v2 of the asset or as another file in the same component.
Just pointing out a potential use case, not sure what would be the correct approach.

@kdmccormick kdmccormick left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the writeups! this is making the whole problem space much more concrete.

i'm still chewing on all three ADRs. here are just a couple specific concerns on ADR 14 I wanted to capture, but don't feel like you need to address these in depth right now as I'm still not sure about ADR 13

- First, if the destination Learning Package already has a File component that is a downstream copy of the same upstream File component (see decision 6), that existing copy is reused, even if its ``component_code`` or content differs. The copy is not updated as a side effect of pasting, since that would change every other component that uses it; if the source uses a newer upstream version, the author can sync the File component as usual. Matching on upstream prevents repeated pastes or imports of the same library content from creating a new copy of the File component each time.
- Otherwise, if the destination Learning Package already has a File component with identical ``component_code`` and media asset file hash(es), it is reused and no File components need to be copied. Identical bytes don't prove that it's the same asset, but reusing a File component with the same code and content is harmless.
- In both of the above cases, only the main Component needs to be copied, but we still have to create ``FileComponentLink`` and :class:`PublishableEntityVersionDependency` objects to track the relationship, using the original ``component_code`` as the ``alias`` if the reused File component's code differs.
- Otherwise, the File component is copied into the destination Learning Package, and becomes a downstream of the original if the original is in a library. If the destination Learning Package already has a conflicting File component, with identical ``component_code`` but different media asset file hash(es), the copy is given a different ``component_code``, and the ``FileComponentLink`` created for it will use the ``alias`` column to alias this new code to the old code, so that no changes to the OLX are required.

@kdmccormick kdmccormick Sep 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does the FileComponentLink's alias get factored in when the OLX is moved out of the target LP? notably:

  • copy
  • export/import

would each of those systems now need to read FileComponentLink in order to make sense of the OLX? is that tradeoff worth the benefit of not rewriting media paths upon OLX paste?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Export/import would presumably be at the LP level, and would need to include some list of all the FileComponentLinks in the package in any case.

Copy and paste into another LP would have to follow the same algorithm described above for handling references in the new learning package, creating an alias if needed.


This scheme:

- **Is easy to detect.** ``oex-asset:…`` does not occur in content for any other reason, so a single pattern finds every reference in any field type (HTML, problem XML, JSON, CSS ``url()``, etc.) with no false positives from platform or theme ``/static/`` URLs. (The only exception is that course content *about* OLX authoring itself would need to escape ``oex-asset:`` if it occurs in text.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine we'll need to support rewriting of /static/, basically forever. So, adding rewrite-detection for oex-asset: would not simplify the system, right?

Beyond that, it seems like all the other listed benefits of oex-asset: also apply to /static.

The net-new thing I see oex-asset: doing is that it provides for both the component-local format (oex-asset:/{path}) and the cross-component format (oex-asset:{component_alias}/{path}).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mentioned this in the meeting, but to recap here:

The problem with /static/ is that it can occur in many unrelated contexts, such as a course referencing https://www.python.org/static/img/python-logo.png or https://en.wikipedia.org/static/images/icons/wikipedia.png (although they shouldn't be hotlinking).

So we use this regular expression to try to only match quoted references that begin with /static/, like <img src="/static/..." .... But this doesn't match every use case; e.g. &quot;/static/...&quot; would not be matched, but could be a valid reference to a file.

Because oex-asset: doesn't occur in any other contexts (I'm pretty sure), we can detect it reliably and robustly.

@bradenmacdonald

Copy link
Copy Markdown
Contributor Author

I've made some major revisions and simplifications. In summary: shared files are now a new entity type, Asset, with one file per Asset, identified by its filename, rather than a file Component type. Content keeps using plain /static/ references instead of a new oex-asset: URL scheme. A new ADR 15 (Shadow Components) covers attaching files to XBlocks that are still in modulestore.

ADR 13: Shared Static Assets (was "Shared Static Assets as File Components")

  • "File component" → "Asset". Shared files are now a new Asset PublishableEntity type. An Asset is not a Component: it has no ComponentType and no OLX, and it points to exactly one Media file. This removes the need to audit code that assumes every Component is an XBlock.
  • One file per Asset. Multi-file File components are gone. Related files are grouped by attaching them to the same XBlock Component, or by putting their Assets in a Collection. "Multiple files per Asset" and a separate "Asset Set" type are now rejected alternatives.
  • Assets are identified by filename. component_code derived from the filename is replaced by a case-sensitive filename column on Asset that is unique per learning package. The filename may contain / for backwards compatibility. Filenames can't be changed, but titles can. Uploading a file with the same name as a deleted Asset un-deletes it and adds a new version.
  • Relative links between Assets. All Assets in a learning package are served from one directory per version (.../{context}/assets/{version}/{filename}), so relative references between Assets work as ordinary relative URLs. This replaces the separate "legacy path" URLs. Historical (v{num}) versions are always served as attachments, because a relative reference from one is meaningless.
  • Fallback from Component files to Assets. A relative reference from a file attached to a Component that doesn't match an attached file redirects to the shared Asset with that name. The fallback only applies to static/ paths. Permissions are checked against the Asset itself.
  • Flags are plain columns. locked and private are unversioned boolean columns on Asset. The FileComponentMetadata side table is dropped.
  • New allow_scripts flag (decision 10). HTML Assets with this flag are served with sandbox allow-scripts allow-forms allow-popups, so legacy HTML interactives keep working after migration.
    • The migration sets the flag where courses need it, and it can be turned on later for any HTML Asset.
    • The platform can disable the flag site-wide or per course.
    • It is copied on rerun, but not when an Asset is copied into another learning package.
    • The ADR summarises the risk trade-offs: the risk is lower than script in HTML components, but phishing on the real domain and requests carrying the viewer's cookies under SameSite=None remain.
  • More migration detail. Contentstore thumbnails are dropped. Transcripts become ordinary Assets. python_lib.zip is automatically private. Each course's Files migrate in one step, so a course is never half in contentstore and half migrated. Legacy asset-v1: URLs must be mapped to Assets after migration.
  • HTML interactives guidance clarified. New interactives should be HTML Components whose own HTML loads the attached .js and .css files. An attached .html file can't run scripts.
  • Smaller changes: the row estimate was updated to about 9 rows per file, and the "Other names" section was updated.

ADR 14: Referencing Assets

  • oex-asset: URL scheme dropped. Content references both attached files and shared Assets with the existing /static/filename.ext format. A new scheme would mean supporting three formats forever, getting every sanitizer and editor to allow it, and rewriting it on export. The proposal is kept as a rejected alternative.
  • Broader /static/ detection. Any /static/... not preceded by a word character, - or . counts as a reference, not just quoted ones. This catches &quot;-escaped and unquoted references.
  • Attached files take precedence. A Component's own attached file wins over a shared Asset with the same name.
  • No FileComponentLink model. References from a saved ComponentVersion to shared Assets are recorded directly as PublishableEntityVersionDependency rows. legacy_path and aliases are gone too.
  • Placeholder Assets (new). A /static/ reference that doesn't match any file creates a version-less placeholder Asset. The dependency therefore exists before the file is uploaded, and the Files page can list missing files.
  • Name conflicts on copy are handled by renaming. When a Component is copied into another learning package, the Assets it references are reused, matched first by upstream and then by name plus hash, or else copied. If an unrelated Asset already has the same name, the copy gets a new filename and the copied OLX's /static/ references are rewritten. Library sync reapplies the same renames.
    • The earlier "OLX should never change" principle and its alternative (turning conflicting Assets into attached files) are now rejected alternatives.
    • A name conflict on python_lib.zip is shown as an error.
  • New consequences and open questions: relative references between Assets aren't tracked as dependencies. Publishing a component that depends on a still-referenced deleted Asset would publish the deletion. Export of attached files is still a TODO (decision 8).
  • Removed: the obsolete TODOs about legacy_path, code collisions and aliases.

ADR 15: Shadow Components (new)

  • Allows files to be attached to XBlocks while the course's XBlocks are still in modulestore, once the course's Files have been migrated.
  • A Shadow Component is an ordinary Component with the XBlock's real type and its modulestore block ID as component_code. It holds only static/... files and no block.xml. There is no new model and no flag: a version without block.xml is a Shadow Component.
  • Shadow Components are created on demand. Studio keeps them in step with the XBlock's edit, publish, discard, delete, duplicate, rerun and import/export.
  • When course content is later migrated, the migrator adds block.xml to the existing Shadow Component, so keys, asset URLs and file history are kept and no OLX is rewritten.
  • /static/ resolution for modulestore XBlocks checks the Shadow Component's files first, then shared Assets.
  • Main trade-off: while this is in use, an XBlock's data lives in two places (modulestore and openedx_content), so their publish states can drift.

ADR 5 and ADR 12

  • ADR 5: now defines a second URL form for shared Assets (.../assets/{version}/{filename}), explains the Component → Asset fallback redirect, and states that the LMS only serves published versions. The relaxed "Insecure Legacy Assets" sandbox policy is now used for HTML Assets flagged with allow_scripts. Also typo fixes.
  • ADR 12: decision 8 now points to ADRs 13 and 14 instead of "a future ADR".

@kdmccormick kdmccormick left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0005 and 0012 both look good to me ✅

I let some comments on 0013 💬

Still reviewing 0014 and 0015... 👀

Comment thread docs/openedx_content/decisions/0013-shared-static-assets.rst Outdated
Comment thread docs/openedx_content/decisions/0013-shared-static-assets.rst Outdated

For example, an HTML Asset served at ``.../assets/published/page.html`` that references ``image.png`` causes the browser to request ``.../assets/published/image.png``, which is the ``image.png`` Asset's own URL. Filenames containing ``/`` work the same way: ``images/diagram.html`` referencing ``../logo.png`` resolves to ``.../assets/published/logo.png``. No server-side fallback is needed for references between Assets. The URL does not contain the Asset's ``entity_ref``.

A ``v{num}`` version segment refers to that version of the requested Asset only. Since version numbers of different Assets are unrelated, a relative reference from a historical version (which would resolve to ``v{num}`` of some other Asset) is not meaningful. Historical versions are therefore always served with ``Content-Disposition: attachment``. They can be downloaded (e.g. from a version history UI) but are never rendered as documents. In other words, relative references are unpinned, consistent with :ref:`openedx-content-adr-0014` (decision 7).

@kdmccormick kdmccormick Oct 14, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.../{context}/assets/v{num}/{filename} strikes me as a non-sequitur, for the reasons you point out in this paragraph.

Would this work?

  • .../{context}/assets/published/{filename}/ -- published, relative links work
  • .../{context}/assets/draft/{filename}/ -- latest draft, relative links work
  • .../{context}/assets/draft/{filename}/v{num} -- specific draft, relative links don't work

I think this would remove the "gotcha" of "requesting v3 of this asset will have references to v3 of another asset, even if those v3s have nothing to done with another." Instead, the relative references will just 404.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would have to be ?v=N or something, because otherwise it's hard to tell if the filename just happens to end in /v4 which seems weird but is allowed. (Or we could disallow it.)

The ?v=N format is also nice because it doesn't break relative references and the relative references will already be dropping the version.

But it's weird that draft is still in the URL if requesting a specific version?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it's weird that draft is still in the URL if requesting a specific version?

that's fair. I guess both schemes are a bit weird.

my thinking is that ?v=N is only weird URL-semantics-wise, whereas /vN/ is weird both URL-semtics-wise and behavior-wise (assets are randomly missing or outdated).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it could make sense that only the "draft" endpoint allows different versions, and the published endpoint always shows only the current version. But yes, the ?v=N has much clearer behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed how the URLs/endpoints work in 7f1c409. Let me know if that makes sense.

* **Thumbnails** generated by contentstore are dropped and not migrated. Thumbnails will be handled separately, as described in an upcoming ADR.
* **Video transcripts** stored in contentstore (e.g. ``subs_*.srt.sjson``) become Assets like any other file. Video blocks reference transcripts by name rather than via ``/static/`` URLs, and during this migration they are still in modulestore with no :class:`Component`, so no dependency tracking is set up for transcript Assets at this stage. Dependencies between video components and their transcript Assets can be added when course content itself is migrated to ``openedx_content``.
* **python_lib.zip** automatically gets ``private=True`` (decision 8), so that it remains inaccessible to learners as it is today.
* **HTML files** get ``allow_scripts=True`` (decision 10) if the course currently relies on them being rendered with scripts enabled, so that they keep working.

@kdmccormick kdmccormick Oct 14, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've found another category of files: ones that referenced from CourseBlock settings fields rather than from content. the instances Claude found are:

Setting Notes
course.course_image Bare filename, not a path (default "images_course_image.jpg"). Converted to an asset-v1: key at read time by StaticContent.compute_location in openedx/core/lib/courses.py:36. Denormalized into CourseOverview.course_image_url. Confirmed present-but-unused on the Files page of a live course.
course.banner_image Same mechanism, same default. Read via course_image_url(course, 'banner_image'); cached as CourseOverview.banner_image_url.
course.video_thumbnail_image Same mechanism, same default. Cached as CourseOverview.video_thumbnail_image_url.
course.certificates['certificates'][i]['signatories'][j]['signature_image_path'] Full asset reference stored as a string (/asset-v1:… or legacy /c4x/…). Rendered by lms/templates/certificates/_accomplishment-rendering.html:51. The only one of these with a direct contentstore().delete() write path, via certificate_manager._delete_asset() (cms/djangoapps/contentstore/views/certificate_manager.py:39).
course.pdf_textbooks[i]['url'] and course.pdf_textbooks[i]['chapters'][j]['url'] Passed through replace_static_urls by lms/djangoapps/staticbook/views.py:51-62, so either form works: /static/foo.pdf gets rewritten, a raw asset-v1: URL passes through unchanged. Studio's upload modal produces the latter.
course.html_textbooks[i]['url'] / [...]['chapters'][j]['url'] Same shape and same remap_static_url treatment as pdf_textbooks.
course.instructor_info['instructors'][i]['image'] Documented in the field's own docstring (xmodule/course_block.py:1010-1020). Set via Studio Schedule & Details; consumed by openedx/core/djangoapps/programs/utils.py:954 and the course-details REST API. Not traced to a rendering template, so whether it is live in the LMS UI is unconfirmed.

Because these aren't reachable from outline content, they show up as Unused in the Files page, which seems like a bug. Maybe there's an easy fix for that, or maybe it needs to be outside the scope of the FC. Either way, could you note it here, or say that it's currently undecided and to-be-addressed in a future ADR?

Other than usage-tracking, I don't think these need special handling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good find. I've mentioned them in f3a8764.

Context
-------

Today, a course's authored "Files" (previously known as "Files & Uploads") live in the legacy MongoDB contentstore, as a semi-flat, course-wide namespace of paths that OLX references as ``/static/{path}``. Semi-flat means that the UI only supports a flat list of files, but by editing course tarballs, it's possible to nest assets within subfolders. The current Course Files system tends to get very disorganized in large courses, as the UI lacks the ability to organize assets into folders, and the reporting of which assets are in use in the course is not reliable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude highlighted to me that the CourseBlock.static_asset_path setting exists and theoretically can be used to tell the platform to look on the filesystem rather than in contentstore. I think this is an XMLModuleStore holdover, since using it would actually mean putting course assets under STATIC_ROOT.

I don't think we should worry about that all in these ADRs. I am drafting a DEPR for it now, hopefully we can just rip it out before Willow: openedx/openedx-platform#39236 . (Even if it turns out we need to keep this setting , I don't think it would actually clash at all with your contentstore migration plan)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Thanks!

5. Shadow Components follow the XBlock's lifecycle in modulestore
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Studio code that changes modulestore must keep the Shadow Components consistent with it:

@kdmccormick kdmccormick Oct 14, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're already handling all these cases, how much harder do you think it'd be to make these "real" components, in that they'd also sync title and OLX? Are there other things that'd need to be synced, and/or would it be hard/expensive to serialize a faithful block.xml?

I just feel like this ADR brings us so close to having modulestore Components mirrored over in openedx-core, so if it's only 10% additional effort to go the full way, then maybe that's worth it.

(Totally sensitive to the fact that you have finite budget for the discovery portion of this FC if you'd rather not address this and move on.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, but I think there's still a lot more work involved. Modulestore doesn't deal with XML, so there's no block.xml to serialize. We could add the serialization layer, but it's not 100% the same thing.

And Shadow Components don't deal with hierarchy at all (yet), so all the open questions around how to best represent the course outline in openedx_content still apply.

From the list below:

  • "Edit" doesn't change any existing APIs - it's only a new API for "files attached to Components" which modulestore doesn't support yet.
  • Publish/Discard/Delete/Duplicate are really very minimal actions that hook into these lifecycle events and then mirror the action to the shadow component. In principle this is just a few lines of code for each.
  • Import/Export is a moderate lift to get working, and is also something we'd have to do as part of moving modulestore courses to openedx_content, but it's pretty independent from how the XBlock fields/OLX is stored.

Comment on lines +89 to +90

**Authors can attach assets to course components before the content migration is done**, and the content migration later becomes a smaller job, because those assets are already in place under their final keys.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
**Authors can attach assets to course components before the content migration is done**, and the content migration later becomes a smaller job, because those assets are already in place under their final keys.

won't authors only be able to use component-local media after the course has been switched from GridFS over to openedx_content?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I was trying to talk about the full migration for all XBlocks from modulstore to openedx_content, not just replacing GridFS.


**We could implement dependency tracking for all XBlocks, even those currently in modulestore.** We can detect ``/static/...`` references in regular modulestore XBlocks, and then record which shared Assets are in use using :class:`PublishableEntityVersionDependency`, exactly the same way as we can for XBlocks/Components that are fully stored in ``openedx_content``. Whether and when we should do this is left to decide at implementation time; this would require changing decision 4 above, that "XBlocks with no attached assets never get" a shadow component.

**There are temporarily two sources of truth for a single XBlock**: modulestore for its fields, and ``openedx_content`` for its attached assets. Every Studio code path that creates, copies, publishes or deletes blocks needs to update both, and a missed code path will leave orphaned Shadow Components or XBlocks with missing assets. Orphaned Shadow Components are harmless apart from storage, and could be found and cleaned up by a periodic task that compares them to modulestore.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think you'll have to play whack-a-mole with Studio code that creates/modifies/deletes modulestore XBlocks, or do you think the Shadow Components could be managed inside the modulestore API implementations, invisible to Studio?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm hoping the latter; if it's not the case, then we can consider dropping Shadow Components for now. The whole feature is a "nice to have" while we're halfway between the current state and fully removing MongoDB, but it's not necessary just to move off GridFS. (It's not necessary for basic contentstore parity, that is, and it's also not necessary when we fully move off modulestore)

@kdmccormick kdmccormick left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alright, I've finished this pass. thanks for all you work so far in speccing this out!

3. References using ``/static/`` URLs are tracked as dependencies
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

When editing an XBlock/Component such as a Text (HTML) component, course authors can freely copy and paste ``/static/`` references into the HTML/OLX to reference any available shared assets in the course (including ones copied from content libraries). When the author saves their changes, the OLX will be scanned for ``/static/`` references using the improved detection from decision 1, and each reference will be resolved as described in decision 2. As part of saving the new ``ComponentVersion``, the system will create a :class:`PublishableEntityVersionDependency` from the component version to any shared assets used. (References that resolve to the component's own attached files need no dependency.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'll also need to fiddle with PublishableEntityVersionDependencys and placeholder Assets when:

  • a Components references /static/blah.png (a shared Asset), and then the author adds blah.png as a local Media file, removing its dependency on the Shared blah.png.
  • a Components references /static/blah.png (a local Media file), and then the author removes the blah.png Media file, such that the reference now falls back to a shared blah.png Asset OR now needs a placeholder Asset

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but I think those cases will be handled naturally if our logic is essentially to re-create the dependencies from scratch every time a component is saved.

Additional rules:

- No placeholder is created if the referencing component has an attached asset file with that name (decision 2).
- A placeholder is identified by being an :class:`Asset` with no versions at all. Placeholders are excluded from regular Asset listings, export, and search indexing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if an Asset which is in-use is soft-deleted, what happens? is it replaced with a Placeholder of the same name but no versions? (will the data model let you do that?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. In f249db4 I've clarified that a placeholder asset is an asset with no current versions. So if an asset exists and then you soft delete it, the Draft pointer no longer points at any specific version. We can't/shouldn't get rid of the previous versions that existed, but they won't be used.

The difference between a placeholder asset and a deleted asset is now just whether or not it ever existed, and the effect is the same: we can be aware of the broken links and report it on the Files page.

- Placeholders that no longer have any dependents (e.g. typos that were later fixed) can be hard-deleted, since they never had any content. The ``on_delete=RESTRICT`` on ``referenced_entity`` ensures a placeholder that is still referenced cannot be deleted by mistake.
- When a component referencing a placeholder is copied to another Learning Package (decision 5), a placeholder is created in the destination too.

5. Conflicting shared assets are renamed when copied to another Learning Package

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will change based on our WG discussion today, right? If I can try to summarize:

when a component with references to shared Assets is copied from one Package and pasted into another (including "linking", which is just paste + upstream), the pasted Assets will become Component-local, thus avoiding any conflicts with the shared Assets in the target Package. This is still storage-efficient, because the files are effectively de-deuped at the Media layer. but we will need to keep 2 things in mind:

  • when the source Package is a library, there should be Dependencies relationships from its internal shared Assets to its Components which use them. that way, published edits to the library's Assets will appear as a dependency changes on its Components, giving course authors the ability to sync. (this is already noted in Decision 3)
  • when exporting a course which heavily uses a library, we might wind up wasting disk space with many copies of the same file. I'm not sure if we need to handle this now (.zip compression will do a lot for us) but it is worth keeping track of.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's what I'm thinking. Updated in 621ebc8 and 3e5c794 - does that match your understanding?

Comment on lines +100 to +105
6. Editors should de-reference full URLs on save
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

In the course of editing courseware, authors may use their browser's "Copy Image URL" to copy the URL of an image and paste it elsewhere, resulting in a full, rewritten URL like ``https://demo.openedx.org/assets/content_libraries/lib:Axim:200/xblock.v1:problem@multi_choice_8/v4/static/images/fig1.png`` ending up in the OLX. If any such URLs are detected, they should be automatically rewritten to the ``/static/`` format (subject to some sanity checks to ensure it will still reference the correct asset).

Note: Only rewrite full URLs that point to a shared asset in the same learning package, and don't conflict with any of the component's own asset files.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting catch!

Comment on lines +128 to +131
8. Backwards compatibility in OLX export
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

TODO: document format for assets attached to components.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you want to do this in a follow-up so we can merge the decisions that we have so far?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'll remove 8 and 9 here to keep this ADR focused, and we can decide on the OLX format and python_lib issues later in follow-up.s

Comment on lines +132 to +136

9. Support for multiple python libraries per course
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The Problem component (also known as "capa") has support for writing advanced problems that use Python to compute parts of the problem or grade user answers. In particular, it allows authors to provide a library of python functions that will be available for use in any python code used to define a given problem. Currently, this is limited to one library file per course, which must be called ``python_lib.zip``. Once courseware is moved into ``openedx_content``, we should extend the Problem component so that the Python library is a field that can point to any shared asset (e.g. ``/static/alternative_python_lib.zip``), falling back to whichever asset has the filename ``python_lib.zip``. This will provide authors with more flexibility and simplify the process of linking advanced python-based Problems from a library into a course, without breaking backwards compatibility.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this issue is temporarily alleviated by the local-to-shared fallback mechanism, plus the new decision to include library assets as local rather than shared.

Still would be nice to consider in the future, but I think we could remove it from the scope of the ADR.

Comment on lines +149 to +150
- Publishing one component publishes the shared assets it uses, which changes what learners see in every other component that uses it. That's consistent with how unit children behave, but may be surprising.
- Deleting a shared asset needs to be handled carefully. Publishing a component publishes its dependencies' unpublished changes, and a soft-delete counts as one. So if an author deletes a still-referenced Asset and then publishes any component that uses it, the deletion goes live and breaks every other published component that uses it. To avoid this, we should warn users when they delete an asset that is still referenced. (Or, don't publish dependency deletions as a side effect.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are just a potential future gotchas, right? not one that will bite users right after the migration? my thinking:

  • for courses, accidental-Asset-publishing would not be an issue, since every change to course assets is auto-published.
  • for libraries, we don't yet have UI support for shared assets. when we do build that, hopefully it'll use the existing method of warnings users, a la "by publishing unit U you will also publish components X, Y and asset Z"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's more a future concern. I just wanted to flag it since it's perhaps an unexpected effect from the interaction of our different publishing rules.

Comment thread docs/openedx_content/decisions/0013-shared-static-assets.rst Outdated
@bradenmacdonald

Copy link
Copy Markdown
Contributor Author

@kdmccormick Thanks for the review! I have pushed some commits that should address everything you mentioned, plus what we discussed at the last meeting. I've also generally tried to simplify the ADRs and shorten them where possible, leaving some details to implementation or future ADRs.

CC @ChrisChV

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core contributor PR author is a Core Contributor (who may or may not have write access to this repo). FC Relates to an Axim Funded Contribution project open-source-contribution PR author is not from Axim or 2U

Projects

Status: Waiting on Author

Development

Successfully merging this pull request may close these issues.

7 participants