Repository navigation
v2.2.4 — Insurance: descriptions before purchase, badges, premium suggestion, and edits that reach current holders
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-001A2.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 columninsurance_policies.marketing_badge(migratione5b7c9d1f3a4); FEAT-CLASS-003 rejects any key outsideMARKETING_BADGESinapp/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 forINV-ARC-007(GET must be pure):cth-get-must-be-pure(nodb.sessioncommit/flush/add/delete/merge orFEATContextinside a GET-only route) andcth-feat-context-on-get-route(no@requires_feat_contexton a route that accepts GET). The job runssemgrep --test semgrep/(18 annotated cases: 10 must match, including decorators in any order with@limiterbetween and routes carryingendpoint=,defaults=ormethods=("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 issemgrep/, not.semgrep/. Then it scansapp/andwsgi.pywith--error. A commit planted in the real/student/dashboardroute 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 andtests/helpers/navigation_traversal.pyremain 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/statusreportsinvariant_verificationasUNKNOWNby contract, andverify_class_ledgerhas 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, ingithub-pages/learnmore.html. -
DOM-CLASS-0032.6 andFEAT-CLASS-0051.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), buteconomic_engine.flat_overdraft_feeremains 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 fieldprotection_enabled, which exists only on the derivedBankingDirectiveread projection (the real column iseconomic_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-1011.3,FEAT-IDEN-1071.2,DOM-IDEN-0071.3,DOM-IDEN-0022.10,FEAT-OBL-0022.2,FEAT-OBL-0040.5-DRAFT), part of the developer-documentation modernization pass:FEAT-IDEN-101described backup-code hashing as "bcrypt + pepper"; corrected to the canonicalHASH_PASSWORD()primitive, matching siblingFEAT-IDEN-106. A review pass on that first correction (1.2) caught that it named the algorithm ("scrypt via werkzeug") at three places, whichSPEC-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 onlyHASH_PASSWORD().FEAT-IDEN-107described persisting anIdempotencyRecordentity that does not exist anywhere in the codebase (same fictional entity already removed fromFEAT-IDEN-001/FEAT-IDEN-002). The first correction (1.1) restated replay safety through a missing-credential-row-plus-matching-idempotency_keycheck, but a review pass caught that this is a read, not a lock:audit_eventscarries 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-007named the identity-profile tableidentity_profile(singular); corrected toidentity_profilesto matchDOM-CORE-002and the model.DOM-IDEN-002named "balance cache" among records untouched by recovery; noBalanceCachemodel exists in v2, corrected to "ledger balance snapshot."FEAT-OBL-002/FEAT-OBL-004cited a nonexistent file,FEAT-OBLI-001_ASSESS_OBLIGATION.md; corrected to the realFEAT-OBL-001_ASSESS_OBLIGATION.md.
No behavior changes; documentation only.
-
FEAT-OBL-004promoted from DRAFT to 1.0 Normative (owner ruling 2026-10-14), resolving §XIII's open question thatFEAT-STOR-001v3.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-0021.11 corrects its post-migration verification checklist fromjoin_codeto the actual route contract. §VIII and §X cited/admin/export-students?join_code=<owned-join-code>andPOST /student/switch-class/<join_code>. Checked against the live routes:/admin/export-studentstakes no query parameter (class comes from the admin's session/sidebar context), and/student/switch-class/<class_id>takesclass_id, notjoin_code. Brings this document in line withSOP-DEP-001§XI, which already states the canonical contract. Part of the developer-documentation modernization pass. -
SOP-DB-0031.4 citesSOP-DB-004in Dependencies and points at itsM2 DATAclassification, whichSOP-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-003gave no hint a reader needed to know). No classification requirement changes. A review pass on the first version (1.3) caught that its pointer citedSOP-DB-004§VI.1 twice for two different things, ambiguously; reworded to say plainly that §VI.1 both definesM2 DATAand states how the gate treats it. Part of the developer-documentation modernization pass. -
SOP-DOC-0013.26 records the two registration gaps above, and backfills a missing Change Note forSOP-SEC-002v1.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-0011.0,PRN-SNP-0011.2,REF-TERM-0011.2,REF-TERM-0021.2), part of the developer-documentation modernization pass:MAP-UI-001had 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_typeterminology against the liveEntitlementEventmodel) and the now-zeroVERIFYsummary line. Review correction: the kept redemption-workflow rows (dashboard read, request, approve, reject) still carried this document's own supersededredemption_events/entitlement_consumptionsterminology instead of the livePendingAction/EntitlementEventmodel, and the reject row was substantively wrong — denial is terminal (REVOKED), not entitlement-preserving; that behavior belongs to the separateapi.return_redemptionaction. Rewrote all four rows againstapp/feats/entitlement_lifecycle_feat.pyandDOM-STORE-001§VI.PRN-SNP-001described credential hashing as "bcrypt with a server-side pepper"; corrected to the canonicalhash_password()primitive (scrypt, salted, never peppered —SPEC-SEC-001), and labelled three inline citations of the retired v1SEC-CORE-000/SEC-CONT-026documents 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-001and 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-004andapp/auth.py/app/session_lifetime.py/app/utils/turnstile.pyfor 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-001labelled two citations of the retired v1ARC-OPS-007_Database_Schema.mdas archived.REF-TERM-002listedpolicy_versions/policy_transitionsas internal developer terms;REF-TERM-001retired both outright on 2026-09-30 and this glossary hadn't caught up — corrected.
No behavior changes; documentation only.
-
19
INV-ARCdocuments, 4DOMdocuments, andSOP-DOC-000itself had a mislabeled Authority Level; corrected to matchSOP-DOC-000's own tier system (developer-documentation modernization pass, 2026-10-14).INV-ARC-000throughINV-ARC-016,INV-ARC-021, andINV-ARC-022self-labeled "Foundational" (Tier 0, reserved forINV-CORE) in both their header table and body## III. Authority Levelsection; they are Tier 1 and now read "Constitutional," consistent with the 4INV-ARCdocuments (017–020) that already did.DOM-OPS-001,DOM-ITR-001, andDOM-SUP-001self-labeled "Normative" (Tier 2, reserved forSPEC-*/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 thatINV-CORE/INV-ARCcontent is non-negotiable.- Review correction (Codex):
SOP-DOC-000itself had the same defect and was outside the reconciliation above — its header and §III said "Foundational (Tier 0)," while its own §V placesdocs/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-000carved itself no exception from the scheme it defines; corrected to "Normative (Tier 2 — SOP Tier)," subordinate toINV-CORE-000,INV-CORE-001, and Tier 1. No change to the tier definitions, taxonomy, or any other document's classification. (SOP-DOC-0003.4 → 3.5)
- Review correction (Codex):
-
SOP-DEP-0021.10 records the single-maintainer limitation (§V.1; owner decision 2026-10-13, Semgrep triage priority 4). Theproductionenvironment has no required reviewers, only a branch policy formain; "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_proxyroute is unreachable in production: live nginx serves/sysadmin/grafanaand/sysadmin/grafana/itself, throughauth_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'sappfixture dropped thepublicschema 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 getsDROP DATABASE … WITH (FORCE)andCREATE 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 DATABASEpassestests/helpers/db_drop_guard.py: a PostgreSQL URL on a local host, a database named as a test database (testas a whole word, socontestand a password containingtestdo not qualify), neverpostgres/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.pycovers each refusal; three mutations (no host check, the app's looser "testanywhere in the URL" check, any_test/_tmpl_name) each turn two cases red. Refusing an unmarked database, dropping a marked one and refusingpostgreswere 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 withoutCREATEDBfalls 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.
- Drop guard. Every
-
The tagged release procedure is written down and scripted (
SOP-DEP-0021.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 theageidentity 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.shruns 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), andrestore(identity checked againstrecipients.txt, restored in memory, deleted withrm -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 fromvX.Y.Z — <title>toClassroom 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.Znow prints the title (--title) or the body from that version's CHANGELOG section, and refuses a section whose first line is not the standardReleased asline, 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
mainthat blocked 2.2.4. The insurance marketplace badge icon used a static inlinestyle="font-size:1em"(SPEC-DES-001 R5); it now uses the existing.icon-inheritclass. The INV-ARC-020 v1.2 review-correction note citeddocs/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-0071.2 §V.A,DOM-STORE-0015.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_uuidchanges, soDOM-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-0022.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(migrationf6c8d0e2a4b5), recorded by FEAT-CLASS-003 on edit and reactivation (DOM-POL-001A2.4 §D,FEAT-CLASS-0030.2). No backfill: production held no retired or hidden insurance versions. Claim waiting-period rule for continuations:FEAT-STOR-0031.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.
- 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
- 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_purchasefails without the fix. - Three routes committed on every GET (
INV-ARC-007; found by the newcth-feat-context-on-get-routerule)./student/login,/sysadmin/loginand/admin/select-class-contextwere 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 fromGET /student/loginand 12 fromGET /sysadmin/login, most of that day'sFEAT-INTEGRITY-WARNINGlines. 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 withfloat(), andfloat("nan") < 10is False, so a hold ofnan(form) or JSONNaNsatisfied the 10-second check. A JSONInfinitycountdown also raised an uncaughtOverflowErrorfromint(), 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 totests/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_eventitself, which now returns the existing event where it used to raiseIntegrityError._post_payroll_corrections(app/routes/admin.py) still relied on thatIntegrityErrorto 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.PayrollEventResultnow carriesreplayed, 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_latenessbooked 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. WithescapeHtmlremoved the test fails with its S1 message, not a timeout. SOP-DEP-002§VII.A step 12'sReleased astemplate 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-002item 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 ownREVOKEDentitlement events in the route, outside any Store command, and differed fromFEAT-LED-002's reversal in two ways: each revocation named the compensatingREVERSALrow asreversed_transaction_id, whereINV-OPS-002(SPEC-OPS-001) requires the reversed purchase, and no $0void_item_removedrow was booked. Both paths now callrevoke_reversed_purchase(app/feats/transaction_void_feat.py), which revokes through the new Store commandentitlement_service.revoke_reversed_purchase_grants(built onrevoke_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 ownreason(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.mdcites 14…/security/code-scanning/<n>pages, which GitHub serves only to a signed-in maintainer and answers 404 otherwise, somd-dead-link-checkreported them dead.pyproject.tomlexcludes that URL prefix. The Semgrep triage record also gains a correction:docs-links.ymlnames Cloudflare Access secrets that do not exist. INV-ARC-000andINV-CORE-001's registeredINV-ARCdocument lists were six and one document behind.INV-ARC-000§VI listed only throughINV-ARC-016, andINV-CORE-001§V stopped atINV-ARC-021, even thoughINV-ARC-017throughINV-ARC-022have been in force since their own effective dates. Both lists now name every registeredINV-ARCdocument. No rule changed; a terminology audit ofdocs/INVARIANT/requested by the owner surfaced the gap.INV-ARC-020's Dependencies also citeddocs/TESTING/SOP-TEST-003_Test_Creation.md, which does not exist at that path (the SOP moved underdocs/STANDARD_OPERATING_PROCEDURES/TESTING/), anddocs/TRACKING/V2_REBUILD_VALIDATION_REPORT.md, which was archived todocs/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 bytest_dependencies_never_cite_archived_materialon 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 (addingSPEC-DES-001as a Dependency, a legitimate addition) reintroduceddocs/TRACKING/V2_REBUILD_VALIDATION_REPORT.md, the exact citation the entry above had already dropped for the same reason: the file lives only underdocs/archive/v2-tracking-2026/now, and its content (a general v2 migration-validation log) never discussedINV-ARC-020's subject matter — no mention of accessibility, WCAG, or axe anywhere in it. Removed again,SPEC-DES-001retained. This specific mistake — a stale pre-archive path with no literalarchive/substring and no markdown-link syntax — passes all three guards intests/test_docs_archive_citations.pyundetected;test_dependencies_never_cite_archived_materialonly matches a line containingdocs/archive/or/archive/literally, so a reviewer, not the suite, caught this one.INV-ARC-020→ 1.2.
Security
SECURITY.mdsays 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, thesemgrep-cloud-platform/scancheck), which uses the app's own credentials..github/workflows/semgrep.ymlran the deprecatedreturntocorp/semgrep-action, pinned to a 2021 commit with no release tag, and referencedSEMGREP_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 onlymigrations/archive/, which Alembic never loads. Managed scans honor it.semgrep show dump-targetswith 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-taghardening findings onmain). #1546 had pinned the third-party actions; the 37actions/*andgithub/codeql-actionreferences, including those inrelease-v2.yml,deploy-status.ymlandgithub-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.Zcomment naming the published release at that commit; existing pins' comments are made exact (e.g.# v2to# 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-actionkeeps 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 weeklygithub-actionsupdates keep pins and comments current. actionlint reports the same two pre-existing info-level shellcheck notes as onmain. - Semgrep open-findings triage recorded (
docs/ops/audits/SECURITY_TRIAGE_2026-10-13_SEMGREP_OPEN_FINDINGS.md; per-finding evidence indocs/ops/audits/evidence/2026-10-13_semgrep-triage/findings.json). Read-only, perSOP-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 acceptsnan), 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; MakoTemplateLookuppath 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 frommigrations/script.py.makowith 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-addr2.0.8,shell-quote1.12.0,compression1.8.2,source-map-js1.2.2,joi17.13.8,postcss-selector-parser7.1.6. Two needoverridesindocs-site/package.json, because the newest Docusaurus (3.10.2) still pinstinypool ^1.0.2(fixed only in 2.1.2+) and cssnano 6's plugins pinpostcss-selector-parser6:tinypool^2.1.2 (resolves 2.2.0) andpostcss-selector-parser^7.1.6. Docusaurus uses only tinypool's constructor andrun(). 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.