Skip to content

Releases: timwonderer/classroom-token-hub

v2.4.1 — Rent-linked store items copy correctly, and production releases are gated on the droplet, not in GitHub

Choose a tag to compare

@timwonderer timwonderer released this 29 Oct 12:07
Immutable release. Only release title and notes can be modified.
44b869e

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-001 1.1, SPEC-STORE-001 1.7, DOM-POL-001 2.10, DOM-POL-001A 2.6, DOM-CORE-002 1.19, FEAT-POL-001 2.3, FEAT-CLASS-003 0.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_KEY is 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_KEY is now restricted too, closing a bypass of the release gate below. Giving release-v2.yml its own key (RELEASE_DEPLOY_KEY, previous entry) didn't by itself close the gap: DO_DEPLOY_KEY is a repository secret available to any workflow, so the same identity the gate exists to constrain could still have pointed release-v2.yml (or a new workflow) back at DO_DEPLOY_KEY and deployed with no gate at all. infra/release-gate/guarded-ci.sh is a new forced command for DO_DEPLOY_KEY's authorized_keys line, restricted to exactly the two operations login-notice.yml and tailscale-ssh-smoke-test.yml use; 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-002 v1.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.sh only recognizes release-v2.yml's three command shapes, so forcing it onto the existing DO_DEPLOY_KEY line would have refused every request from those other two workflows the moment it was installed. release-v2.yml now authenticates with a new secret, RELEASE_DEPLOY_KEY, installed on its own authorized_keys line; DO_DEPLOY_KEY is untouched. SOP-DEP-002 v1.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.yml before the maintenance window opened, after an agent misread a time hint as a deadline and skipped SOP-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 required workflow_dispatch input, a GitHub environment's required reviewers, and branch protection requiring review on main were each evaluated and rejected — none of them act on anything outside the identity the incident itself used. infra/release-gate/guarded-deploy.sh is 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 of release-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-002 v1.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

Choose a tag to compare

