# Global School-Year Selector and Historical Read-Only Mode — Reviewed Implementation Plan v2

## Purpose

Add a global school-year selector to the authenticated CodeIgniter 4 application so a user can:

1. See the active school year by default.
2. Select another available school year.
3. View data belonging to the selected school year across all school-year-aware pages.
4. Clearly see **READ-ONLY** when a non-active school year is selected.
5. Never create, update, delete, approve, publish, charge, pay, enroll, assign, or otherwise mutate school-year-owned business data while a non-active school year is selected.

This is not merely a view change. The selected year must control school-year-aware database reads, AJAX/API requests, exports, print views, and year-owned write endpoints. A decorative dropdown that does not change the query scope would be impressively useless.

---

## Current Project Audit

The project already contains part of the required foundation:

- `app/Support/SchoolYear/SchoolYearContext.php`
- `app/Services/SchoolYearContextService.php`
- `app/Services/SchoolYearWriteGuard.php`
- `app/Models/Concerns/SchoolYearScopedModelTrait.php`
- `app/Filters/RequireSchoolYearFilter.php`
- `app/Models/SchoolYearModel.php`
- school-year lifecycle management under `app/Controllers/Administrator/SchoolYearController.php`
- an existing page-level filter at `app/Views/partials/academic_filter.php`

However, the current implementation is incomplete and inconsistent:

- The application has approximately **372 PHP view files**.
- Approximately **229 application views** use one of the shared layouts:
  - `layout/management_layout`
  - `layout/main_layout`
  - `layout/main`
- Approximately **33 views** directly include `partials/academic_filter.php`, which would create duplicate school-year selectors after a global selector is added.
- Approximately **57 controllers** still read the school year directly from session or configuration instead of resolving `SchoolYearContext`.
- Approximately **63 models** reference `school_year` or `school_year_id`, but the scoped-model trait is not currently used by those models.
- `RequireSchoolYearFilter` exists as an alias but is not broadly applied to the relevant route groups.
- There are no dedicated automated tests covering school-year selection, scoping, or historical write protection.

The implementation must consolidate these competing patterns rather than adding a third one.


### Additional findings from the review

The first plan was directionally correct, but the project audit exposed several implementation blockers that must be resolved before the selector is released:

- `BaseController::syncActiveSchoolYearSession()` currently removes `selected_school_year_id` whenever the configured active year differs from the session. That method would erase a historical selection on the next request and must be removed or replaced.
- `AuthController`, `SchoolYearManagementService`, and `SchoolYearClosingService` still write the active year name into session. The active configuration value may remain a temporary legacy mirror, but it cannot remain a request-time source of truth.
- Approximately **30 views contain their own school-year `<select>`**, in addition to the 33 views using `partials/academic_filter.php`. Removing only the shared partial is insufficient.
- Approximately **71 views contain a school-year form control** of some kind, including hidden fields used by JavaScript and write forms. Each must be classified as display-only, legacy compatibility, or unsafe posted authority.
- School-year state is also read in services, libraries, listeners, commands, helpers, navbars, and layouts. Migrating controllers and models alone will leave active-year leaks.
- `Views/partials/navbar.php`, `Views/layout/main_layout.php`, and `Views/teacher/teacher_navbar.php` directly use the configured active year for teacher assignments, score-card students, and event counts. The global selector would otherwise show one year while the navbar queries another.
- Several dependent session values, especially `class_section_id`, `semester`, `teacher_scores_selected_semester`, and `grading_selected_semester`, can become invalid when the selected year changes.
- Multiple routes use GET for mutations, including delete, restore, state-change, and recalculation actions. A write guard based only on POST routes would be bypassable.
- The project has CLI commands, scheduled work, event listeners, generated reports, and notification code that cannot use browser session context. They require an explicit school-year contract.
- The current database and model code do not guarantee exactly one active year. `SchoolYearModel::active()` silently chooses one row if corruption creates multiple active rows.
- Active school-year names are currently editable even though many domain tables store the year as a string. Renaming an in-use year can orphan historical data.
- The proposed rollout originally exposed the selector before completing query migration. That would create pages labeled with a historical year while still returning active-year data. The release order is corrected below.

### Review verdict

The feature should proceed, but the selector must not be enabled globally until the route inventory, data-integrity preflight, and read-scope migration are complete. The visible dropdown is the final release switch, not the first implementation milestone.

---

## Required Behavior

### Default selection

When no school year has been explicitly selected:

1. Resolve the single row in `school_years` whose status is `active`.
2. Store or expose that row as the current `SchoolYearContext`.
3. Display it as the selected option in the global selector.
4. Allow normal read and write behavior.

If no active school year exists, authenticated school-year-aware pages must fail clearly with a configuration error. They must not silently query unscoped data.

### Explicit selection

When a user selects a school year:

1. Validate the selected `school_year_id` against the `school_years` table and the current user access policy.
2. Store only `selected_school_year_id` as the persistent selection. Do not persist a second name value that can become stale. Legacy code must use a compatibility accessor derived from `SchoolYearContext`.
3. When the selected row is the active year, clear the explicit override instead of storing the active ID. This ensures a future activation automatically becomes the default.
4. Clear or revalidate dependent session state such as `class_section_id` and semester-selection keys.
5. Redirect back to the originating internal page.
6. Resolve all later school-year-aware requests from that session selection until the user changes it or returns to the active default.

