Scope: the four API static-file mounts in SpikerSoft.Api/Extensions/WebApplicationExtensions.cs:69-76:
Blogs:Path/app/blogs at /blog-pictures
Books:Path/app/ebooks at /ebooks
Media:Path/app/uploads/media at /geography-media
Videos:Path/app/lesson-videos at /lesson-videos
Goal: replace disk-backed UseStaticFiles with S3-backed delivery so the backend API loses its media binds. Decide per route: streaming proxy endpoint (keeps URLs stable, adds backend CPU/IO) vs presigned redirects (cheapest, changes response semantics — check Angular consumers). Note SpikerSoft.Data/Mongos/BookPageImage.cs:46 stores absolute /app/ebooks/... paths in Mongo — needs a path→key mapping shim (Storage:PathPrefix strip, same trick as S3StagedObjectStore.TryMapKey).
Unblocks: the Angular nginx root-bind migration (which serves the same tree from nginx today).
Parent epic: #413.
**Scope:** the four API static-file mounts in `SpikerSoft.Api/Extensions/WebApplicationExtensions.cs:69-76`:
- `Blogs:Path` `/app/blogs` at `/blog-pictures`
- `Books:Path` `/app/ebooks` at `/ebooks`
- `Media:Path` `/app/uploads/media` at `/geography-media`
- `Videos:Path` `/app/lesson-videos` at `/lesson-videos`
**Goal:** replace disk-backed `UseStaticFiles` with S3-backed delivery so the backend API loses its media binds. Decide per route: streaming proxy endpoint (keeps URLs stable, adds backend CPU/IO) vs presigned redirects (cheapest, changes response semantics — check Angular consumers). Note `SpikerSoft.Data/Mongos/BookPageImage.cs:46` stores absolute `/app/ebooks/...` paths in Mongo — needs a path→key mapping shim (`Storage:PathPrefix` strip, same trick as `S3StagedObjectStore.TryMapKey`).
Unblocks: the Angular nginx root-bind migration (which serves the same tree from nginx today).
Backend PR #263: S3MediaFallbackMiddleware behind the four disk mounts — streaming proxy chosen over presigned (URLs and semantics unchanged → no Angular work; presign stays a later optimization). Range pass-through for video seek via new IObjectStore.OpenReadRangeAsync. Dual-run = byte-for-byte today; cutover = drop binds, same URLs. 13/13 middleware tests. This unblocks #531.
Backend PR #263: S3MediaFallbackMiddleware behind the four disk mounts — streaming proxy chosen over presigned (URLs and semantics unchanged → no Angular work; presign stays a later optimization). Range pass-through for video seek via new IObjectStore.OpenReadRangeAsync. Dual-run = byte-for-byte today; cutover = drop binds, same URLs. 13/13 middleware tests. This unblocks #531.
Code MERGED (backend #263 — S3MediaFallbackMiddleware behind the four disk mounts, Range pass-through). Live but inert until the API stack runs Storage__UseS3=true (infra #56 merged the env; needs api-svc key with read on blogs/ebooks/uploads/lesson-videos + API_S3_SECRET_KEY secret). Bind drop rides #529 phase 2; #531 (angular #178) is now unblocked.
Code MERGED (backend #263 — S3MediaFallbackMiddleware behind the four disk mounts, Range pass-through). Live but inert until the API stack runs Storage__UseS3=true (infra #56 merged the env; needs api-svc key with read on blogs/ebooks/uploads/lesson-videos + API_S3_SECRET_KEY secret). Bind drop rides #529 phase 2; #531 (angular #178) is now unblocked.
Findings — the code half is done and live; the data half is not. Keeping this open.
Worked this ticket today. The headline: #528's implementation already merged and is running in production (PR spikersoft-backend#263, S3MediaFallbackMiddleware), but the ticket's actual goal — "the backend API loses its media binds" — cannot be done yet, and it is important that nobody assumes it can.
What is already true (verified, not assumed)
S3MediaFallbackMiddleware serves all four routes from S3, with a route table mapping /blog-pictures→blogs, /ebooks→ebooks, /geography-media→uploads (key prefix media), /lesson-videos→lesson-videos.
It is enabled in production: spikersoft-infrastructure/spikersoft-backend/docker-stack.yml:23 sets Storage__UseS3=true with the api-svc key, and the deploy job refuses to deploy on an empty API_S3_SECRET_KEY (good — that is the #592 failure mode, already guarded here).
The decision the ticket asked for was made: streaming proxy, not presigned redirects. That is the right call — every Angular consumer binds these URLs straight into <img src> / <video src> / poster with no rewriting layer, and the native <video> player relies on Range requests for seeking. A 302 to a presigned URL adds seek amplification + mid-playback expiry risk for long videos and would need MinIO-side CORS (which does not exist).
Proof that the seam is correct
The existing unit suite mocks IObjectStore, so it cannot see what a real S3 server decides. I added a real-MinIO integration suite — spikersoft-backend PR #294 (proof only, no production code changed):
It drives the production middleware order (four disk mounts, then the S3 fallback) over a live bucket and pins: 206/Content-Range with exact bytes, suffix ranges, 416, HEAD, the media key prefix, traversal containment, and the ServeUnknownTypes split. The load-bearing case is DiskMount_TakesPrecedenceOverBucket_DuringDualRun — #528 ships the buckets behind the binds, so a wrong-way precedence bug would silently serve stale bucket bytes over live disk bytes. Nothing asserted that before.
Why this stays OPEN — three blockers to removing the binds
Because the middleware is a fallback behind the disk mounts, every S3 miss is currently invisible. Drop the binds today and those misses become user-visible 404s:
The lesson-videos bucket is empty — guaranteed. Its only writer, lesson-video-processor, has never deployed (#581: VIDEOS_S3_SECRET_KEY was never created, so its own deploy guard rejects every run). Nothing has ever written that bucket.
/geography-media has no writer at all./app/uploads/media is a static tree; StagingMirror only mirrors files flowing through the upload lifecycle. Only a backfill can populate it.
The Phase 0 mc mirror backfill appears never to have run. Nothing in the repo, runbooks, or tickets records it as done.
Additionally — a cutover-day landmine that is not tracked anywhere: SpikerSoft.EventHandlers.UploadCoordinator/Services/UploadOrchestrator.cs:891 does a raw File.Exists(image.AbsoluteImagePath) on /app/ebooks/.... Dropping the ebooks bind makes that silently return false — book page images get marked missing and descriptions never generate. Filing separately.
Recommended next steps (in order)
Fix#581 (issue VIDEOS_S3_SECRET_KEY) so lesson-video-processor can actually deploy and start writing its bucket.
Run Phase 0: create/verify buckets, then mc mirror the four trees; confirm with mc du against du -sh on disk.
Route AbsoluteImagePath consumers through IObjectStore (separate ticket).
Then drop the binds as part of the #529 coordinated batch — not before.
Doc correctness note
docs/minio-storage-migration.md:3 says "MinIO deployed, zero services migrated" and .claude/rules/infrastructure-reality.md repeats "No service has migrated yet."Both are now false and actively misleading — 8+ services carry S3 code with Storage__UseS3=true. The accurate map is docs/uploads-batch-cutover-runbook.md. Worth correcting before someone plans a cutover off the stale line.
## Findings — the code half is done and live; the data half is not. Keeping this open.
Worked this ticket today. The headline: **#528's implementation already merged and is running in production** (PR spikersoft-backend#263, `S3MediaFallbackMiddleware`), but the ticket's actual goal — *"the backend API loses its media binds"* — **cannot be done yet**, and it is important that nobody assumes it can.
### What is already true (verified, not assumed)
- `S3MediaFallbackMiddleware` serves all four routes from S3, with a route table mapping `/blog-pictures`→`blogs`, `/ebooks`→`ebooks`, `/geography-media`→`uploads` (key prefix `media`), `/lesson-videos`→`lesson-videos`.
- It is **enabled in production**: `spikersoft-infrastructure/spikersoft-backend/docker-stack.yml:23` sets `Storage__UseS3=true` with the `api-svc` key, and the deploy job refuses to deploy on an empty `API_S3_SECRET_KEY` (good — that is the #592 failure mode, already guarded here).
- The decision the ticket asked for was made: **streaming proxy, not presigned redirects.** That is the right call — every Angular consumer binds these URLs straight into `<img src>` / `<video src>` / `poster` with no rewriting layer, and the native `<video>` player relies on Range requests for seeking. A 302 to a presigned URL adds seek amplification + mid-playback expiry risk for long videos and would need MinIO-side CORS (which does not exist).
### Proof that the seam is correct
The existing unit suite mocks `IObjectStore`, so it cannot see what a real S3 server decides. I added a real-MinIO integration suite — **spikersoft-backend PR #294** (proof only, no production code changed):
```
12/12 SpikerSoft.Tests.Integration (real MinIO, production image, path-style)
13/13 S3MediaFallbackMiddlewareTests (unit)
34/34 SpikerSoft.Storage.Tests
dotnet build SpikerSoft.UnitTests.slnf -> 0 Error(s)
```
It drives the production middleware order (four disk mounts, then the S3 fallback) over a live bucket and pins: 206/`Content-Range` with exact bytes, suffix ranges, 416, HEAD, the `media` key prefix, traversal containment, and the `ServeUnknownTypes` split. The load-bearing case is **`DiskMount_TakesPrecedenceOverBucket_DuringDualRun`** — #528 ships the buckets *behind* the binds, so a wrong-way precedence bug would silently serve stale bucket bytes over live disk bytes. Nothing asserted that before.
### Why this stays OPEN — three blockers to removing the binds
Because the middleware is a **fallback behind the disk mounts, every S3 miss is currently invisible.** Drop the binds today and those misses become user-visible 404s:
1. **The `lesson-videos` bucket is empty — guaranteed.** Its only writer, lesson-video-processor, has **never deployed** (#581: `VIDEOS_S3_SECRET_KEY` was never created, so its own deploy guard rejects every run). Nothing has ever written that bucket.
2. **`/geography-media` has no writer at all.** `/app/uploads/media` is a static tree; `StagingMirror` only mirrors files flowing through the upload lifecycle. Only a backfill can populate it.
3. **The Phase 0 `mc mirror` backfill appears never to have run.** Nothing in the repo, runbooks, or tickets records it as done.
Additionally — **a cutover-day landmine that is not tracked anywhere**: `SpikerSoft.EventHandlers.UploadCoordinator/Services/UploadOrchestrator.cs:891` does a raw `File.Exists(image.AbsoluteImagePath)` on `/app/ebooks/...`. Dropping the ebooks bind makes that silently return false — book page images get marked missing and descriptions never generate. Filing separately.
### Recommended next steps (in order)
1. Fix #581 (issue `VIDEOS_S3_SECRET_KEY`) so lesson-video-processor can actually deploy and start writing its bucket.
2. Run Phase 0: create/verify buckets, then `mc mirror` the four trees; confirm with `mc du` against `du -sh` on disk.
3. Route `AbsoluteImagePath` consumers through `IObjectStore` (separate ticket).
4. *Then* drop the binds as part of the #529 coordinated batch — not before.
### Doc correctness note
`docs/minio-storage-migration.md:3` says *"MinIO deployed, zero services migrated"* and `.claude/rules/infrastructure-reality.md` repeats *"No service has migrated yet."* **Both are now false and actively misleading** — 8+ services carry S3 code with `Storage__UseS3=true`. The accurate map is `docs/uploads-batch-cutover-runbook.md`. Worth correcting before someone plans a cutover off the stale line.
Update — Phase 0 backfill is now actually run, and one of the two blockers is fixed.
Following the findings above, we did the work on SERVER rather than just documenting it.
Phase 0 backfill — DONE
We found 6 of the 7 buckets did not exist — mc mb had never been run, so the whole migration was blocked at step 1. Ran Phase 0 via a one-shot minio/mc job pinned to SERVER (additive only: mc mb -p + mc mirror --overwrite, no --remove, disk mounted read-only). Result, verified with mc du vs du -sh:
The zero-object buckets are correct — those trees are empty on disk. Note there are no lesson videos anywhere (disk or bucket), which independently confirms #581: nothing has ever produced one, because lesson-video-processor has never deployed.
The UploadOrchestrator raw-File.Exists on /app/ebooks/... that would fail books as "corrupt" once the bind drops now goes through an IBookImageLocator that falls back to the ebooks bucket (filesystem-first, same contract the Python image-description worker already uses). 19/19 tests, UnitTests.slnf clean.
What still blocks the bind removal
#581 — lesson-videos bucket is empty because its writer never deployed. Fix the VIDEOS_S3_SECRET_KEY secret so it can start writing before anyone drops the lesson-videos bind.
Scoped-key policies — before #598 can be enabled, uploads-svc needs read on ebooks (runbook Phase 0 step 2), and #522 needs blogs-svcwrite on blogs confirmed. Buckets existing ≠ keys authorized; a 403 is as silent as a missing bucket was.
#599 — the /lesson-videos and /geography-media URLs are root-relative and resolve against nginx, not the API, so the media routes are unreachable from the SPA regardless of what's in the buckets.
Net: the storage half is now real (buckets populated) and one code blocker is fixed. The remaining blockers are secret/policy provisioning (#581, key policies) and the URL-origin bug (#599) — none in the #528 middleware itself, which our integration suite (PR #294) proves correct.
**Update — Phase 0 backfill is now actually run, and one of the two blockers is fixed.**
Following the findings above, we did the work on SERVER rather than just documenting it.
## Phase 0 backfill — DONE
We found **6 of the 7 buckets did not exist** — `mc mb` had never been run, so the whole migration was blocked at step 1. Ran Phase 0 via a one-shot `minio/mc` job pinned to SERVER (additive only: `mc mb -p` + `mc mirror --overwrite`, no `--remove`, disk mounted read-only). Result, verified with `mc du` vs `du -sh`:
| bucket | before | after | disk |
|---|---|---|---|
| ebooks | 1.1 GiB / 1403 obj | **1.6 GiB / 2301 obj** | 1.7 G ✓ |
| blogs | *did not exist* | **2.3 MiB / 3 obj** | 2.3 M ✓ |
| uploads | 51 MiB / 7 obj | unchanged | 200 K |
| lesson-videos, dlls, assets, quarantine, reference-data | *did not exist* | **created, 0 obj** | empty on disk ✓ |
The zero-object buckets are correct — those trees are empty on disk. Note **there are no lesson videos anywhere** (disk or bucket), which independently confirms #581: nothing has ever produced one, because lesson-video-processor has never deployed.
## Blocker #598 — FIXED (spikersoft-backend PR #297)
The `UploadOrchestrator` raw-`File.Exists` on `/app/ebooks/...` that would fail books as "corrupt" once the bind drops now goes through an `IBookImageLocator` that falls back to the `ebooks` bucket (filesystem-first, same contract the Python image-description worker already uses). 19/19 tests, `UnitTests.slnf` clean.
## What still blocks the bind removal
1. **#581** — lesson-videos bucket is empty because its writer never deployed. Fix the `VIDEOS_S3_SECRET_KEY` secret so it can start writing before anyone drops the lesson-videos bind.
2. **Scoped-key policies** — before #598 can be enabled, `uploads-svc` needs **read on `ebooks`** (runbook Phase 0 step 2), and #522 needs `blogs-svc` **write on `blogs`** confirmed. Buckets existing ≠ keys authorized; a 403 is as silent as a missing bucket was.
3. **#599** — the `/lesson-videos` and `/geography-media` URLs are root-relative and resolve against nginx, not the API, so the media routes are unreachable from the SPA regardless of what's in the buckets.
Net: the storage half is now real (buckets populated) and one code blocker is fixed. The remaining blockers are secret/policy provisioning (#581, key policies) and the URL-origin bug (#599) — none in the #528 middleware itself, which our integration suite (PR #294) proves correct.
Epic #413 accuracy pass (2026-07-18) — closing: all three named blockers from the 2026-07-14 findings are resolved; the sole remaining action is #529 phase 2 itself.
Where each blocker stands now (verified):
lesson-videos bucket empty because its writer never deployed → #581 CLOSED (2026-07-15, backend #303 — key now fetched from OpenBao, deploy guard passes). The bucket being empty is correct: no lesson videos exist on disk either.
Phase 0 backfill never run → DONE 2026-07-14 (buckets created, ebooks 1.6 GiB/2301 obj ✓ matches disk, blogs ✓; documented in the update above).
/lesson-videos + /geography-media unreachable from the SPA → #599 CLOSED (2026-07-16).
Also landed since: the cutover-day landmine (UploadOrchestrator raw File.Exists on /app/ebooks) is fixed — BookImageLocator with ebooks-bucket fallback is on backend master (#598, PR #297). The S3MediaFallbackMiddleware itself is merged, enabled in prod (Storage__UseS3=true + api-svc on the API stack), and pinned by the real-MinIO integration suite (PR #294), including the load-bearing disk-over-bucket dual-run precedence test.
This ticket's remaining goal — the API loses its media binds — is by definition the #529 phase-2 bind drop (its four binds are in the batch table, row 1). One residual to carry there: uploads-svc needs read on ebooks before the drop (for BookImageLocator), plus the geography-media prefix backfill check. Both added to #529.
Also note: the doc-correctness item from the findings is finally fixed — infra PR #125 corrects docs/minio-storage-migration.md + the infrastructure-reality rule mirrors.
**Epic #413 accuracy pass (2026-07-18) — closing: all three named blockers from the 2026-07-14 findings are resolved; the sole remaining action is #529 phase 2 itself.**
Where each blocker stands now (verified):
1. ~~lesson-videos bucket empty because its writer never deployed~~ → **#581 CLOSED** (2026-07-15, backend #303 — key now fetched from OpenBao, deploy guard passes). The bucket being empty is correct: no lesson videos exist on disk either.
2. ~~Phase 0 backfill never run~~ → **DONE 2026-07-14** (buckets created, ebooks 1.6 GiB/2301 obj ✓ matches disk, blogs ✓; documented in the update above).
3. ~~`/lesson-videos` + `/geography-media` unreachable from the SPA~~ → **#599 CLOSED** (2026-07-16).
Also landed since: the cutover-day landmine (`UploadOrchestrator` raw `File.Exists` on `/app/ebooks`) is fixed — `BookImageLocator` with ebooks-bucket fallback is on backend master (#598, PR #297). The `S3MediaFallbackMiddleware` itself is merged, **enabled in prod** (`Storage__UseS3=true` + `api-svc` on the API stack), and pinned by the real-MinIO integration suite (PR #294), including the load-bearing disk-over-bucket dual-run precedence test.
This ticket's remaining goal — *the API loses its media binds* — is by definition the #529 phase-2 bind drop (its four binds are in the batch table, row 1). One residual to carry there: **`uploads-svc` needs read on `ebooks`** before the drop (for `BookImageLocator`), plus the geography-media prefix backfill check. Both added to #529.
Also note: the doc-correctness item from the findings is finally fixed — infra PR #125 corrects `docs/minio-storage-migration.md` + the `infrastructure-reality` rule mirrors.
Residual tracked in: #529.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Parent epic: #413.
Scope: the four API static-file mounts in
SpikerSoft.Api/Extensions/WebApplicationExtensions.cs:69-76:Blogs:Path/app/blogsat/blog-picturesBooks:Path/app/ebooksat/ebooksMedia:Path/app/uploads/mediaat/geography-mediaVideos:Path/app/lesson-videosat/lesson-videosGoal: replace disk-backed
UseStaticFileswith S3-backed delivery so the backend API loses its media binds. Decide per route: streaming proxy endpoint (keeps URLs stable, adds backend CPU/IO) vs presigned redirects (cheapest, changes response semantics — check Angular consumers). NoteSpikerSoft.Data/Mongos/BookPageImage.cs:46stores absolute/app/ebooks/...paths in Mongo — needs a path→key mapping shim (Storage:PathPrefixstrip, same trick asS3StagedObjectStore.TryMapKey).Unblocks: the Angular nginx root-bind migration (which serves the same tree from nginx today).
Backend PR #263: S3MediaFallbackMiddleware behind the four disk mounts — streaming proxy chosen over presigned (URLs and semantics unchanged → no Angular work; presign stays a later optimization). Range pass-through for video seek via new IObjectStore.OpenReadRangeAsync. Dual-run = byte-for-byte today; cutover = drop binds, same URLs. 13/13 middleware tests. This unblocks #531.
Code MERGED (backend #263 — S3MediaFallbackMiddleware behind the four disk mounts, Range pass-through). Live but inert until the API stack runs Storage__UseS3=true (infra #56 merged the env; needs api-svc key with read on blogs/ebooks/uploads/lesson-videos + API_S3_SECRET_KEY secret). Bind drop rides #529 phase 2; #531 (angular #178) is now unblocked.
Findings — the code half is done and live; the data half is not. Keeping this open.
Worked this ticket today. The headline: #528's implementation already merged and is running in production (PR spikersoft-backend#263,
S3MediaFallbackMiddleware), but the ticket's actual goal — "the backend API loses its media binds" — cannot be done yet, and it is important that nobody assumes it can.What is already true (verified, not assumed)
S3MediaFallbackMiddlewareserves all four routes from S3, with a route table mapping/blog-pictures→blogs,/ebooks→ebooks,/geography-media→uploads(key prefixmedia),/lesson-videos→lesson-videos.spikersoft-infrastructure/spikersoft-backend/docker-stack.yml:23setsStorage__UseS3=truewith theapi-svckey, and the deploy job refuses to deploy on an emptyAPI_S3_SECRET_KEY(good — that is the #592 failure mode, already guarded here).<img src>/<video src>/posterwith no rewriting layer, and the native<video>player relies on Range requests for seeking. A 302 to a presigned URL adds seek amplification + mid-playback expiry risk for long videos and would need MinIO-side CORS (which does not exist).Proof that the seam is correct
The existing unit suite mocks
IObjectStore, so it cannot see what a real S3 server decides. I added a real-MinIO integration suite — spikersoft-backend PR #294 (proof only, no production code changed):It drives the production middleware order (four disk mounts, then the S3 fallback) over a live bucket and pins: 206/
Content-Rangewith exact bytes, suffix ranges, 416, HEAD, themediakey prefix, traversal containment, and theServeUnknownTypessplit. The load-bearing case isDiskMount_TakesPrecedenceOverBucket_DuringDualRun— #528 ships the buckets behind the binds, so a wrong-way precedence bug would silently serve stale bucket bytes over live disk bytes. Nothing asserted that before.Why this stays OPEN — three blockers to removing the binds
Because the middleware is a fallback behind the disk mounts, every S3 miss is currently invisible. Drop the binds today and those misses become user-visible 404s:
lesson-videosbucket is empty — guaranteed. Its only writer, lesson-video-processor, has never deployed (#581:VIDEOS_S3_SECRET_KEYwas never created, so its own deploy guard rejects every run). Nothing has ever written that bucket./geography-mediahas no writer at all./app/uploads/mediais a static tree;StagingMirroronly mirrors files flowing through the upload lifecycle. Only a backfill can populate it.mc mirrorbackfill appears never to have run. Nothing in the repo, runbooks, or tickets records it as done.Additionally — a cutover-day landmine that is not tracked anywhere:
SpikerSoft.EventHandlers.UploadCoordinator/Services/UploadOrchestrator.cs:891does a rawFile.Exists(image.AbsoluteImagePath)on/app/ebooks/.... Dropping the ebooks bind makes that silently return false — book page images get marked missing and descriptions never generate. Filing separately.Recommended next steps (in order)
VIDEOS_S3_SECRET_KEY) so lesson-video-processor can actually deploy and start writing its bucket.mc mirrorthe four trees; confirm withmc duagainstdu -shon disk.AbsoluteImagePathconsumers throughIObjectStore(separate ticket).Doc correctness note
docs/minio-storage-migration.md:3says "MinIO deployed, zero services migrated" and.claude/rules/infrastructure-reality.mdrepeats "No service has migrated yet." Both are now false and actively misleading — 8+ services carry S3 code withStorage__UseS3=true. The accurate map isdocs/uploads-batch-cutover-runbook.md. Worth correcting before someone plans a cutover off the stale line.Update — Phase 0 backfill is now actually run, and one of the two blockers is fixed.
Following the findings above, we did the work on SERVER rather than just documenting it.
Phase 0 backfill — DONE
We found 6 of the 7 buckets did not exist —
mc mbhad never been run, so the whole migration was blocked at step 1. Ran Phase 0 via a one-shotminio/mcjob pinned to SERVER (additive only:mc mb -p+mc mirror --overwrite, no--remove, disk mounted read-only). Result, verified withmc duvsdu -sh:The zero-object buckets are correct — those trees are empty on disk. Note there are no lesson videos anywhere (disk or bucket), which independently confirms #581: nothing has ever produced one, because lesson-video-processor has never deployed.
Blocker #598 — FIXED (spikersoft-backend PR #297)
The
UploadOrchestratorraw-File.Existson/app/ebooks/...that would fail books as "corrupt" once the bind drops now goes through anIBookImageLocatorthat falls back to theebooksbucket (filesystem-first, same contract the Python image-description worker already uses). 19/19 tests,UnitTests.slnfclean.What still blocks the bind removal
VIDEOS_S3_SECRET_KEYsecret so it can start writing before anyone drops the lesson-videos bind.uploads-svcneeds read onebooks(runbook Phase 0 step 2), and #522 needsblogs-svcwrite onblogsconfirmed. Buckets existing ≠ keys authorized; a 403 is as silent as a missing bucket was./lesson-videosand/geography-mediaURLs are root-relative and resolve against nginx, not the API, so the media routes are unreachable from the SPA regardless of what's in the buckets.Net: the storage half is now real (buckets populated) and one code blocker is fixed. The remaining blockers are secret/policy provisioning (#581, key policies) and the URL-origin bug (#599) — none in the #528 middleware itself, which our integration suite (PR #294) proves correct.
Epic #413 accuracy pass (2026-07-18) — closing: all three named blockers from the 2026-07-14 findings are resolved; the sole remaining action is #529 phase 2 itself.
Where each blocker stands now (verified):
lesson-videos bucket empty because its writer never deployed→ #581 CLOSED (2026-07-15, backend #303 — key now fetched from OpenBao, deploy guard passes). The bucket being empty is correct: no lesson videos exist on disk either.Phase 0 backfill never run→ DONE 2026-07-14 (buckets created, ebooks 1.6 GiB/2301 obj ✓ matches disk, blogs ✓; documented in the update above).→ #599 CLOSED (2026-07-16)./lesson-videos+/geography-mediaunreachable from the SPAAlso landed since: the cutover-day landmine (
UploadOrchestratorrawFile.Existson/app/ebooks) is fixed —BookImageLocatorwith ebooks-bucket fallback is on backend master (#598, PR #297). TheS3MediaFallbackMiddlewareitself is merged, enabled in prod (Storage__UseS3=true+api-svcon the API stack), and pinned by the real-MinIO integration suite (PR #294), including the load-bearing disk-over-bucket dual-run precedence test.This ticket's remaining goal — the API loses its media binds — is by definition the #529 phase-2 bind drop (its four binds are in the batch table, row 1). One residual to carry there:
uploads-svcneeds read onebooksbefore the drop (forBookImageLocator), plus the geography-media prefix backfill check. Both added to #529.Also note: the doc-correctness item from the findings is finally fixed — infra PR #125 corrects
docs/minio-storage-migration.md+ theinfrastructure-realityrule mirrors.Residual tracked in: #529.