# AI E-Learning (`Src\AiLearning`)

**Portals:** Manage (library + board configuration + moderation) + Main user portal (reading, downloading,
posting) · **Routes:** `manage.ai-elearning.*`, `manage.settings.ai-elearning.*`,
`main.portal.ai-elearning.*` · **Nav:** the module appears in **three** places — Manage: Channel →
**Portal**, the *AI E-Learning* hub tab (`/manage/ai-elearning`, the CONTENT) **and** Setting → *AI
E-Learning* (`/manage/settings/ai-elearning`, the GATE); Main: the member sidebar under the **AI
Active** pillar, labelled **"Vibe Coding"** (`/ai/vibe-coding` — URI moved 2026-08-24, old
`/ai-elearning` 301s, route names unchanged; ⚠️ the signed/sandboxed routes — `sites/…`,
`unsubscribe`, `resubscribe` — deliberately STAY at `/ai-elearning/…`: their URLs live in
already-sent emails and minted site tokens, and a moved path invalidates the signatures),
where it is the sole entry under the **AI Second Active Income** section and is labelled **"Vibe
Coding"** since 2026-08-24. ⚠️ Only the customer-facing LABEL changed — routes, tables, permissions and
every Manage surface are still `ai-elearning` / *AI E-Learning*

## What it does
The membership-gated hub behind the AI programme. It is three shelves sold as one thing:

| Part | Members get | Admins get |
|---|---|---|
| **Resource Library** | Download the prompt packs, powerpoints and guides. Each card reveals an AI-written line about **what it teaches**, marks what they have already taken away, and a corner chat answers "which of these is the one" | Upload / edit / hide / delete resources (Manage → *Resources* tab, or the composer on the member page) |
| **Guidelines** | **Open** a handbook — an uploaded micro-site we host, or a link to a page we do not. Same cards, same gate; the control goes straight there rather than to a download | Upload a micro-site zip, or paste a url (Manage → *Resources* filtered by **Kind**, or the composer on the member page) |
| **Community** | Read every board's topics inline, ask questions, reply, attach a screenshot | Configure boards, and moderate (pin / unpin / remove) — Manage → *Boards* tab |

**Why Guidelines is its own shelf.** The library and Guidelines answer different questions — "what can I
take away and use" versus "how do I do this" — and mixed together a member scanning for a prompt pack
read past handbooks, and vice versa. The split is by the VERB: take away, or open.

It is two labelled GROUPS on one page for **members**, who are browsing, and a **Kind filter** for
admins, who are managing a list — a second paginated list in Manage would have to keep its own sort,
counts and page cursor in step with the first one, for an audience of three.

⚠️ They were two TABS until 2026-09-02. The split was right; hiding one behind a tab was not. The whole
shelf is a few dozen files, so the tab was never about volume — and a member who did not know Guidelines
existed had no way to find out it was there. Guidelines is listed FIRST, because it is the shelf that
explains how to use the other one.

Access is decided by **one settings row**: the admin picks which membership tiers unlock the hub on
Setting → AI E-Learning. A portal user without a qualifying tier gets an **upsell page**, not a 403 —
the lock is the sale. Every route that hands over something real (a file, a write) re-checks and **403s**.

### Why this is not part of Courses
[Courses / LMS](/docs/modules_handbook/manage/lms/readMe.md) also gates content by membership, so the
obvious question is why there are two mechanisms. They gate **different things in different directions**:

- **Courses gate per COURSE.** Visibility is a property of each course (`Free` / `Members only` /
  `Internal` / `Members or buy` / `Buy only`), a course can be **bought** without any membership, and the
  qualifying tiers live in a pivot table per course. The unit of access is one course.
- **AI E-Learning gates the WHOLE HUB, once.** There is no per-resource and no per-board visibility, and
  nothing here is purchasable. One settings row decides who is inside; everything inside is then open.

Folding this into Courses would mean either giving every resource and every board a five-mode visibility
they do not have, or giving Courses a hub-wide override that contradicts its per-course model. The two
also differ in what they *are*: a course is a structured curriculum you progress through, this is a
reference shelf plus a conversation. **They are separate on purpose, and neither should grow the other's
gate.**

## How it works

### The gate — `AiLearningAccess`, and why it fails closed

> **2026-09-09 — the tiers now come from the Function access matrix**, not a settings row. `AiLearningAccess` is a VIEW over
> `feature_gates` (Manage → Sales → Memberships → Function access): Vibe Coding, Investment Prompt and Grant Application each
> have their own row there, beside every other gated portal function, and `allows()` / `membershipNames()` take the feature
> key (default `Feature::AI_ELEARNING`). What this class still owns is the staff bypass on the `view-ai-learning`
> PERMISSION. `ai_learning.membership_ids` was carried over by migration `2026_09_09_100002` and is no longer read; Setting →
> AI E-Learning keeps only the reply-email switch. Full write-up:
> [Memberships handbook → Function access](/docs/modules_handbook/manage/membership/memberships/readMe.md). The paragraphs
> below describe the fail-closed shape, which is unchanged.

[`Src\AiLearning\Support\AiLearningAccess::allows(?User)`](/src/AiLearning/Support/AiLearningAccess.php)
is the **entire** access decision. There is no policy, no middleware and no query scope in this module —
every entry point calls it directly. Its order is exhaustive and deliberate:

1. no user → **denied**
2. holds `VIEW_AI_LEARNING` → **allowed** (staff bypass; staff hold no AI membership in practice, and
   "admins are leads too" must not be what lets them in)
3. an **active** subscription to a configured tier → **allowed**
4. anything else → **denied — including "nothing is configured"**