@timwonderer timwonderer released this 29 Oct 12:07
Immutable release. Only release title and notes can be modified.
3ce50ec

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 now autocomplete="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
  • /metrics answered 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-001 1.3, INV-ARC-015 §VII; owner rulings 2026-10-24). The time guardrail in tests/dom/operation/test_time_money_guardrails.py had scanned tests/dom/app, a directory that does not exist, since it was moved on 2026-07-18, so it passed while checking nothing; the separate policy_guardrails.py rule banned only timedelta, 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/pytz conversions, timedelta date arithmetic and UTC .date() derivations. Every one now goes through a resolver primitive. The resolver gains shift_calendar_days (calendar-day shifts that keep local midnight or the local clock across DST), localize_timestamp (an instant in the class timezone) and same_instant; elapsed_duration now sums intervals exactly and truncates once instead of per interval; ensure_utc and advance_local_calendar_days are gone, and private helpers that only forwarded to the resolver are inlined. tests/test_SPEC_TIME_001__resolver_is_the_only_time_tool.py parses every file in app/, fails if it scans fewer than 150, and proves each prohibited pattern with a mutation case, including the two shipped DST defects the retired timedelta rule 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 from tests/helpers/temporal.py, thin wrappers over the resolver (SPEC-TEST-001 1.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): the fmt_timestamp/fmt_date/fmt_compact_date/fmt_time Jinja filters and the format_utc_iso template global are removed, along with every strftime/isoformat and 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 the local-timestamp spans 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-001 2.14, SPEC-OPS-006 1.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 diagnostic NOT_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-001 2.11 and SOP-DEP-002 1.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_reward and reward_amount if 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; the issues.eligible_for_reward column and the bug_reward ledger 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). Pinning tailscale/github-action by SHA did not pin the client: v4.2.0 defaults version to 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 weekly github-actions update 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 outside unattended-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). backup started ssh -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). backup keeps it on success, because counts and restore need it, and closes it on failure. counts and restore reuse or reopen it. restore, the last step, closes it on exit alongside deleting the identity, which now falls back to rm -f so a failed rm -P cannot skip the close. close-tunnel handles an abandoned run. tests/test_prerelease_tunnel.py runs the real script against stub ssh, docker and tools in a throwaway HOME; 6 of its 7 tests fail on the previous script. SOP-DEP-002 1.13 step 7 and infra/db-backup/README.md describe 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 example DASHBOARD DEBUG: Student 211, TAP success - seat 211, user_id=…). The line prefix now carries class_public_id; every log call that printed one now names seats by seats.public_id and classes by class_public_id, or drops the id where the prefix already names the actor, and the authentication principal is omitted. FEAT-ENTRY and FEAT-COMMIT-OWNERSHIP print 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-entitlements take 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 showed Seat 211 read 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.
  • /metrics answered the internet, and the health checks could be polled without limit (#1661). The /metrics guard allowed any request whose remote_addr was 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. /health and /health/status are no longer exempt from rate limiting: each carries "30 per minute" per...
Read more

v2.3.0 — Accessible in-app dialogs, Delete Class and status page fixes, closed ledger type vocabulary

Choose a tag to compare

@timwonderer timwonderer released this 22 Oct 06:17
Immutable release. Only release title and notes can be modified.
339c775

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/prompt is 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/prompt is 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_code had two rules, POST /admin/join-code/delete and a DELETE /admin/join-code that nothing used. Decorators register bottom-up, so the DELETE rule came first and url_for built it. Since 2026-09-09 (8651c274e), admin_class_delete.html fetched url_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, so tests/dom/identity/test_teacher_last_class_deletion.py now 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 where url_for builds a rule that refuses its caller's method.
  • Every browser-native alert/confirm/prompt is 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() and AppCore.prompt() (static/js/app-core.js) build a Bootstrap modal. data-confirm does 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 page inert while it is open. Chromium leaves content outside an aria-modal element 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-invalid plus aria-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 an actor_request_trace row, 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-001 INV-LED-016). No human decides, in the moment, that a given payout window's interest posts — app/services/ledger_interest_service.py's apply_savings_interest now supplies mechanism="system". Test: test_INV_LED_016__scheduled_interest_posts_mechanism_system, confirmed to fail with mechanism=self restored.
  • 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.py now 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.type is 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.type is now a DB-enforced closed vocabulary (DOM-LED-001 INV-LED-017). Migration a4d7e9c2f1b6 adds ck_ledger_transaction_type_closed_vocabulary (added NOT 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; NULL is still permitted. This closes the set of lawful values; it does not yet require canonical spelling on a new row (both Interest/interest, REVERSAL/reversal, Withdrawal/withdrawal, and Deposit/deposit still 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 critically app/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 a type value 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 placeholder type no writer produces ("act", "test") are likewise corrected to manual_payment. No existing row's type value changes. FEAT-LED-005 v1.3 reconciles this PR's and #1599's independently-recorded v1.2 histories (route/registry cutover and DB CHECK constraint both now landed). PR 5 of the approved sequence from the 2026-10-17 SYSTEM_ORIGINATED_FEAT_CODES provenance 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-006 1.5, owner ruling 2026-10-21). On 2026-10-20 the app's rate limit answered the status collector's /health/status read 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 diagnostic HTTP_REFUSED, and the hero reads "Unknown". tests/test_status_platform.py covers 500/502/503 as FAIL and 429/404 as UNKNOWN with an Unknown hero; the 429 and 404 cases fail on the old mapping. Deploys with the status service (deploy-status.yml), not the app release.
  • **Publi...
Read more

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

Choose a tag to compare

@timwonderer timwonderer released this 16 Oct 05:44
Immutable release. Only release title and notes can be modified.
68ead42

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

Added

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

Changed

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

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

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

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

    No behavior changes; documentation only.

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

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

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

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

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

    • MAP-UI-001 had two sections both numbered "§VII" (renumbered through XI), and its Store/Entitlements table carried an earlie...
Read more

v2.2.3 — Audit remediation: login hardening, financial race fixes, scheduler and attendance load

Choose a tag to compare

@timwonderer timwonderer released this 12 Oct 05:37
Immutable release. Only release title and notes can be modified.
5d29abb

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_rent and pay_rent_bill now refuse with INSUFFICIENT_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 refuses INSUFFICIENT_FUNDS before 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 one premium_is_affordable check in insurance_premium_payment_feat.py.

    FEAT-OBL-003 §1.a and FEAT-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 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_bill now refuse any amount that isn't a positive, finite, whole-cent value with INVALID_AMOUNT before touching the ledger. An extreme-but-finite amount (e.g. 1e999999) is also refused rather than raising decimal.InvalidOperation out 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_bill and settle_insurance_premium now 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 in pay_rent_bill's remaining_after: it was computed as group_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 on assessment_events.correlation_id — was deliberately dropped in 8a7b6c5d4e3f because one correlation_id legitimately carries several event types (ASSESSMENT, PAYMENT, WAIVED). Migration 6a01dc7a606b adds 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 revision 35a9a0d3ab93 and 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() called scheduler.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_request only 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_request now takes an unconditional with_for_update() lock on the seat row as its first statement, serializing the pair the same way transfer_feat.py and store_purchase_feat.py already serialize theirs. Proven against the emitted SQL (statement order), not a hold-and-timeout race — pending_actions carries a foreign key to seats, so an INSERT into it already takes an implicit FOR KEY SHARE lock 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_settings counted 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.py now locks the ClassEconomy row (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, since hall_pass_logs also 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 is roster_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_expression already uses. Separately, "earnings" only excluded "Transfer"-prefixed deposits, so the automatic overdraft-protection funding leg (type Deposit) and reversal refund credits (type REVERSAL) 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-built REVERSAL row is rejected by the enforce_ledger_recovery_cap() tr...

Read more

Classroom Token Hub v2.2.2

Choose a tag to compare

@timwonderer timwonderer released this 11 Oct 00:20
Immutable release. Only release title and notes can be modified.
61b509b

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 destination is 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 via tbody.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 permits unsafe-inline/unsafe-eval and 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 $0 void_item_removed entry 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, and reverse_transaction refuses them too. Tests in tests/test_collective_goal_purchase_reversal.py, each confirmed red with its guard removed.

Classroom Token Hub v2.2.1

Choose a tag to compare

@timwonderer timwonderer released this 10 Oct 20:30
Immutable release. Only release title and notes can be modified.

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_id in hall_pass_logs.hall_pass_id, as FEAT-PROD-002 §III and DOM-STORE-001 §VIII.E.6 require, and no longer also writes a Store CONSUMED event. 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_pass is removed and registered as a deprecated symbol (SOP-DB-002 2.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:

    1. Available: usable, reversible or refundable under its rules.
    2. 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.
    3. Rejected (or cancelled by the student): the reservation is released and the pass is usable again.
    4. Approved: approval uses exactly the named pass, consuming it whether or not the student leaves.
    5. 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 REVOKED or EXPIRED event, 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 35a9a0d3ab93 adds the partial unique index uq_hall_pass_logs_hall_pass_id (DOM-CORE-002 1.17). It replaces the protection the one-terminal-event index gave while use also wrote CONSUMED. 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

Choose a tag to compare

@timwonderer timwonderer released this 09 Oct 07:15
Immutable release. Only release title and notes can be modified.
5ebae62

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:

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.md records, from read-only aggregate queries against production at revision f9a3c7d1e620 (2026-10-06 06:08-06:11Z), the timestamped SOP-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 two H5 items the owner ruled on 2026-10-06. First, all 854 signature-v1 ledger rows store feat_code = FEAT-LED-003 (the settlement restamp fixed in 81ea0fff5); their originating FEAT is taken from the linked creation audit event, and actor attribution is unavailable rather than system. Recording that ruling in DOM-OPS-002 is left to U13. Second, 14 of 16 hall-pass logs store policy_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 in docs/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.txt was enforced only by the Schema Change Gate, which runs on pull requests touching migrations/versions/** or the model files and skipped the scan for NON-MODEL CHANGE. So a route, template or script that named ClassMembership, is_bundle or get_default_pass_types merged unscanned. The scan is now a pytest guard (tests/test_deprecated_symbols_guard.py, detector in tests/guards/deprecated_symbols.py) that policy-guardrails.yml runs on every pull request and push to main without a database. The Schema Change Gate runs the same guard in place of its inline grep. The scope now includes first-party JavaScript (*.js under app/, templates/, scripts/ and static/, excluding static/vendor/). Mutation proofs plant near-miss violations: an aliased import, a reflective string, a longer identifier, a template attribute, static/js and 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-002 v2.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-008 v1.2 removes the "explicitly approved bridge code" allowance from §VI (Student ID Quarantine); DOM-OPS-002 v1.11 §6.2A and SPEC-PROD-002 v1.4 §VII state that historical diagnostics and reconstruction are read-side interpretation that creates no state; SOP-DB-004 v1.2 §VII.8 and SOP-DOC-001 v3.25 follow. Documentation only; no code or schema change.
  • Forward-only revision register, enforced. migrations/forward_only_register.txt lists every Alembic revision whose downgrade() raises or does nothing (34 today, including the three attendance/payroll recovery revisions SOP-DEP-001 §XIV named; the prose list had missed the rest). tests/test_forward_only_revision_register.py derives the set from migrations/versions (detector in tests/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 conditional raise, a raise hidden in a helper, no-op spellings, a missing downgrade(), and a merge that does work; reversible downgrades and pure merges stay quiet. SOP-DB-004 v1.1, SOP-DEP-001 v2.8 and SOP-DEP-002 v1.8 point at it; SOP-DEP-002 and SOP-DEP-001 no longer cite archived SOP-DB-009 or the nonexistent SOP-DEP-016 as dependencies. The "does nothing" test is positive (no op.* or .execute call, 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 State H0-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 for M0/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-001 v1.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.yml now also runs tests/test_axe_app_pages.py, tests/test_axe_fixture_pages.py and tests/test_axe_compliance.py with AXE_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.py fails 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.json is 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-students and /admin/student/unclaim returned str(error) from any ValueError. They now return a message only if it is in ROSTER_ACTION_MESSAGES (app/feats/identity_feat.py), which lists the teacher-facing messages those FEATs raise; any other ValueError gets 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_docs percent-encodes the reader-supplied path, refuses . and .. segments, and checks the target still has the configured docs host. Tests in tests/dom/docs/test_docs_platform_split.py.
  • Docs site npm advisories. The docs-site lockfile moves fast-uri to 4.2.1, brace-expansion to 5.0.12 an...
Read more

v2.1.1 — Attendance correction, store redemption decisions, no student names in logs

Choose a tag to compare

@timwonderer timwonderer released this 04 Oct 20:51
Immutable release. Only release title and notes can be modified.

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_state reads the scoped reconciliation cursor with one query per access, so transaction loops issued one query per row and could autoflush. New pure reads in ledger_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 one no_autoflush query 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 query verified_creation_evidences walks 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.sh and scripts/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 a gunicorn unit that no longer exists. Replaced by infra/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_unit rule), store_service validation and its definition allow-list (a definition still carrying a bundle field is refused as unknown), the policy resolver, the purchase multiplier (units_to_grant is the purchase quantity), the admin and student store cards, the student buy modal, and the redemption modal.
    • The StoreProduct model attributes are removed; the two store_products columns stay until a separate CONTRACT (DATABASE) migration drops them. The flag column defaults to false in 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.txt and 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.md becomes store-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.py now proves the multi-unit grant for a purchase quantity and the pack-by-bulk-discount recipe; tests/test_store_policy_resolver.py refuses the removed fields; the gating tests use fields that still exist.

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 store details. 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: new app/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 guide store-redemptions.md is 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.py takes a snapshot-consistent pg_dump, encrypts it with age to 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. status reports CURRENT, STALE or DEGRADED. restore only restores into an empty database and, given --live-url and 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 writes instance/login_notice.json on 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", h2 under the page's h1), 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...
Read more

v2.1.0 — Payroll settings, OPS-DB-001 fixes, student removal

Choose a tag to compare

@timwonderer timwonderer released this 02 Oct 04:48
Immutable release. Only release title and notes can be modified.
5ac05ea

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 sets classes.unpaid_work_notice_acknowledged_at once (migration a4b50fee84c3); 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.py from the payroll settings service, the new class_has_claimed_student_work attendance read and the class's acknowledgement.
    • Normative text: DOM-PROD-001 1.7 (§XIII.7, §XV.6), DOM-CLASS-001 3.6 (§VII.1, §VII.3: payroll cannot be turned off), new FEAT-CLASS-008, SOP-DOC-001 3.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 a role="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.
  • 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.py replays 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 one manual_credit whose actor is the approving teacher's seat and whose mechanism is SYSTEM (system-calculated; SYSTEM carries 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.com read 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 REACHABLE and 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.com host is GATE_CLOSED. The service-token health read cannot stand in for this, because there ACCESS_DENIED also means a lapsed token. Platform records move to platform-check-v2 with a third gate check; v1 records stay readable. A malformed HTTP response (http.client.HTTPException, such as BadStatusLine from a broken proxy) is not an OSError and previously escaped the collector, losing that minute's record; on all three fetches it is now recorded as TRANSPORT_UNAVAILABLE. While the gate is closed every area reads Closed for maintenance, since requests from behind the gate cannot make an area usable.
    • New AWARE notice stage (SPEC-OPS-002 v1.6): something may be up, not yet confirmed. It precedes INVESTIGATING, 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, and status.js localises it. Storage, logs and evidence stay UTC.
    • Design system. status_service/static/tokens.css is a byte-identical copy of static/css/tokens.css (SPEC-DES-001 v2.3 §XIII, gate R10), and status-overrides.css is replaced by status.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, --success on --success-subtle, is 4.41:1. A contrast-pinned --alert-success-text (#1f5c42, 6.94:1) joins the three existing alert text tokens in static/css/tokens.css (SPEC-DES-001 §VI.6), and the status chips and pill use it; the audit then reports none. The app's own bg-success-subtle text-success badges 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.py and tests/test_status_resolution.py cover 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, the AWARE stage, 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, AWARE dropped from the active states, area cards ignoring notices, and the maintenance override removed) each turn at least one test red.
  • 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_YEAR in app/services/economic_engine.py is now the one definition, cites the clause, and also feeds _COMPOUND_FREQ_PER_YEAR["daily"], which had repeated the literal 365. New tests: a February 2028 monthly window pays 29 × rate/365 t...
Read more