Skip to content

v2.2.4 — Insurance: descriptions before purchase, badges, premium suggestion, and edits that reach current holders

Choose a tag to compare

@timwonderer timwonderer released this 16 Oct 05:44
· 352 commits to main since this release
Immutable release. Only release title and notes can be modified.
68ead42

Released as 68ead4230 on 2026-10-16 at 03:57 UTC by release run 38848094348. Migrations e5b7c9d1f3a4, f6c8d0e2a4b5. Record: DEPLOY_2026-10-16_68ead4230.md. Includes 79cbb2615, released untagged on 2026-10-13 at 03:19 UTC by release run 38341128952 (record): #1559 (PROD-PAY-001 reporting fix) and #1564 (Werkzeug 3.1.9, Mako 1.4.2).

Added

  • Economic Engine suggestion on the insurance policy form (SPEC-ECON-003 §4.4.8, §4.9). The create/edit form showed no premium guidance; the Engine's per-tier recommendation reached teachers only as one class-wide range on the Economic Engine page. The form now shows the suggested premium for the chosen tier and billing frequency (Basic = band bottom, Mid/single = midpoint, Premium = band top; monthly uses the 30-day authoring normalization), with a one-click "Use $X" button. It also reports the typed premium's position in the band, its share of a student's full-attendance income, what is left after rent (only while the rent feature is on), and for monetary plans the maximum payout (premium × payout multiple); non-monetary plans get an affordability note instead. Without a positive CWI the box says to set up Payroll instead. Figures are resolved server-side on GET (app/services/insurance_premium_helper.py, read-only) and updated in the browser as the teacher types (static/js/insurance-premium-helper.js, templates/macros/insurance_premium_helper.html). Tests: tests/test_insurance_premium_helper.py.
  • Insurance marketing badges, ported from v1 (DOM-POL-001A 2.3 §D). A teacher can pin one of v1's 16 optional badges on an insurance policy, from "Best Value!" to "Definitely Not a Scam". Students see it as a coloured label in Buy coverage and My coverage. New nullable column insurance_policies.marketing_badge (migration e5b7c9d1f3a4); FEAT-CLASS-003 rejects any key outside MARKETING_BADGES in app/services/insurance_policy_service.py; reactivating a hidden policy keeps its badge. Presentation only: no premium, coverage or claim logic reads it. Tests: tests/test_insurance_marketing_badge.py.
  • Semgrep architecture rules, versioned in the repository and run on every pull request (semgrep/, new .github/workflows/architecture-rules.yml). The Semgrep GitHub App's managed scan runs only the dashboard's generic rules; these encode CTH's own. First two, both for INV-ARC-007 (GET must be pure): cth-get-must-be-pure (no db.session commit/flush/add/delete/merge or FEATContext inside a GET-only route) and cth-feat-context-on-get-route (no @requires_feat_context on a route that accepts GET). The job runs semgrep --test semgrep/ (18 annotated cases: 10 must match, including decorators in any order with @limiter between and routes carrying endpoint=, defaults= or methods=("GET", "HEAD"); 8 must not) and fails if no tests are found, since Semgrep exits 0 then; it skips hidden directories, which is why the folder is semgrep/, not .semgrep/. Then it scans app/ and wsgi.py with --error. A commit planted in the real /student/dashboard route is reported at its line. Not covered: a GET route calling a helper that commits (no cross-function analysis in Semgrep OSS); the FEAT runtime guard and tests/helpers/navigation_traversal.py remain the backstop.