**Step 2 is a PERMISSION, not the admin role**, and so are the two staff branches beside it —
`mayRemove()` (deleting another member's post) and the `post_access` / `allow_replies` overrides in
`StoreTopicRequest` / `StoreReplyRequest`, which ask `MANAGE_AI_LEARNING`. They were role checks
(`isAdmin()`) until 2026-08-03. An admin whose AI E-Learning permissions had been revoked was refused at
`/manage/ai-elearning` by the route middleware and then handed the entire member community, every paid
download and every moderation delete on the portal — one capability, two answers, depending on which
surface you asked from. Super-admin is unaffected either way: `Gate::before` grants it every ability.

**Step 4 is the feature. Do not compress these branches into a one-liner.** The LMS gate once shipped as
`empty($required) || array_intersect(...)`, which returns TRUE for anyone holding any membership the
moment the required list is empty — and here that list is a single settings row an admin can clear with
one click. The same shape would open a paid programme to every member on the platform, silently, with
nothing in the log. `membershipIds()` likewise returns `[]` for every kind of unusable value (unset,
invalid JSON, a JSON object rather than a list) and never throws, because this runs on a portal page: a
hand-edited row must **deny**, not 500. [`AiLearningAccessTest`](/tests/Feature/AiLearning/AiLearningAccessTest.php)
exists so it can never stop failing closed.

### ⚠️ The gate now covers the WHOLE AI Coach hub (2026-09-02)

`AiLearningAccess` is no longer this module's gate — it is the AI membership's. **Investment Prompt**
and **Grant Application** call it too, after both turned out to be reachable by any signed-in lead
while spending the COMPANY's AI key: a free name on the list could take twenty analyst-grade briefs
and ten grant applications a day off our bill, with the per-user daily caps as the only guard. One
settings row (Setting → AI E-Learning) decides all of it.

The class was **not renamed**. Renaming it means renaming the settings key and a seeded permission —
a data migration for a label — and one gate with an old name beats two gates that drift. Its own
docblock carries the warning.

`AiLearningAccess::membershipNames()` moved onto the gate for the same reason: three controllers need
the tier names now, and a second copy is how one of them ends up naming a retired tier. It filters to
ACTIVE memberships, so it can be empty while `membershipIds()` is not — every locked page falls back
to a generic line rather than inventing a name.

**Where each surface asks it:**

| Surface | Asked in | Denied answer |
|---|---|---|
| Vibe Coding page / topic | `AiLearningController` | the upsell |
| Vibe Coding downloads, previews, every write | `AiLearningController` + the Form Requests | **403** |
| Investment Prompt page | `InvestmentPromptController::index` | the upsell |
| Investment Prompt `run` / `chat` (SSE) | `RunRequest` / `ChatRequest` `authorize()` | **403** |
| Grant page | `GrantApplicationController::index` | the upsell |
| Grant `generate` / `update` / `ask` | the three Form Requests' `authorize()` | **403** |
| Grant `sectionB` / `deck` / `destroy` | `abort_unless` in the controller (no Form Request) | **403** |

**READ degrades to an upsell, WRITE refuses.** A member who lands on a locked page should want the
membership; a caller who POSTs by hand should be refused before validation runs — which on these
endpoints is also before anything reaches a provider.

**The locked pages share [`Components/Portal/AiCoachLocked.vue`](/resources/js/Components/Portal/AiCoachLocked.vue)**:
the same navy band the unlocked tab wears, then a **preview** of the tab's own interface held `inert`
and faded out under the unlock card, then what the membership hands over. A wall that only says
"members only" asks somebody to buy a thing they have never seen.

⚠️ **What goes in a preview is a LEAK decision, and it belongs to the page.** The rule all three
follow: show the SHAPE, never the contents. Investment Prompt shows its six asset classes and a play
COUNT — catalogue structure, which is marketing — and ghost bars where the play titles would be. Vibe
Coding shows the two group headings over ghost cards and **no titles and no counts at all**, because
both are facts about the paid library. Do not pass real records through to make a mock look better.

### Staff can look at the upsell (`?preview=locked`)

`AiLearningAccess` lets staff through on the `VIEW_AI_LEARNING` **permission** — right, because staff
hold no AI membership and "admins are leads too" must not be what grants access. The side effect is
that **the people who own the upsell can never see it**: every admin opening an AI Coach tab lands on
the unlocked page, so the one screen whose whole job is to sell the membership is the one screen the
team never looks at.

[`PreviewsAiCoachLock`](/app/Http/Controllers/Concerns/PreviewsAiCoachLock.php) is the way in. All
three controllers ask `showsAiCoachLock($request)` instead of the gate directly on their INDEX
action; it is `allows()` **or** `?preview=locked` from a viewer holding the permission. The locked
page also receives `preview: true` so it can say which of the two it is — a member shown "you are
previewing" would reasonably read the lock as fake.

⚠️ **It is a RENDERING switch and only that.** No write path calls the trait: downloads, the SSE
endpoints and every Form Request keep asking `AiLearningAccess::allows()`, so an admin "previewing"
still has full access to everything behind it. The banner says so out loud. The alternative — a flag
that genuinely drops a person's permissions for the length of a request — is a far bigger thing to
get wrong, and staff are not the audience the gate protects. The param is not a hole either: a member
who types it sees what they already saw.

The bar itself is [`Components/Portal/AiCoachStaffPreview.vue`](/resources/js/Components/Portal/AiCoachStaffPreview.vue),
mounted under `AiCoachTabs` on the three unlocked pages and inside `AiCoachLocked` for the other
half. **One component holds both states** — the invitation and the "you are previewing, here is the
way back" — because they are two halves of one switch, and splitting them is how the copy on the way
in stops matching the way out. It builds its hrefs off the CURRENT url, so `?run=` and `?tab=`
survive the round trip.

### Resources
- Manage `index` is the shared §14 DataTable; create / edit happen in a **modal** on the page, so there
  are no create / edit routes.
- The file is not a column — it becomes a `media` row in the `ai_resource` collection via
  [MediaService](/docs/modules_handbook/shared/media/readMe.md).
- **Upload order is deliberate and is NOT the Profile-avatar order.** `replaceFile()` stores the NEW file
  first and only discards the previous one once the new one is safely down. The avatar flow deletes
  first; there, a failure costs a thumbnail, here it would 404 a paid download a member could open a
  minute ago.
- A resource whose upload failed still exists as a row (the amber **No file** marker tells the admin to
  fix it). The **portal list requires the file**, not merely ACTIVE status, so a customer is never shown
  a Download button that 404s.
- The download URL is minted **per request** and never travels in page props — shipping it with the
  library would give every viewer a working link regardless of the gate, and it would outlive the
  membership that earned it.

### A resource is a FILE, a MICRO-SITE or a LINK (`kind`)
`kind` is how a resource is **delivered**, and since 2026-09-02 it is also **the only thing that decides
which group a member finds it in** — see the note on the retired `category` column below:

| `kind` | What it is | What the card offers | Shelf |
|---|---|---|---|
| `KIND_FILE` (1) | one file, any type | **Download** | Resource Library |
| `KIND_SITE` (2) | a zip of `index.html` + its CSS / JS / images | **Open** (new tab) | Guidelines |
| `KIND_LINK` (3) | a `url` to a page we do not host | **Open** (new tab) | Guidelines |

A site's archive is validated, then extracted to `ai-sites/{resource-uuid}/{token}/` on the media disk
and that directory is stored in `site_path`. **The zip stays attached as the resource's media**, so it
remains downloadable and re-extractable — and so the extraction job can read it after the request that
uploaded it is over. Replacing it extracts to a FRESH directory and removes the old one only once the
new one is down — the same store-before-delete ordering as the file replace, because extracting over a
live directory leaves members reading a half-updated site.

**A link stores nothing.** No media row, no directory — just `url`. Two consequences that are not
obvious:

- The member list requires a resource to have a stored FILE, which is what stops an upload that failed
  from offering a Download button that 404s. A link has no file, so that guard alone hid every link
  ever filed while Manage looked perfectly fine. The query asks for a file **or** a `KIND_LINK` with a
  url: one rule, two columns.
- **`https` only, and that is security rather than tidiness.** A micro-site is sandboxed into an opaque
  origin; a link is simply handed to the browser, so a `javascript:` scheme would execute in OUR origin
  and `http://` would downgrade a member silently. The anchor also carries `rel="noopener noreferrer"`,
  or the page it opens keeps a handle that can navigate the tab the member left behind.

**The url is written on EVERY save, null for every kind that is not a link.** A resource that stops
being a link must lose its address — otherwise the row holds two answers to "where is this", and the
old one comes back the moment the kind is flipped again. Same shape, same reason, as the `site_path`
guard beside it.

### The card is the whole interaction (2026-09-02)

There is **no detail modal**. It existed because the card could not show a long description or the
whole summary — so a "View more" button opened a dialog whose contents were the card plus a Download.
The card does all three itself now:

- **A LEFT RAIL plus a text column**, not a 16:10 band stacked on top of the text. The band gave a
  picture of a .zip more vertical room than the title and description together, on a shelf where the
  title is what a member is scanning. The grid drops to **two columns** to pay for it — at three, the
  text column is about 170px, which is four words a line.
- **The DOWNLOAD is on the card** (Guidelines get **Open**), and **Edit / Delete came out with it** as
  quiet icon buttons for anyone holding `manage-ai-learning`. Drawing them is UX; both routes ask again.
- **The description expands in place.** `ExpandableText` MEASURES (`scrollHeight > clientHeight`) rather
  than guessing from a character count — the card's width moves with the sidebar and the breakpoint, so
  a count is wrong in both directions. It can only measure while CLAMPED, so the flag is taken on mount
  and on resize and a resize arriving while the text is open leaves it alone.
- **An opened description suppresses that card's summary glass.** Without it, a member who expanded the
  text would have a frosted panel slide over it the moment the pointer moved.
- Cards are tinted by **FILE TYPE** — PDF red, archives amber, decks orange, sheets green — the
  conventions people already carry from every file manager. Shelf colour would repeat what the group
  heading directly above already says, in a wall of one colour.
- ⚠️ **An `<iframe>` fires `load` on an error page too.** A PDF whose media row is broken answered with
  OUR 404 page and the browser rendered it happily, so a card showed a tidy "404 · Not Found" where the
  first page should be — on screen for days looking like a design. Same-origin makes the check cheap:
  `contentDocument.contentType !== 'application/pdf'` means we were served the wrong thing, and the card
  falls back to the drawn sheet.

### ⚠️ `category` was RETIRED (2026-09-02)

`category` (Prompts / Powerpoint / Guides) is gone from every surface. It was a **second answer to a
question `kind` already answered**: once the member page grouped itself by shelf, a card could sit in the
"Guidelines" group wearing a "Guides" badge — or worse a "Prompts" one — with nothing saying which of the
two counted. One of its three values was even a near-homonym of a shelf, which is how the overlap got
noticed.

What that means in practice:

- **The COLUMN is still there and still holds every old value.** Nothing reads or writes it: it is out of
  `$fillable`, out of `$casts`, out of `AiLearningResourceRepository`'s two `data_only` allowlists, out of
  the Manage Form Requests, and off every screen (no filter chips on the portal, no Category column,
  filter or select in Manage). It was kept rather than dropped because the drop buys nothing — no query
  touches it, it costs one small int per row — and it is the only way back.
- ⚠️ **Its CREATE migration now uses a LITERAL default.** `->default(AiLearningResource::CATEGORY_PROMPTS)`
  became `->default(1)`: a migration is replayed years later against whatever the model looks like THEN,
  and a deleted constant only ever fatals on a FRESH database (GUIDELINES §7,
  `scripts/check-migration-constants.php`).
- **The Manage list sorts and filters by Kind instead**, and its column is headed *Shelf* — the same
  grouping a member sees, so the two surfaces describe a resource the same way.
- **Cards are coloured by FILE TYPE**, not by shelf and not by category — PDF red, archives amber, decks
  orange, sheets green, the conventions people already carry from every file manager. Shelf colour would
  repeat what the group heading directly above already says, in a wall of one colour.

### Extraction is QUEUED (`site_queued_at`)
Unpacking runs in [`ExtractResourceSite`](/app/Jobs/AiLearning/ExtractResourceSite.php) on the
**`redis-video`** lane, not in the upload request. It used to be inline: unzip, then write every entry
to the media disk one at a time. On production that disk is GCS, so a site of a few dozen files is a
few dozen sequential round trips out to Google before the admin gets a response — a gateway timeout for
anything sizeable, and a timeout **mid-write**, leaving a half-extracted tree nothing knows is
half-extracted. It rides the video lane because the shape of the work is identical (one big slow file
job, 1800s timeout, one process) and a second single-process supervisor would idle most of the week.

`site_path` alone could not describe the new middle state, so there are three:

| `site_path` | `site_queued_at` | Means |
|---|---|---|
| set | null | openable |
| null | set | still unpacking — the card says so |
| null | null | nothing there; the upload failed |

**Every exit path of the job clears `site_queued_at`**, `failed()` included — that one exists precisely
for the timeout/dead-worker case where `handle()` never returns. Miss one and a card reads as
"Unpacking" forever, which looks like a stuck queue rather than a job that already died.

Two traps, both found by tests rather than by reading:

- **Look the archive up with a FRESH query, never `resourceMedia()`.** That helper reads the loaded
  `media` relation, which by then still holds the row `replaceResourceFile` just hard-deleted. The job
  found nothing, and a REPLACEMENT settled the resource with a null path — replacing a micro-site
  destroyed it.
- **Mark the resource queued BEFORE dispatching.** A sync connection runs the job inside `dispatch()`,
  so marking afterwards writes "queued" over a job that already finished and cleared it.

The summary dispatch moved here too: `SummariseResource` reads a site's own front page and does not
wait, so fired at upload time it would find no text, write the "nothing readable here" line about a
perfectly good site, and never revisit it — a written summary is what its own skip-guard looks for.

**Two security properties carry this feature. Neither is polish, and both have tests that exist only to
notice if they are removed** ([`SiteResourceTest`](/tests/Feature/AiLearning/SiteResourceTest.php)).

**1. Zip slip — [`SiteArchive::safeEntries()`](/src/AiLearning/Support/SiteArchive.php).** A zip is a list
of attacker-controlled names, and PHP's `ZipArchive` preserves them verbatim (verified: it stores
`../../evil.txt`, `/etc/passwd` and `C:\windows\evil.txt` exactly as given). Every entry is checked
**before a single byte is written**: absolute paths, drive letters and any `..` segment are refused,
splitting on **both** `/` and `\` — a zip written on Windows uses backslashes, and treating one as an
ordinary filename character is precisely how `a\..\..\.env` gets through. An archive with no
`index.html` is refused too (nothing to open), as is one declaring more than 2 GB unpacked. It runs as a
**validation rule**, so a bad zip is a field error the admin can fix rather than a half-extracted site.
**The same normaliser answers the `{path}` segment of the serving URL** — traversal on READ is the
identical attack from the other end, and that one reaches `.env` rather than merely writing it.

`safeEntries()` also **refuses scripts and server-configuration files outright** — `.php`, `.phtml`,
`.phar`, `.htaccess`, `web.config`, `.sh`, `.aspx` and friends. Nothing here ever executes an extracted
file, so this reads like belt-and-braces until you notice that **`MEDIA_DISK=public` is a supported
value and the local default**: it puts the tree under `storage/app/public`, which `storage:link`
publishes as static files served by the **web server** — outside the token gate and outside the sandbox
CSP. The random directory segment is obscurity, not a control. The whole archive is rejected rather than
the entry silently skipped, so an admin learns their zip is unacceptable instead of discovering later
that a file they packed is missing.

**2. Session isolation — `Content-Security-Policy: sandbox allow-scripts allow-forms`.** These files are
**admin-uploaded HTML and JavaScript served from our own origin**. Without the sandbox, a script inside
an uploaded page runs *as petav3*: it can read a member's session cookie, call our authenticated
endpoints as them, and post the result anywhere. The header makes the browser give the page a **unique
opaque origin** — it still renders and runs, but `document.cookie` and `localStorage` throw
`SecurityError` (verified in a real browser against the 172 MB sample). It is the cheap equivalent of a
separate domain and needs no DNS work.

> **Never "fix" a site by adding `allow-same-origin`.** That single flag undoes the entire property and
> nothing visible changes, which is why it would survive review. If sites ever genuinely need cookies or
> cross-frame messaging, the upgrade path is a **separate subdomain**, not a weaker sandbox.

**Why the site route sits OUTSIDE the portal's `auth` group.** It looks like a hole and is the opposite.
A sandboxed page has an opaque origin, so the browser sends **no `SameSite=Lax` cookie** with the
requests that page makes. Measured: `index.html` (a top-level navigation) rendered, and then every
`styles.css` and `images/*.png` request arrived with no session and was redirected to `/login` — the
member read unstyled HTML. A sandboxed page and cookie-authenticated assets cannot both work. So
identity travels in the **path**, above the site's own files, as a short-lived signed token
([`SiteToken`](/src/AiLearning/Support/SiteToken.php)):

```
/ai-elearning/sites/{uuid}/{token}/index.html
/ai-elearning/sites/{uuid}/{token}/images/hero.png   ← relative URL, inherits the token
```

Every response also carries **`Referrer-Policy: no-referrer`** — the token is in the path, so a `Referer`
header would hand the whole capability to any third party the page contacts, and an uploaded page can
widen the browser's own default with `<meta name="referrer" content="unsafe-url">`.

The token **names a member; it does not grant anything by itself**. `AiLearningAccess::allows()` is
asked about that member on every request, so a lapsed membership stops serving files even with an
unexpired token. It is HMAC-signed with the app key (editing the user id in the URL fails), scoped to
one resource, and expires after `SiteToken::TTL_MINUTES` (4 h). Authorization did not move out of the
route — it moved **into the controller**, where it is asked out loud, exactly as everything else in this
module is.

Deleting a resource removes its **whole** `ai-sites/{uuid}/` folder alongside its media. An orphan here
is not a stray thumbnail: the sample archive unpacks to **172 MB**, and once the row naming the
directory is gone nothing in the system knows it exists.

### Community
- A **board** is a section with two independent rules: `post_access` (who may START a topic) and
  `allow_replies` (whether anyone may reply). An "Announcements" board is simply
  `POST_ADMINS_ONLY` + replies off — there is no announcement type and no special-casing.
- The portal feed renders **every active board as a section with its topics listed inline**. That is the
  design, not a detail: you see what is happening without clicking into folders. A board rendered as a
  link to a list would be the wrong build.
- **Expansion and deep-linking are one mechanism.** Opening a topic issues
  `router.get('/ai/vibe-coding/community/topics/{uuid}', { preserveState, preserveScroll, only: ['activeTopic'] })`;
  landing on that URL cold renders the feed with the same row already open (the page forces the Community
  tab when `activeTopic` is set). Replies are therefore fetched for the thread being read, not shipped
  for every thread on the page.
- Topic order within a board is `is_pinned DESC`, then `last_activity_at DESC`. `ReplyRepository::create`
  bumps `last_activity_at` **inside the same transaction** — a reply that commits while the bump does not
  leaves a just-answered thread at the bottom of its board, a bug that only appears under concurrency.
- **Admins author from the PORTAL and moderate from MANAGE.** There is deliberately no compose box on the
  Manage page: a second authoring surface would need its own rules and preview, and the two would drift.

### Authorization is server-side, always
Hiding a button is UX. The rule is the Form Request's `authorize()`:

| Route | Rule |
|---|---|
| `POST .../boards/{id}/topics` | gate **AND** (`post_access === POST_EVERYONE` **OR** admin) — [`StoreTopicRequest`](/app/Http/Requests/Main/AiLearning/StoreTopicRequest.php) |
| `POST .../topics/{id}/replies` | gate **AND** (`allow_replies` **OR** admin) — [`StoreReplyRequest`](/app/Http/Requests/Main/AiLearning/StoreReplyRequest.php) |
| both destroys | gate **AND** (author **OR** admin) — a member removing someone else's post is a **403** |

A missing or **inactive** board / topic is resolved with `firstOrFail()` **before** the access question is
asked, so it 404s for everyone: answering 403 would confirm the uuid exists.

Two invariants on the destroys that look like oversights and are not:

- **They are NOT constrained to an active board.** The read paths hide an inactive board deliberately;
  an author's right to remove their own words is not a function of whether an admin has since hidden the
  section. Copying the read constraint here 404s the delete on a page the member still has open.
- **`destroyTopic` returns `redirect()->route(...)`, not `back()`.** The delete button only renders on an
  EXPANDED topic, and expanding puts that topic's own URL in the address bar — so `back()` returns the
  member to the thread they just deleted, which now 404s inside an Inertia invalid-response modal.
  `destroyReply` keeps `back()`, because there the parent page survives. Asserting the 302 does not catch
  this; the test follows the redirect and asserts the landing page renders.

**`is_pinned` must never be mappable from portal input.** It sits in `TopicRepository`'s `data_only`
allowlist because the Manage side needs to create a pinned announcement in one write, so nothing *below*
the controller would stop a member who simply sends the field — only the controller's explicit
field-by-field mapping does. A member who could set it would park their own question above every
announcement on the board. [`BoardPostingRulesTest`](/tests/Feature/AiLearning/BoardPostingRulesTest.php)
hits every write route directly with the UI bypassed, including that case.

### Member-authored text is never HTML
This is the **first place in the codebase where MEMBERS submit stored content**. `body` is plain text and
stays plain text: [`Components/PostBody.vue`](/resources/js/Components/PostBody.vue) splits it into
segments and lets Vue emit text nodes and anchors, so the body never becomes markup at any point and
there is no sanitiser to get wrong. **There is no `v-html` anywhere in this feature.** The lesson
`RichTextEditor` is deliberately not reused — lessons are *admin*-authored, and that asymmetry is exactly
why one may ship HTML and this may not. URL auto-linking is http/https only (no `javascript:`, no
`data:`, no scheme-less promotion) and links carry `rel="noopener noreferrer nofollow ugc"`.

### A byline is never an email address
`PresentsCommunityThread::communityAuthorName()` deliberately does **not** use GUIDELINES §4.8's
`$user->profile->full_name ?: $user->email` fallback. That rule is written for staff screens, where an
admin seeing a colleague's email on a record they already own is a convenience. This byline is read by
every other member of the programme, so the same fallback would publish one member's email address to
all of them the moment their profile row was missing or `full_name` blank — and `user_profiles.full_name`
is nullable, so blank is a state the schema permits. The fallbacks here are placeholders only:
`Former member` for a purged account, `Member` for a present one with no usable name.

### Deleting a post: row first, then the files
Every delete in this module — portal topic, portal reply, Manage topic, Manage reply, Manage board,
Manage resource — runs its repository delete **before** `deleteCommunityImages()` /
`deleteResourceFiles()`, and each one reads a relation eager-loaded before the delete so the cleanup can
still name what it is removing. `MediaService::delete()` takes the bucket object with the row and neither
comes back, while the repository delete is a transaction that can roll back — so files-first had a losing
outcome (screenshots gone, thread still on the feed) and no winning one, because the rows are only
**soft**-deleted and the media relation stays resolvable however long the cleanup waits. The board
destroy is the sharpest case: its cascade is one transaction over every topic and reply, which on a busy
board is exactly the transaction that loses a lock race. Same rule the resource replace already states in
`StoresAiLearningResource` — store the new thing before discarding the old one.

### What a card knows about itself — the summary, and why it is a column

Each resource carries a stored **`summary`**: the "what this teaches" line the card reveals on hover,
tap or keyboard focus. Three facts about it are load-bearing.

**It is written ONCE, by a queued job, and never on view.** The library is a page members leave open on
a second monitor. Composing this on hover would bill one provider request per mouse movement across a
shelf of forty cards, on a page that earns nothing while it sits there. `SummariseResource` is
dispatched from the upload path — both surfaces, via `queueResourceSummary()` on
[`StoresAiLearningResource`](/app/Http/Controllers/Concerns/StoresAiLearningResource.php) — and the
stored column means the running cost after upload is **zero**.

**Regeneration is deliberately narrow.** A changed title or description clears the summary and requeues;
re-ordering the shelf or hiding a resource does not, because neither changes anything the summary said.
The clear is what makes the job's "already written, skip" guard correct under retry: `AiJob` re-enters
`run()` from the top on every attempt, so without it a flaky provider would bill a line per attempt.

**The dispatch is caught.** `AiJob` pins itself to the `redis-ai` connection, so an unreachable Redis
throws at the point of dispatch — and an admin who has just uploaded a 140 MB micro-site would get a 500
for a decorative line, with the file already stored. A missing summary is a state the card renders (no
panel); a lost upload is not.

**What the model is shown** is [`ResourceText`](/src/AiLearning/Support/ResourceText.php), which reads the
resource's own text: **PDF** (`smalot/pdfparser`, the one added dependency), **PPTX** and **DOCX** (zips
of XML — `ZipArchive` was already here for micro-site archives, so they cost nothing), plain text, and a
micro-site's front page. It lives apart from the job because the two change for different reasons: the
job is queueing, retries and cost; this is file formats. Three things in it are not obvious:

- **Slides are natural-sorted.** `slide10.xml` sorts second under a plain string sort, handing the model
  a deck ordered 1, 10, 2 — and truncating the real ending off the character cap.
- **Text is capped at 8,000 characters.** A 200-page guide would otherwise spend real money producing
  forty-five words, once per upload.
- **An unreadable file says so out loud in the prompt.** A model handed a silent gap writes as though it
  had read the document; told plainly it has not, it writes from the metadata. This is what makes the
  prompt's "invent nothing" rule bite on a scanned PDF.

### Ask the library

`POST /ai/vibe-coding/ask` ([`AiLearningAskController`](/app/Http/Controllers/Main/Portal/AiLearningAskController.php))
answers a member's natural-language question with two or three resources to open. The panel is
[`Partials/AskLibrary.vue`](/resources/js/Pages/Main/Portal/AiLearning/Partials/AskLibrary.vue), mounted
at PAGE level rather than inside the Library tab — a member reading the Community is just as likely to be
hunting for a file, and a launcher that vanished on tab switch reads as broken.

**It runs on the COMPANY key**, against the AI handbook's usual rule of thumb, and deliberately: the
membership already bought the library, so charging credits to ask where a file is would be a second toll.
That makes **`throttle:10,1` the entire budget** — it is on the route, not in a config, and it has its own
test. Widening it is a decision about who pays.

Two guards exist because a prompt is not a control:

- **Every returned id is checked against the list we sent.** The prompt forbids inventing resources and a
  good model obeys — but a hallucinated uuid that happened to be real would surface a record this member
  was never allowed to see.
- **An empty library is answered with no provider call at all.** Not an optimisation: a model handed an
  empty catalogue invents one.

The model sees only what the library itself renders — ACTIVE, and with a file. A resource whose upload
failed is hidden from the page so nobody is offered a download that 404s; an *answer* recommending one is
the same fault with better manners. Named resources render as **links back to the library**, never as
download URLs: opening one still goes through the route that re-checks the gate and mints a fresh signed
link.

**Provider and model are pinned by migration**, not left to an admin (`2026_08_07_000002`). Unpinned,
`AiClient` falls back to `config('ai.default_provider')` — Anthropic — and a deployment without that key
fails every summary permanently, silently, with an unsummarised card looking exactly like one whose queue
has not run. The pins stay editable on Manage → AI Prompts.

### The member page's design — one band, shared with Investment Prompt (2026-09-02)

The hub opens with the **navy `PortalHero` band**, the same masthead
[Investment Prompt](/docs/modules_handbook/main/investment-prompt/readMe.md) wears. It is shared code,
not a copy: the two are tabs of ONE hub (`AiCoachTabs`), the glow radii / grid pitch / shadow ARE the
identity a member reads as "same product", and there is no way to notice a copied band has drifted
except by flipping between the two tabs. `PortalHero` is the shell, `HeroStats` + `HeroStat` the
counted-up figures, `HeroToggle` the glass segmented control.

Three things about it are decisions rather than styling:

- **The sub-tabs live INSIDE the band.** They were a second UNDERLINE strip sitting directly under
  `AiCoachTabs` — two identical controls, with nothing saying which was the parent. GUIDELINES §15's
  grammar is underline mains + something quieter below, and sitting the sub-level in the band settles it
  by position as well as by style. `ShowTabs` stays in the tree with the **`hideStrip`** prop: it still
  owns lazy-mounting only the active tab and the `?tab=` URL sync, which is exactly what a `v-if` chain
  would have thrown away (the community feed mounting behind the library and firing its reply requests is
  the failure that prop's test exists to catch).
- **The middle figure is the member's own progress** — `你已取用 N / M`, read from the same `opened` flag
  the card badges use, so it costs no query. It is the reason the rail is worth having at all: the other
  two numbers describe the shelf, this one describes the reader. Staff correctly read 0, because
  `trail()` files them no rows.
- **A figure with nothing to count is LEFT OUT, not shown as zero** (`社群讨论` on a hub whose community
  has not opened). Zero beside two real numbers reads as a broken counter. The TOGGLE is the opposite —
  a tab always shows its count, including 0, because there "none" is the answer and a missing number is
  the bug. **The Locked page shows no figures at all**: a count is a fact about the paid library.

**The Learning Hub strip item was removed** in the same pass. It was a `tabs-end` link to
`/property/academy` — which is its own sidebar entry — so it was a second door to a place already one
click away, occupying the one slot in the strip that could be mistaken for a panel. Session videos still
live in Courses; nothing about that changed.

Smaller pieces of the same pass, each fixing something rather than restyling it:

- **Resource cards gained the play-card physics** (hover hairline, lift, brand shadow) — and the hover
  properties had to be named in `.ai-learning-grid > li`'s `transition` in `app.css`. That rule is
  UNLAYERED while Tailwind's utilities are layered, so it does not add to the card's `transition`
  utility, it REPLACES it: the card's existing `hover:border` / `hover:shadow` had been snapping rather
  than easing, on a rule written for the Ask panel's spotlight. **Anything a card animates on hover must
  be listed there.** The cards deliberately have **no entry stagger**, unlike the play grid:
  `.ai-learning-found` owns that element's `animation`, so a one-shot fade-up would replay the moment the
  spotlight released a card.
- **A pinned topic is a RAIL, not an amber wash.** The wash was a second `bg-` utility on a row that
  already sets one for open/hover — resolved by stylesheet order rather than intent, so a pinned row that
  was open could render either colour.
- **A board states its rules as an eyebrow**, always, instead of as a fallback line shown only when the
  board had no description — so the two boards most worth labelling said nothing about how they work.

### The opened-before marker

The badge on a card, and the "Not opened yet" filter (the page's only filter, since the category chips
went with the column), have **no table of their
own**: they are the activity trail read back. A download and a micro-site open are already the two rows
that mean "they have had this", so a second store would only be a thing to keep in step. Staff see
nothing marked, correctly — `trail()` files no row for them, so their auto-created lead has none to read.

### The activity trail — and why its dashboard source is the odd one out
Four actions reach the lead's trail via [`ActivityLogger`](/docs/modules_handbook/shared/activity-log/readMe.md):
a resource **downloaded**, a micro-site **opened** (both coalesced per resource — the same file twice is
one intent), and a community **topic** or **reply** posted (never coalesced — folding them would hide the
member who asked five things this morning). Browsing the library is deliberately **not** logged: one row
per hub render buries the four that matter, and an unreadable trail is the same as no trail.

Three things here are not obvious, and each one fails quietly:

- **The aggregate page (`Manage\Portal\ActivityController`) unions FEATURE TABLES, not the log.** Writing
  rows is therefore *not* enough to appear on it. AI E-Learning's sixth source reads **`activity_logs`
  itself**, narrowed to its four types — and it has to, because this module has no artefact table that
  fits: posts key on `user_id` not `lead_id` (admins post too), and a download leaves no row anywhere
  else at all. `TYPES` **and** `sources()` are separate lists that must both learn the new key; miss the
  first and the source is never unioned, miss the second and the page throws. Do **not** copy the
  neighbouring `whereNull('deleted_at')` onto it — `activity_logs` has no soft delete.
- **The micro-site route serves every asset**, so the log call is guarded on the requested path being the
  index. Without that it writes a row per stylesheet — and since the type coalesces, those rows fold away
  silently instead of showing up as a bug.
- **Staff are excluded explicitly** (`AiLearningController::trail()`), not by accident. `EnsureUserIsMainUser`
  gives every portal visitor a lead, admins included, and admins **author from the portal by design** in
  this module — so without the guard each announcement would read as customer engagement and inflate the
  tile sales uses to decide who is using their membership.

- **A tokenised site URL is a capability, not an identity**, so a site open is logged only when the
  SESSION identifies the reader. `SiteToken::viewer()` resolves the user a token was *minted for*, not
  whoever is holding it — and that URL is copy-pasteable for four hours by design. Attributing
  sessionless reads would credit one member with a whole group chat's reading, on the tile sales uses to
  judge membership usage. A sessionless read is anonymous to us and is recorded as nothing rather than as
  somebody.

Proved by [`ActivityTrailTest`](/tests/Feature/AiLearning/ActivityTrailTest.php), which asserts the
silences as hard as the rows.

> **⚠️ Before anyone builds a PER-LEAD purge** (today `LeadRepository::purgeAll()` is the only one, and
> it takes every lead, so this cannot bite yet): replying freezes the *asker's* topic title into the
> *replier's* `activity_logs` row, so erasing one member leaves their question verbatim on every lead
> that answered it. `activity_logs` is registered `PURGE_DELETE_BY_LEAD`, which by definition cannot
> reach a row on somebody else's lead. A single-lead purge needs a leg that scrubs
> `activity_logs.title` where `subject_type = Reply::class` and `subject_id` is in the deleted-reply set
> — keep the row (the replier really did answer something; that is their engagement record) and drop
> only the frozen text.

### Privacy purge
Both post tables are registered in [`IdentityChildMap`](/src/Lead/Support/IdentityChildMap.php). A merge
**repoints** a member's own posts to their surviving identity; a **purge** takes a member's topic together
with its WHOLE thread (`PURGE_SPECIAL` in `LeadRepository::purgeRows`), because the body is free-text PII
and a reply whose thread is gone is unreachable debris. The purge also deletes post images through
`MediaService` — proved by `PurgeAllLeadsTest`, which attaches an `ai_post` image to a purged member's
topic *and* to their reply and asserts both the row and the stored object are gone.

> **Always read replies by walking board → topic → replies. Never query `ai_learning_replies` directly.**
> A purge leaves OTHER members' replies hanging under a deleted topic; those rows are unreachable through
> the parent path, which is what keeps them invisible. A direct query would surface orphans belonging to
> a conversation that no longer exists.

### The URL / namespace / permission spelling boundary
Three spellings, on purpose, and they are **not** interchangeable:

| Thing | Spelling | Why |
|---|---|---|
| PHP namespace | `Src\AiLearning` | a code identifier |
| Every URL and route name | **`ai-elearning`** | the public name of the product |
| Permissions | **`view-ai-learning` / `manage-ai-learning`** | already seeded |

The permissions keep the older spelling deliberately: renaming a seeded permission row is a **data
migration** that would strip access from whoever already holds the old name. A code identifier and a
public URL are different things — one boundary, not three drifting names.
`artisan route:list --name=ai-learning` must keep returning **nothing**.

## Related files

**Backend — Models**
- [src/AiLearning/Resource.php](/src/AiLearning/Resource.php) — a downloadable file (`CATEGORIES`, `STATUSES`, `COLLECTION_FILE`).
- [src/AiLearning/Board.php](/src/AiLearning/Board.php) — a community section (`POST_ACCESSES`, `STATUSES`, `allow_replies`).
- [src/AiLearning/Topic.php](/src/AiLearning/Topic.php) — a thread root (`COLLECTION_IMAGE = 'ai_post'`).
- [src/AiLearning/Reply.php](/src/AiLearning/Reply.php) — a flat reply (carries a `uuid`: its DELETE route exposes the identifier).

**Backend — Gate & Repositories**
- [src/AiLearning/Support/AiLearningAccess.php](/src/AiLearning/Support/AiLearningAccess.php) — **the** access decision; fails closed.
- [src/AiLearning/Support/SiteArchive.php](/src/AiLearning/Support/SiteArchive.php) — the **zip-slip guard**, extraction and removal. Pure; no HTTP.
- [src/AiLearning/Support/SiteToken.php](/src/AiLearning/Support/SiteToken.php) — the path-borne capability that lets a **sandboxed** page load its own assets.
- [src/AiLearning/Support/ResourceText.php](/src/AiLearning/Support/ResourceText.php) — PDF / PPTX / DOCX / text / micro-site extraction for the summary. Fails soft in every direction; capped at 8k.
- [app/Jobs/AiLearning/SummariseResource.php](/app/Jobs/AiLearning/SummariseResource.php) — the queued `AiJob` that writes `summary`, once, at upload.
- [src/AiLearning/Repositories/ResourceRepository.php](/src/AiLearning/Repositories/ResourceRepository.php)
- [src/AiLearning/Repositories/BoardRepository.php](/src/AiLearning/Repositories/BoardRepository.php) — `delete()` cascades board → topics → replies in one transaction.
- [src/AiLearning/Repositories/TopicRepository.php](/src/AiLearning/Repositories/TopicRepository.php) — cascades topic → replies; stamps `last_activity_at`.
- [src/AiLearning/Repositories/ReplyRepository.php](/src/AiLearning/Repositories/ReplyRepository.php) — bumps the topic's `last_activity_at` in the same transaction.

**Backend — Controllers**
- [app/Http/Controllers/Manage/AiLearning/ResourcesController.php](/app/Http/Controllers/Manage/AiLearning/ResourcesController.php) — the library; also assembles the Boards tab's props (it renders `Manage/AiLearning/Index`).
- [app/Http/Controllers/Manage/AiLearning/BoardsController.php](/app/Http/Controllers/Manage/AiLearning/BoardsController.php) — board CUD + moderation drill-in.
- [app/Http/Controllers/Manage/Settings/AiLearningController.php](/app/Http/Controllers/Manage/Settings/AiLearningController.php) — the Setting tab (which tiers unlock the hub).
- [app/Http/Controllers/Main/Portal/AiLearningController.php](/app/Http/Controllers/Main/Portal/AiLearningController.php) — the member hub, the feed, the deep-linked topic, the four writes and the download.
- [app/Http/Controllers/Main/Portal/AiLearningSiteController.php](/app/Http/Controllers/Main/Portal/AiLearningSiteController.php) — streams one file of a micro-site, **sandboxed**, with the path re-validated on read.
- [app/Jobs/AiLearning/ExtractResourceSite.php](/app/Jobs/AiLearning/ExtractResourceSite.php) — unpacks a micro-site archive on the `redis-video` lane, off the upload request. Clears `site_queued_at` on every exit path, `failed()` included.
- [app/Http/Controllers/Main/Portal/AiLearningAskController.php](/app/Http/Controllers/Main/Portal/AiLearningAskController.php) — ask-the-library; company key, throttled, every returned id re-checked against what was sent.
- [app/Http/Controllers/Concerns/StoresAiLearningResource.php](/app/Http/Controllers/Concerns/StoresAiLearningResource.php) — the one upload path both surfaces use (store-before-delete), and the delete cascade.
- [app/Http/Controllers/Concerns/PresentsCommunityThread.php](/app/Http/Controllers/Concerns/PresentsCommunityThread.php) — the one topic/reply shape both surfaces render, so a moderator sees what the member sees.

**Backend — Form Requests**
- [app/Http/Requests/Manage/AiLearning/Resources/](/app/Http/Requests/Manage/AiLearning/Resources/) — `QueryRequest`, `StoreRequest`, `UpdateRequest`.
- [app/Http/Requests/Manage/AiLearning/Boards/](/app/Http/Requests/Manage/AiLearning/Boards/) — `StoreRequest`, `UpdateRequest`.
- [app/Http/Requests/Main/AiLearning/StoreTopicRequest.php](/app/Http/Requests/Main/AiLearning/StoreTopicRequest.php) · [StoreReplyRequest.php](/app/Http/Requests/Main/AiLearning/StoreReplyRequest.php) — **the per-board rules**.

**Frontend — Manage**
- [Pages/Manage/AiLearning/Index.vue](/resources/js/Pages/Manage/AiLearning/Index.vue) — Resources + Boards tabs; the "New" button follows the active tab.
- [Pages/Manage/AiLearning/Board.vue](/resources/js/Pages/Manage/AiLearning/Board.vue) — the moderation drill-in (pin / remove), with a `PageHeader` back link.
- [Partials/Tabs/BoardsTab.vue](/resources/js/Pages/Manage/AiLearning/Partials/Tabs/BoardsTab.vue) · [BoardForm.vue](/resources/js/Pages/Manage/AiLearning/Partials/BoardForm.vue) · [BoardFormModal.vue](/resources/js/Pages/Manage/AiLearning/Partials/BoardFormModal.vue) · [ResourceForm.vue](/resources/js/Pages/Manage/AiLearning/Partials/ResourceForm.vue) · [ResourceFormModal.vue](/resources/js/Pages/Manage/AiLearning/Partials/ResourceFormModal.vue)

**Frontend — Main portal**
- [Pages/Main/Portal/AiLearning/Index.vue](/resources/js/Pages/Main/Portal/AiLearning/Index.vue) — the hero band + the three tabs; opens on Community when `activeTopic` is set.
- [Locked.vue](/resources/js/Pages/Main/Portal/AiLearning/Locked.vue) — the upsell (names the qualifying tier; sends no resource titles, and shows **no counts** — the band's figures are a fact about the paid library).
- The band itself is shared: [Components/Portal/PortalHero.vue](/resources/js/Components/Portal/PortalHero.vue) · [HeroStats.vue](/resources/js/Components/Portal/HeroStats.vue) / [HeroStat.vue](/resources/js/Components/Portal/HeroStat.vue) (the count-up) · [HeroToggle.vue](/resources/js/Components/Portal/HeroToggle.vue) (the glass segmented control). See the design note below.
- [Partials/AskLibrary.vue](/resources/js/Pages/Main/Portal/AiLearning/Partials/AskLibrary.vue) — the launcher + chat panel (page level, not tab level) · [Components/AiLearning/LibraryBot.vue](/resources/js/Components/AiLearning/LibraryBot.vue) — the mascot: one SVG, four states, CSS only, brand tokens
- [Partials/ResourceShelves.vue](/resources/js/Pages/Main/Portal/AiLearning/Partials/ResourceShelves.vue) (both groups + composer + modals) · [ShelfGroup.vue](/resources/js/Pages/Main/Portal/AiLearning/Partials/ShelfGroup.vue) (one group's heading + card grid) · [Components/ExpandableText.vue](/resources/js/Components/ExpandableText.vue) (the measured Show more) · [CommunityTab.vue](/resources/js/Pages/Main/Portal/AiLearning/Partials/CommunityTab.vue) · [BoardSection.vue](/resources/js/Pages/Main/Portal/AiLearning/Partials/BoardSection.vue) · [TopicRow.vue](/resources/js/Pages/Main/Portal/AiLearning/Partials/TopicRow.vue) · [ReplyList.vue](/resources/js/Pages/Main/Portal/AiLearning/Partials/ReplyList.vue)
- [Components/PostBody.vue](/resources/js/Components/PostBody.vue) — the only renderer for member-written text.

**Migrations** (one table each, in dependency order)
- `2026_07_31_000001_create_ai_learning_resources_table.php`
- `2026_07_31_000002_create_ai_learning_boards_table.php`
- `2026_07_31_000003_create_ai_learning_topics_table.php`
- `2026_07_31_000004_create_ai_learning_replies_table.php`
- `2026_08_01_000001_add_kind_and_site_path_to_ai_learning_resources.php` — the file / micro-site split.
- `2026_08_24_000001_add_url_to_ai_learning_resources.php` — `url`, for `KIND_LINK`.
- `2026_08_24_000002_add_site_queued_at_to_ai_learning_resources.php` — the third site state, once extraction became a queued job.
- `2026_08_07_000001_add_summary_to_ai_learning_resources.php` — the AI-written "what this teaches".
- `2026_08_07_000002_pin_ai_learning_prompts_to_gemini.php` — pins both prompt keys, so a deployment cannot silently fall back to a provider with no key.

**Seeders**
- `database/seeds/RolesSeeder.php` — seeds `view-ai-learning` / `manage-ai-learning`. **Re-run it on any deploy of this feature**, or admins see a view-only page.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.settings.ai-elearning.*` (the gate) and `manage.ai-elearning.*` (the content). They sit on different second segments so neither is a prefix of the other in `useActivePath` (GUIDELINES §15).
- [routes/main.php](/routes/main.php) — `main.portal.ai-elearning.*`. Every community write answers with `back()`, so one endpoint serves both the inline feed and a direct topic URL. **`…ai-elearning.site` is declared OUTSIDE the `auth` group on purpose** — see the micro-site section above before "fixing" it.

**Tests**
- [tests/Feature/AiLearning/AiLearningAccessTest.php](/tests/Feature/AiLearning/AiLearningAccessTest.php) — the fail-closed gate.
- [tests/Feature/AiLearning/BoardPostingRulesTest.php](/tests/Feature/AiLearning/BoardPostingRulesTest.php) — per-board rules, hit directly with the UI bypassed.
- [tests/Feature/AiLearning/ResourceUploadTest.php](/tests/Feature/AiLearning/ResourceUploadTest.php) · [ResourceDownloadTest.php](/tests/Feature/AiLearning/ResourceDownloadTest.php)
- [tests/Feature/AiLearning/SiteResourceTest.php](/tests/Feature/AiLearning/SiteResourceTest.php) — the **zip-slip guard**, the **sandbox header**, token forgery, and the delete cascade.
- [tests/Feature/AiLearning/ResourceSummaryTest.php](/tests/Feature/AiLearning/ResourceSummaryTest.php) — generated once, never twice; real PPTX slide ORDER; the 8k cap; an unreadable file told to the model.
- [tests/Feature/AiLearning/AskLibraryTest.php](/tests/Feature/AiLearning/AskLibraryTest.php) — the gate, **the throttle** (the budget), invented ids dropped, fail-soft, empty library spends nothing.

## Known gaps
- **Uploads are capped at 100 MB, and the cap is CLOUDFLARE'S, not ours.** Production sits behind
  Cloudflare, which refuses any request body over 100 MB on its current plan. Measured against
  production rather than read off a docs page: a 100 MB body reached Laravel, a 104 MB body came back
  `413` carrying a `CF-RAY` header, and the rejection arrived in under a second after ~1.1 MB had been
  sent — refused at the edge on `Content-Length`, before our server saw anything. The connection is then
  closed with no response the browser can read, so an oversized upload does not fail, it **stops**:
  1.1 MB of a 137 MB archive is 0.8%, which is how this first arrived as *"keeps jamming, stuck at 1%"*.
  `StoreRequest::MAX_FILE_KB` is set below it deliberately, so an admin gets a field error naming the
  real fix (link videos out) instead of a blank Cloudflare page. **Raising the constant does not move
  the wall** — only a Cloudflare plan change or an upload that bypasses Cloudflare does.
- **`upload_max_filesize` / `post_max_size` on the app server are unknown and unmeasurable from
  outside** — when exceeded, PHP discards the body but still runs the request, so it looks identical to
  a normal redirect. Cloudflare binds first today, so this is the NEXT wall rather than the current one.
  A php.ini change, not code.
- **Video inside a micro-site does not seek.** The site route streams from byte zero and ignores
  `Range`, so an mp4 packed into an archive cannot be scrubbed and some browsers refuse to show a scrub
  bar at all. The fix is not to implement Range but to stop relaying bytes — redirect video to a
  `temporaryUrl()` and let GCS answer ranges, which it does natively. Deferred with the direct-upload
  work; the standing advice meanwhile is that **videos do not belong in the zip**.
- **The site route drops `main` and `contact.verified` along with `auth`.** A member holding a
  qualifying tier but an unproven email/phone is bounced from `/ai/vibe-coding` and from downloads, but `sessionViewer()`
  would serve them site files if they somehow obtained a URL. Narrow — they cannot reach the page that
  mints a token — but it is a real asymmetry between this route and every other one in the module.
- **A PDF inside a micro-site renders blank.** It inherits the sandbox CSP, and the browser's native PDF
  viewer is a browsing context of its own that does not survive one. Same behaviour the preview route
  works around by exempting PDFs; here the sandbox cannot be dropped, so link PDFs as downloads instead
  of embedding them.
- **Each cookie-less asset request starts a throwaway session.** A 26-file site writes ~26 short-lived
  session records per open, and it scales with asset count, not with readers. Worth watching if sites
  grow to hundreds of assets.
- **A micro-site cannot use cookies, `localStorage` or `sessionStorage`.** That is the sandbox working as
  intended (opaque origin), not a fault to fix — but an uploaded page whose JavaScript saves progress
  will silently fail to. Sites must degrade gracefully; the ones that need state belong on a subdomain.
- **A site URL is a capability for its lifetime.** `SiteToken` expires in 4 hours and re-checks the
  member's access on every request, but within that window a copied URL works for whoever holds it. Same
  shape as the signed download URL, shorter than nothing, and the reason the TTL is hours rather than days.
- **No pagination on the community feed — but it is capped.** Each board carries at most
  `AiLearningController::FEED_TOPICS` (30) topics, applied **per board** by a `limit()` inside the eager
  load (Laravel partitions it with a window function; a plain limit on the combined query would hand all
  30 rows to whichever board sorted first and leave the rest empty). `BoardSection` still shows five with
  "Show all" behind them, so the cap bounds what that control can reveal. A topic past the cap is still
  reachable by its own URL — `feed()` splices a deep-linked topic back into its board, or the shared link
  would render a feed with nothing open. What is still missing is a way to BROWSE past 30 from the page
  itself; that is the unsolved half, and it wants a board page rather than a bigger number.
- **A signed download URL stays valid for its TTL once issued.** Revoking a membership stops NEW URLs
  being minted; it cannot recall one already handed out (default 15 min on GCS).
- **Membership TIER status does not affect access — only the SUBSCRIPTION status does.** A member whose
  subscription is `ACTIVE` still qualifies even if the tier itself was later set inactive. The Setting page
  lists only active tiers, so the usual way this happens is a tier being retired under live subscribers.
- **A summary is written once and is not revisited.** Editing the title or description reissues it; replacing the
  downloadable FILE does not, because the job never opened that file's replacement — it reads what was there when
  it ran. A resource whose PDF is swapped for a different document keeps a summary describing the old one.
- **A scanned PDF gets no summary.** There is no text layer to read and OCR is a far larger dependency than a
  parser. The card renders with no panel, which is the same as a resource whose queue has not run yet.
- **Ask-the-library sees titles, categories and summaries — never the files.** A question whose answer is on page
  forty of a guide can only be answered as far as that guide's summary goes.
- **The ask takes around 13 seconds** on `gemini-3.5-flash` with the whole catalogue in the prompt (measured
  locally). The panel says "Looking through the library…" for that whole time. `AiClient::stream()` exists and is
  what AI Conversations uses; streaming this would fix the perceived wait without changing the answer.
- **The ask persists nothing.** A member's thread is gone on reload. Storing it is free-text PII on a lead and
  wants its own decision.
- **Images are one per post**, and a failed image upload is reported by a flash while the text still
  saves (the post must not be lost to a storage hiccup).