### Resolution precedence

Use this exact precedence:

1. Route-level school-year ID, when a route explicitly owns the context.
2. Valid canonical request parameter `school_year_id`.
3. Valid legacy request parameter `school_year`; temporarily also recognize `schoolYear` and `year` only on inventoried compatibility endpoints.
4. Valid session `selected_school_year_id`.
5. Active school year.

Conflicting canonical and legacy parameters must return a validation error. Legacy aliases must emit migration telemetry and be removed after their callers are migrated.

### Historical read-only mode

For normal application pages, define historical/read-only mode as:

```php
$context->status() !== SchoolYearStatus::ACTIVE
```

This intentionally treats `draft`, `closing`, `closed`, and `archived` as non-writable in the global application context. The dedicated school-year lifecycle administration screen is exempt because it already has controlled status-transition actions.

When the selected year is non-active:

- Display a highly visible Bootstrap button-style label:

```html
<span class="btn btn-warning btn-sm disabled" aria-disabled="true">
    <i class="bi bi-lock-fill"></i> READ-ONLY
</span>
```

- Display the selected school-year name beside the label.
- Disable or hide create/edit/delete/import/approve/publish/payment/assignment actions in the UI.
- Reject every school-year-owned write request on the server, even if a user manually submits a request.
- Continue to allow reads, searches, pagination, exports, PDF generation, and printing, provided those operations do not mutate data.

UI disabling is only a convenience. Controller filters, domain-service guards, context-token validation, and record-year ownership checks form the actual protection boundary.

---

## Which School Years Appear in the Dropdown

### Normal users

Show:

- `active`
- `closing`
- `closed`
- `archived`

Do not show `draft` years to normal users. A draft is not historical school data; it is unfinished administrative state.

### Administrators

Administrators may see all statuses, including `draft`, but selecting a draft through the global selector must still be read-only. Draft editing and activation remain available only on the dedicated school-year management screen.

### Ordering and option labels

Order by `name DESC`, then `id DESC`.

Recommended labels:

```text
2026-2027 — Active
2025-2026 — Closed
2024-2025 — Archived
```

The active option should be visually identifiable but must remain a standard accessible `<option>`.

---

## Scope: Global UI, Selective Data Filtering

The selector should appear on every authenticated application page that uses a normal application layout. That does **not** mean every database table should be filtered by school year.

### School-year-scoped examples

These must use the selected context where the underlying table supports it:

- enrollments
- student-class assignments
- teacher-class assignments
- attendance
- grading and scores
- homework, quizzes, projects, exams, and participation
- invoices, payments, discounts, refunds, and additional charges
- events and event charges
- flags and behavior records
- class preparation and progress
- certificates, badges, and report cards
- financial reports
- school calendar records when year-bound
- inventory movements when explicitly year-bound

### Global/master-data examples

These must not be falsely filtered just because the selector is visible:

- users and authentication
- roles and permissions
- global configuration
- static class definitions
- static section definitions
- contact/support content
- email templates
- navigation configuration
- school-year lifecycle management itself

Every action must use the six-category policy defined in the next section. Do not guess based on the page title. Verify the queried tables, side effects, and relationships.

---


## Correct Write-Policy Boundary

The reviewed plan narrows the meaning of read-only. Selecting a historical year must block mutations to **school-year-owned business data**, not freeze the entire authenticated application.

Classify every action as one of:

1. `GLOBAL_READ`
2. `GLOBAL_WRITE`
3. `SCHOOL_YEAR_READ`
4. `SCHOOL_YEAR_WRITE`
5. `OPERATIONAL_SIDE_EFFECT`
6. `SCHOOL_YEAR_LIFECYCLE_ADMIN`

Examples:

- Updating a password, user display preference, navigation preference, or global email template is a `GLOBAL_WRITE` and is not blocked merely because a historical year is selected.
- Editing attendance, scores, invoices, enrollment, class assignments, event charges, or year-owned inventory movements is a `SCHOOL_YEAR_WRITE` and must be blocked for every non-active context.
- Recording an audit log, blocked-write attempt, download event, or notification-read timestamp may be an `OPERATIONAL_SIDE_EFFECT`. It may be allowed only when it does not alter historical business state.
- Report-card acknowledgement, regenerated certificates, overwritten PDFs, print logs, and similar read-looking actions must be explicitly classified. If the action changes a year-owned record or replaces a historical artifact, default to blocked.

The UI must combine normal permission checks with school-year writability. Read-only mode does not grant visibility, and normal permissions do not override historical immutability.

## Authorization and Accessible-Year Policy

A valid school-year ID is not automatically authorized.

- The resolver must call a policy such as `SchoolYearAccessPolicy::canView($user, $year)`.
- Administrators with the school-year-management permission may see drafts in the global selector; role-name string checks alone are insufficient.
- Parent, student, teacher, and assistant queries must continue enforcing ownership and assignment rules inside the selected year.
- Route parameters and report URLs must pass the same authorization policy as the dropdown.
- Switching roles must revalidate the selected year. A draft selected while acting as administrator must not survive after switching to a non-administrator role.

