Repository navigation
Releases: timwonderer/classroom-token-hub
Release list
v2.4.1 — Rent-linked store items copy correctly, and production releases are gated on the droplet, not in GitHub
Released 2026-10-29 at 05:55 UTC. No migration.
Fixed
- Copy Configuration exports store items that are linked to rent; only the rent link stays behind
Full details
- Copy Configuration exports store items that are linked to rent; only the rent link stays behind (owner ruling 2026-10-28;
SPEC-CLASS-0011.1,SPEC-STORE-0011.7,DOM-POL-0012.10,DOM-POL-001A2.6,DOM-CORE-0021.19,FEAT-POL-0012.3,FEAT-CLASS-0030.4). The export left rent perks out entirely, so a class with eleven store items offered seven. They now copy like any other item, without their rent link; the teacher links them to rent again in the other class. An item students can't buy directly (handed out only when rent is paid) keeps its settings and arrives hidden until the teacher links it or gives it a price, because an item on sale that isn't rent-linked must be directly purchasable.
Security & Reliability
- The shared
DO_DEPLOY_KEYis now restricted too, closing a bypass of the release gate below - The release gate uses its own dedicated SSH key, not the one shared with
login-notice.yml/tailscale-ssh-smoke-test.yml - Production releases are gated on the droplet, not in GitHub
Full details
- The shared
DO_DEPLOY_KEYis now restricted too, closing a bypass of the release gate below. Givingrelease-v2.ymlits own key (RELEASE_DEPLOY_KEY, previous entry) didn't by itself close the gap:DO_DEPLOY_KEYis a repository secret available to any workflow, so the same identity the gate exists to constrain could still have pointedrelease-v2.yml(or a new workflow) back atDO_DEPLOY_KEYand deployed with no gate at all.infra/release-gate/guarded-ci.shis a new forced command forDO_DEPLOY_KEY'sauthorized_keysline, restricted to exactly the two operationslogin-notice.ymlandtailscale-ssh-smoke-test.ymluse; a deploy-shaped command sent over this key is refused regardless of which workflow sent it. Found before the dedicated-key install was completed, so nothing needed to be undone.SOP-DEP-002v1.19. Owner direction, 2026-10-29. - The release gate uses its own dedicated SSH key, not the one shared with
login-notice.yml/tailscale-ssh-smoke-test.yml. Found while installing the gate below:guarded-deploy.shonly recognizesrelease-v2.yml's three command shapes, so forcing it onto the existingDO_DEPLOY_KEYline would have refused every request from those other two workflows the moment it was installed.release-v2.ymlnow authenticates with a new secret,RELEASE_DEPLOY_KEY, installed on its ownauthorized_keysline;DO_DEPLOY_KEYis untouched.SOP-DEP-002v1.18. Owner direction, 2026-10-29. - Production releases are gated on the droplet, not in GitHub. v2.4.0 (2026-10-29) was dispatched through the real, unmodified
release-v2.ymlbefore the maintenance window opened, after an agent misread a time hint as a deadline and skippedSOP-DEP-002§VII.A steps 4-8 (backup, restore check, balance baseline). This repository has one collaborator, and every session acting on the owner's behalf authenticates as that same GitHub account with a connector scope that cannot be narrowed further, so a requiredworkflow_dispatchinput, a GitHub environment's required reviewers, and branch protection requiring review onmainwere each evaluated and rejected — none of them act on anything outside the identity the incident itself used.infra/release-gate/guarded-deploy.shis a forced SSH command, installed by hand on the droplet (root-owned, outside~/classroom-economy, so no PR can edit or remove it), that refuses every one ofrelease-v2.yml's calls to production unless an uncredentialed probe confirms the Cloudflare Access gate is actually closed and a signed, fresh (two-hour window) backup/restore evidence file names the exact revision being deployed.infra/db-backup/prerelease.sh evidence <sha>produces that file, signed with a key that has never been issued to any GitHub-authenticated identity.SOP-DEP-002v1.17 adds §VII.A step 7a and documents the gate. Owner direction, 2026-10-29.
Deployment record: DEPLOY_2026-10-29_44b869e.md · Release run: 40826089677
Follows v2.4.0 (3ce50ec), released the same day at 03:07 UTC outside SOP-DEP-002 §VII.A — see the Security entry below and the [2.4.0] section.
v2.4.0 — Copy class configuration between classes, time logic through one resolver, and hardened production checks
Released 2026-10-29 at 03:07 UTC. Migrations c8f1a2b3d490, e3b7c1d9a2f4.
Highlights
- Class-scoped configuration file export/import with authenticated five-day files, atomic imports, and temporary retry receipts
- All time logic goes through the canonical temporal resolver, and a guard now actually checks it
Fixed
- Store purchases failed with "Incorrect passphrase"
Full details
- Store purchases failed with "Incorrect passphrase" for students whose browser had saved their PIN as the site password: the transfer page's PIN field was marked
autocomplete="current-password", so Chrome offered to save the PIN and then autofilled it into passphrase prompts. The field is nowautocomplete="off", like the dashboard PIN field (support ticket #13).
Security & Reliability
- All time logic goes through the canonical temporal resolver, and a guard now actually checks it
- The status collector reads nothing from the application during maintenance
- The sysadmin bug reward is retired until badges are formally implemented
- The production deploy workflows installed Tailscale 1.94.2
- The pre-release backup script left an SSH tunnel to the production database open indefinitely
- Logs and URLs no longer carry internal identities
/metricsanswered the internet, and the health checks could be polled without limit (#1661)
Full details
- All time logic goes through the canonical temporal resolver, and a guard now actually checks it (
SPEC-TIME-0011.3,INV-ARC-015§VII; owner rulings 2026-10-24). The time guardrail intests/dom/operation/test_time_money_guardrails.pyhad scannedtests/dom/app, a directory that does not exist, since it was moved on 2026-07-18, so it passed while checking nothing; the separatepolicy_guardrails.pyrule banned onlytimedelta, in three directories, with an allowlist. Together they let 464 prohibited time operations stand in 72 application files: direct clock reads,utc_now()/ensure_utc()helpers outside the resolver,astimezone/pytzconversions,timedeltadate arithmetic and UTC.date()derivations. Every one now goes through a resolver primitive. The resolver gainsshift_calendar_days(calendar-day shifts that keep local midnight or the local clock across DST),localize_timestamp(an instant in the class timezone) andsame_instant;elapsed_durationnow sums intervals exactly and truncates once instead of per interval;ensure_utcandadvance_local_calendar_daysare gone, and private helpers that only forwarded to the resolver are inlined.tests/test_SPEC_TIME_001__resolver_is_the_only_time_tool.pyparses every file inapp/, fails if it scans fewer than 150, and proves each prohibited pattern with a mutation case, including the two shipped DST defects the retiredtimedeltarule recorded; that rule and its allowlist are removed. Corrected behaviour: "Transactions today" on the teacher dashboard counts the class's day, not the UTC day; recurring rent late fees count class-local calendar days (INV-ARC-015§VIII.1), not 24-hour blocks; custom N-month rent steps count from the anchor date instead of drifting (no production class uses them). Timestamps without a timezone are now rejected where they were silently read as UTC; nothing in the application writes one. Dead rent-period and waiver helpers that added fixed periods to UTC instants are deleted. Tests take time fromtests/helpers/temporal.py, thin wrappers over the resolver (SPEC-TEST-0011.1). Displayed times are built by page builders, not templates (INV-ARC-022§V.8,SPEC-UI-001§VI–§XI; owner rulings 2026-10-25, resolved by authority): thefmt_timestamp/fmt_date/fmt_compact_date/fmt_timeJinja filters and theformat_utc_isotemplate global are removed, along with everystrftime/isoformatand time comparison in templates and every time format in route bodies; each page's builder produces the text through the resolver and the template prints it. A guard (tests/test_INV_ARC_022__templates_do_not_format_time.py) holds templates and routes to this, with mutation cases. Display corrections found on the way: the rent page's waiver tab showed blank due and waived dates (nothing filled thelocal-timestampspans since an earlier rework) and now prints them in class time; the hall-pass verification kiosk showed UTC to an anonymous visitor and now uses the verified class's time zone; the insurance-claim transaction picker and the admin issue page's recent transactions showed UTC dates and now show class-local ones; the teacher recovery pages show their deadlines in UTC explicitly instead of whatever class session the browser held. - The status collector reads nothing from the application during maintenance (owner ruling 2026-10-23;
DOM-OPS-0012.14,SPEC-OPS-0061.6). It probes the Access gate first; while the gate is closed it stores only that gate check, makes no health read and stores no request telemetry, and records the endpoint and database as Unknown under the new diagnosticNOT_COLLECTED. The page already shows "Under maintenance" from the gate alone. The collector no longer sends the Cloudflare Access service token, which existed only to reach production during maintenance; the health endpoints are public, and the deploy no longer injects the token's secrets into the collector.SOP-DEP-0012.11 andSOP-DEP-0021.16 drop "public health probes require service-token headers". Found by the #1656 outside check. - The sysadmin bug reward is retired until badges are formally implemented (owner ruling 2026-10-27). Resolving an escalated ticket records the technical fix and returns it to the teacher; the form no longer offers a reward, and the resolve route ignores
eligible_for_rewardandreward_amountif a request still sends them, so a hand-built POST can no longer credit a student. Production has never issued one. DOM-SUP-001 v1.10 and SPEC-OPS-004 v1.5 record the change; theissues.eligible_for_rewardcolumn and thebug_rewardledger type are dropped by a follow-up contract migration. - The production deploy workflows installed Tailscale 1.94.2 (
release-v2.yml,login-notice.yml,tailscale-ssh-smoke-test.yml). Pinningtailscale/github-actionby SHA did not pin the client: v4.2.0 defaultsversionto 1.94.2, which predates Tailscale's 2026-10-21 bulletin TS-2026-013 (distrust of the compromised eMudhra certificate authorities, fixed in 1.104.2). The three workflows now pin the action at v4.3.0 (99a8333), whose default is 1.104.2; Dependabot's weeklygithub-actionsupdate keeps the client current with the SHA. The production host was upgraded from Tailscale 1.102.4 to 1.104.2 on 2026-10-24 (docs/ops/audits/HOST_UPGRADE_2026-10-24_TAILSCALE.md); the host package sits outsideunattended-upgrades, tracked in #1665. No exploitation was found and no credentials were rotated. - The pre-release backup script left an SSH tunnel to the production database open indefinitely (
infra/db-backup/prerelease.sh).backupstartedssh -f -N -L 55433:…and nothing closed it; the v2.2.4 tunnel was still listening six days later and made the v2.3.0 backup refuse to start. The tunnel is now owned through a control socket (~/cth-backup/tunnel.sock).backupkeeps it on success, becausecountsandrestoreneed it, and closes it on failure.countsandrestorereuse or reopen it.restore, the last step, closes it on exit alongside deleting the identity, which now falls back torm -fso a failedrm -Pcannot skip the close.close-tunnelhandles an abandoned run.tests/test_prerelease_tunnel.pyruns the real script against stubssh,dockerand tools in a throwaway HOME; 6 of its 7 tests fail on the previous script.SOP-DEP-0021.13 step 7 andinfra/db-backup/README.mddescribe the lifecycle. - Logs and URLs no longer carry internal identities (DOM-OPS-001 v2.15, owner ruling 2026-10-27). Every application log line named its class by the internal
class_id, and several messages printed internal seat and user ids (for exampleDASHBOARD DEBUG: Student 211,TAP success - seat 211,user_id=…). The line prefix now carriesclass_public_id; every log call that printed one now names seats byseats.public_idand classes byclass_public_id, or drops the id where the prefix already names the actor, and the authentication principal is omitted.FEAT-ENTRYandFEAT-COMMIT-OWNERSHIPprint idempotency keys and correlation ids as label plus fingerprint (loggable_token), since a composed key carries the ids it was built from; stored keys are unchanged./student/switch-class/and/admin/student/…/adjust-hall-pass-entitlementstake public ids, so nginx no longer logs the internal ones; the hall-pass adjustment now also resolves the seat within the teacher's active class only. Teacher-facing fallbacks that showedSeat 211read Unnamed student. Fresh support and identity-bind correlation ids no longer embed the class or user id. A structural guard (tests/test_DOM_OPS_001__logs_carry_no_internal_identity.py) rejects any log call reading an internal identity; it reports 103 on the previous tree. /metricsanswered the internet, and the health checks could be polled without limit (#1661). The/metricsguard allowed any request whoseremote_addrwas loopback, but nginx proxies every public request to gunicorn over loopback, so the guard never refused anything. It now uses the same host-local test as the Cloudflare-bypass check,ip_handler.is_host_local_request(): loopback and none of the proxy headers nginx always sets. The Prometheus scrape (127.0.0.1:8000, no proxy headers) is unaffected. The exposed payload carried no personal or tenant data (SPEC-OPS-003), only runtime and traffic statistics./healthand/health/statusare no longer exempt from rate limiting: each carries "30 per minute" per...
v2.3.0 — Accessible in-app dialogs, Delete Class and status page fixes, closed ledger type vocabulary
Released 2026-10-22 at 04:38 UTC. Migration a4d7e9c2f1b6.
Highlights
- Delete Class did nothing: the page posted to a URL that refused the request (405)
- The delete safety gate was mostly silent to screen readers, and the student-deletion gate shut out keyboard users
- Every browser-native
alert/confirm/promptis replaced with the in-app modal - Students on some in-app browsers could not request a hall pass
- Public status page showed an outage while the app was up (2026-10-20)
Fixed
- Contextual help described features that do not exist or work differently (#1619, #1620, #1621, #1622)
- Delete Class did nothing: the page posted to a URL that refused the request (405)
- Every browser-native
alert/confirm/promptis replaced with the in-app modal - Students on some in-app browsers could not request a hall pass
- Ticket correlation packs listed
/static/<path:filename>instead of the routes the user visited - Scheduled savings interest posted
mechanism=self, misattributing a system-triggered credit as the student's own voluntary act - The insurance premium suggestion showed monthly rent as slightly less than students are billed
Full details
- Contextual help described features that do not exist or work differently (#1619, #1620, #1621, #1622; a full pass over the "Need Help?" drawers, tooltips and diagnostics guides). Among the fixes: the student Add Class drawer invented a "seat code" field; the teacher hall-pass drawer described a granting toggle and a student kiosk that do not exist; the store guide said Delete Permanently destroys records, though the route refuses it; the teacher analytics diagnostic described the retired v1 analytics engine;
docs/view.html's Back to Dashboard link read session keys nothing writes; and the support-ticket guide made three claims the code contradicts. Help links on the insurance claim and general issue pages now reach their troubleshooting guides instead of the generic index. - Delete Class did nothing: the page posted to a URL that refused the request (405).
admin.delete_join_codehad two rules,POST /admin/join-code/deleteand aDELETE /admin/join-codethat nothing used. Decorators register bottom-up, so the DELETE rule came first andurl_forbuilt it. Since 2026-09-09 (8651c274e),admin_class_delete.htmlfetchedurl_for("admin.delete_join_code")and POSTed to/admin/join-code, got 405, and the class was never deleted. It was found during local VoiceOver testing on 2026-10-20. No production request reached the URL in the 14 days of retained logs. The unused DELETE rule is removed, leaving only the URL FEAT-CLASS-006 and FEAT-IDEN-007 define. Every existing test POSTed to the literal path, sotests/dom/identity/test_teacher_last_class_deletion.pynow follows the URL the page renders. It fails with the old routes with('/admin/join-code', 405). A scan of the URL map finds no other endpoint whereurl_forbuilds a rule that refuses its caller's method. - Every browser-native
alert/confirm/promptis replaced with the in-app modal (SPEC-DES-001 v2.4 §X, §XI.6). After the hall-pass PIN prompt failed silently in an in-app browser, the remaining 20 native dialogs went too. They covered Start Work, hall-pass cancel, leave and return, passkey naming, rent payment, insurance cancel and retire, transaction reversal, bulk student actions, and teacher and sysadmin deletes.AppCore.confirm()andAppCore.prompt()(static/js/app-core.js) build a Bootstrap modal.data-confirmdoes the same for plain form posts, and its capture-phase listener runs before any other submit handler, so cancelling leaves the form untouched. Screen readers: every Bootstrap modal in the app now makes the rest of the pageinertwhile it is open. Chromium leaves content outside anaria-modalelement in the accessibility tree, so browse mode could leave any modal, existing ones included. Live regions stay live. The new dialogs return focus to the control that opened them, start destructive actions on Cancel, and announce a field error only once it applies (aria-invalidplusaria-describedby). The break modal now returns focus to Break. Checked in Chromium against its accessibility tree over CDP, with an axe-core WCAG 2.1 A/AA audit (tests/test_app_dialog_accessibility_browser.py), each check mutation-proved. Guard:tests/test_no_native_browser_dialogs.py, with mutation proofs. It reports all 24 pre-change call sites. - Students on some in-app browsers could not request a hall pass. Picking a destination in Choose Break Type called
window.prompt()for the PIN from inside the open modal. On 2026-10-20 a student on the Google app's iOS browser chose Bathroom seven times. Each time the prompt came back empty, the modal closed, and no request reached the server. The modal now has its own Your PIN field, used by both hall-pass destinations and Done for the day. An empty field keeps the modal open with an error instead of failing silently. The student guide also wrongly said no PIN was needed to request a pass (tests/test_break_modal_pin_browser.py). - Ticket correlation packs listed
/static/<path:filename>instead of the routes the user visited. Every static asset wrote anactor_request_tracerow, about 20 per page load. They filled the pack's fetch window and padded out its 20 entries. Static requests no longer write traces (DOM-SUP-001: "relevant request trace entries"), which also removes one DB write per asset (tests/test_request_trace_lock_safety.py). - Scheduled savings interest posted
mechanism=self, misattributing a system-triggered credit as the student's own voluntary act (DOM-LED-001INV-LED-016). No human decides, in the moment, that a given payout window's interest posts —app/services/ledger_interest_service.py'sapply_savings_interestnow suppliesmechanism="system". Test:test_INV_LED_016__scheduled_interest_posts_mechanism_system, confirmed to fail withmechanism=selfrestored. - The insurance premium suggestion showed monthly rent as slightly less than students are billed. On a monthly policy, "After rent" converted the class's monthly rent to a weekly figure and back over a 30-day month, so $600.00 a month read as $591.39 (reported by the owner on production after the v2.2.4 release). Rent billed at the policy's own frequency is a fixed amount per period, like the premium, so
app/services/insurance_premium_helper.pynow shows the billed amount when the frequencies match, and still scales when they differ (weekly rent on a monthly policy, monthly rent on a weekly one). Test:test_monthly_rent_shows_the_billed_amount_on_a_monthly_policy, confirmed to fail without the fix (591.39 == 600.0).
Security & Reliability
ledger_transaction.typeis now a DB-enforced closed vocabulary- Status page hero read "Mostly unavailable" when the health check was only refused
- Public status page showed an outage while the app was up (2026-10-20)
- The status operator console verifies IAP's signed assertion again, instead of relying on the unsigned email header
Full details
ledger_transaction.typeis now a DB-enforced closed vocabulary (DOM-LED-001INV-LED-017). Migrationa4d7e9c2f1b6addsck_ledger_transaction_type_closed_vocabulary(addedNOT VALID, so it guards every new row without a long lock or a failed deploy over an unexpected historical value), enumerating INV-LED-017's full canonical/legacy table — a write outside that set is now rejected at the database level;NULLis still permitted. This closes the set of lawful values; it does not yet require canonical spelling on a new row (bothInterest/interest,REVERSAL/reversal,Withdrawal/withdrawal, andDeposit/depositstill pass) — that write-time casing validator is separate, later-PR scope, since it requires retiring every current legacy-cased writer and every read-site comparison that still expects the legacy spelling (most criticallyapp/utils/transaction_idempotency.py's idempotent-type and fingerprint-exclusion sets, whose silent mis-update would break interest-posting idempotency). Five pre-existing tests that fixtured atypevalue never actually written by production code ('Rent Payment','adjustment','credit','manual_credit') are corrected to the real production values (rent_payment,manual_payment) the constraint now requires; three Interpretation test fixtures that used a placeholdertypeno writer produces ("act","test") are likewise corrected tomanual_payment. No existing row'stypevalue changes.FEAT-LED-005v1.3 reconciles this PR's and #1599's independently-recorded v1.2 histories (route/registry cutover and DBCHECKconstraint both now landed). PR 5 of the approved sequence from the 2026-10-17SYSTEM_ORIGINATED_FEAT_CODESprovenance investigation. Tests:tests/dom/ledger/test_transaction_type_closed_vocabulary.py.- Status page hero read "Mostly unavailable" when the health check was only refused (
SPEC-OPS-0061.5, owner ruling 2026-10-21). On 2026-10-20 the app's rate limit answered the status collector's/health/statusread with 429.collect_platform()mapped every HTTP error other than a redirect, 401 or 403 to an endpoint FAIL (HTTP_UNAVAILABLE), so the hero said "Mostly unavailable" while the feature cards, from real traffic, said "Working". Only a 5xx is now a FAIL. Any other non-redirect, non-auth HTTP error (429, 404, ...) means the check observed nothing: the endpoint and database checks are UNKNOWN with no timestamp under the new closed diagnosticHTTP_REFUSED, and the hero reads "Unknown".tests/test_status_platform.pycovers 500/502/503 as FAIL and 429/404 as UNKNOWN with anUnknownhero; the 429 and 404 cases fail on the old mapping. Deploys with the status service (deploy-status.yml), not the app release. - **Publi...
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 earlie...
v2.2.3 — Audit remediation: login hardening, financial race fixes, scheduler and attendance load
Released as 5d29abb41 on 2026-10-12 at 05:27 UTC by release run 38207971990. Migration 6a01dc7a606b. Record: DEPLOY_2026-10-12_5d29abb41.md. The F1–F4 payment entries below (#1534) were first filed under [2.2.2], but #1534 merged after the v2.2.2 tag; they shipped in this release.
Fixed
-
Rent and insurance premium payments no longer overdraw checking (financial-correctness audit F1–F4, 2026-10-10; DOM-OBL-001 3.3 §II.C /
SPEC-ECON-003§4.5.1.1, owner ruling 2026-10-07). Three live payment paths accepted a payment the seat could not cover and posted it anyway:- Rent (
pay_rent,FEAT-OBL-001) applied its resolved Ledger plan with no affordability check; a student could pay rent into a negative checking balance, and the obligation was marked paid regardless.pay_rentandpay_rent_billnow refuse withINSUFFICIENT_FUNDS— leaving the obligation unpaid, owed, and subject to late fees — when checking, together with any full overdraft-protection transfer, does not cover the payment. A bill payment (rent + its late fees) is checked against its full combined amount before any slice posts, so a bill is never partially charged. - Insurance first premium (
execute_purchase_insurance,FEAT-OBL-004) debited the premium with no balance read at all. It now refusesINSUFFICIENT_FUNDSbefore any mutation — no entitlement grant, bill cycle, assessment, or ledger row — the same "no agreement was formed" outcome as a refused Store purchase. - Manual insurance premium payment (
execute_insurance_premium_payment,FEAT-OBL-003) had no affordability check at all, unlike the automatic-renewal path (_attempt_autopay), which already refused. Both now share onepremium_is_affordablecheck ininsurance_premium_payment_feat.py.
FEAT-OBL-003§1.a andFEAT-OBL-004§VII/§VIII/§XII are amended (1.2 and 0.4) to withdraw their stale "checking-only affordability rejection does not apply" wording, which predated the 2026-10-07 owner ruling. - Rent (
-
Rent payment amounts are validated before any mutation (same audit, F4). A
NaN,Infinity, negative, or sub-cent (e.g.$0.001) payment amount raised mid-write or silently rounded to an amount different from the one reported paid.pay_rent/pay_rent_billnow refuse any amount that isn't a positive, finite, whole-cent value withINVALID_AMOUNTbefore touching the ledger. An extreme-but-finite amount (e.g.1e999999) is also refused rather than raisingdecimal.InvalidOperationout of the whole-cent check (PR #1534 review). -
A double-click or back-button retry of a payment this command already partly or fully posted no longer sees
INSUFFICIENT_FUNDS(PR #1534 review, same audit). The F1–F4 affordability checks above re-evaluated the request against the current balance on every call, including a replay — so a retry with the same idempotency key, arriving after its own first call already spent the money it needed, was wrongly refused instead of cleanly replaying its already-accepted charge.pay_rent_billandsettle_insurance_premiumnow check for an existing command reservation first and only screen a genuinely fresh command's affordability; a true replay reaches the existing idempotent Ledger write path unconditionally. Fixing this also surfaced and corrected a related bug inpay_rent_bill'sremaining_after: it was computed asgroup_remaining - total_paid, which double-counted an already-applied amount on replay and under-reported what was still owed. -
A bulk manual payroll credit could pay the same students twice on retry (audit finding F5). Each selected student's credit committed in its own FEAT transaction, keyed by a submission-wide nonce that was freshly randomized on every POST. A mid-batch failure (a lock timeout on student 15 of 30, say) left the first 14 already paid, and the teacher's retry — the only recovery the UI offered — minted new idempotency keys for every student and paid those 14 a second time. The nonce is now derived from the submission's own content (teacher, class, selected students, description, amount) within a 5-minute window, so an immediate retry of the identical batch replays the same per-seat ledger effect instead of creating a new one; the same batch run again outside that window (a genuinely new payment) still pays normally.
-
Two overlapping rent-reconciliation runs could double-assess the same obligation (audit finding F8).
assess_obligation()'s idempotency check is a plain read before the insert, with no row lock, and the one guard that used to exist — a unique index onassessment_events.correlation_id— was deliberately dropped in8a7b6c5d4e3fbecause one correlation_id legitimately carries several event types (ASSESSMENT, PAYMENT, WAIVED). Migration6a01dc7a606badds a partial unique index on(internal_ref, correlation_id) WHERE event_type = 'ASSESSMENT', which protects exactly FEAT-OBLI-001 §IV.3's promise — one ASSESSMENT per lineage — without resurrecting the blanket uniqueness the prior migration correctly removed. (A full(internal_ref, correlation_id, event_type)constraint was rejected: it would make a second partial PAYMENT on the same obligation an IntegrityError.) The index build fails if duplicate ASSESSMENT rows already exist; production was checked read-only on 2026-10-12 at revision35a9a0d3ab93and has none.assess_obligation()now recovers the winning row on a constraint collision instead of surfacing an error to the scheduler. -
A scheduled job still running could be double-run by a new scheduler owner (audit finding O5).
app/scheduler_ownership.py::_release_ownership()calledscheduler.shutdown(wait=False), which returns immediately, then closed the advisory-lock connection on the very next line — releasing the lock for a waiting successor while the old job's worker thread could still be executing. Release now waits for the scheduler's executor threads to actually finish (wait=True) before the connection, and the lock it holds, are released. -
Two concurrent hall-pass requests from the same seat could reserve the same pass twice (audit finding F6).
submit_hall_pass_requestonly locked a seat's existing pending-request row before clearing it and checking entitlement availability; a seat with no pending row yet has nothing for that to lock, so two near-simultaneous submissions could both read "nothing pending," both find the same available grant, and both insert — two pending rows reserving one pass instead of the second genuinely replacing the first.app/feats/hall_pass_request_feat.py::submit_hall_pass_requestnow takes an unconditionalwith_for_update()lock on the seat row as its first statement, serializing the pair the same waytransfer_feat.pyandstore_purchase_feat.pyalready serialize theirs. Proven against the emitted SQL (statement order), not a hold-and-timeout race —pending_actionscarries a foreign key toseats, so an INSERT into it already takes an implicitFOR KEY SHARElock on the parent regardless of whether the explicit fix is present, which made a first draft of the regression test pass unchanged with the fix reverted. -
The "max students out" hall-pass limit could be exceeded (audit finding F7), two ways. First, a concurrency race:
_enforce_hall_pass_settingscounted today's approved/out hall-pass logs for the class with no class-wide lock, so two concurrent approvals for two different students could both read the same "currently out" count before either had inserted its row, and both pass the check.app/feats/prod.pynow locks theClassEconomyrow (with_for_update()) before counting, as the first statement of the approval path. Second, a logic gap, independent of any race: the count only included passes already in the"left"state, so a student approved but not yet tapped "Left Class" didn't count against the limit at all — rapid-fire approvals with nobody yet walked out always read zero and all succeeded, defeating the limit entirely. The count now includes both"approved"and"left"states. Both proven in real PostgreSQL: the lock by statement order (same false-positive trap and fix as F6, sincehall_pass_logsalso carries a class foreign key), the logic gap by a plain behavioral test confirmed to fail with the fix reverted. -
The teacher roster's balance figures could read a transaction's money as settled by neither "posted" nor "pending" (audit finding F9), and "earnings" overcounted non-earned credits.
get_batch_balances_by_class_seat(the only caller isroster_view_model.py, behind the teacher's Student Management roster) read the posted snapshot and the pending-transaction sum as two separate statements — exactly the race_available_balance_expression's own code comment already documents for the single-seat read (bug #1364): a settlement committing between the two round trips moves a transaction out of "pending" after the first statement already read the stale posted figure, so the amount is counted in neither. The batch read now computes both in one statement, the same model_available_balance_expressionalready uses. Separately, "earnings" only excluded "Transfer"-prefixed deposits, so the automatic overdraft-protection funding leg (typeDeposit) and reversal refund credits (typeREVERSAL) both inflated a student's reported earnings; the exclusion list now covers both. Proven in real PostgreSQL: the statement-count fix by counting emitted SQL statements (the only reliable way to prove "one round trip," since no table-level locking trick distinguishes it the way F6/F7's did), the earnings fix with a real purchase-and-refund via the production reversal path (a hand-builtREVERSALrow is rejected by theenforce_ledger_recovery_cap()tr...
Classroom Token Hub v2.2.2
Deployed to production 2026-10-11 00:23 UTC as 61b509b7f (release workflow run 38098238705). No migration. Release record: docs/ops/audits/DEPLOY_2026-10-11_61b509b7f.md.
Released as 61b509b7f on 2026-10-11 at 00:23 UTC (record). It ships #1535 (GHSA-7fxj-gpw3-23g3) on top of the untagged c2f16fb11 hotfix (#1532). No migration.
Security
- Stored script injection via hall-pass destination and announcements (S1, Critical; S4, Medium; 2026-10-10 audit; GHSA-7fxj-gpw3-23g3). A hall pass's
destinationis student-supplied free text with no enum check against the class's configured pass types, and was rendered unescaped in two places: the teacher's hall-pass history table (templates/admin_hall_pass.html, built viatbody.innerHTML), letting a script-bearing destination run in the teacher's session once the request was approved and the history tab opened; and the student's own dashboard server-state blob (templates/student_dashboard.html,attendance_state_json|safe), letting the same payload run in the student's own session immediately on submission. Separately,nl2br_filter(app/__init__.py) marked a teacher's raw announcement message safe with no escaping, so a hijacked or malicious teacher account could run script in every student's dashboard in that class. All three sinks now escape before introducing trusted markup; no stored or rendered data changes shape. Reach assessment: the exploit chain was reproduced end-to-end pre-fix in a real browser and confirmed to reach full teacher authority on the compromised account (the app's CSP permitsunsafe-inline/unsafe-evaland the CSRF token is DOM-readable); no cross-tenant or sysadmin path was found or exercised. A read-only check of production data found no evidence either sink was ever exploited there. Accessibility: no issues found or fixes needed — the diff adds no new DOM elements or visible markup, only escapes existing attacker-reachable text and switches one hidden data node back to default autoescaping; CI's Accessibility Gate passed on the changed templates (a local axe-core run was blocked by a sandbox-only Chromium mismatch, not attempted as evidence). (#1535)
Fixed
- Reverse no longer appears on the $0 "item removed" entry a purchase reversal writes (#1532; owner report 2026-10-10). Reversing a purchase writes a refund (
REVERSAL) and a $0void_item_removedentry in one operation (SPEC-OPS-001§3.5); both are the compensation, which is terminal (§3.1, §3.6). The student page offered Reverse on the second, and pressing it failed with an error. One list,REVERSAL_RECORD_TYPES(app/services/ledger_correction_service.py), now names a reversal's rows: the student page offers no Reverse on them, the reverse action refuses them with "This entry is part of a reversal and can't be reversed." before anything is written, andreverse_transactionrefuses them too. Tests intests/test_collective_goal_purchase_reversal.py, each confirmed red with its guard removed.
Classroom Token Hub v2.2.1
Deployed to production 2026-10-10 20:27 UTC as f98d8a206 (release workflow run 38083783000). One migration, 35a9a0d3ab93 (EXPAND only). Release record: docs/ops/audits/DEPLOY_2026-10-10_f98d8a206.md.
Released as f98d8a206 on 2026-10-10 at 20:27 UTC (record). It ships #1530 on top of v2.2.0, plus AGENTS.md, which changes no application behavior. One migration, 35a9a0d3ab93 (EXPAND only).
Fixed
- A purchased hall pass can be reversed (owner ruling 2026-10-08). Reversing a purchase removes every item it paid for (
SPEC-OPS-001§3.3), hall passes included; DOM-STORE-001's direct-grant rule governs removing a pass on its own, not a reversed purchase. v2.2.0 wrongly excluded hall passes, so a 1,000-pass purchase made on 2026-10-08 without the money could not be reversed. The reversal stays all-or-nothing: a purchase with any pass already used is refused, with nothing changed.
Changed
-
Using a hall pass writes nothing to the entitlement history (owner ruling 2026-10-09). The hall-pass log is the only consumption record: approval names the pass's
entitlement_idinhall_pass_logs.hall_pass_id, asFEAT-PROD-002§III andDOM-STORE-001§VIII.E.6 require, and no longer also writes a StoreCONSUMEDevent. One rule now decides whether a pass is spent (a terminal event, or a log naming it), and every reader uses it: the balance, which pass is used next, teacher removal, rent-perk expiry, item status, the store's held count, rent privileges, insurance's claim basis and the reversal check. Historical hall passes carry both records and read the same.consume_hall_passis removed and registered as a deprecated symbol (SOP-DB-0022.4). -
An item with a request still waiting can't be reversed (owner ruling 2026-10-09), for every item type: a hall-pass request or a delayed-use redemption. The teacher first resolves the request without using the item: Return a redemption, or reject a hall-pass request (or the student cancels it). The item is then neither pending nor used, so it can be reversed or refunded (
DOM-SUP-001§IX,SPEC-OPS-001§3.1A). Accepting uses it, and denying a redemption revokes it. The Reverse button, the reverse action and issue resolution all apply it. -
A hall pass with a request waiting is reserved for it (owner ruling 2026-10-09). The lifecycle is:
- Available: usable, reversible or refundable under its rules.
- Pending request: a request names one particular pass, and its availability is decided when it is submitted (
DOM-STORE-001§IX). While it waits, nothing ends that pass: reversal and refund refuse; teacher removal, rent-perk expiry and a direct use skip it. - Rejected (or cancelled by the student): the reservation is released and the pass is usable again.
- Approved: approval uses exactly the named pass, consuming it whether or not the student leaves.
- Expired: terminal. Expiry never happens while a pass is reserved.
Hall-pass requests are resolved on the Hall Passes page (
FEAT-PROD-002§III.A), not in the Store's redemption dialog, and the Reverse refusal says so. -
Ending a hall pass is serialized. A pass ends by use (a hall-pass log) or by a
REVOKEDorEXPIREDevent, and those live in different tables, so no index can stop an approval and a reversal, removal or rent-perk expiry from both ending the same pass. Every one of those writers now takes the same lock on the holder's seat row (lock_hall_pass_holder) before deciding whether the pass is spent; reversal takes it before its eligibility read. -
A hall pass can be spent only once, enforced by the database. Migration
35a9a0d3ab93adds the partial unique indexuq_hall_pass_logs_hall_pass_id(DOM-CORE-0021.17). It replaces the protection the one-terminal-event index gave while use also wroteCONSUMED. Approval also locks the student's seat, so two approvals at once take two different passes instead of colliding. The migration refuses, changing nothing, if any pass is already logged twice; production had 17 logs on 17 distinct passes. EXPAND only and reversible. -
The teacher guide's student-detail page describes Reverse as it works today (it still described a Void button), including which entries can be reversed.
Classroom Token Hub v2.2.0
Deployed to production 2026-10-09 07:10 UTC as 5ebae62db (release workflow run 37897523165). No migration. Release record: docs/ops/audits/DEPLOY_2026-10-09_5ebae62db.md.
Everything merged to main since v2.1.1, released as 5ebae62db on 2026-10-09 at 07:10 UTC (record). Three untagged releases shipped part of it (SOP-DB-004 §X.4):
381a12d49(2026-10-05 01:25Z): support notifications (#1488), the backup-tool documentation (#1485) and the security fixes (#1486);35bad089a(2026-10-05 05:13Z): the sysadmin UI shell (#1489);1cdbf03da(2026-10-06 03:10Z): teacher-ticket routing (#1491).
The last two have no release record. Against 1cdbf03da, which production runs, this release changes application behavior in four places: #1522, the hall-pass settings gate and preset removal; #1526, under which a purchase needs the money, a collective goal is one buy-in per student, and a collective-goal purchase and the NSF fee can be reversed; #1527, which drops a cross-class query that ran on every page render; and #1528, which removes the sysadmin console's log viewers (logs are read in Grafana). The rest is documentation, tests and CI:
- the P0A migration contract (#1518, #1519, #1521);
- the P0B record (#1523);
- the every-PR deprecated-symbol guard (#1524).
There is no schema migration and no change to the application's Python dependencies (requirements.txt). The only dependency change is the docs-site npm update in #1486 (docs-site/package.json and package-lock.json, build-time only for the static docs site), which shipped in 381a12d49. MINOR under SOP-DB-004 §X.3, because #1522 changes a capability: hall passes are unavailable until the teacher saves settings.
Added
- P0B production-state reconnaissance and baseline (#1495).
docs/ops/audits/RECON_2026-10-06_P0B_PRODUCTION_STATE.mdrecords, from read-only aggregate queries against production at revisionf9a3c7d1e620(2026-10-06 06:08-06:11Z), the timestampedSOP-DB-004§IX.1.7 baseline (row counts per table and class; ledger equals posted snapshot in all 14 class/account pairs). It also records the persisted semantics and runtime writers each of U1-U14 would touch, and twoH5items the owner ruled on 2026-10-06. First, all 854 signature-v1 ledger rows storefeat_code = FEAT-LED-003(the settlement restamp fixed in81ea0fff5); their originating FEAT is taken from the linked creation audit event, and actor attribution is unavailable rather than system. Recording that ruling inDOM-OPS-002is left to U13. Second, 14 of 16 hall-pass logs storepolicy_uuid = 'default'; they are kept as historical facts with their policy terms unavailable, and the live writer is fixed separately in #1522. The queries are indocs/ops/audits/evidence/2026-10-06_p0b-recon/recon_readonly.sql. No PII was read and nothing in production changed. - Deprecated symbols are scanned on every pull request.
DEPRECATED_SYMBOLS.txtwas enforced only by the Schema Change Gate, which runs on pull requests touchingmigrations/versions/**or the model files and skipped the scan forNON-MODEL CHANGE. So a route, template or script that namedClassMembership,is_bundleorget_default_pass_typesmerged unscanned. The scan is now a pytest guard (tests/test_deprecated_symbols_guard.py, detector intests/guards/deprecated_symbols.py) thatpolicy-guardrails.ymlruns on every pull request and push tomainwithout a database. The Schema Change Gate runs the same guard in place of its inlinegrep. The scope now includes first-party JavaScript (*.jsunderapp/,templates/,scripts/andstatic/, excludingstatic/vendor/). Mutation proofs plant near-miss violations: an aliased import, a reflective string, a longer identifier, a template attribute,static/jsand the service worker. The guard also fails on an empty registry, or one that disagrees with SOP-DB-002 §VI. No symbol was added or removed (SOP-DB-002v2.3). - Optional IFTTT email alerts for direct teacher support filings and teacher-escalated student tickets (DOM-SUP-001 v1.8; FEAT-SUP-001 v1.1). The existing app sends one HTTPS request after commit, containing only a fixed event label, opaque ticket reference, and UTC timestamp. Delivery failure preserves ticket success. Setup: SOP-OPS-002 — Support IFTTT Notification Setup; no new worker or schema change.
- Compatibility ruling written into the governing documents.
INV-ARC-008v1.2 removes the "explicitly approved bridge code" allowance from §VI (Student ID Quarantine);DOM-OPS-002v1.11 §6.2A andSPEC-PROD-002v1.4 §VII state that historical diagnostics and reconstruction are read-side interpretation that creates no state;SOP-DB-004v1.2 §VII.8 andSOP-DOC-001v3.25 follow. Documentation only; no code or schema change. - Forward-only revision register, enforced.
migrations/forward_only_register.txtlists every Alembic revision whosedowngrade()raises or does nothing (34 today, including the three attendance/payroll recovery revisionsSOP-DEP-001§XIV named; the prose list had missed the rest).tests/test_forward_only_revision_register.pyderives the set frommigrations/versions(detector intests/guards/forward_only_revisions.py) and fails when the register is missing a revision or lists one that is not forward-only, so a new forward-only revision must be registered in the PR that creates it. Mutation proofs cover direct and conditionalraise, a raise hidden in a helper, no-op spellings, a missingdowngrade(), and a merge that does work; reversible downgrades and pure merges stay quiet.SOP-DB-004v1.1,SOP-DEP-001v2.8 andSOP-DEP-002v1.8 point at it;SOP-DEP-002andSOP-DEP-001no longer cite archivedSOP-DB-009or the nonexistentSOP-DEP-016as dependencies. The "does nothing" test is positive (noop.*or.executecall, directly or through same-module helpers), so logging or control flow around a no-op cannot hide it. The test runs in the migration PR workflow (check-migrations.yml, no database needed), which now also triggers on the register and guard files. No schema or runtime change. - SOP-DB-004: Live-v2 migration and semantic-versioning contract. Defines, for changes made after 2026-10-06, how a change is classified (Migration Class
M0-M4, Historical StateH0-H5, Risk), the compatibility rule for existing history (schema coexistence and version-keyed read interpretation allowed; dual-write, fallback and heuristic interpretation prohibited; unversioned history blocked for an owner ruling), the evidence required before, during and after cutover, the rollback decision (code redeploy only forM0/M1; otherwise snapshot restore or reviewed fix-forward), and when a release is MAJOR, MINOR or PATCH. Documentation only; no schema or code change.SOP-DB-001v1.7 drops "application supports both" from expand/contract Phase 1 to match. - Accessibility Gate: axe-core runs in Chromium on every template-affecting PR, and every changed template must have an accessibility render path. The per-PR check previously ran only the static
tests/test_accessibility.py, which skips every template that extends a layout (so it audited none of the app pages)..github/workflows/accessibility-gate.ymlnow also runstests/test_axe_app_pages.py,tests/test_axe_fixture_pages.pyandtests/test_axe_compliance.pywithAXE_REQUIRE_BROWSER=1(a missing browser fails instead of skipping) whenever a template, CSS, template-driven JS or the harness changes.scripts/check_axe_template_coverage.pyfails the PR when a changed template is neither rendered by the real-route sweep nor by a registered fixture state (directly, or via extends/include/import). Fixture states (tests/a11y_fixtures/) render a template from a deterministic presentation fixture through the real Jinja environment, CSS and JavaScript, under a guard that fails on any SQL statement, and are audited by axe; they prove a supported presentation state is accessible, not that a route assembles it. The route sweep gained the public auth pages, announcements, attendance log, class creation, payroll correction and the student store, rent and insurance pages; 54 fixture states cover the error pages, auth flows, student detail (every tab), claims, issues and the rest.tests/axe_coverage_exemptions.jsonis empty: all 102 templates are covered. SOP-TEST-002 v1.1 §X documents the contract, how to register a state, and the narrow exemption criteria. Gate tests, with mutation proofs:tests/test_axe_fixture_gate.py,tests/test_axe_template_coverage_guard.py. The check blocks merging only once it is marked required in branch protection.
Security
- CodeQL: roster import and unclaim no longer echo exception text.
/admin/upload-studentsand/admin/student/unclaimreturnedstr(error)from anyValueError. They now return a message only if it is inROSTER_ACTION_MESSAGES(app/feats/identity_feat.py), which lists the teacher-facing messages those FEATs raise; any otherValueErrorgets a generic message. Tests:tests/dom/identity/test_roster_action_messages.py, including a guard that fails when a FEAT raises a message missing from the list. - CodeQL: docs redirect to the external site.
_redirect_to_public_docspercent-encodes the reader-supplied path, refuses.and..segments, and checks the target still has the configured docs host. Tests intests/dom/docs/test_docs_platform_split.py. - Docs site npm advisories. The
docs-sitelockfile movesfast-urito 4.2.1,brace-expansionto 5.0.12 an...
v2.1.1 — Attendance correction, store redemption decisions, no student names in logs
Deployed to production 2026-10-04 20:38 UTC as 2bdfac65e (release workflow run 37232860617).
Everything merged to main since v2.1.0, including #1477 and its four migrations, the sysadmin logout change (#1482), the backup proposal (#1481, not installed) and the performance fixes for #1478 and #1479 (#1483). It includes the untagged production release of 2026-10-03 (ad9574334, #1466-#1470). The release SHA is the tip of main chosen at release; the date, the tag and the release record (docs/ops/audits/DEPLOY_<date>_<sha>.md, drafted as DEPLOY_PENDING_v2.1.1.md) are filled in when the operator deploys. Three of the four migrations cannot be downgraded: rollback past them is a database restore, not a redeploy.
Testing
- Full-suite fixtures now create immutable payroll sources with real audit lineage, build historical migration inputs forward from pinned predecessors, and preserve historical monetary evidence. The full-suite workflow installs Chromium for browser checks; balance verification prints the canonical posting-state value across Python versions.
Accessibility
- Store redemption controls respect hidden state. The hidden decision/action containers now contain their flex layout instead of carrying a conflicting Bootstrap display utility. Student note line breaks use stylesheet rules; attendance disclosure padding uses the canonical spacing token.
Performance
- Posting state is projected once per list, not queried once per row (#1478) —
Transaction.posting_statereads the scoped reconciliation cursor with one query per access, so transaction loops issued one query per row and could autoflush. New pure reads inledger_balance_query_service(resolve_reconciliation_cursors,classify_posting_state,project_posting_states) resolve every(class_id, seat_id, account_type)cursor a list needs in oneno_autoflushquery and classify in memory with the same rule as the hybrid SQL expression, which is unchanged. The savings-interest ledger reconstruction, the transaction list view builder and the teacher transactions page use it. Tests:tests/dom/ledger/test_posting_state_projection.py(agreement with the SQL expression, class/seat/account isolation, boundary cases, constant query count, no autoflush). - Batch creation proof walks each class audit chain once (#1479; DOM-OPS-002 1.10 §6.2C) — Attendance correction previews, historical payroll reconstruction, the historical attendance assessment and reversal-input collection proved each record with
verified_creation_evidence, which re-verifies the complete class chain per record (O(rows × chain length) HMACs). New Operations queryverified_creation_evidenceswalks the chain once per invocation between two scalar head reads, within server-owned chain and row budgets, then applies every row-specific creation-proof check. Budget exhaustion, a head that changes during the read, a missing head or key, and infrastructure failure return unavailable evidence; chain or head contradictions return invalid; neither authorizes recovery. No proof cache, verification flag or bypass is added, and the single-row query is unchanged. Tests:tests/dom/ops/test_creation_evidence_batch.py(parity with the single-row proof, HMAC and query counts, budgets, head changes, malformed chains, purity).
Attendance correction
- Teachers can inspect completed attendance intervals and invalidate eligible work using a labelled reason, consequence preview, and explicit confirmation. The modal supports keyboard dismissal, focus management and restoration, readable errors, and mobile controls. Unpaid work leaves payroll eligibility; proven paid contributions recover atomically under a shared cap while original scans, payments and signatures remain unchanged. Intervals bounded at class day end without a closing event are identified separately from work still open and cannot be invalidated. Ledger posting derives from scoped reconciliation cursors; its migration is forward-only and requires a read-only production preflight.
Removed
scripts/backup-database.shandscripts/restore-database.sh— v1-era and never scheduled. They wrote plaintext gzip dumps to the droplet's own disk and restored over the live database through agunicornunit that no longer exists. Replaced byinfra/db-backup/.- Store bundles (SPEC-STORE-001 1.5; schema gate CONTRACT (CODE ONLY), SOP-DB-003) — A bundle ("a 3-pack") and a bulk purchase ("3 at a bulk price") already stored the same way, as N independent entitlements under one charge, and since DOM-STORE-001 5.4 every redemption verdict moves no money for either, so a bundle was a second concept with no distinct behavior. A pack is now a purchase quantity at a bulk price: a $5 item with "3+ for 20% off" sells three for $12, and a student can still buy one at $5.
- Removed from the item form, its type-gating JS and form contract (the
multi_unitrule),store_servicevalidation and its definition allow-list (a definition still carrying a bundle field is refused as unknown), the policy resolver, the purchase multiplier (units_to_grantis the purchase quantity), the admin and student store cards, the student buy modal, and the redemption modal. - The
StoreProductmodel attributes are removed; the twostore_productscolumns stay until a separate CONTRACT (DATABASE) migration drops them. The flag column defaults tofalsein the database, so inserts that omit it stay valid. Production held no bundle product on 2026-10-03 (read-only check: no row with the flag set or a bundle quantity among 15 versions), so nothing on sale changes. - Both symbols join
DEPRECATED_SYMBOLS.txtand SOP-DB-002 §VI (2.1). SPEC-STORE-001 1.5 replaces the bundling rule with the purchase-quantity rule and pairs bulk discount, not bundle, with collective goal (§V.B). - The teacher guide
store-bundles-goals.mdbecomesstore-bulk-goals.md, with a "selling a pack" recipe; the store-items, pricing, redemptions and student browse guides drop bundles. - Tests:
tests/test_store_sale_mechanics.pynow proves the multi-unit grant for a purchase quantity and the pack-by-bulk-discount recipe;tests/test_store_policy_resolver.pyrefuses the removed fields; the gating tests use fields that still exist.
- Removed from the item form, its type-gating JS and form contract (the
Changed
- One Redemptions tab replaces the pending table and the Redemption Audit tab on Economy > Store — The Overview's pending table showed a dash for every student note: it read
redemption_details, but requests storedetails. The Redemption Audit tab printed each pending action's raw payload. The new Redemptions tab lists Waiting for your decision (oldest first) above Decided (newest first). Clicking a request opens a modal with the student, the item and its description, how the student got it and what they paid, the redemption prompt and the student's answer, and what Accept, Deny and Return will each do. Each decision has its own confirm step; a decided request opens read-only. The Overview keeps a one-line summary with a link to the tab. On the dashboard, each pending redemption now shows the student's note and links to its review (/admin/store?request=<id>#redemptions) instead of offering inline buttons. Read side: newapp/services/redemption_query_service.py. Requests denied under the old behavior still show under Decided, marked as kept by the student. A returned request leaves no durable record (no entitlement event, and §VII.B deletes the pending row), so it does not appear under Decided. The teacher guidestore-redemptions.mdis rewritten to match.
Added
- Proposed: encrypted off-host database backups with a post-destruction baseline (not installed) — Production has no backup of any kind (tracker §IV-B).
infra/db-backup/cth_db_backup.pytakes a snapshot-consistentpg_dump, encrypts it withageto the owner's public key, restores the same byte stream into a scratch database and checks every table count and the Alembic revision against counts taken inside the snapshot, uploads the ciphertext off-host and re-hashes it there. systemd timers run it nightly at 03:15 Pacific and check every 15 minutes whether a seat, class or user in the newest point has been destroyed; if so, a replacement baseline is taken and, once verified, every older point is deleted off-host and on the host.statusreports CURRENT, STALE or DEGRADED.restoreonly restores into an empty database and, given--live-urland a reachable live database, refuses cutover if the restored data would bring back a destroyed seat, class or user (INV-CORE-000 §III.5); without one it refuses unless told the live database is lost, and then marks the result unverified. The same tool takes the one-off pre-release backup from the operator's Mac. Plan, restore runbook and failure-mode questions for the owner's review:docs/ops/DATABASE_BACKUP_PLAN.md; install sheet:infra/db-backup/README.md. Tested against a local database built by the real migration chain; not run against production. - Login page notice, posted from a GitHub Action — A manually run workflow, Login page notice (
.github/workflows/login-notice.yml), posts a short notice (message plus an expiry time and time zone) or clears it. It writesinstance/login_notice.jsonon the production host over Tailscale; the student and teacher login pages show it as a guardian-gray card headed Scheduled maintenance until it expires (white on#303030, about 13:1 contrast;role="status",h2under the page'sh1), with no deploy or restart. A missing, malformed or expired file shows nothing and never breaks sign-in. Reader:app/utils/login_notice.py; runbook:docs/ops/LOGIN_NOTICE.md; tests:tests/test_login_notice.py. - The status page has a Hall passes line (SPEC-OPS-006 v1.4, DOM-OPS-001 2.12, ratified by the owner 2026-10-02) — On 2026-10...
v2.1.0 — Payroll settings, OPS-DB-001 fixes, student removal
Deployed to production 2026-10-02 04:47 UTC as 5ac05ea6f (release workflow run 36966092454).
Everything merged to main since v2.0.1. It includes the untagged production releases of 2026-09-29 (bc5c07a2, eaca2a7e, 29b99b14, 00166e56) and 2026-09-30 (314158d53). The release commit is set when the tag is cut, and the release record is written after deploy.
Added
- Teachers are told once when students work before payroll is set up (2026-10-01) — Start Work does not need payroll, so a class can log hours before it has a payroll setting. The teacher dashboard and the payroll page now show "Students are working, but payroll hasn't been set up yet." with "These hours will be paid at your first rate once you set up payroll.", a Set up payroll link and a Dismiss button. It shows only while the class has no payroll setting, a claimed student has a session in progress or finished, and the teacher has not dismissed it. Once the first payroll setting exists it never shows again. Unclaimed seats do not count, and students see nothing.
- Dismiss is a CSRF-protected POST through the new
FEAT-CLASS-008, which acts as the class's teacher seat after the ownership check. It setsclasses.unpaid_work_notice_acknowledged_atonce (migrationa4b50fee84c3); a second dismissal keeps the first timestamp, and a dismissal from a tab left open after payroll setup is recorded without error. Rendering either page writes nothing (INV-ARC-007). - The condition is composed by
app/services/unpaid_work_notice_view_model.pyfrom the payroll settings service, the newclass_has_claimed_student_workattendance read and the class's acknowledgement. - Normative text:
DOM-PROD-0011.7 (§XIII.7, §XV.6),DOM-CLASS-0013.6 (§VII.1, §VII.3: payroll cannot be turned off), newFEAT-CLASS-008,SOP-DOC-0013.11. Owner rulings 2026-10-01. - Accessibility: the notice is a labelled region with an
h2, not a live region, and Dismiss is a real keyboard-operable form button. On the payroll page's Settings tab, the "No payroll settings yet" card was arole="alert"with a browser-only close button; it is now a static "Set up payroll" helper, so the page has one payroll-setup alert, not two. The Set up payroll link opens the Settings tab.
- Dismiss is a CSRF-protected POST through the new
- One-time payroll correction for incident PROD-PAY-001, reviewed by the teacher (2026-09-28) — Restores the pay that the earlier payroll rule left unpaid on 2026-09-28.
app/services/payroll/corrections.pyreplays what each earlier-rule payroll run paid, compares it with the time worked inside that run's window, and proposes the difference per student. The replay must reproduce the amount the ledger actually paid; a student where it does not is shown as needing review and cannot be approved. A banner on the payroll page and dashboard leads to Payroll → correction (/admin/payroll/correction), which explains the issue, lists each student's unpaid time and proposed amount, and lets the teacher exclude students. Amounts are not editable and are recomputed on the server at approval. Each approved correction is onemanual_creditwhose actor is the approving teacher's seat and whose mechanism isSYSTEM(system-calculated;SYSTEMcarries no authority to post without the teacher). A deterministic per-student key means no student can be corrected twice. Viewing the page writes nothing. The tool closes after October 31, 2026 and will be removed in a later release.
Changed
- The status page and operator console use the app's own design, and speak to teachers and students (2026-09-30) —
status.classroomtokenhub.comread like an engineer's page: an all-caps verdict (APP REACHABLE), notices led by investigation references and enum states, and p80/p95, HTTP 404/500/5xx rates and 90-day tables open on the page under a six-paragraph methodology note.APP REACHABLEand the other labels were an agent's wording (d4fd99b96), not a ruling; SPEC-OPS-006 §VIII is amended to v1.3 to carry the replacements.- Public page. The hero is the public site's landing hero: wordmark, gold rule, then Is Classroom Token Hub Working? and one state pill, which is No known issues, Detected problems, Mostly unavailable, Under maintenance or Unknown, with precedence fixed in §VIII. No known issues claims only that nothing is known to be wrong. Notices lead with the impact and a What to do box; the investigation reference is a footnote. Areas are named after what teachers do (Signing in, Attendance, Payroll, Student roster, Store, rent & insurance) and read Working, Slower than usual, Checking: errors seen, Nothing to report, Quiet or Status unknown. An operator notice for an area outranks the measurement: Checking reports for
AWARE, Having problems otherwise. A fresh 5xx is still an automatic observation and never creates a notice (DOM-OPS-001). Measurements, platform checks and per-area history stay on the page, closed by default; a right-hand column shows the 90-day strip in words and the last three resolved incidents. The footer is the public site's footer with absolute links. - Under maintenance comes from the access gate. Cloudflare Access is the only availability gate (DOM-OPS-001), and outside operational work nothing stands in front of public pages. The collector now sends one uncredentialed request for a static asset; a redirect to a
*.cloudflareaccess.comhost isGATE_CLOSED. The service-token health read cannot stand in for this, because thereACCESS_DENIEDalso means a lapsed token. Platform records move toplatform-check-v2with a thirdgatecheck; v1 records stay readable. A malformed HTTP response (http.client.HTTPException, such asBadStatusLinefrom a broken proxy) is not anOSErrorand previously escaped the collector, losing that minute's record; on all three fetches it is now recorded asTRANSPORT_UNAVAILABLE. While the gate is closed every area reads Closed for maintenance, since requests from behind the gate cannot make an area usable. - New
AWAREnotice stage (SPEC-OPS-002 v1.6): something may be up, not yet confirmed. It precedesINVESTIGATING, is active and resolvable, and is the default for a new issue. - Operator console. One issue at a time, in the sysadmin identity (
body.sysadmin-shell). Open issues and automatic detections sit in a rail; selecting an issue carries its notice id and reference forward, so an update no longer needs the reference retyped. Area and stage are plain-language choices that still submit the stored values. Next update is a quick pick (30 min, 1 h, 2 h, a chosen time, or none); typed times are the operator's local time, echoed in UTC, and stored in UTC. A live preview renders the public notice card from the same macro. Resolving is a mode of the selected issue, with the other open issues offered as checkboxes. - Past incidents uses the same hero and footer, groups incidents by month, shows each one's start, end and duration, leads with the resolution message, and lists every update in order with the stage label readers saw.
- Times. Readers see their own local time; each time is a
<time datetime>written in UTC by the server, so the page is unambiguous without JavaScript, andstatus.jslocalises it. Storage, logs and evidence stay UTC. - Design system.
status_service/static/tokens.cssis a byte-identical copy ofstatic/css/tokens.css(SPEC-DES-001 v2.3 §XIII, gate R10), andstatus-overrides.cssis replaced bystatus.css, whose every design value resolves to a token. The old sheet had 207 raw sizes and no spacing tokens because the public token block it drew on defines no scales. - Accessibility. An axe WCAG 2 A/AA audit of every page and state (incident, maintenance, Past incidents, and the console's update, resolve, new-issue and detection-draft views, at 1440 and 412 px) found one defect: green text on the green chip,
--successon--success-subtle, is 4.41:1. A contrast-pinned--alert-success-text(#1f5c42, 6.94:1) joins the three existing alert text tokens instatic/css/tokens.css(SPEC-DES-001 §VI.6), and the status chips and pill use it; the audit then reports none. The app's ownbg-success-subtle text-successbadges share the defect and are not changed here. Every status shows a word as well as a colour, every time is a<time>element, and the decorative wordmark and icons are hidden from screen readers. - Tests:
tests/test_status_page.py,tests/test_status_platform.pyandtests/test_status_resolution.pycover the hero precedence, area labels, notice precedence, maintenance override, every gate outcome including a lapsed token and host near misses, an uncredentialed probe, v1 compatibility, theAWAREstage, quick and local-time next updates, and the console's carried-forward identity. Eight deliberate mutations (any redirect closing the gate, a suffix match without the dot, a credentialed probe, a bare 403, maintenance losing to a failed check,AWAREdropped from the active states, area cards ignoring notices, and the maintenance override removed) each turn at least one test red.
- Public page. The hero is the public site's landing hero: wordmark, gold rule, then Is Classroom Token Hub Working? and one state pill, which is No known issues, Detected problems, Mostly unavailable, Under maintenance or Unknown, with precedence fixed in §VIII. No known issues claims only that nothing is known to be wrong. Notices lead with the impact and a What to do box; the investigation reference is a footnote. Areas are named after what teachers do (Signing in, Attendance, Payroll, Student roster, Store, rent & insurance) and read Working, Slower than usual, Checking: errors seen, Nothing to report, Quiet or Status unknown. An operator notice for an area outranks the measurement: Checking reports for
- Savings interest day count, simple-interest disclosure, and the transfer page's forecast label (2026-09-30) — Operator ruling 2026-09-30. SPEC-ECON-001 is amended to v1.2:
- 365-day year, stated. §5.2 now says the daily rate is the annual rate ÷ 365 in every year, leap years included, for runtime accrual, forecasts and the daily-compounding ceiling alike, as Regulation DD permits (12 CFR 1030, Supplement I, comment 7(a)(1)-4). The code already did this.
SAVINGS_DAYS_PER_YEARinapp/services/economic_engine.pyis now the one definition, cites the clause, and also feeds_COMPOUND_FREQ_PER_YEAR["daily"], which had repeated the literal365. New tests: a February 2028 monthly window pays 29 × rate/365 t...
- 365-day year, stated. §5.2 now says the daily rate is the annual rate ÷ 365 in every year, leap years included, for runtime accrual, forecasts and the daily-compounding ceiling alike, as Regulation DD permits (12 CFR 1030, Supplement I, comment 7(a)(1)-4). The code already did this.