Changed

  • The public learnmore page no longer claims ledger inconsistencies are reported automatically on the status page (#1581). No such pipeline exists: /health/status reports invariant_verification as UNKNOWN by contract, and verify_class_ledger has no callers. The card now says what is true: the ledger is append-only, and every protected record carries a tamper-evident audit chain. The page also gains three shipped features it never mentioned: classroom announcements, roster import and export, and passwordless teacher login. Copy only, in github-pages/learnmore.html.

  • DOM-CLASS-003 2.6 and FEAT-CLASS-005 1.3 stop describing the overdraft/NSF fee as an active charge, and name the rebalance's actual field scope correctly. CTH charges no such fee (owner ruling 2026-10-07, DOM-OBL-001 §II.C), but economic_engine.flat_overdraft_fee remains a real, rebalance-able configuration field that no live FEAT currently applies to a charge — it was documented as if currently charged. A review pass on the first correction (2.5/1.2) caught two further errors: it named the rebalance-selected field protection_enabled, which exists only on the derived BankingDirective read projection (the real column is economic_engine.overdraft_protection_enabled), and it implied the rebalance preview (_build_rebalance_preview/_apply_change_list) can select the protection setting and the progressive fee, when only the flat fee is a supported rebalance change type. Both documents now say only the flat fee is rebalance-selectable; the protection setting and progressive fee are changed through the Economic Engine evolution command directly. Overdraft protection (the savings-covers-shortfall transfer) is a separate mechanism, unaffected by the 2026-10-07 ruling, and remains fully live for every payment type; neither document changes that. Documentation only, part of the developer-documentation modernization pass.

  • Identity-domain documentation staleness cleaned up (FEAT-IDEN-101 1.3, FEAT-IDEN-107 1.2, DOM-IDEN-007 1.3, DOM-IDEN-002 2.10, FEAT-OBL-002 2.2, FEAT-OBL-004 0.5-DRAFT), part of the developer-documentation modernization pass:

    • FEAT-IDEN-101 described backup-code hashing as "bcrypt + pepper"; corrected to the canonical HASH_PASSWORD() primitive, matching sibling FEAT-IDEN-106. A review pass on that first correction (1.2) caught that it named the algorithm ("scrypt via werkzeug") at three places, which SPEC-SEC-001 §V.5 forbids ("Algorithm and parameters MUST be centralized in the canonical primitive, not duplicated in FEAT documents or route code"); reworded to name only HASH_PASSWORD().
    • FEAT-IDEN-107 described persisting an IdempotencyRecord entity that does not exist anywhere in the codebase (same fictional entity already removed from FEAT-IDEN-001/FEAT-IDEN-002). The first correction (1.1) restated replay safety through a missing-credential-row-plus-matching-idempotency_key check, but a review pass caught that this is a read, not a lock: audit_events carries no unique constraint on (feat_id, idempotency_key), so two concurrent replays could both pass the check before either wrote its audit event. §V now requires that unique constraint as part of the FEAT's schema contract.
    • DOM-IDEN-007 named the identity-profile table identity_profile (singular); corrected to identity_profiles to match DOM-CORE-002 and the model.
    • DOM-IDEN-002 named "balance cache" among records untouched by recovery; no BalanceCache model exists in v2, corrected to "ledger balance snapshot."
    • FEAT-OBL-002/FEAT-OBL-004 cited a nonexistent file, FEAT-OBLI-001_ASSESS_OBLIGATION.md; corrected to the real FEAT-OBL-001_ASSESS_OBLIGATION.md.

    No behavior changes; documentation only.

  • FEAT-OBL-004 promoted from DRAFT to 1.0 Normative (owner ruling 2026-10-14), resolving §XIII's open question that FEAT-STOR-001 v3.2 already shipped assuming this document's authority while it remained DRAFT. Incorporates the 0.5 citation fix above (FEAT-OBLI-001 → FEAT-OBL-001).

  • SOP-DEP-002 1.11 corrects its post-migration verification checklist from join_code to the actual route contract. §VIII and §X cited /admin/export-students?join_code=<owned-join-code> and POST /student/switch-class/<join_code>. Checked against the live routes: /admin/export-students takes no query parameter (class comes from the admin's session/sidebar context), and /student/switch-class/<class_id> takes class_id, not join_code. Brings this document in line with SOP-DEP-001 §XI, which already states the canonical contract. Part of the developer-documentation modernization pass.

  • SOP-DB-003 1.4 cites SOP-DB-004 in Dependencies and points at its M2 DATA classification, which SOP-DB-003's own three-box EXPAND/CONTRACT-CODE/CONTRACT-DATABASE classification doesn't cover (SOP-DB-004 §XII already said so one-directionally; SOP-DB-003 gave no hint a reader needed to know). No classification requirement changes. A review pass on the first version (1.3) caught that its pointer cited SOP-DB-004 §VI.1 twice for two different things, ambiguously; reworded to say plainly that §VI.1 both defines M2 DATA and states how the gate treats it. Part of the developer-documentation modernization pass.

  • SOP-DOC-001 3.26 records the two registration gaps above, and backfills a missing Change Note for SOP-SEC-002 v1.0, which the index had already listed without one. A review pass caught that the first draft of this entry also registered version bumps from the identity-domain and economic-policy documentation fixes (separate PRs, separate branches) that do not exist in this commit's own tree — a reader following this registration on this branch alone would find the index describing amendments it cannot locate. Those PRs register their own bumps against this same index independently; this entry now covers only what this PR itself commits.

  • Tier 3 documentation staleness cleaned up (MAP-UI-001 1.0, PRN-SNP-001 1.2, REF-TERM-001 1.2, REF-TERM-002 1.2), part of the developer-documentation modernization pass:

    • MAP-UI-001 had two sections both numbered "§VII" (renumbered through XI), and its Store/Entitlements table carried an earlier editing pass's rows alongside a later pass's rows for the same 11 capabilities — removed the superseded rows (verified the kept rows' acquisition_type terminology against the live EntitlementEvent model) and the now-zero VERIFY summary line. Review correction: the kept redemption-workflow rows (dashboard read, request, approve, reject) still carried this document's own superseded redemption_events/entitlement_consumptions terminology instead of the live PendingAction/EntitlementEvent model, and the reject row was substantively wrong — denial is terminal (REVOKED), not entitlement-preserving; that behavior belongs to the separate api.return_redemption action. Rewrote all four rows against app/feats/entitlement_lifecycle_feat.py and DOM-STORE-001 §VI.
    • PRN-SNP-001 described credential hashing as "bcrypt with a server-side pepper"; corrected to the canonical hash_password() primitive (scrypt, salted, never peppered — SPEC-SEC-001), and labelled three inline citations of the retired v1 SEC-CORE-000/SEC-CONT-026 documents as archived. Review correction: those three citations were labelled archived but left standing as the stated support for still-current claims — re-grounded each in a current source (DOM-SUP-001 and a direct code check for the sysadmin-cannot-decrypt-names claim, which also gained the seat's own student as a second principal who can see it; SPEC-OPS-004 and app/auth.py/app/session_lifetime.py/app/utils/turnstile.py for TOTP/session/CSRF/Turnstile, correcting "non-sliding" session windows to note teacher and sysadmin sessions are sliding idle timeouts, only the student session is a fixed hard cap) and dropped one stale audit claim outright.
    • REF-TERM-001 labelled two citations of the retired v1 ARC-OPS-007_Database_Schema.md as archived.
    • REF-TERM-002 listed policy_versions/policy_transitions as internal developer terms; REF-TERM-001 retired both outright on 2026-09-30 and this glossary hadn't caught up — corrected.

    No behavior changes; documentation only.

  • 19 INV-ARC documents, 4 DOM documents, and SOP-DOC-000 itself had a mislabeled Authority Level; corrected to match SOP-DOC-000's own tier system (developer-documentation modernization pass, 2026-10-14). INV-ARC-000 through INV-ARC-016, INV-ARC-021, and INV-ARC-022 self-labeled "Foundational" (Tier 0, reserved for INV-CORE) in both their header table and body ## III. Authority Level section; they are Tier 1 and now read "Constitutional," consistent with the 4 INV-ARC documents (017–020) that already did. DOM-OPS-001, DOM-ITR-001, and DOM-SUP-001 self-labeled "Normative" (Tier 2, reserved for SPEC-*/FEAT-*/SOP-*); DOM-STORE-001's header table said "Normative" while its own body already, correctly, said "Tier 1 — Constitutional." All four now read "Constitutional" in both places. No rule, dependency, or scope changed in any of these 23 documents — this is a label correction only, per Tim's standing instruction that documentation authority must resolve top-down and that INV-CORE/INV-ARC content is non-negotiable.

    • Review correction (Codex): SOP-DOC-000 itself had the same defect and was outside the reconciliation above — its header and §III said "Foundational (Tier 0)," while its own §V places docs/STANDARD_OPERATING_PROCEDURES/ (SOP-*) in Tier 2, and §VI's Directory Taxonomy names the documentation SOPs at that root as Tier 2 explicitly. SOP-DOC-000 carved itself no exception from the scheme it defines; corrected to "Normative (Tier 2 — SOP Tier)," subordinate to INV-CORE-000, INV-CORE-001, and Tier 1. No change to the tier definitions, taxonomy, or any other document's classification. (SOP-DOC-000 3.4 → 3.5)
  • SOP-DEP-002 1.10 records the single-maintainer limitation (§V.1; owner decision 2026-10-13, Semgrep triage priority 4). The production environment has no required reviewers, only a branch policy for main; "environment approval" means that policy plus the operator's recorded go-ahead under the solo-operator exception (SOP-DEP-001 §XIII), not independent review. No procedure change.

  • Grafana proxy investigation recorded in the 2026-10-13 Semgrep triage (§2.2 item 2). The Flask grafana_proxy route is unreachable in production: live nginx serves /sysadmin/grafana and /sysadmin/grafana/ itself, through auth_request, and the journal shows no requests to the route. Removing it is proposed, not done.

  • The test suite migrates once per session and copies the result per test, instead of replaying every migration for every test (follow-up from the v2.2.0 release record). tests/conftest.py's app fixture dropped the public schema and replayed the whole Alembic chain (about 180 revisions, ~6 s) before every test, which made a full run take 4–8 hours. The first test of a session now migrates the test database exactly as before and snapshots it as <testdb>_tmpl_<pid>; each later test gets DROP DATABASE … WITH (FORCE) and CREATE DATABASE … TEMPLATE (~0.2 s for the 33 MB migrated database). Every test still starts from a migration-built database, not ORM metadata or a previous test's state. The cross-process advisory lock moves to the maintenance database, since the test database is now dropped under it. Measured on 454 identity, request-trace and payroll-lineage tests: 7 min 32 s, all passing, against roughly 45 minutes of setup alone under the old rebuild.

    • Drop guard. Every DROP DATABASE passes tests/helpers/db_drop_guard.py: a PostgreSQL URL on a local host, a database named as a test database (test as a whole word, so contest and a password containing test do not qualify), never postgres/template0/template1, and exactly the configured test database or this process's own template. It must also carry a comment holding a random token this pytest session wrote when it created or migrated it, so a database the session did not make is refused even when its name matches. tests/test_conftest_drop_guard.py covers each refusal; three mutations (no host check, the app's looser "test anywhere in the URL" check, any _test/_tmpl_ name) each turn two cases red. Refusing an unmarked database, dropping a marked one and refusing postgres were also checked live.
    • Concurrent sessions and roles without CREATEDB (review on #1558). The configured test database carries one marker shared by every pytest session, since concurrent sessions (serialized by the advisory lock) take turns recreating it; only each session's template carries a per-session marker. Two overlapping sessions on one database pass (38 and 62 tests); with the per-session marker put back, one of them fails 37 tests on "Refusing DROP DATABASE". A role without CREATEDB falls back to the old per-test migration replay with a single warning (checked live: 11 passed as such a role, no template created). CONTRIBUTING.md and README.md describe the template, the drop guard's requirements and the privilege.
  • The tagged release procedure is written down and scripted (SOP-DEP-002 1.9, §VII.A). v2.0.0 through v2.2.3 each ran a different variant: whether a backup was taken, where the database credential and the age identity came from, and whether the full suite ran before dispatch. The steps that actually worked lived in an untracked script on the operator's Mac. §VII.A is now an ordered, numbered checklist from the release PR to the release record. infra/db-backup/prerelease.sh runs its backup steps: backup (stop the app, take and verify a point, source URL read from ~/cth-backup/source_url), counts (manifest table counts against live production; exits 1 on any mismatch), and restore (identity checked against recipients.txt, restored in memory, deleted with rm -P). Owner rulings 2026-10-12: every tagged release takes the backup and restore check, and the full suite passes before dispatch unless a security or data-integrity fix goes first. infra/db-backup/README.md's hand-typed block, which asked for a password and an identity path, now points at the script.

  • GitHub release notes are generated from the CHANGELOG in one fixed shape (SOP-DEP-002 §VII.A steps 1, 12, 13). The published releases had drifted: titles changed from vX.Y.Z — <title> to Classroom Token Hub vX.Y.Z, v2.2.x bodies carried two near-identical deployment lines, subsection order differed every release, and v2.2.2 was published before its own deploy. scripts/release_notes.py X.Y.Z now prints the title (--title) or the body from that version's CHANGELOG section, and refuses a section whose first line is not the standard Released as line, whose subsections are not Added, Changed, Deprecated, Removed, Fixed, Security, Investigated (in that order, each once), or whose tag does not point at the stated SHA. The release is published after the release record's CHANGELOG is final. Tests: tests/test_release_notes.py, including one mutation per refusal. [2.2.3] is the first conforming section; earlier sections are left as written.

Fixed

  • Two guard failures on main that blocked 2.2.4. The insurance marketplace badge icon used a static inline style="font-size:1em" (SPEC-DES-001 R5); it now uses the existing .icon-inherit class. The INV-ARC-020 v1.2 review-correction note cited docs/archive/ without labelling it archived (SOP-DOC-000 §V); it is now labelled.
  • Editing an insurance policy now moves its current holders to the new version at their next renewal, and hiding or retiring one ends coverage at the boundary (owner ruling 2026-10-14). Before, an edited, hidden or retired version kept renewing for its holders indefinitely on the old premium and terms; edits only ever reached new buyers. Now (FEAT-STOR-007 1.2 §V.A, DOM-STORE-001 5.7 §VIII.E.1):
    • Edit. The holder's entitlement keeps the version it was granted under, stops renewing and expires at its coverage boundary. At the assessment point before that boundary, a new entitlement under the latest version that supersedes it is granted, effective at the boundary: its premium lineage is anchored at the boundary's date (so a weekly-to-monthly edit works), its first premium is assessed and auto-paid then like a renewal, and no waiting period applies. Several edits in a row land on the latest; the tier-group exclusion does not block it. No stored policy_uuid changes, so DOM-POL-001 §VII is unaffected, and claims still resolve from each entitlement's own grant.
    • Hide or retire with no successor on sale. Renewal stops at the assessment point and coverage ends at the boundary (FEAT-STOR-002 2.4 §IX.C already said teacher cancellation "affects … renewal"). Putting a hidden policy back on sale before then continues its holders.
    • Notice. My coverage shows Your teacher changed this plan with the new plan's premium and terms and the date its first premium is charged, so the student can cancel first; afterwards the old card reads Moving to … (not You cancelled) and the new one carries a Starts [date] badge. A withdrawn plan shows This plan is ending.
    • New column insurance_policies.supersedes_policy_uuid (migration f6c8d0e2a4b5), recorded by FEAT-CLASS-003 on edit and reactivation (DOM-POL-001A 2.4 §D, FEAT-CLASS-003 0.2). No backfill: production held no retired or hidden insurance versions. Claim waiting-period rule for continuations: FEAT-STOR-003 1.5. Teacher and student help pages and on-page help updated.
    • Tests: tests/test_insurance_coverage_continuation.py (15). With the new renewal branches disabled, 11 of the 12 renewal cases fail.
  • Students could not read a tiered insurance plan's description until after buying it. The Buy coverage tab showed the teacher's description only on single (ungrouped) plans; tier rows and the buy confirmation omitted it, so conditions a teacher wrote there (e.g. how many absences are covered) first appeared under My coverage. The description now shows on every tier row and, under "Your teacher's terms", in the confirmation where the student enters their passphrase. Regression test test_tier_description_shown_before_purchase fails without the fix.
  • Three routes committed on every GET (INV-ARC-007; found by the new cth-feat-context-on-get-route rule). /student/login, /sysadmin/login and /admin/select-class-context were wrapped whole in @requires_feat_context, which opens a FEAT and commits on exit, so every page load committed: on 2026-10-12, 310 commits from GET /student/login and 12 from GET /sysadmin/login, most of that day's FEAT-INTEGRITY-WARNING lines. Each FEAT now wraps only the submission (_complete_student_login, _complete_sysadmin_login, _apply_teacher_class_selection). Each helper's statements are AST-identical to the original POST block, so submissions behave as before; 297 tests across the 21 files that exercise these routes pass. New tests: tests/test_inv_arc_007_get_does_not_commit.py (zero commits per GET, and the class-selection POST still commits its switch). Against the original routes, the three GET cases fail.
  • The destructive-action timer gates no longer accept non-finite values (Semgrep triage, owner-approved 2026-10-13). Account deletion (/admin/account-delete) and the shared _validate_destruction_gate (teacher deletion of student seats and classes) parsed the client-reported hold time with float(), and float("nan") < 10 is False, so a hold of nan (form) or JSON NaN satisfied the 10-second check. A JSON Infinity countdown also raised an uncaught OverflowError from int(), returning 500 instead of 400. One parser, _gate_seconds (app/routes/admin.py), now returns 0 (which fails the gate) for any unusable, overflowing or non-finite value. Low impact: the typed confirmation phrase, authentication and CSRF were always required. Regression cases added to tests/dom/identity/test_destruction_authority_boundary.py; all three failed before the fix.
  • A test session killed mid-reset no longer leaves the test database missing for every later session (follow-up to the template-clone harness). Each test drops the test database and recreates it from the session template, so a session killed between the two statements left it gone, and the next session errored on every test (seen on 2026-10-13: 2,344 errors, all database "…" does not exist). The first test of a session now creates the configured test database if it is absent, after the same local-host and test-name checks a drop requires, then migrates it as before.
  • The PROD-PAY-001 payroll correction no longer reports an already-corrected student as paid again (found by the v2.2.3 full suite). F5 (#1536) moved idempotency-key replay into record_payroll_event itself, which now returns the existing event where it used to raise IntegrityError. _post_payroll_corrections (app/routes/admin.py) still relied on that IntegrityError to skip a student a concurrent approval had already corrected, so it counted the replay as a new payment. No second credit was ever written; only the list of seats the approval reported as paid was wrong. PayrollEventResult now carries replayed, set by the FEAT, and the route skips replays on that flag; the database replay-guard branch remains as a fallback. Regression test: test_PROD_PAY_001__overlapping_approvals_skip_the_student_already_paid, which failed on v2.2.3 and passes now.
  • Three test and docs defects the v2.2.3 full suite surfaced, none in production behavior:
    • test_filed_within_window_reflects_lateness booked a −$5 loss between two premium payments that were each funded with exactly the premium, so F1–F4 correctly refused the second as unaffordable. Both grants now precede both losses.
    • The S1 browser regression (test_hall_pass_destination_cannot_execute_in_teacher_history_view) waited for any history row, and the "Select filters and click Apply" placeholder is itself a row, so it read the table before the fetched rows arrived. It now waits for its own entry. With escapeHtml removed the test fails with its S1 message, not a timeout.
    • SOP-DEP-002 §VII.A step 12's Released as template contained a placeholder Markdown link, which the docs link check reported as broken. It now describes the link instead of embedding one.
  • The docs link check no longer reads Markdown link syntax inside code as a link (follow-up to the SOP-DEP-002 item above). test_internal_markdown_links_resolve (tests/test_docs_archive_citations.py) already stripped code before extracting links, but its fence pattern only matched ``` at column 0, so a fence indented inside a list item, a ~~~ fence, a fence nested in a longer one, and a multi-backtick code span all passed through. A line that opens with a code span such as ``` is not a fence, since a backtick fence's info string cannot contain a backtick (review on #1570). Fences are now stripped line by line (``` or ~~~, any indentation, optional info string, closed by a run of the same character at least as long, unclosed runs to the end) and code spans close on a backtick run of the opening length. Detection is the pure function _broken_links; per SOP-TEST-003 §IX.A, new tests show a dead link in prose (including on a line with a code span and after a closed fence) is still reported and a link inside each code form is not. Reverting to the old stripping turns six of them red; stripping everything turns the two prose cases red.
  • Reversing a purchase from a support ticket now leaves the same record as the banking page's Reverse (owner decision 2026-10-13, priority 3 of the Semgrep triage; INV-ARC-006 §VII). resolve_issue (app/routes/admin.py) wrote its own REVOKED entitlement events in the route, outside any Store command, and differed from FEAT-LED-002's reversal in two ways: each revocation named the compensating REVERSAL row as reversed_transaction_id, where INV-OPS-002 (SPEC-OPS-001) requires the reversed purchase, and no $0 void_item_removed row was booked. Both paths now call revoke_reversed_purchase (app/feats/transaction_void_feat.py), which revokes through the new Store command entitlement_service.revoke_reversed_purchase_grants (built on revoke_entitlement, so a lineage that already ended fails closed) and books the item-removal row, credited (like the REVERSAL row) to the teacher who reversed it rather than the purchaser; the banking Reverse now passes the teacher seat too, and a purchase reversal with no actor supplied resolves the class teacher seat (DOM-STORE-001: teacher-directed entitlement events name the teacher seat). Each path keeps its own reason (issue_reversal, reversal_propagated); a ticket refund still keeps the item and books no removal row. Regression test: test_SPEC_OPS_001__issue_reversal_matches_the_banking_reverse, which failed before the change.
  • The nightly docs link check no longer fails on private code-scanning alert links (failing on every push and scheduled run since 2026-10-12). docs/ops/audits/SECURITY_TRIAGE_2026-10-11_CODEQL_FOLLOWUP.md cites 14 …/security/code-scanning/<n> pages, which GitHub serves only to a signed-in maintainer and answers 404 otherwise, so md-dead-link-check reported them dead. pyproject.toml excludes that URL prefix. The Semgrep triage record also gains a correction: docs-links.yml names Cloudflare Access secrets that do not exist.
  • INV-ARC-000 and INV-CORE-001's registered INV-ARC document lists were six and one document behind. INV-ARC-000 §VI listed only through INV-ARC-016, and INV-CORE-001 §V stopped at INV-ARC-021, even though INV-ARC-017 through INV-ARC-022 have been in force since their own effective dates. Both lists now name every registered INV-ARC document. No rule changed; a terminology audit of docs/INVARIANT/ requested by the owner surfaced the gap. INV-ARC-020's Dependencies also cited docs/TESTING/SOP-TEST-003_Test_Creation.md, which does not exist at that path (the SOP moved under docs/STANDARD_OPERATING_PROCEDURES/TESTING/), and docs/TRACKING/V2_REBUILD_VALIDATION_REPORT.md, which was archived to docs/archive/v2-tracking-2026/. The SOP path is corrected; the archived report is dropped from Dependencies outright rather than re-pointed — SOP-DOC-000 §V holds that a live document's Dependencies section must never cite archived material, caught here by test_dependencies_never_cite_archived_material on review. INV-ARC-000 → 1.2, INV-ARC-020 → 1.1, INV-CORE-001 → 1.2.
  • INV-ARC-020's Dependencies picked up the same archived citation a second time. A later edit (adding SPEC-DES-001 as a Dependency, a legitimate addition) reintroduced docs/TRACKING/V2_REBUILD_VALIDATION_REPORT.md, the exact citation the entry above had already dropped for the same reason: the file lives only under docs/archive/v2-tracking-2026/ now, and its content (a general v2 migration-validation log) never discussed INV-ARC-020's subject matter — no mention of accessibility, WCAG, or axe anywhere in it. Removed again, SPEC-DES-001 retained. This specific mistake — a stale pre-archive path with no literal archive/ substring and no markdown-link syntax — passes all three guards in tests/test_docs_archive_citations.py undetected; test_dependencies_never_cite_archived_material only matches a line containing docs/archive/ or /archive/ literally, so a reviewer, not the suite, caught this one. INV-ARC-020 → 1.2.

Security

  • SECURITY.md says how students and teachers using the app should report a vulnerability (d746d5482). Students may use GitHub private reporting or an in-app support ticket that their teacher escalates; teachers may use either, and are asked to escalate a student's security ticket promptly. It also states that the policy does not authorize testing beyond what the reporter is permitted to access.
  • The redundant GitHub Actions Semgrep job is removed, and migrations/archive/ is no longer scanned (owner approval 2026-10-13; Semgrep triage follow-ups). Semgrep scanning runs through the Semgrep GitHub App (semgrep-code-timwonderer, the semgrep-cloud-platform/scan check), which uses the app's own credentials. .github/workflows/semgrep.yml ran the deprecated returntocorp/semgrep-action, pinned to a 2021 commit with no release tag, and referenced SEMGREP_APP_TOKEN/SEMGREP_DEPLOYMENT_ID, neither of which exists. So it never authenticated, and all 90 SARIF analyses it uploaded carried 0 rules and 0 results. The job was not a required check. New .semgrepignore: a repository file replaces Semgrep's built-in ignore list, so it reproduces the documented defaults verbatim and adds only migrations/archive/, which Alembic never loads. Managed scans honor it. semgrep show dump-targets with and without the file differs by exactly the 197 archive files (plus the ignore file itself).
  • Every GitHub Actions reference is pinned to a commit SHA (owner decision 2026-10-13, priority 1 of the Semgrep triage; closes the 38 github-actions-mutable-action-tag hardening findings on main). #1546 had pinned the third-party actions; the 37 actions/* and github/codeql-action references, including those in release-v2.yml, deploy-status.yml and github-pages.yml, still used movable major tags. Each is now pinned to the exact commit its tag pointed to, so behavior is unchanged, with a # vX.Y.Z comment naming the published release at that commit; existing pins' comments are made exact (e.g. # v2 to # v2.1.13). Verified twice through the GitHub API: tag to commit (annotated tags dereferenced), then each comment's release tag back to the pinned SHA (47 of 47 match; every release published and not a prerelease). returntocorp/semgrep-action keeps its SHA: it is a 2021 commit with no release tag, in a deprecated action, now commented as such; replacing it is a separate follow-up. Dependabot's existing weekly github-actions updates keep pins and comments current. actionlint reports the same two pre-existing info-level shellcheck notes as on main.
  • Semgrep open-findings triage recorded (docs/ops/audits/SECURITY_TRIAGE_2026-10-13_SEMGREP_OPEN_FINDINGS.md; per-finding evidence in docs/ops/audits/evidence/2026-10-13_semgrep-triage/findings.json). Read-only, per SOP-SEC-002 §V.2: all 270 open findings from the Semgrep dashboard, each with its trust boundary, traced path, named defenses and constitutional relevance. 202 false positives, 67 hardening opportunities, 1 confirmed low-impact defect (the account-deletion hold accepts nan), and no reachable injection, authentication, tenant-isolation, PII or financial-integrity violation. Records the owner's decisions of 2026-10-13: pin deployment-related actions (this change), remove obsolete GitHub Pages branch allowances, consolidate the issue-driven reversal with the Store revocation command (INV-ARC-006 §VII), and document the single-maintainer limitation. The Grafana proxy and migration-baseline questions stay open. No Semgrep disposition was changed.
  • Werkzeug 3.1.8 → 3.1.9 and Mako 1.3.12 → 1.4.2 (Dependabot alerts #93 and #92, both medium; supersedes #1542 and #1543). Both advisories are Windows-only (Werkzeug safe_join() and Windows device names; Mako TemplateLookup path traversal via drive-letter URIs), so production on Ubuntu was not exposed. Mako is used only through Alembic to render new migration files; Alembic generated and compiled a revision from migrations/script.py.mako with 1.4.2. Full suite on Python 3.10 with the new pins: 5,399 passed, 16 skipped, the same per-test outcomes as the previous green run.
  • Docs-site build dependencies: eight Dependabot advisories closed (alerts #85–#91, #94; four critical). All are in docs-site/, the Docusaurus project GitHub Actions builds into static HTML for Pages, so they reach the CI build machine, not readers of the published site or the Flask app. In-range lockfile updates: proxy-addr 2.0.8, shell-quote 1.12.0, compression 1.8.2, source-map-js 1.2.2, joi 17.13.8, postcss-selector-parser 7.1.6. Two need overrides in docs-site/package.json, because the newest Docusaurus (3.10.2) still pins tinypool ^1.0.2 (fixed only in 2.1.2+) and cssnano 6's plugins pin postcss-selector-parser 6: tinypool ^2.1.2 (resolves 2.2.0) and postcss-selector-parser ^7.1.6. Docusaurus uses only tinypool's constructor and run(). The override build succeeds with the same 361 files; every page is identical apart from asset hashes, and the CSS bundle is byte-identical. Supersedes #1520, which covered four of the eight.