Whether non-administrators see every non-draft year or only years in which they have a relationship should be decided explicitly. The safer default is to show only authorized years while preserving an administrator view of all years.

## Architecture

### 0. Establish one source of truth and request-scoped context

Before changing views:

- Remove `BaseController::syncActiveSchoolYearSession()`.
- Stop clearing explicit selection merely because the configured active year differs.
- Treat `school_years` as the authoritative lifecycle table.
- Treat configuration key `school_year` as a temporary compatibility mirror of the active row only.
- Deprecate direct reads from `ConfigurationModel`, `session('school_year')`, `getSchoolYear()`, and controller properties initialized from configuration.
- Cache the resolved context only for the current request. Do not cache user selection across users or processes.
- Expose one canonical request variable and one canonical session key: `school_year_id` and `selected_school_year_id`.

The compatibility helper `getSchoolYear()` may temporarily return `service('schoolYearContext')->resolve(...)->yearName()` for authenticated HTTP requests, but it must be marked deprecated and removed after migration. CLI code must never depend on that helper.

### 1. Extend `SchoolYearContext`

Update:

`app/Support/SchoolYear/SchoolYearContext.php`

Required behavior:

```php
public function isReadonly(): bool
{
    return ! $this->isActive();
}

public function isHistorical(): bool
{
    return ! $this->isActive();
}
```

Keep `id()`, `yearName()`, `status()`, and `isExplicitSelection()`.

Use distinct terminology for global browsing and lifecycle editing:

- `SchoolYearContext::isReadonly()` means non-active years cannot receive normal domain writes.
- `SchoolYearStatus::isLifecycleMetadataLocked()` should preserve the lifecycle-specific rule used by school-year administration.

Do not change a shared `SchoolYearStatus::isReadonly()` method to mean two incompatible things. Draft metadata must remain editable on the dedicated lifecycle screen while draft selection through the global context remains read-only.

The context object is the canonical value. Controllers must stop passing unrelated `$schoolYear`, `$selectedYear`, and session/config variants when they all represent the same concept.

### 2. Extend `SchoolYearContextService`

Update:

`app/Services/SchoolYearContextService.php`

Add methods similar to:

```php
public function selectableYears(bool $includeDraft = false): array;
public function select(int $schoolYearId): SchoolYearContext;
public function clearSelection(): void;
public function active(): SchoolYearContext;
```

Change session resolution so a valid explicitly selected non-active year is retained. The current implementation only accepts a session-selected row when its status is `active`, which defeats persistent historical browsing.

Required session logic:

```php
$sessionYearId = session('selected_school_year_id');

if (is_numeric($sessionYearId)) {
    $row = $this->schoolYearModel->find((int) $sessionYearId);

    if ($row !== null && $this->isSelectable($row, $currentUserIsAdmin)) {
        return $this->fromRow($row, true);
    }

    $this->clearSelection();
}
```

Do not trust the session value merely because it is numeric. Reload and validate the database row on every context resolution.

### 2A. Enforce active-year and name integrity

`SchoolYearModel::active()` must not use `orderBy(...)->first()` as a corruption fallback.

Required behavior:

- Query up to two active rows.
- Return the row only when exactly one active row exists.
- Throw a configuration-integrity exception when zero or multiple active rows exist, except when an explicit authorized historical selection is valid and the page is read-only.
- Protect lifecycle transitions with a transaction and locking.
- Add a database-level single-active guard where supported by the deployed MySQL/MariaDB version, or an equivalent generated-column unique constraint.
- Add a health check confirming the configuration mirror matches the active `school_years` row.

School-year names must become immutable once the year has dependent records or has left draft status. Because many legacy tables store `school_year` as text, renaming an in-use year would disconnect those records. The alternative, a transactional cascade across every year-owned table, is substantially riskier and is not recommended.

### 2B. Define dependent-session invalidation

When the year changes, remove or revalidate at least:

```php
class_section_id
semester
active_semester
teacher_scores_selected_semester
grading_selected_semester
```

Any other session key representing a student, class, section, event, report, or filter must be validated against the new year before reuse. A class selected in 2025-2026 cannot be assumed to exist, or be assigned to the same teacher, in 2024-2025.

### 3. Add a selection controller

Create:

`app/Controllers/View/SchoolYearSelectionController.php`

Suggested actions:

```php
public function select(): RedirectResponse;
public function reset(): RedirectResponse;
```

`select()` must:

1. Require authentication.
2. Validate CSRF.
3. Validate `school_year_id` as an existing, authorized, selectable row.
4. Clear the override when the submitted row is active; otherwise save `selected_school_year_id`.
5. Clear or revalidate dependent session filters.
6. Redirect only to a safe internal `return_to` path.
7. Remove stale query keys `school_year_id`, `school_year`, `schoolYear`, and legacy `year` from the return URL.
8. Fall back to the role dashboard when `return_to` is invalid.

Never redirect directly to an arbitrary URL supplied by the request. That would turn a school-year selector into an open-redirect feature, because apparently dropdowns also need threat models.

### 4. Add routes

Add authenticated routes:

```php
$routes->post('school-year/select', 'View\SchoolYearSelectionController::select', [
    'filter' => 'auth',
]);

$routes->post('school-year/reset', 'View\SchoolYearSelectionController::reset', [
    'filter' => 'auth',
]);
```

Use the project’s actual authentication filter arguments consistently with existing protected routes.

### 4A. Add domain-specific exceptions and response mapping

Do not use generic `RuntimeException` for all school-year failures. Add exceptions for:

- invalid or conflicting selection: HTTP 400
- unknown or unauthorized year: HTTP 404, to avoid leaking inaccessible records
- historical write conflict: HTTP 409
- missing or multiple active rows: HTTP 500 with a safe configuration message for users and detailed server logs

`RequireSchoolYearFilter` must return JSON only for JSON/AJAX/API requests. Normal HTML requests require an HTML error page or safe redirect. Use one status-code convention throughout the application; do not alternate between 403 and 409 for the same condition.

### 4B. Add a defense-in-depth write filter

Create a `SchoolYearWritableFilter` for known school-year mutation routes. It is not a substitute for controller/service guards, but it blocks obvious forged requests before domain work begins.

The filter must be applied regardless of HTTP verb because the current route file contains legacy GET mutations. Those routes should also be converted to POST or DELETE with CSRF protection as part of the migration.

### 5. Provide shared view data

Create a small service or protected BaseController method that returns:

```php
[
    'schoolYearContext' => $context,
    'schoolYearOptions' => $years,
    'schoolYearReadonly' => $context->isReadonly(),
]
```

Recommended location:

`app/Services/SchoolYearViewDataService.php`

Register it in:

`app/Config/Services.php`

Do not query `SchoolYearModel` independently from every view. Views should render data, not invent application state.

For authenticated HTML requests, inject this data once through `BaseController::initController()` into the shared renderer. Use this single approach rather than leaving the implementation split between a controller hook and a filter. Resolve lazily only when `user_id` exists and the request is an HTML application request.

Avoid resolving school-year context on:

- login and account activation pages
- public landing pages
- password-reset pages
- email rendering
- CLI error views
- API documentation

### 6. Create the global selector partial

Create:

`app/Views/partials/school_year_selector.php`

Required elements:

- POST form to `/school-year/select`
- CSRF field
- `<select name="school_year_id">`
- all allowed options
- current request URI in a hidden `return_to` field
- auto-submit on change or a clear Apply button
- active status label
- `READ-ONLY` button-style label when non-active
- accessible label and keyboard behavior

Suggested markup:

```php
<?php if (isset($schoolYearContext, $schoolYearOptions)): ?>
<div class="school-year-context-bar">
    <form method="post" action="<?= site_url('school-year/select') ?>" class="d-flex align-items-center gap-2">
        <?= csrf_field() ?>
        <?php $query = service('request')->getUri()->getQuery(); ?>
        <input type="hidden" name="return_to" value="<?= esc(current_url() . ($query !== '' ? '?' . $query : '')) ?>">

        <label for="global-school-year" class="form-label mb-0 fw-semibold">
            School year
        </label>

        <select
            id="global-school-year"
            name="school_year_id"
            class="form-select form-select-sm"
            onchange="this.form.submit()"
        >
            <?php foreach ($schoolYearOptions as $year): ?>
                <option
                    value="<?= (int) $year['id'] ?>"
                    <?= (int) $year['id'] === $schoolYearContext->id() ? 'selected' : '' ?>
                >
                    <?= esc($year['name']) ?> — <?= esc(ucfirst($year['status'])) ?>
                </option>
            <?php endforeach; ?>
        </select>

        <?php if ($schoolYearContext->isReadonly()): ?>
            <span class="btn btn-warning btn-sm disabled" aria-disabled="true">
                <i class="bi bi-lock-fill"></i> READ-ONLY
            </span>
        <?php else: ?>
            <span class="badge text-bg-success">ACTIVE</span>
        <?php endif; ?>
    </form>
</div>
<?php endif; ?>
```

Preserve relevant existing query parameters when redirecting, but remove stale `school_year_id`, `school_year`, `schoolYear`, and legacy `year` parameters so the session selection is not immediately overridden by an old URL.

### 7. Insert the partial into shared layouts

Update:

- `app/Views/layout/management_layout.php`
- `app/Views/layout/main_layout.php`
- `app/Views/layout/main.php`

Place the selector immediately below the application navbar and above page content.

Do not add it to:

- `layout/register_layout.php`
- `layout/email_layout.php`
- PDFs
- print-only layouts
- public pages
- fragments/modals/partials

This covers most UI pages without editing hundreds of files individually.

### 8. Remove duplicate page-level school-year selectors

Refactor:

`app/Views/partials/academic_filter.php`

The partial is currently used by approximately 33 views and combines school year with semester. After the global selector is introduced:

- remove the school-year control from this partial, or
- add a parameter such as `$showSchoolYearControl = false` and default it to false.

Retain page-specific controls such as semester and class section.

All affected controllers must use the global `SchoolYearContext` instead of trusting `$_GET['school_year']` from the old partial.

### 9. Migrate all custom selectors and shared navigation queries

`partials/academic_filter.php` is only one source of duplication. The audit found approximately 30 views with a school-year `<select>`. Each must be reviewed.

High-priority custom selectors include:

- invoice management and financial reports
- report cards and certificates
- enrollment/withdrawal and class assignment
- teacher attendance and grading decision pages
- inventory summaries and reimbursements
- tuition forecast, trophies, events, and class preparation
- parent attendance, scores, enrollment, and invoice pages

Page-specific school-year selectors should be removed when they represent the same global browsing context. A local selector may remain only when the page intentionally compares years or generates a report for a year independent of the global context. Such pages must clearly label the difference and must not silently change the global session.

Also update year-dependent logic in:

- `app/Views/partials/navbar.php`
- `app/Views/layout/main_layout.php`
- `app/Views/teacher/teacher_navbar.php`
- any navbar, dashboard, counter, badge, or class switcher that currently reads the configured active year

The visible selector, navbar counts, class options, badges, and page data must all use the same request context.

---

## Controller Migration Contract

Every school-year-aware controller action must begin by resolving context:

```php
$schoolYearContext = $this->resolveSchoolYearContext();
$schoolYear = $schoolYearContext->yearName();
$schoolYearId = $schoolYearContext->id();
```

### Read actions

Use the resolved year in every query:

```php
$records = $model
    ->where('school_year', $schoolYear)
    ->findAll();
```

or, once the model trait is adopted:

```php
$records = $model
    ->forSchoolYear($schoolYearContext)
    ->findAll();
```

Pass the same context to the view:

```php
return view('example/index', [
    'records' => $records,
    'schoolYearContext' => $schoolYearContext,
]);
```

### Write actions

Resolve and guard before validation side effects or database writes:

```php
$schoolYearContext = $this->resolveSchoolYearContext();
$this->assertSchoolYearWritable($schoolYearContext);
```

Then force the year from context into the payload:

```php
$payload['school_year'] = $schoolYearContext->yearName();
```

Never accept a posted `school_year` as authoritative. A user can edit HTML.

### Record ownership validation

For updates/deletes, guarding the selected context is insufficient. Also verify that the target row belongs to that context:

```php
$record = $model
    ->where('id', $id)
    ->where('school_year', $schoolYearContext->yearName())
    ->first();

if ($record === null) {
    throw PageNotFoundException::forPageNotFound();
}
```

This prevents an active-year request from modifying a historical row by changing the record ID.

### Stale-page and cross-tab protection

Session-wide selection changes affect every browser tab. Every school-year-owned write form and AJAX mutation must include the year context in which the form was rendered:

```html
<input type="hidden" name="school_year_context_id" value="<?= $schoolYearContext->id() ?>">
```

Before a write, verify that:

1. the submitted context ID matches the currently resolved context;
2. the context is active;
3. the target record belongs to that context.

Reject mismatches with HTTP 409 and ask the user to reload. This prevents a stale form in one tab from writing after another tab changes the global selection.

### Redirects and links

Because selection persists in session, ordinary internal links do not need to append the school year. For shareable reports or exports, explicit `school_year_id` may be included in the URL and must take precedence over session.

---

## Model Migration Contract

Apply `SchoolYearScopedModelTrait` to models that directly own a school-year column.

Example:

```php
use App\Models\Concerns\SchoolYearScopedModelTrait;

class AttendanceRecordModel extends Model
{
    use SchoolYearScopedModelTrait;
}
```

Do not apply the trait blindly to models that only mention a school year in joined tables or helper methods. Confirm the model’s base table owns either:

- `school_year_id`, or
- `school_year`

Prefer `school_year_id` for new schema work. Continue supporting name-based legacy tables during migration.

Do not perform database schema introspection inside every `forSchoolYear()` call. Each scoped model should declare its year column explicitly, for example:

```php
protected string $schoolYearColumn = 'school_year';
```

The trait should fail loudly when no declared year column exists. Silent guessing or filtering a nonexistent `school_year_id` column turns a safety mechanism into theater.

### Query rule

Every query against a year-owned table must include one of:

```sql
WHERE school_year_id = :selected_id:
```

or:

```sql
WHERE school_year = :selected_name:
```

No school-year-aware list query may rely on the absence of duplicate rows across years.

### Join rule

When joining multiple year-owned tables, scope every relevant table or ensure the join keys and source table guarantee the same year. Do not filter one table and assume the others follow politely.

Example:

```php
$builder
    ->where('student_class.school_year', $year)
    ->where('teacher_class.school_year', $year);
```

---

## Services, Libraries, Listeners, CLI, and Scheduled Work

The context migration extends beyond controllers and models.

Audit and update:

- `GlobalConfigHelper::getSchoolYear()`
- `FeeCalculationService`
- `SemesterScoreService`
- tuition forecast services
- attendance tracking defaults
- `SchoolEventListener`
- all report, notification, email, and calculation services that read active configuration
- CLI commands such as exam-draft reminders and payment notifications

Rules:

- Browser-driven services receive `SchoolYearContext` explicitly from the controller or request-scoped service.
- Event payloads include `school_year_id` or immutable year name. Listeners must not re-read the current active configuration and assume it matches the event.
- CLI commands require `--school-year-id` when operating on a historical year. Active-year jobs may explicitly resolve the active row, but never use session state.
- Queued or deferred jobs must persist the year ID in the job payload. A job executed after the active year changes must still operate on the originally requested year.
- Scheduled jobs that should only affect the active year must assert that policy before writing.

## Data-Integrity Preflight and Historical Accuracy

Before enabling the selector, run a read-only audit over every year-owned table:

- null or empty school-year values
- values not present in `school_years`
- malformed year strings
- duplicate business keys that omit school year
- joins whose related rows belong to different years
- missing indexes on year filters
- rows whose date falls outside the declared school-year range, where that rule applies

Do not silently backfill unknown rows into the active year. Produce an exception report and resolve or quarantine ambiguous records.

The team must also decide what “historical data” means for master data that can change. A historical grade joined to a currently renamed class, section, student, fee rule, or event may not reproduce what users saw at the time. For legally or operationally significant reports, use effective-dated master data or snapshots rather than live mutable joins.

## AJAX and API Requests

All page AJAX requests must inherit the current context.

Preferred approach:

- session selection for authenticated same-origin browser requests
- explicit `school_year_id` for report endpoints, exports, and requests that may be opened independently
- an explicit `X-School-Year-ID` header or documented query parameter for JWT APIs that do not use browser sessions

The resolver must not treat a posted business-data field as the authoritative context. API context belongs in the route, query, or dedicated header; the business payload is then forced to that resolved context.

Apply `schoolYear` validation to relevant API/AJAX route groups.

JSON school-year write endpoints must return HTTP `409 Conflict` for historical context:

```json
{
  "status": 409,
  "error": "School year is read-only",
  "school_year": "2024-2025"
}
```

Do not return a successful response with no mutation. Silent failure is not protection; it is future debugging folklore.

---

## Caching, Browser History, and Generated Artifacts

Any server-side or client-side cache containing school-year data must include the selected year ID and user/permission scope in its cache key.

For school-year-aware HTML responses:

- send appropriate HTTP `Cache-Control` headers, not merely `<meta>` tags;
- test browser back/forward cache behavior after switching years;
- ensure DataTables, AJAX caches, local storage, and saved filters do not reuse active-year responses in a historical context.

Exports and generated files must include the selected year in their filename and metadata. Historical generation must not overwrite an existing artifact for another year. When generation writes an audit log, that log is an allowed operational side effect; when generation updates a year-owned business record or replaces an official historical artifact, it requires explicit policy and normally remains blocked.

## Observability and Audit

Log at least:

- year selection and reset events
- invalid, conflicting, or unauthorized year requests
- blocked historical write attempts, including user, route, method, selected year, and target record type/ID when safe
- active-year integrity failures
- uses of legacy configuration/session/request fallbacks during the migration period

Add a temporary migration metric showing how many requests still read the active year from legacy sources. The feature is not done while that count remains nonzero on school-year-aware routes.

## UI Read-Only Rules

Create a reusable helper or partial for action availability instead of scattering status comparisons everywhere.

### Required UI behavior in historical mode

Disable or omit:

- Create/Add/New buttons
- Edit buttons
- Delete/Archive row actions unrelated to school-year lifecycle
- Save/Submit/Approve/Publish buttons
- payment collection and refund actions
- student enrollment/withdrawal mutation
- class and teacher assignment mutation
- attendance entry or correction
- score entry, grading, and finalization
- imports that write records
- bulk actions
- drag-and-drop mutations
- inline editable controls

Keep available:

- View/details
- filtering/search
- pagination
- sorting
- export
- print
- PDF generation
- audit history

Use actual disabled form controls where possible, but continue enforcing the server guard.

### Historical banner

In addition to the compact navbar label, display a page-width warning on application content pages:

```html
<div class="alert alert-warning d-flex align-items-center gap-2" role="alert">
    <i class="bi bi-lock-fill"></i>
    <div>
        You are viewing school year <strong>2024-2025</strong>. This school year is read-only.
    </div>
</div>
```

The warning should be rendered once by the shared layout, not copied into every view.

---

## Exemptions

The global selector and read-only banner must not appear in:

- public landing pages
- login/logout pages
- account activation and password reset
- registration flow before authentication
- email templates
- PDFs and print fragments unless the selected year must be printed as report metadata
- CLI and framework error pages
- Swagger/API documentation
- partial views and modal fragments rendered inside another page

The selector may appear on global authenticated pages, but those pages must not claim their data is filtered when it is not.

---

## Implementation Phases

### Phase 0: Inventory, policy, and data preflight

- [ ] Produce a route/action inventory with method, controller, queried tables, policy classification, migration status, and test status.
- [ ] Inventory custom school-year selectors and hidden form fields.
- [ ] Audit controllers, models, services, libraries, listeners, commands, helpers, navbars, and layouts.
- [ ] Run the data-integrity preflight and resolve ambiguous/null year data.
- [ ] Decide accessible-year policy by role/permission.
- [ ] Decide historical snapshot requirements for mutable master data.
- [ ] Add a feature flag; keep the global selector hidden in production.

### Phase 1: Source of truth and lifecycle integrity

- [ ] Remove `BaseController::syncActiveSchoolYearSession()`.
- [ ] Make `school_years` authoritative and configuration a compatibility mirror only.
- [ ] Prevent renaming in-use/non-draft years.
- [ ] Enforce exactly one active year with service checks, locking, and a database guard where supported.
- [ ] Add domain-specific exceptions and consistent HTML/JSON responses.
- [ ] Clear explicit selection on login/account change and revalidate on role switch.

### Phase 2: Context, selection, and dependent state

- [ ] Change global historical determination to non-active = domain-read-only.
- [ ] Preserve separate lifecycle metadata-lock semantics.
- [ ] Allow valid authorized non-active session selections.
- [ ] Add selectable-year listing through an access policy.
- [ ] Add selection and reset methods.
- [ ] Selecting active clears the explicit override.
- [ ] Add safe redirect validation and strip all legacy year query aliases.
- [ ] Clear or revalidate class, section, student, and semester session state.
- [ ] Add stale-form context tokens.

### Phase 3: Read scoping before public UI

Migrate high-risk domains first:

1. financial, invoice, payment, refund, and reimbursement
2. enrollment, promotion, student-class, and teacher-class assignment
3. attendance, late slips, and dismissals
4. grading, scores, decisions, report cards, and certificates
5. events, charges, calendar, and communications
6. inventory, class preparation, progress, and dashboards

For every action:

- [ ] classify it as global/year-read/year-write/operational/lifecycle
- [ ] resolve and authorize context
- [ ] replace direct configuration/session year reads
- [ ] scope base queries, joins, subqueries, unions, counts, and totals
- [ ] scope exports, PDFs, print views, dashboard widgets, and DataTables count queries
- [ ] scope AJAX child endpoints
- [ ] migrate services, listeners, helpers, and calculations used by the action
- [ ] add isolation tests before marking the action complete

### Phase 4: Write protection and route hygiene

- [ ] Add `SchoolYearWritableFilter` to known year-owned mutation routes.
- [ ] Add `assertSchoolYearWritable()` at controller and domain-service boundaries.
- [ ] Verify submitted context token and target-record ownership.
- [ ] Force inserted year fields from context.
- [ ] Reject posted mismatched year fields.
- [ ] Convert legacy GET mutations to POST/DELETE with CSRF.
- [ ] Guard bulk actions, imports, background-trigger endpoints, listeners, and commands.
- [ ] Define allowed operational side effects and block historical artifact replacement.

### Phase 5: Shared UI and custom-selector migration

- [ ] Inject request-scoped shared context data.
- [ ] Create selector and read-only banner partials.
- [ ] Update all shared layouts, navbars, counters, and class switchers.
- [ ] Remove or repurpose all duplicate school-year selectors, not only `academic_filter.php`.
- [ ] Disable/hide year-owned write actions while preserving permission checks.
- [ ] Add global JavaScript context metadata for AJAX consumers.
- [ ] Verify mobile and accessibility behavior.

The global feature flag remains off during this phase.

### Phase 6: Caching, observability, and compatibility removal

- [ ] Vary caches by year and authorization scope.
- [ ] Add HTTP cache headers and browser-history tests.
- [ ] Log selections, invalid requests, blocked writes, and active-year integrity failures.
- [ ] Add metrics for remaining legacy year-source usage.
- [ ] Migrate `schoolYear`, `school_year`, `year`, and session-name compatibility paths.
- [ ] Remove the legacy `selected_school_year` name key and direct config/session reads.

### Phase 7: Release and regression review

- [ ] Run the full required test matrix.
- [ ] Verify every inventoried school-year route is migrated or explicitly global.
- [ ] Enable the selector in a staging environment.
- [ ] Perform cross-tab, back-button, role-switch, and stale-form testing.
- [ ] Enable production access by domain or role only after coverage is complete.
- [ ] Monitor blocked-write and legacy-fallback metrics.
- [ ] Remove the feature flag only after the selector cannot misrepresent query scope.

## Required Test Matrix

### Context resolution

| Case | Expected result |
|---|---|
| No request/session selection | Active year |
| Valid active session selection | Active year, writable |
| Valid closed session selection | Closed year, read-only |
| Valid archived request ID | Archived year, read-only |
| Invalid request ID | 400 validation response |
| Conflicting ID and name | 400 validation response |
| Deleted session year | Clear session and use active year |
| No active year and no valid selection | Clear configuration error |

### Read scoping

Create fixtures with identical student/class/parent relationships in two years and verify:

- active selection returns only active-year rows
- historical selection returns only historical rows
- totals and counts change with selection
- pagination does not mix years
- exports/PDFs use the selected year
- dashboard widgets use the selected year or are explicitly marked global

### Write protection

For each high-risk domain, test:

- active year write succeeds with permission
- closed year write fails
- archived year write fails
- closing year write fails
- draft global-context write fails
- manually posted `school_year=active` while historical context is selected still fails
- active context cannot update a historical record by ID

### Session and concurrency

Verify:

- selecting the active year clears the explicit override
- activating a new year automatically changes the default for users without an override
- switching year clears or revalidates class and semester state
- switching roles invalidates unauthorized draft selections
- a stale write form opened in another tab fails with 409 after context changes
- browser back/forward navigation does not display a mismatched year and dataset

### Non-HTTP execution

Verify:

- CLI commands require or deliberately resolve a year
- queued/deferred work preserves the original year ID after active-year transition
- listeners use the event's year rather than current configuration
- scheduled active-only jobs reject historical writes

### Data integrity

Verify:

- multiple active rows cause a hard integrity failure
- an in-use school-year name cannot be changed
- unknown/null legacy year rows are reported rather than silently assigned
- compound uniqueness prevents duplicates across and within years as intended
- cache keys prevent cross-year response reuse

### UI

Verify:

- selector appears once, not twice
- active year is default
- selected historical year persists across pages
- `READ-ONLY` label and warning appear for non-active years
- action buttons disappear or disable
- view/export/print actions remain available
- mobile navbar remains usable

---

## Acceptance Criteria

The feature is complete only when all of the following are true:

1. Every authenticated application layout displays one global school-year selector.
2. The active year is selected by default.
3. A user can select an allowed historical year and the selection persists across navigation.
4. Every school-year-aware page reads data only from the selected year.
5. Global pages remain global and do not pretend to be year-filtered.
6. A non-active year displays a button-style **READ-ONLY** label and a warning banner.
7. No school-year-owned record in a non-active year can be mutated through HTML, AJAX, API, direct URL, forged POST data, record-ID substitution, CLI, listeners, or scheduled work.
8. Existing page-level school-year dropdowns are removed or converted to non-year filters.
9. Exports, PDFs, print views, totals, and dashboard metrics use the same selected context.
10. Automated tests cover resolution, persistence, isolation, and write rejection.
11. School-year lifecycle administration continues to work through its dedicated controller and is not blocked by the global read-only UI.
12. No query silently falls back to unscoped data when context resolution fails.
13. Selecting the active year clears the session override, so future activation changes the default automatically.
14. Changing year cannot reuse an invalid class, section, student, or semester selection.
15. Global writes and allowed operational logs remain governed by their own permissions and are not accidentally blocked.
16. School-year-owned writes are blocked from HTML, AJAX, API, CLI, listeners, scheduled jobs, stale tabs, and forged IDs.
17. Exactly one active year is enforced and an in-use year name is immutable.
18. No navbar, dashboard counter, class switcher, export, or generated report uses a different year from the visible context.
19. All custom school-year selectors are removed, intentionally retained for comparison/reporting, or documented as lifecycle controls.
20. Legacy configuration/session/request sources are removed from school-year-aware execution paths.
21. The global selector is not released until all exposed routes are scoped and tested.

---

## Definition of Done Checklist by File

### Existing files to modify

- `app/Support/SchoolYear/SchoolYearContext.php`
- `app/Services/SchoolYearContextService.php`
- `app/Services/SchoolYearWriteGuard.php`
- `app/Controllers/BaseController.php`
- `app/Config/Services.php`
- `app/Config/Routes.php`
- `app/Config/Filters.php`
- `app/Views/layout/management_layout.php`
- `app/Views/layout/main_layout.php`
- `app/Views/layout/main.php`
- `app/Views/partials/academic_filter.php`
- `app/Views/partials/navbar.php`
- `app/Views/teacher/teacher_navbar.php`
- `app/Helpers/GlobalConfigHelper.php`
- `app/Controllers/AuthController.php`
- school-year-aware services, libraries, listeners, and commands
- all custom school-year selector views
- all school-year-aware controllers
- all models that directly own a school-year column
- migrations enforcing active-year/name integrity and required indexes/constraints

### New files recommended

- `app/Controllers/View/SchoolYearSelectionController.php`
- `app/Filters/SchoolYearWritableFilter.php`
- `app/Policies/SchoolYearAccessPolicy.php`
- school-year domain exception classes
- `app/Services/SchoolYearViewDataService.php`
- `app/Views/partials/school_year_selector.php`
- `app/Views/partials/school_year_readonly_banner.php`
- `tests/Unit/SchoolYearContextTest.php`
- `tests/Unit/SchoolYearContextServiceTest.php`
- `tests/Feature/SchoolYearSelectionTest.php`
- `tests/Feature/HistoricalSchoolYearWriteGuardTest.php`
- domain-specific school-year isolation feature tests

---

## Final Engineering Rule

There must be exactly one authoritative selected school-year context per request.

The following are not independent sources of truth and must not compete:

- configuration value
- session `school_year`
- session `selected_school_year`
- GET `school_year`
- GET `school_year_id`
- POST `school_year`
- controller defaults
- model defaults

All of them must resolve into one validated `SchoolYearContext`, and every school-year-aware read or write must use that object. Anything less will eventually display one year in the dropdown while querying another year in the database, which is the sort of bug humans call “intermittent” when they mean “we built several sources of truth.”


## Review Gate Before Coding

Coding should not begin with the dropdown. The first deliverable is the route/data inventory and the removal of the session-sync behavior that currently destroys historical selection. The second is a working context and write guard proven on one high-risk vertical, preferably invoices or attendance. Only then should the shared layouts expose the selector.

A convincing demo is not “the dropdown changes and one list refreshes.” A convincing demo is: the year changes, every visible count and child request changes with it, historical writes fail through every path, stale tabs are rejected, and returning to active restores normal writes without carrying invalid class or semester state.
