Skip to content

Plan 72 R2

github-actions[bot] edited this page Oct 14, 2026 · 1 revision

R2 — Access (v0.8.0)

R2 turns a one-admin application into a multi-teacher one and changes no screen. It does six things:

  • stores the permission catalogue and the built-in roles as data (70-redesign-overview.md §4.1);
  • enforces one declared permission on every operation, driven by the contract;
  • gives every class, test, question, question group and media asset an owner, and scopes every teacher read and write by that owner;
  • moves the teaching operations from /admin/* to /teacher/*;
  • stores new join codes encrypted, found through a keyed lookup;
  • raises the join and sign-in rate limits so a whole class can sign in from one school address.

It ships alone (70 §2 rule 3), so any regression between v0.7.0 and v0.8.0 is an access bug. At release, production has one staff account and its students. The owner's role='admin' maps to the built-in Admin role; students map to the built-in Student role. No UI can create a second teacher until R5's Users page (rule 2). Until then, three things prove the multi-teacher behaviour instead of production traffic: the seeded second teacher, the two-teacher isolation suite and the escalation tests.

The Assistant role exists with the deck's grants. It owns nothing and is attached to no class until DG-04 is drawn, so an assistant reaches the teacher workspace and finds it empty. No role picker offers the role yet (D14).

Every change here is the expand half. The contract half belongs to R3, or to whichever release ships next (70 §2 rule 4). That release:

  • validates the NOT NULL … NOT VALID constraints and drops the fill triggers;
  • drops users.role, its sync trigger, users_role_active_idx and app.user_role;
  • removes the legacy /admin alias;
  • stops issuing the legacy role claim.

Nothing in R2 may rely on those objects being gone, and nothing R2 writes may rely on them staying.

Order of work: T-R2.1 to T-R2.8 build the permission model and the path move. T-R2.9 to T-R2.13 add ownership, scoping and the guards that are not permissions. T-R2.14 and T-R2.15 cover join codes and rate limits. T-R2.16 to T-R2.19 prove the release, align the web and update the docs. The join-code work (T-R2.14a) and the ownership migrations (T-R2.9) do not depend on each other and can run beside T-R2.3 to T-R2.8.

Deliverable:

  • Permissions. Roles and the permission catalogue are data. Every one of the 104 operations (103 today plus getJoinCode) declares x-permission, or is open. httpx.RequirePermission replaces the /admin/ prefix gate. A short per-user principal cache supplies each request's role, permissions, disabled_at and session epoch.
  • Ownership. Classes, tests, questions, question groups and media assets have owners. Repositories filter by access.Scope, and another teacher's ids answer 404.
  • Paths. Teaching operations live under /teacher/*. An outer alias serves the old paths for one release, and every rate-limit key has moved.
  • Join codes. New codes are stored with AES-256-GCM and found by a keyed HMAC. Legacy codes still redeem. The key can be rotated, and the procedure is documented.
  • Guards that are not permissions. Strict student targets, the subset rule, the last Admin, and foreign-key refusals answered as 409.
  • Rate limits. Join and sign-in limits are sized for a classroom.
  • Web. Guards read permissions instead of a role, with no visual change.

Exit criteria:

  • Contract checks hold.
    • The startup assertion, permissions_test.go and the web contract test agree: every operation declares a permission, the open operations declare none, and every key is in the catalogue.
    • RequireRole is gone.
  • Isolation and escalation tests are green.
    • The isolation suite shows teacher B getting 404 for every /teacher/* operation aimed at teacher A's data, and never seeing A's rows in a list; student B gets the same on /app/*.
    • The escalation tests are green, including an Admin with "Take tests" turned on as the target.
  • Migrations are safe to roll out.
    • The old-binary insert test is green.
    • Migrations pass up, down, up.
    • The Neon rehearsal on a production branch is recorded.
  • The rest of CI and the live join test pass.
    • Every CI step passes locally, in order (70 §8).
    • The live test in which thirty students join from one address is green.
  • v0.8.0 runs in production with nothing visibly changed.
    • The owner's daily work is unchanged, and so is a student's.
    • A legacy join code redeems.
    • Tabs still open on v0.7.0 keep working through the alias.

Decisions taken in this file, worth a second look.

  • x-permission is a key, a pseudo-key, or a list meaning "any of". 70 §4.2 gives each operation one key. Three families of operations need two:

    • Question and question-group writes serve both the bank (content.questions.write) and the test builder (content.tests.write).
    • The attempt-review reads serve both graders and teachers who intervene.

    The middleware accepts any listed key. When the body decides which key applies (a bank destination or a section), the handler narrows the check. Handlers already own rules the schema cannot express. The alternative, workspace.teacher on those writes, would let a role that only takes attendance edit the bank. 70 §4.2 states this form and names the families that use a list.

  • Reads declare workspace.teacher; matrix rows gate actions and student data. The deck's matrix has no "view tests" row. Listing tests, questions, groups, media, classes and assignments, and opening the dashboard, needs any teacher key. Student data needs people.students.read or a teaching key. Ownership scoping, not the permission, keeps another teacher's rows out.

  • scope.all lifts every owner filter, in lists as well as by id. The owner owns everything today. Filtering their Students list to "my classes or created by me" would hide students who belong to no class and have a NULL created_by, and hiding them is a visible change. R4 decides the Admin's teacher-workspace lists (DG-53). The proposal for 70 §4.1: scope.all keeps reads and edits by id and the Admin console's cross-teacher views, while content lists in the teacher workspace (tests, bank, media, imports, assignments) show the caller's own rows, admins included; Students and Classes keep scope.all lists until R5's Admin Classes and Users take them over. Either answer is the Scope a list query receives, so R2 builds nothing that R4 must undo.

  • The subset rule ignores learning.take_tests. 70 §4.3 requires the target's permissions to be a subset of the actor's. Read literally, no Admin could disable a student, because the Admin wildcard lacks "Take tests" unless it is turned on (DG-51). That key gives no power over another person, so the rule compares the target's set without it. Admin targets stay protected, because the wildcard holds every other key. 70 §4.3 states the rule this way; Thuong confirms this reading of D13 (open items).

  • One database view, app.student_like_roles, is the strict student predicate. It holds the built-in Student role, plus any custom role (builtin_key IS NULL) whose grants are a subset of {learning.take_tests}. Every repository and core/maintenance use it. The Admin row stores only its "Take tests" cell, so a definition based only on grants would classify the Admin as a student. That is the takeover the critic found; excluding built-in roles by key closes it. There is one definition and one test.

  • deleteStudent becomes DELETE /admin/users/{id} (deleteUser, people.users.manage) and stays student-only in R2. Deleting an account is a People-row action, not a teaching one. Under /teacher/* it would break the path-tree rule (T-R2.7). R5 keeps it student-only, with no console control: offboarding is disable, then transfer.

  • A teacher resets or re-addresses only a student no one else reaches. A student is visible to every teacher who reaches them (T-R2.12d), so without this rule teacher B could reset a student shared with teacher A, receive the temporary password and sign in as the student. Changing the student's email to an address B controls does the same through Google sign-in, which links by email. resetStudentPassword, and updateStudent with a new email, therefore require that no one else reaches the student:

    • every class of theirs, archived ones included, is one the actor teaches;
    • no other account created them;
    • no other account's assignment targets them individually;
    • with no class, the actor created them.

    Anything else answers 403 STUDENT_SHARED, and the write goes to a holder of people.users.manage (T-R2.13). In R2 the only staff account is the Admin, which holds that key, so nothing visible changes.

  • updateStudent with disabled also needs people.users.manage. The operation itself declares people.students.create. "Add and disable users" is Admin-only in the matrix, and the deck's teacher Students page has no Disable.

  • A test draft's references keep one owner. A draft may reference only bank questions owned by the test's owner. The owner is checked after questions.LockForDraftUse, which stays the lock the canary pins. A duplicate or a restored draft belongs to the source test's owner. The cost in R2: an Admin editing another teacher's draft can pick only that teacher's questions. Creating a new question from the builder inside that draft fails too: the question belongs to the Admin, so the draft refuses it, and it stays in the Admin's own bank. R7's copy-on-add and forTestId remove both limits. Media works differently: assets are immutable, so a reference only requires that the actor can read the asset.

  • Principals are cached for 10 seconds. A write on the same machine forgets the entry at once. A disable or a matrix edit therefore applies on the next request on that machine, and within 10 seconds on any other. This keeps autosave and the monitor's polling from adding a query per request, which would keep Neon awake (PR-7).

  • In R2 the session epoch moves only on disable and on a staff password reset. Those are the only R2 operations that must end live sessions at once. R5's "Sign out everywhere" and role changes will also move it.

  • The last-admin guard is a trigger now, although no R2 operation can reach it. users_last_admin serialises on the Admin role row and refuses to leave zero active Admins, whatever the path: R5's commands, a bulk action or a hand-written UPDATE. Its function is SECURITY DEFINER, because the FOR NO KEY UPDATE it takes needs UPDATE privilege on app.roles, which T-R2.1 revokes from the app role. The 409 LAST_ADMIN code joins the contract with the first operation that can return it (R5).

  • The API can read a full join code in R2; the screen that shows it comes in R4.

    • GET /teacher/classes/{id}/join-code returns {code, legacy}.
    • A legacy row answers code: null, legacy: true, the explicit state the critic asked for.
    • R4 is then only UI plus the rotation of legacy codes (D5).
    • The key id is a 16-bit HKDF fingerprint of the key rather than a number an operator picks, so a rotation cannot reuse an id by mistake.
  • Built-in role names and descriptions are seeded in Vietnamese: Quản trị viên, Giáo viên, Trợ giảng, Học viên. They are data in the organisation's language and editable later (D2). R5 renders a built-in role whose stored name is still the seeded one from role.builtin.<key>, so an English matrix reads "Teacher". Thuong reviews the four rows in T-R2.1 (D11).

  • The rate-limit numbers in T-R2.15 are proposals, built as corrected in T-R2.15 (Thuong confirms).

    • 120 requests a minute and 600 an hour per address for join preview, join, Google sign-in and sign-in; 120 a minute and 1,200 an hour for refresh and logout.
    • 200 an hour per code; per email, 10 a minute from one address and 20 an hour overall.
    • No bucket per address and code. The first text proposed one at 10 a minute "so one device cannot hammer a code", but a class behind a school's shared address is one address with one code: its 11th student would have been refused, which contradicts this task's own classroom test. Repeating a valid code gains nothing, and the per-code bucket already bounds how often any code is tried.

    A class of 40 with three tries at each step fits inside a minute. Join preview and in-app join each tell a caller whether a code is valid, and each has its own 600-an-hour budget per address, so one address with a student account can test about 1,200 codes an hour; Google sign-in adds its own, but each try needs a fresh Google authorization. Against 32⁸ possible codes that is still negligible. Each keyed operation takes a body of at most 8 KiB, and a body key is decoded as the handler decodes it, so no body shape skips the per-email or per-code bucket.


T-R2.1 — Permission catalogue and built-in roles

Depends on: — Touches:

  • migrations/NNNNN_create_roles_and_permissions.sql (new)
  • migrations/NNNNN_create_student_like_roles_view.sql (new)
  • api/openapi.yaml (PermissionKey)
  • server/gen/openapi/, web/src/lib/api/schema.d.ts
  • server/internal/shared/access/ (new): key.go, set.go, requirement.go, scope.go, principal.go
  • server/internal/shared/access/tests/ (new)
  • server/internal/core/tests/catalogue_test.go (new)
  • docs/plan/20-data-model.md

Size: M Done when:

  • create_roles_and_permissions creates app.permissions:

    • Columns: key text PRIMARY KEY CHECK (key ~ '^[a-z]+(\.[a-z_]+)+$'), group_key text NOT NULL CHECK (group_key IN ('content','teaching','people','system')), ordinal smallint NOT NULL CHECK (ordinal > 0), in_matrix boolean NOT NULL, and UNIQUE (group_key, ordinal).
    • Rows: the eighteen matrix rows of 70 §4.1, in the deck's order (Quizzivy Admin.dc.html, PERMS), with people.students.create second in People (DG-03).
    • Rows: the four hidden keys (scope.all, system.api_reference, system.data_export, system.leads) with in_matrix = false.
  • The same file creates app.roles:

    • Columns:
      • id uuid PRIMARY KEY DEFAULT uuidv7()
      • builtin_key text UNIQUE CHECK (builtin_key IN ('admin','teacher','assistant','student'))
      • name text NOT NULL CHECK (length(btrim(name)) BETWEEN 1 AND 60)
      • description text NOT NULL DEFAULT '' CHECK (length(description) <= 300)
      • icon text NOT NULL, checked against the Edit role dialog's twelve icons: shield, graduation-cap, hand-helping, user, key-round, crown, briefcase, book-open, users, clipboard-check, headset, star
      • color text NOT NULL, checked against the seven card colours: dark, lime, blue, green, amber, rose, gray
      • copied_from uuid REFERENCES app.roles ON DELETE SET NULL
      • revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0)
      • created_by uuid REFERENCES app.users ON DELETE SET NULL
      • created_at timestamptz NOT NULL DEFAULT now()
      • updated_at timestamptz NOT NULL DEFAULT now()
    • Indexes and triggers: UNIQUE INDEX roles_name_lower_key ON app.roles (lower(name)), and the roles_set_updated_at trigger.
  • The same file creates app.role_permissions (role_id uuid REFERENCES app.roles ON DELETE CASCADE, permission_key text REFERENCES app.permissions ON DELETE RESTRICT, PRIMARY KEY (role_id, permission_key)), with an index on (permission_key) for the foreign key.

  • Reference rows: the four built-in roles, with the deck's default cards and names written in Vietnamese.

    | Role | Icon | Colour | Name | Description | Grants |
    |---|---|---|---|---|---|
    | Admin | shield | dark | Quản trị viên | "Điều hành trung tâm. Quản lý người dùng và cài đặt." | none stored (wildcard; "Take tests" off) |
    | Teacher | graduation-cap | lime | Giáo viên | "Soạn đề, quản lý lớp, chấm bài." | 13 (70 §4.1) |
    | Assistant | hand-helping | blue | Trợ giảng | "Hỗ trợ giáo viên chấm bài và điểm danh." | 6 |
    | Student | user | gray | Học viên | "Làm bài và xem kết quả của mình." | `learning.take_tests` |
    
    Ids come from `uuidv7()`. Code and seeds always resolve a role by
    `builtin_key`.
    
  • Triggers:

    • roles_builtin_key_immutable (BEFORE UPDATE).
    • role_permissions_guard (BEFORE INSERT): a key with in_matrix = false is never granted, and the Admin role may hold only learning.take_tests.
    • role_permissions_keep_student_take_tests (BEFORE DELETE): the built-in Student role cannot lose learning.take_tests.
    • role_permissions_bump_revision (AFTER INSERT OR DELETE, FOR EACH ROW): roles.revision + 1 for the affected role. The table is tiny, and a statement-level trigger with transition tables would need one trigger per event. It runs as the invoker: in R2 only quizzivy_migrate writes grants, and from R5 the app role's UPDATE on revision covers it.
  • As built:

    • The hidden keys are in group system at ordinals 4–7, after learning.take_tests at 3.
    • The grant triggers also fire on UPDATE: role_permissions_guard is BEFORE INSERT OR UPDATE, role_permissions_keep_student_take_tests BEFORE DELETE OR UPDATE, and role_permissions_bump_revision AFTER INSERT OR UPDATE OR DELETE. An in-place UPDATE of role_id or permission_key would otherwise pass the Admin, hidden-key and Student guards and skip the revision bump.
    • roles_builtin_key_immutable is BEFORE UPDATE OF builtin_key.
  • Privileges: REVOKE INSERT, UPDATE, DELETE on the three tables from quizzivy_app, because 00009's default privileges grant them. R5's grant_role_writes (T-R5.8) grants the role commands' writes: INSERT and UPDATE (name, description, icon, color, revision, updated_at) on roles, INSERT and DELETE on role_permissions. DELETE on roles is never granted (DG-52), and permissions stays read-only.

  • Down drops the triggers, the functions and the three tables.

  • create_student_like_roles_view creates app.student_like_roles (id): the roles with builtin_key = 'student', or with builtin_key IS NULL AND NOT EXISTS a grant other than learning.take_tests.

    • REVOKE INSERT, UPDATE, DELETE on the view: a single-table view is automatically updatable, so the default privileges would otherwise let the app role delete roles through it.
    • Down drops the view.
  • api/openapi.yaml gains components.schemas.PermissionKey, a string enum of the 22 keys, documented with its matrix row. T-R2.6 references it from CurrentUser. make gen output is its own commit.

  • shared/access (kernel; imports nothing outside shared/) contains:

    • Key and constants for the 22 keys and the pseudo-keys self, workspace.teacher and workspace.admin.
    • Set with Has, SubsetOf and Without.
    • Requirement (any-of; pseudo-keys evaluated as in 70 §4.1) and Workspaces(Set).
    • IsStudentLike(builtinKey, Set).
    • CanActOn(actor, target Set): the subset rule without learning.take_tests.
    • Scope{UserID, All} and Principal{UserID, RoleID, BuiltinKey, Permissions, Epoch, Disabled}.
  • Unit tests in shared/access/tests/:

    • Every pseudo-key; workspace.teacher is true for the Assistant's six keys and false for {learning.take_tests}.
    • CanActOn:
      • true for Admin acting on a student;
      • false for a custom role holding people.users.manage acting on an Admin with "Take tests" turned on;
      • false for a Teacher acting on an Admin.
    • IsStudentLike is false for the Admin role with learning.take_tests granted, and true for an empty custom role.
  • Integration, core/tests/catalogue_test.go:

    • The contract's PermissionKey enum equals access.All(), which equals SELECT key FROM app.permissions.
    • The built-in grants equal 70 §4.1, cell by cell.
    • The app role cannot write the three tables or the view.
    • Removing the Student's learning.take_tests raises; granting any role scope.all raises; granting the Admin role any other key raises.
    • A grant insert and a grant delete each bump revision.
  • 20-data-model.md gains a "Roles and permissions" section and a deviation at the next free D-number: reference data the app cannot run without lives in a migration (70 §3).

  • up/down/up green; Thuong has reviewed the four role names and descriptions (D11).


T-R2.2 — users.role_id, session epoch and the last-admin guard

Depends on: T-R2.1 Touches:

  • migrations/NNNNN_add_users_role_id.sql (new)
  • migrations/NNNNN_index_users_role_id.sql (new)
  • migrations/NNNNN_add_users_session_epoch.sql (new)
  • migrations/NNNNN_add_users_last_admin_guard.sql (new)
  • server/internal/modules/identity/repositories/store.go
  • server/internal/modules/identity/repositories/students_write.go
  • server/internal/modules/classes/repositories/enrol.go
  • server/internal/platform/db/tests/expand_r2_test.go (new)
  • docs/plan/20-data-model.md

Size: M Done when:

  • add_users_role_id:
    • Adds role_id uuid REFERENCES app.roles ON DELETE RESTRICT, nullable.
    • Backfills admin → Admin and student → Student. The backfill runs with users_set_updated_at disabled, so updated_at keeps meaning "the user changed".
    • Raises if any row is still NULL.
    • ALTER TABLE app.users ADD CONSTRAINT users_role_id_not_null NOT NULL role_id NOT VALID (PG18, AGENTS).
  • The same file adds the users_sync_legacy_role trigger, BEFORE INSERT OR UPDATE OF role, role_id:
    • An insert with no role_id takes it from role; this is how the old binary inserts.
    • An insert with role_id gets role = 'student' when the role is in app.student_like_roles, and 'admin' otherwise. This overrides the column default.
    • An update of role_id re-derives role; an update of role alone re-derives role_id.
    • Down drops the trigger, the function, the constraint and the column.
  • index_users_role_id: -- +goose NO TRANSACTION; CREATE INDEX CONCURRENTLY users_role_id_active_idx ON app.users (role_id) WHERE disabled_at IS NULL; Down is DROP INDEX CONCURRENTLY IF EXISTS.
  • add_users_session_epoch: session_epoch integer NOT NULL DEFAULT 0 CONSTRAINT users_session_epoch_check CHECK (session_epoch >= 0). A constant default is metadata-only on PG18. Down drops the column.
  • add_users_last_admin_guard adds the users_last_admin trigger, AFTER UPDATE OF role_id, disabled_at OR DELETE, FOR EACH ROW:
    • It acts when the old row was an active Admin and the new row is not.
    • It takes FOR UPDATE on the Admin row of app.roles, which serialises concurrent demotions.
    • It then raises check_violation with CONSTRAINT = 'users_last_admin' when no active Admin remains.
    • The function is SECURITY DEFINER, owned by quizzivy_migrate, with SET search_path = app, pg_catalog. FOR UPDATE needs UPDATE privilege on app.roles, which T-R2.1 revokes from quizzivy_app; as the invoker, R5's demote and disable would raise 42501 instead of the guard. It is the schema's first SECURITY DEFINER function, and 20-data-model.md records why.
    • Down drops the trigger and its function.
  • As built, 00059_add_users_last_admin_guard.sql differs from the item above in three ways (20-data-model.md §30 gives the reasons):
    • The trigger is AFTER UPDATE OR DELETE with no column list. The v0.7.0 binary demotes by writing role, and a column list matches the columns an UPDATE names, not the role_id that users_sync_legacy_role sets.
    • It takes FOR NO KEY UPDATE on the Admin role row, not FOR UPDATE. That still serialises the guard against itself, and it does not wait on the FOR KEY SHARE a foreign-key check on users.role_id takes.
    • The function sets search_path = pg_catalog, app, pg_temp, qualifies every type and relation, and has EXECUTE revoked from PUBLIC.
  • The new binary writes and reads role_id, never role:
    • students_write.go Create and enrol.go's Google self-enrolment insert role_id as the built-in Student, resolved by builtin_key in the statement.
    • store.go (sign-in, Google, refresh) derives the legacy role value from role_id through app.student_like_roles, and selects session_epoch.
    • The remaining reads of role move in T-R2.12d and T-R2.13.
  • expand_r2_test.go (integration) first sets up the legacy state:
    • goose.UpTo the version before create_roles_and_permissions, found by file name, because numbers are assigned at merge.
    • Inserts the legacy dataset the v0.7.0 way: two admins and three students, written by role.
    • Migrates the rest of the way up.
  • It then asserts:
    • Every user has the right role_id, and no updated_at moved.
    • As the app role, an insert with role only gets role_id.
    • An insert with role_id set to Teacher gets role = 'admin'; set to Student, it gets 'student'.
    • As the app role, demoting, disabling or deleting the last active Admin raises users_last_admin, not 42501.
    • When two transactions each disable one of the only two Admins, exactly one commits.
    • The users_last_admin function has prosecdef, its owner is quizzivy_migrate, its proconfig pins search_path, and quizzivy_app has no UPDATE on app.roles. Every other integration test connects as quizzivy_migrate, so these checks and the app-role cases above are what catch a missing privilege.
    • T-R2.9 and T-R2.14a extend the same test.
  • up/down/up green; 20-data-model.md records the four files.

T-R2.3 — The access module: effective permissions and a cached principal

Depends on: T-R2.2 Touches:

  • server/internal/modules/access/domain/ (new)
  • server/internal/modules/access/application/{query,ports,model,internal/support}/ (new)
  • server/internal/modules/access/repositories/ (new)
  • server/internal/modules/access/*/tests/ (new)
  • server/internal/core/wiring/access.go (new)
  • server/internal/core/wiring/wiring.go (Assembly.Principals)
  • server/internal/core/tests/catalogue_test.go

Size: L Done when:

  • Domain: PermissionManager.Effective(role, grants, catalogue) returns an access.Set.
    • The Admin gets every compiled key except learning.take_tests, and that key too when it is granted.
    • Every other role gets exactly its grants.
    • A key added by a later migration and compiled into a later binary reaches the Admin with no data change.
  • Repositories: Principal(ctx, userID) is one statement: SELECT u.role_id, r.builtin_key, r.revision, u.disabled_at IS NOT NULL, u.session_epoch FROM app.users u JOIN app.roles r ON r.id = u.role_id WHERE u.id = $1.
    • Grants(ctx, roleID) and Catalogue(ctx) complete the read side.
    • No SELECT *.
  • Application queries:
    • ResolvePrincipal returns an access.Principal; a missing user gives ErrUnknownUser.
    • RolePermissions is cached by (role_id, revision).
    • Matrix returns the roles with their grants in catalogue order. The catalogue test reads the built-in grants through it; R5's Roles page extends it with member counts.
  • The principal cache lives in internal/support: a 10-second TTL, 10 000 entries, least-recently-used eviction, and Forget(userID) and ForgetAll() for writes on this machine.
  • wiring.Build loads the catalogue and refuses to start when a compiled key is missing from app.permissions. Extra rows written by a newer migration are ignored, so an old machine still running during a later rollout keeps serving.
  • Unit tests:
    • The wildcard, and the "Take tests" toggle.
    • TTL expiry with a fake clock; Forget forces a re-read; the entry bound holds.
    • A cache miss costs exactly one user query, plus one grants query per (role, revision).
  • Integration tests:
    • A disabled user resolves as Disabled.
    • A grant inserted as the migrate role bumps revision, and the next resolve after ForgetAll returns the new set.
    • The epoch is read.
  • core/tests/architecture_test.go is green, and the package comment names the context's model.

T-R2.4 — x-permission on every operation

Depends on: T-R2.1 Touches:

  • api/openapi.yaml, server/gen/openapi/
  • server/internal/platform/httpx/permission_spec.go (new)
  • server/internal/core/router/router.go
  • server/internal/core/router/tests/permissions_test.go (new)
  • server/internal/core/router/tests/testdata/permissions.golden (new)
  • web/tests/units/contract/openapi-contract.test.ts

Size: M Done when:

  • Every operation declares its permission as below, keyed by operationId. Paths move in T-R2.7; operationIds do not.

    | Operations | `x-permission` |
    |---|---|
    | previewJoinCode, login, googleAuth, refreshSession, logout, flushEvents (the beacon), getPublicStatus (R1) | none: open, because they do not require the bearer token (refreshSession and logout declare `security: [refreshCookie]`, the beacon `[bearerAuth, {}]`) |
    | getCurrentUser, updateCurrentUser, changePassword, linkGoogle, unlinkGoogle | `self` |
    | listMyClasses, joinClass, listMyAssignments, getMyAssignment, startOrResumeAttempt, getAttempt, saveAnswers, recordAudioPlay, recordGroupAudioPlay, submitAttempt, getAttemptResult, getMediaUrl | `learning.take_tests` |
    | openDocsSession | `system.api_reference` |
    | deleteStudent (→ `deleteUser`, T-R2.7) | `people.users.manage` |
    | listQuestionGroups, getQuestionGroup, listTests, getTest, listTestVersions, previewTest, listQuestions, getQuestion, listMedia, listAssignments, getAssignment, getAssignmentMonitor, getDashboard, listClasses, getClass | `workspace.teacher` |
    | createQuestionGroup, updateQuestionGroup, deleteQuestionGroup, copyQuestionGroup | `[content.questions.write, content.tests.write]`; the handler requires the first for a bank group or destination and the second for a test section |
    | archiveQuestionGroup, deleteQuestion, tagQuestions | `content.questions.write` |
    | createQuestion, updateQuestion, duplicateQuestion | `[content.questions.write, content.tests.write]` |
    | createTest, updateTest, deleteTest, duplicateTest, createDraftFromTestVersion | `content.tests.write` |
    | publishTest, setCurrentTestVersion, deleteTestVersion | `content.tests.publish` |
    | createWordImport, listWordImports, getWordImport, uploadImportSource, downloadImportSource, getWordImportCapabilities, getWordImportLimits, processWordImport, cancelWordImport, getWordImportReview, saveWordImportReview, adoptWordImportReprocessed, getWordImportSource, commitWordImport | `content.tests.write` (DG-50) |
    | uploadMedia, deleteMedia | `content.media.write` |
    | createAssignment, updateAssignment, deleteAssignment, reopenAssignment | `teaching.assignments.write` |
    | listAnswersForQuestion, gradeAttempt, finishGrading, setAttemptNote, flagAttempt | `teaching.grading` |
    | listAttempts, getAttemptForReview, getAttemptEvents | `[teaching.grading, teaching.attempts.intervene]` |
    | extendAttempt, resetAttempt, voidAttempt | `teaching.attempts.intervene` |
    | listStudents, getStudent, listClassMembers | `people.students.read` |
    | createStudent, updateStudent | `people.students.create`; `disabled`, and a new `email` for a student someone else also reaches, additionally need `people.users.manage` (T-R2.13) |
    | resetStudentPassword | `people.students.reset_password`; a student someone else also reaches additionally needs `people.users.manage` (T-R2.13) |
    | createClass, updateClass, deleteClass, addClassMember, removeClassMember, rotateJoinCode, revokeJoinCode | `teaching.classes.write` |
    
    Totals: 7 open, 5 `self`, 12 student, 2 admin and 77 teaching, which
    makes 103, the v0.7.0 contract. The open seven are R1's `theOpenSeven`
    pin, unchanged by R2.
    
  • info.description states the three forms (key, pseudo-key, any-of list), what each pseudo-key means, and that open operations declare none.

  • httpx.PermissionRequirements(spec, scheme) builds a map[pattern]access.Requirement. An operation is open when it does not require scheme (bearerAuth), by OpenRoutes' rule with the global security as the fallback. Keys are checked against the compiled catalogue (access.Key.Known, Pseudo, Hidden). It fails, listing every offender, when:

    • a non-open operation declares nothing, or an open one declares something;
    • a value is neither a catalogue key nor a pseudo-key;
    • a list is empty or repeats a key;
    • a hidden key appears outside /admin/*.
  • router.New calls PermissionRequirements next to AssertPublicRoutesLimited, and the process refuses to start on an error.

  • permissions_test.go:

    • TestEveryNonOpenOperationDeclaresAPermission, TestOpenOperationsDeclareNone, TestEveryDeclaredValueIsKnown.
    • TestTheAssertionRefusesAnUndeclaredOperation runs on a mutated copy of the spec.
    • TestThePermissionMapIsPinned compares against testdata/permissions.golden: one line per operation giving method, path and requirement, regenerated by -update. Every permission change then shows up as a diff in review.
  • The web contract suite, which make contract runs, adds a test: every operation that requires the bearer token declares x-permission, no other declares one, and every value is in PermissionKey or is a pseudo-key.

  • RequireRole still gates in this PR. Nothing enforces the declarations until T-R2.5, so no commit on the branch is ever ungated.


T-R2.5 — RequirePermission replaces the prefix gate

Depends on: T-R2.3, T-R2.4 Touches:

  • server/internal/platform/httpx/auth.go
  • server/internal/platform/httpx/permission.go (new)
  • server/internal/platform/httpx/tests/permission_test.go (new)
  • server/internal/core/router/{router.go,server.go,deps.go}
  • server/internal/core/core.go, server/internal/core/wiring/wiring.go
  • server/internal/modules/identity/application/token/token.go
  • server/internal/modules/identity/application/command/{login.go,google_sign_in.go,refresh.go,update_student.go,reset_student_password.go}
  • server/internal/modules/identity/application/ports/ports.go (Principals.Forget)
  • server/internal/core/adapters/principals.go (new)
  • server/internal/modules/identity/repositories/students_write.go
  • server/internal/modules/identity/http/docs.go
  • server/internal/core/router/tests/{permissions_test.go,router_test.go,docs_gate_test.go,stubs_test.go}
  • server/internal/core/router/tests/role_test.go (removed, its cases carried over)
  • server/internal/modules/access/application/tests/revocation_test.go (new)
  • every Go test that mints a token (mechanical commit)

Size: L Done when:

  • Tokens:
    • Claims gains Epoch int as sep.
    • Issue(userID, legacyRole string, epoch int).
    • A token without sep reads as epoch 0.
    • The legacy role claim is still issued (student for a student-like role, admin otherwise), so a v0.7.0 machine's RequireRole keeps working during the rolling deploy.
    • Sign-in, Google sign-in and refresh read session_epoch and put it in the token.
  • httpx.RequireAuth puts {UserID, Epoch} in the context.
  • httpx.RequirePermission(requirements, resolve):
    • Open routes pass untouched.
    • Any other request with no principal gets 401.
    • An unknown user, a disabled user, or a token epoch older than the user's gets 401 UNAUTHORIZED with WWW-Authenticate. The single-flight refresh settles it: refresh refuses the disabled user, and so the user is signed out.
    • An unmet requirement gets 403 FORBIDDEN, with the existing Vietnamese message and no detail.
    • On success, the resolved access.Principal replaces the context value.
  • The chain runs RateLimit → RequestMeta → RefreshCookie → RequireAuth → RequirePermission → body limit → validate. R1's maintenance middleware stays where R1 put it, outside the list (maintenance_gate_test.go). Router tests pin every stage that can refuse a request: the rate limit before authentication and the gate, authentication before the gate, and the gate before the body limit and validation (hardening_test.go pins the body limit before validation).
  • RequireRole, RoleAdmin, AdminPathPrefix and IsAdminPattern are deleted; grep -rn "RequireRole\|IsAdminPattern\|RoleAdmin\|AdminPathPrefix" server --exclude-dir=gen is empty. The generated openapi.RoleAdmin, from the contract's Role enum, stays until T-R3.1 retires the enum.
  • router.Deps gains Principals, the access resolver, wired in core.go. router.New returns an error when it is nil.
  • Docs session:
    • OpenDocsSession issues the docs token with the epoch.
    • RequireDocsSession resolves the cookie's subject through the same resolver, and requires system.api_reference, an active account and the current epoch: 401 and 403 as spec §5.5 describes.
  • Epoch bumps: updateStudent with disabled: true and resetStudentPassword add 1 to session_epoch and revoke the student's refresh families in their transaction (plan 70 §4.2), then call the Principals.Forget port, which a re-enable calls too. The student's live access token gets 401 on its next request, and no session survives a disable, even after the account is enabled again.
  • permissions_test.go uses a fake resolver with the built-in sets. From the spec, it generates:
    • A student gets 403 on every /admin/* operation, and on every /teacher/* one once T-R2.7 lands.
    • A Teacher gets 403 on openDocsSession and deleteStudent.
    • An Admin passes every staff operation, and gets 403 on every /app/* operation except the open beacon.
    • self operations pass for all four roles.
    • A disabled user gets 401; a stale epoch gets 401.
    • A revision bump in the fake changes the answer on the next request.
  • role_test.go's five tests move into permissions_test.go before the file is deleted, each as its permission-model equivalent. They are carried over, not dropped (T-R2.7 renamed four of them for the /teacher/ tree they test now):
    • TestAStudentCannotReachTheTeacherTree: a student gets 403 FORBIDDEN on the dashboard, a workspace.teacher operation.
    • TestATeacherReachesTheTeacherTree: an Admin and a Teacher both reach the dashboard.
    • TestEveryStaffOperationIsGated: every non-open operation has an entry in the requirement map, with the same floor on how many it finds.
    • TestTheStudentTreeIsNotGatedByRole: the /app tree needs learning.take_tests; a student passes, and an Admin without "Take tests" gets 403.
    • TestAnAnonymousCallerToTheTeacherTreeGetsAuthenticationNotAuthorization: an anonymous caller to a gated operation gets 401, never 403.
  • httpx/tests/permission_test.go covers an open route with and without a token, any-of lists, each pseudo-key, and the resolved set in the context.
  • revocation_test.go (integration) runs through the real router and database:
    • Disabling a user answers 401 on their next request.
    • A grant removed as the migrate role is refused after ForgetAll.
  • A separate mechanical commit with no behaviour change: every Go test that mints a token uses the new Issue signature, and every test router passes a fake resolver.
  • Canary web/tests/units/api/client.refresh.test.ts is untouched and green.

T-R2.6 — The signed-in user carries permissions and workspaces

Depends on: T-R2.5 Touches:

  • api/openapi.yaml (CurrentUser, Workspace)
  • server/gen/openapi/, web/src/lib/api/schema.d.ts
  • server/internal/modules/identity/application/{ports/ports.go,model,internal/support/{service.go,students.go}}
  • server/internal/modules/identity/http/{auth.go,account.go,google.go}
  • server/internal/modules/access/application/app.go (Resolve)
  • server/internal/modules/identity/http/tests/current_user_test.go
  • server/internal/modules/identity/application/tests/session_permissions_test.go
  • web/src/features/auth/api.ts, web/src/stores/auth.ts
  • web/tests/support/fixtures.ts, web/tests/e2e/fixtures/

Size: S Done when:

  • CurrentUser holds User's properties plus two required fields:

    • permissions: PermissionKey[]: the effective set in catalogue order, including the hidden keys for the Admin.
    • workspaces: Workspace[], from the teacher, admin and app workspaces, derived by access.Workspaces.
  • CurrentUser is written flat, because a closed schema cannot be extended with allOf (issue #41).

  • CurrentUser replaces User wherever the response describes the caller: getCurrentUser, updateCurrentUser, linkGoogle, AuthSuccess and GoogleSignInSuccess.

  • User stays where it describes someone else: getAttemptForReview's student. A student's permissions never appear in a teacher's payload.

  • role keeps its two legacy values, derived from the role, so a tab still open on v0.7.0 keeps routing. The additions are ignored by the old SPA.

  • As built: sign-in and Google sign-in resolve the permissions through identity's Principals port (Resolve beside Forget) and carry them on model.Session; /auth/me, PATCH /auth/me and the Google link read them from the principal the gate resolved, so they cost no query. The table below lives in identity/http/tests/current_user_test.go, at the HTTP layer, because an Admin fixture in the database could not be removed again (the last-Admin guard).

  • The per-role table:

    | Role | `permissions` | `workspaces` |
    |---|---|---|
    | Admin | every key except `learning.take_tests` | `[teacher, admin]` |
    | Teacher | the 13 keys | `[teacher]` |
    | Assistant | the 6 keys | `[teacher]` |
    | Student | `[learning.take_tests]` | `[app]` |
    
  • The web auth store and features/auth/api.ts use CurrentUser. The fixtures gain both fields in one mechanical commit, and web/tests/units/contract/msw-contract.test.ts is green.


T-R2.7 — Teaching operations move to /teacher/*

Depends on: T-R2.5 Touches:

  • api/openapi.yaml (paths, tags, descriptions)
  • server/gen/openapi/, web/src/lib/api/schema.d.ts
  • server/internal/modules/identity/http/delete.go (DeleteUser)
  • server/internal/core/router/ratelimits.go
  • server/internal/platform/httpx/permission_spec.go (tree rule)
  • server/internal/platform/httpx/hardening.go (/teacher/ gets no-store)
  • server/internal/core/router/tests/hardening_test.go
  • server/internal/core/router/tests/credential_limits_test.go (new)
  • server/internal/core/router/tests/permissions_test.go
  • server/internal/core/router/tests/testdata/permissions.golden
  • the Go tests that name /admin/ API paths (23 files today)
  • the feature api.ts modules that call teaching operations (10 today)
  • web/src/lib/api/client.typecheck.ts
  • the web test files that stub API paths (about 55: MSW handlers, Playwright page.route stubs, live specs)

Size: L (most of it mechanical commits) Done when:

  • The 77 teaching operations move from /admin/<path> to /teacher/<path>, with operationIds unchanged so the Go and TypeScript names do not change.
  • Every admin-* tag except admin-docs becomes teacher-*.
  • Descriptions that say "admin" and mean the teacher are corrected, and info.description explains the split into /teacher, /admin, /app and /auth, with /public and /join for open operations and /me reserved for R4.
  • deleteStudent becomes DELETE /admin/users/{id} (deleteUser, tag admin-users). Its handler is renamed; the command stays DeleteStudent, and the target stays strictly a student (T-R2.13).
  • openDocsSession stays at /admin/docs-session.
  • The path-tree rule is switched on in PermissionRequirements, with TestPermissionTreesMatchPaths:
    • /teacher/* accepts only content.*, teaching.*, people.students.* and workspace.teacher.
    • /admin/* accepts people.users.manage, people.roles.manage, system.*, scope.all and workspace.admin.
    • /app/* accepts learning.take_tests; the open beacon is exempt.
    • /auth/* accepts self; the open operations are exempt.
    • /me/* accepts only self. R4 creates the tree; the rule is in place before it.
    • /public/* and /join/* hold only open operations.
    • Open operations are exempt wherever their path lies: /public/status, /join/preview, the beacon and the four under /auth/*.
    • Any other prefix fails PermissionRequirements at startup.
    • TestPermissionTreesMatchPaths pins each rule, including an operation under an unknown prefix, a /me/* operation declaring a key, and a /public/* operation that is not open.
  • Every RateLimits() key moves: POST /teacher/students/{id}/reset-password (5/min, 30/h, numbers unchanged). Two credential-minting operations that had no limit gain one:
    • POST /teacher/students (createStudent returns a temporary password): 30/min and 300/h per address.
    • POST /teacher/classes/{id}/join-code (rotateJoinCode mints a bearer code): 10/min and 60/h.
    • These limits are per address, because the limiter runs before RequireAuth and no principal exists yet. In R2 the only staff account is the owner, so no centre NAT is shared. R4's principal-keyed limiter (PrincipalRateLimits()) is where the authenticated minters, createStudent, rotateJoinCode and getJoinCode, move to per-actor keys.
  • TestRegistryHasNoStaleEntries is green.
  • credential_limits_test.go pins theCredentialMinters: login, googleAuth, refreshSession, resetStudentPassword, createStudent, rotateJoinCode and openDocsSession. T-R2.14b adds getJoinCode.
    • Each has a registry entry.
    • An operation whose 2xx body carries temporaryPassword or accessToken, or a join code (an object with both code and expiresAt), at any depth, and is not on the list fails the test. The walk goes through arrays of objects (items[].temporaryPassword), maps (additionalProperties) and allOf/oneOf/anyOf. The next credential-minting operation, a bulk one included, cannot ship without a limit.
  • SecurityHeaders (hardening.go) sets Cache-Control: no-store on /teacher/ as it does on /auth/, /app/ and /admin/. Without it, createStudent's and resetStudentPassword's temporaryPassword and every student record become cacheable on the move. R4 adds /me/.
  • TestSecurityHeadersOnSuccessFailureAndPreflight asserts no-store on one path per private tree (/auth, /app, /teacher, /admin), and that /public/status keeps its own public, max-age=30.
  • Commits, in this order:
    1. contract
    2. make gen
    3. Go: the handler rename, the limits, the tree rule, the no-store prefix, the tests
    4. Go test path sweep (mechanical)
    5. web API-path sweep (mechanical)
  • pnpm typecheck passing proves every call site matches the new schema.
  • The web sweep changes API paths only: api.ts modules, client.typecheck.ts, and any MSW handler, Playwright route stub or live spec that addresses the API origin.
  • Web routes (to="/admin/…", navigate("/admin/…")) stay until R4.
  • A grep in the PR shows nothing under web/ addressing an API path under /admin/ except /admin/docs-session and /admin/users/{id}.
  • permissions.golden is regenerated. make gen-check, pnpm typecheck, pnpm test:unit, pnpm test:integration and pnpm e2e are green.

T-R2.8 — The legacy /admin alias

Depends on: T-R2.7 Touches:

  • server/internal/core/router/legacy_admin.go (new)
  • server/internal/core/router/legacy_admin_paths.go (new)
  • server/internal/core/router/router.go
  • server/internal/core/router/tests/legacy_admin_test.go (new)

Size: M Done when:

  • The alias is an outer http.Handler, inside CORS and outside the generated handler. It has to be outside: the generated middlewares run only after the mux has matched a pattern, and an /admin path no longer in the spec never matches.
  • Its own ServeMux holds the 78 pinned pairs: the 77 renames, plus DELETE /admin/students/{id} → /admin/users/{id}.
  • A match rewrites r.URL.Path and rebuilds RawPath from the escaped segments, so an encoded %2F stays inside its wildcard as it would on the new path (a cleared RawPath would let /admin/tests/{id} land on DELETE /teacher/tests/{id}/versions/{version}). It logs legacy_admin_path at info with the method, the old pattern and the request id. The request then continues to the real mux, so auth, permission, rate limit and validation run exactly as they do on the new path.
  • A request that matches no pair passes untouched. POST /admin/docs-session and DELETE /admin/users/{id} are never rewritten.
  • legacy_admin_test.go:
    • The table equals a hard-coded list of the v0.7.0 /admin operations minus openDocsSession, so a later edit cannot shrink it silently.
    • For every pair, a student token gets the same status on the old path as on the new one (403), and an Admin token reaches the handler (never 404 or 405).
    • The sixth reset-password in a minute through an old path gets 429, counted under the new key.
    • An OPTIONS preflight to an old path is answered as the new path's would be.
    • One log line per rewritten request.
  • The release notes say the alias lasts one release; 73-r3.md removes it.

T-R2.9 — Ownership columns

Depends on: T-R2.2 Touches:

  • migrations/NNNNN_add_classes_teacher.sql (new)
  • migrations/NNNNN_add_tests_owner.sql (new)
  • migrations/NNNNN_add_questions_owner.sql (new)
  • migrations/NNNNN_add_question_groups_owner.sql (new)
  • migrations/NNNNN_add_media_assets_owner.sql (new)
  • migrations/NNNNN_add_users_created_by.sql (new)
  • migrations/NNNNN_add_<table>_owner_fkey.sql and NNNNN_add_classes_teacher_fkey.sql (new, five: the foreign keys apart from their columns)
  • server/internal/modules/classes/repositories/postgres.go (Create)
  • server/internal/modules/identity/repositories/students_write.go
  • server/internal/modules/tests/repositories/{store.go,duplicate.go,version_draft.go,group_create.go}
  • server/internal/modules/questions/repositories/store.go
  • server/internal/modules/media/repositories/store.go
  • the commands that carry the owner into those statements
  • server/internal/platform/db/tests/expand_r2_test.go
  • docs/plan/20-data-model.md

Size: L Done when:

  • add_classes_teacher:

    • teacher_id uuid REFERENCES app.users ON DELETE RESTRICT.
    • Backfilled from the oldest active Admin (by created_at, then id), the teacher v0.7.0 shows for every class. app.classes has never had a created_by (an earlier draft of this plan assumed 00006 declared one); Thuong chose this rule on 2026-09-28 (D-23). In production the one staff account is that Admin, so no class's teacher changes. The file raises if a class exists and no active Admin does.
    • Constraint classes_teacher_id_not_null NOT NULL teacher_id NOT VALID.
    • Trigger classes_fill_teacher (BEFORE INSERT), with its function app.classes_fill_teacher(), applies the same rule when the old binary inserts without a teacher. It reads users.role_id, not role.
  • add_tests_owner, add_questions_owner and add_question_groups_owner:

    • owner_id uuid REFERENCES app.users ON DELETE RESTRICT, backfilled from created_by.
    • Constraints <table>_owner_id_not_null NOT NULL owner_id NOT VALID.
    • A BEFORE INSERT trigger through app.fill_owner_from_created_by(), which the tests file creates and the other two reuse.
  • add_media_assets_owner does the same from uploaded_by, through app.fill_owner_from_uploaded_by().

  • add_users_created_by: created_by uuid REFERENCES app.users ON DELETE SET NULL, nullable, with no backfill. A NULL means provenance is unknown, and a Google self-join has no creator.

  • Each backfill disables the table's *_set_updated_at trigger for its UPDATE. Otherwise every test's updated_at would move, and an open builder tab's next autosave would get STALE_WRITE during the deploy.

  • Each Down drops the trigger, the constraint and the column, and the function in the file that created it.

  • No column file names REFERENCES app.users: each foreign key is its own file after the columns (00066–00070). A column file holds ACCESS EXCLUSIVE on its table until it commits, and an inline reference would then wait on users; v0.7.0's student creation (writes users, then checks classes through class_members) or a user delete (writes users, then checks the referencing tables) would deadlock with it and abort the release. expand_r2_test.go drives the student-creation case.

  • The new binary sets the column explicitly; the triggers exist only for the old binary:

    | Row | Owner written |
    |---|---|
    | a class | the acting user |
    | a new test | the acting user |
    | a duplicated test, a restored draft | the source test's owner; `created_by` stays the actor |
    | a bank group | the acting user |
    | a section-owned group | the test's owner |
    | a bank question | the acting user |
    | a group member | the group's owner |
    | a restored-draft question | the test's owner |
    | a media asset | the uploader |
    | a student created by staff | `created_by` = the actor |
    | a Google self-join | `created_by` = NULL |
    
  • expand_r2_test.go extended:

    • The legacy dataset backfills to the right owners: created_by for tests, questions and question groups, uploaded_by for media, and the oldest active Admin for classes (D-23).
    • No backfilled updated_at moved.
    • Old-binary inserts without owner columns are filled by the triggers.
  • Duplicate and restore insert into test_section_questions, a high-risk path: every such insert still calls questions.LockForDraftUse first. The five canaries and the module's unit tests run on the branch point and on the last commit, both runs in the PR.

  • Repository tests:

    • A Teacher's createClass stores that teacher, not the oldest Admin.
    • A committed Word import's test and questions belong to the importer.
  • R3's validation will fail if any insert path forgets the column. Every insert path is exercised by an existing test, and R3 drops the triggers.

  • Fixtures and the seed that insert a class name teacher_id: the class fill reads other rows, so a fixture relying on it would take another package's Admin, whose delete would then fail one side on the foreign key. The shared constraints_test.go fixture also runs at schemas before 00065, so it names teacher_id when the column exists.

  • 20-data-model.md records the eleven files (§31) with D-22 (owner_id separate from created_by; provenance must survive R5's ownership transfer and R7's co-editing) and D-23 (classes.teacher_id from the oldest active Admin, no classes.created_by).

  • up/down/up green.


T-R2.10 — Ownership indexes

Depends on: T-R2.9 Touches:

  • migrations/NNNNN_index_classes_teacher.sql (new)
  • migrations/NNNNN_index_tests_owner.sql (new)
  • migrations/NNNNN_index_questions_owner.sql (new)
  • migrations/NNNNN_index_question_groups_owner.sql (new)
  • migrations/NNNNN_index_media_assets_owner.sql (new)
  • migrations/NNNNN_index_users_created_by.sql (new)
  • migrations/NNNNN_index_assignments_creator.sql (new)
  • docs/setup/deploy.md, docs/plan/20-data-model.md

Size: S Done when:

  • Each file is -- +goose NO TRANSACTION with a single CREATE INDEX CONCURRENTLY. Down is DROP INDEX CONCURRENTLY IF EXISTS. One index per file, so an interrupted build affects one index.

    | Index | Definition |
    |---|---|
    | `classes_teacher_idx` | `(teacher_id)` |
    | `tests_owner_idx` | `(owner_id, id DESC) WHERE deleted_at IS NULL` |
    | `questions_owner_bank_idx` | `(owner_id, id DESC) WHERE deleted_at IS NULL AND context_group_id IS NULL` |
    | `question_groups_owner_bank_idx` | `(owner_id, updated_at DESC, id DESC) WHERE owner_section_id IS NULL AND archived_at IS NULL`, mirroring `00049` |
    | `media_assets_owner_idx` | `(owner_id, kind, created_at DESC) WHERE deleted_at IS NULL` |
    | `users_created_by_idx` | `(created_by) WHERE created_by IS NOT NULL` |
    | `assignments_creator_idx` | `(created_by, id DESC)` |
    
  • deploy.md lists the DROP INDEX CONCURRENTLY IF EXISTS statements to run before redeploying after an interrupted build of any of these or users_role_id_active_idx, as it already does for 00036 and 00038.

  • EXPLAIN (ANALYZE, BUFFERS) of a teacher's tests list, bank list and media library at seeded volume is recorded in the PR. No plan is forced: a sequential scan on a small table is correct (20-data-model.md §14).

  • up/down/up green.


T-R2.11 — Seeds and a test harness for several staff roles

Depends on: T-R2.2, T-R2.5, T-R2.9 Touches:

  • seed/06-dev-access.sql (new), seed/07-dev-classroom.sql (new)
  • seed/99-assert.sql, seed/README.md
  • server/tests/harness_test.go
  • the per-module integration harnesses that create users (for example server/internal/modules/tests/application/tests/harness_test.go)

Size: M Done when:

  • Seeds load in lexical order, so each file is numbered at merge, one number per file in merge order, as R7 onwards already do (NN-dev-…). 06 and 07 are the next free numbers today, and R2's seeds merge before any later release's. Each file's header names the seeds it relies on, if any.
  • 06-dev-access.sql is idempotent; every user in it has the password quizzivy-dev, and role_id is looked up by builtin_key. It seeds:
    • giaovien@quizzivy.com (Teacher), with a class, a draft test holding one bank question, and one bank group;
    • giaovien2@quizzivy.com (Teacher), with its own class and test;
    • trogiang@quizzivy.com (Assistant).
  • 07-dev-classroom.sql seeds thirty students, hocvien.lop01@quizzivy.com … hocvien.lop30@quizzivy.com, with must_change_password = false, for T-R2.15's live test.
  • 99-assert.sql asserts:
    • every user has role_id;
    • every class has teacher_id;
    • every test, question, question group and media asset has owner_id;
    • the four built-in roles exist.
  • thuong@quizzivy.com (Admin) and hocvien@quizzivy.com are unchanged. seed/README.md lists every login.
  • The Go end-to-end harness gains createStaff(t, builtinKey), and a login helper that returns a real token with its epoch. The module harnesses that insert users write role_id.
    • As built, every Go test fixture that creates a user at the current schema writes role_id, so R3's DROP COLUMN role finds none to fix. platform/db/tests keeps role where it builds v0.7.0 data or runs at an older schema; its shared constraints_test.go fixture picks by which column exists.
    • staff_roles_flow_test.go signs in an Admin, a Teacher and an Assistant through createStaff. For each it checks that the token's epoch matches the account's, and checks the workspaces, people.users.manage and the gate on DELETE /admin/users/{id}, all against the real database.

T-R2.12a — Scoping: the HTTP edge, tests and question groups

Depends on: T-R2.5, T-R2.9, T-R2.11 Touches:

  • server/internal/shared/actor/actor.go (Actor.Scope)
  • server/internal/platform/httpapi/httpapi.go (ActorFromContext fills the scope; ScopeFromContext)
  • server/internal/modules/tests/{application,repositories,http}/**
  • server/internal/modules/tests/repositories/store.go (QuestionLocks gains NotOwnedBy)
  • server/internal/modules/questions/repositories/references.go (NotOwnedBy)
  • server/internal/core/adapters/group_questions.go (NotOwnedBy, because GroupQuestionStore embeds QuestionLocks)
  • server/internal/core/adapters/import_commit.go (the commit's scope and key)
  • api/openapi.yaml (403 on the four group writes), server/gen/openapi/, web/src/lib/api/schema.d.ts
  • server/internal/modules/tests/application/tests/scope_test.go (new)
  • server/internal/modules/tests/http/tests/group_permission_test.go (new)

Size: L Done when:

  • ActorFromContext builds Scope{UserID, All} from the principal, with All true only for scope.all. Queries that serve teacher operations gain a Scope field. The plumbing lands with its first consumer here, because unparam forbids unused parameters.
  • Every read and write in the tests module filters owner_id = scope.UserID, and All lifts the filter:
    • lists, facets and search;
    • get, update, delete and publish;
    • versions, set-current and delete-version;
    • preview and restore-as-draft;
    • autosave and the outline.
  • Section-owned groups follow their test; bank groups filter on their own owner_id.
  • copyQuestionGroup needs a readable source and a writable destination.
  • The question-group writes narrow their any-of x-permission (T-R2.4, plan 70 §4.2), after scoping so an invisible target still answers 404:
    • createQuestionGroup and copyQuestionGroup require content.questions.write when the destination is the bank (no ownerSectionId) and content.tests.write when it is a test section;
    • updateQuestionGroup and deleteQuestionGroup require the key that matches the stored group's OwnerSectionID, checked in the repository after the group is read FOR UPDATE and found in scope;
    • any other principal gets 403 FORBIDDEN;
    • a test per operation and per direction, with a principal holding one key only. No R2 built-in role differs on the two keys; R5's custom roles would.
  • Draft references keep one owner: after questions.LockForDraftUse has locked every referenced question, the same transaction asks QuestionLocks.NotOwnedBy once for the whole set (one statement, not one per question, so an autosave does not double its round trips). A question not owned by the test's owner answers exactly as a missing one does (ErrUnknownQuestion: 400 VALIDATION_FAILED on updateTest, 404 on duplicateTest), so it reveals nothing, as T-R2.16 requires. The owner compared is the test's, not the actor's: the Admin editing A's draft may reference only A's questions.
  • LockForDraftUse itself is unchanged, so its canary still describes the path production uses.
  • Duplicate and restore write the source test's owner (T-R2.9).
  • Another teacher's id answers 404, never 403, or exactly what an unknown id answers where the contract has no 404: previewTest answers 409 TEST_NOT_PUBLISHED and listTestVersions returns no rows.
  • The scope comes from the resolved principal (httpapi.ScopeFromContext, and Actor.Scope from ActorFromContext), with All only for scope.all. A zero scope matches nothing. Single-row locks carry ($x OR owner_id = $y); lists add owner_id = $n in Go only without All, so their plans can use T-R2.10's owner indexes.
  • The narrowing's grants travel beside the scope (Grants on command.CreateGroup, model.GroupMutation and the domain inputs, not on Actor, which stays comparable). The repository checks them under the lock, after the scope: a create after its destination is locked, a mutation after the group is read FOR UPDATE. The internal group creates of duplicate and restore pass content.tests.write, the key those operations are admitted with, and the Word-import commit passes the same.
  • scope_test.go, with teachers A and B:
    • B gets 404 or no rows when listing, faceting, opening, updating, publishing, duplicating, previewing, restoring or deleting A's test or bank group.
    • B referencing A's question in B's draft gets the error a missing question gets.
    • The Admin opens and edits A's test.
  • Canaries: before and after, and untouched:
    • tests/application/tests/publish_snapshot_test.go
    • questions/application/tests/draftrace_test.go (TestLockForDraftUseSerialisesAgainstDelete)
    • tests/application/tests/publish_lockorder_test.go
  • No SELECT *.

T-R2.12b — Scoping: questions

Depends on: T-R2.12a, T-R2.12c Touches:

  • server/internal/modules/questions/{application,repositories,http}/** (search.go, facets.go, store.go, question_content.go, references.go)
  • server/internal/modules/questions/domain/{input.go,repository.go} (WriteRequest.All and Scope(), WriteInput.All, ListInput.Scope)
  • server/internal/modules/questions/application/ports/ (MediaKinds.Kind takes the scope)
  • server/internal/modules/tests/application/ports/group_media.go
  • server/internal/modules/tests/application/internal/support/groups.go and command/{create_group,update_group}.go (Prepare takes the scope)
  • server/internal/modules/tests/repositories/{store.go,group_assets.go,group_create.go,group_update.go} (MediaLocks.RequireReadable)
  • server/internal/modules/media/repositories/references.go (RequireReadable, the in-transaction form of Readable)
  • server/internal/core/adapters/media.go
  • docs/plan/60-backend-architecture.md (what the two lock ports carry)
  • server/internal/modules/{questions,tests}/application/tests/mediakinds_test.go, tests/application/tests/group_media_race_test.go (the fakes gain the scope)
  • server/internal/modules/questions/application/tests/scope_test.go, questions/http/tests/, tests/application/tests/group_media_scope_test.go (new)

Size: M Done when:

  • Bank list, facets, the tag rail and both counts (the bank total and the filtered count) filter owner_id, and scope.all lifts it. The owner goes in as $1 in Go only without All, so questions_owner_bank_idx serves the page.
  • Get, the update row lock, the delete row lock and bulk tagging carry ($x OR owner_id = $y). Another teacher's question answers as a missing one: 404 on get, update, delete and duplicate, and tagging skips it as it skips a missing id. The delete checks the owner in its lock, before the drafts that would refuse it are read, so B never sees the 409 that names A's tests.
  • Writes carry All beside ActorID, not a Scope, as media's DeleteInput does (T-R2.12c): the owner compared is ActorID, and WriteRequest.Scope() derives {ActorID, All} for reads and bindings. The canaries build WriteRequest{ID, ActorID}; a zero Scope would turn draftrace's delete into the not-found it accepts, and TestLockForDraftUseSerialisesAgainstDelete could no longer fail. A group member's write is authorized by its group (T-R2.12a), so the update lock lifts the owner check whenever a group is named: the Admin editing A's group writes members A owns.
  • createQuestion writes the actor as the owner (T-R2.9); a test pins it for R3, when the fill trigger goes. An Admin's duplicate of A's question belongs to the Admin, still bound to A's asset, as T-R2.9's table says; R7's forTestId is the way to add to A's bank.
  • A question's "used in" list shows only tests the viewer can read. Residual: the usedInTests count beside it stays unscoped. It equals the list while drafts keep one owner (T-R2.12a) and the release gate requires no cross-owner draft reference.
  • updateQuestion answers 404 for ErrNotFound, as the contract says; it answered 501.
  • Every media binding uses media/repositories.Readable under the writer's scope, so an Admin may attach any asset and its owner then reads it through the reference arm. Anything else gives the same error as a missing asset: 400 on a question, 422 on a group.
    • A question's asset goes through the MediaKinds port, whose adapter calls query.Readable and looks the id up lowercased. The check runs before validation, so an unreadable asset's kind never shapes the error, and before the write, so the question's existing binding counts.
    • The port alone is not enough: a group's stimulus assets and recordings never pass through it (Prepare resolves only member questions), and their one check was LockForVersionUse, which checks existence. lockGroupAssets calls media.RequireReadable(tx, scope, ids) once, after the locks and before the members, stimuli and recordings that reference an asset are written (on create the group row itself is already in, but it references none), so a write never authorizes itself. Create, update, copy, duplicate, restore and the Word-import commit all pass through it. It names recordings explicitly, although the validator already makes each one a stimulus asset.
    • Prepare passes the scope too, so a member's unreadable asset is refused before the bundle is validated.
    • The test fakes of MediaKinds call mediarepo.Readable, so both module harnesses run the one definition.
    • Not re-checked, deliberately: the publish snapshot and restore-as-draft's question copy, which copy bindings already checked from content the actor can read.
  • Residuals, recorded:
    • The question's asset check runs before the write transaction and takes no media lock. Assets are immutable and owners do not move in R2; R5's transfer must re-read this.
    • lockGroupAssets locks each asset before RequireReadable refuses it, so B naming A's asset briefly holds A's row, and waits while A holds it. That is a timing difference, which T-R2.16's response-level rule does not cover. Closing it means a second, unlocked Readable before the locks.
    • The OpenAPI descriptions of bankTotal and of tagging's updated do not yet say "in the caller's scope", as T-R2.12c left media's.
  • scope_test.go covers B on each of A's operations, including a delete A's draft would refuse, and the zero scope. It also covers binding A's asset in a question (create, update, with an audio policy), a group member, a stimulus, a recording, and a group naming B's asset beside A's. And it covers the Admin path: edit, tag, duplicate and delete without taking ownership, and an asset the Admin bound that the owner then re-saves. Each predicate and each scope hand-off was mutated once, and a test failed each time.

T-R2.12c — Scoping: media

Depends on: T-R2.12a Touches:

  • server/internal/modules/media/domain/{commands.go,repository.go} (ListInput.Scope, DeleteInput.All, Readable)
  • server/internal/modules/media/repositories/{list.go,delete.go,store.go,references.go}
  • server/internal/modules/media/application/{app.go,query/readable.go,query/total_bytes.go,query/get.go}
  • server/internal/modules/media/http/media.go
  • server/internal/modules/media/application/tests/scope_test.go and media/http/tests/scope_test.go (new); the library listings in list_test.go, delete_test.go and versionref_test.go gain a scope

Size: S Done when:

  • The library list and its byte total filter owner_id, and All lifts the filter as in T-R2.12a. One WHERE builder serves the count, the page and the total, so the header agrees with the rows; the kind is a plain kind = $n rather than ($1 IS NULL OR kind = $1), so a generic plan can use media_assets_owner_idx.

  • Delete needs the owner or All, checked in the row lock before the references are read: B deleting A's asset answers 404 exactly as a missing asset does, and never the 409 that names the tests and groups using it. The owner compared is DeleteInput.ActorID, and All is a flag on the input rather than a Scope: the canary file builds DeleteInput{ID, ActorID}, and a zero Scope would turn every one of its deletes into a not-found, leaving TestLockForVersionUseSerialisesAgainstDelete green because it could no longer fail.

  • "Used in" is computed only for the page the scoped list returned. Residual until R7: an owner's "used in" and the 409 details name every test and group using the asset, including another owner's where an Admin bound it there. R7's MEDIA_IN_USE_ELSEWHERE {count} replaces those titles.

  • Upload writes the actor as the owner (T-R2.9); a test pins it.

  • media/repositories.Readable is the one definition of a readable asset, one statement over a batch of ids, with no lock. An asset is readable when it is live and it is the actor's own; when the actor holds scope.all; or when content the actor can read already uses it:

    • one of their live bank questions;
    • a group they own (a member question, a stimulus or a recording), where a section group follows its live test's owner, as in T-R2.12a;
    • a published version of one of their live tests (a question, a group stimulus or a group recording).

    A missing, deleted or unreadable id is simply absent from the answer, and a zero scope reads nothing. query.Readable serves it to other modules; T-R2.12b wires every binding check to it. That arm is what lets R7's copier edit a copy that still binds the source owner's asset; R7 adds the share arm and R10 its transitive arm to the same statement. Every arm is driven by the reference table's media index (questions_media_idx, group_stimulus_assets_media_idx, group_recordings_media_idx, tvq_media_idx, tvga_asset_idx, tvgr_asset_idx), and the bank-question arm by questions_owner_bank_idx.

  • CountByChecksum (the D-06 duplicate warning, no caller yet) counts only the owner's uploads.

  • Staff mint signed URLs only through a parent they can read: a question, a group, a test preview or a version. query.Get and SignedURL stay unscoped and say so: callers name only an asset reached through such a parent, never an id from a request. Every asset of a readable parent is readable by definition, so a media-level check there would always pass. Attempt review (T-R2.12e) is gated by the assignment, not by this definition; the student path (MintForStudent) is unchanged.

  • LockForVersionUse is unchanged. Canary media/application/tests/versionref_test.go (TestLockForVersionUseSerialisesAgainstDelete) is green before and after, and the function is untouched. Two other tests in that file list the library; their listings gain Scope: everyone, the whole shelf they read before, or their loops would walk an empty page and assert nothing.

  • scope_test.go: B cannot list, total, delete or see the usage of A's asset; the Admin lists and deletes it; the owner still sees the usage. The readability definition has one case per arm and per reference table, each granted and then revoked, and while granted it reads only the asset that use names, not another of A's. The negatives are a use B does not own, a member question B owns inside A's group, a section group recorded as B's inside A's test, a deleted asset, a missing id and the zero scope. Every predicate, including each arm's link to its asset, was mutated once and a test failed each time.

T-R2.12d — Scoping: classes, students and join

Depends on: T-R2.12a Touches:

  • server/internal/shared/visibility/visibility.go (new: StudentIDs, AssignmentIDs)
  • server/internal/shared/stats/stats.go (Source takes the scope)
  • server/internal/modules/attempts/repositories/studentstats.go, attempts/http/{attempts_handlers.go,admin_attempts.go}, core/wiring/attempts.go (review reads StudentAccount)
  • server/internal/modules/classes/{domain,application,repositories,http}/** (postgres.go, lookup.go, delete.go, joincodes.go)
  • server/internal/modules/identity/{domain,application,repositories,http}/** (students_students.go, students_write.go, delete.go, students.go, query/student_account.go)
  • api/openapi.yaml (404 on createStudent, scoped descriptions), server/gen/openapi/, web/src/lib/api/schema.d.ts
  • docs/plan/60-backend-architecture.md
  • server/internal/modules/classes/application/tests/scope_test.go, classes/http/tests/scope_test.go, identity/application/tests/students_scope_test.go, identity/http/tests/students_scope_test.go (new)

Size: L Done when:

  • Classes filter teacher_id, with All lifting the filter: list and facets (in Go, only without All, so classes_teacher_idx serves them), and get, update (even with nothing to change), archive and restore, delete, members, add and remove member, and rotating and revoking the code (($x OR teacher_id = $y) in the row lock). Another teacher's class answers exactly as a missing one: 404, an empty member page, or ErrClassNotFound for the codes, and before a delete's two 409s can reveal its state.
  • Writes carry All beside ActorUserID (or actor.Actor.Scope.All), as T-R2.12b and T-R2.12c do: every existing test names the class's own teacher as the actor, so they keep exercising the owner arm.
  • createClass writes the actor as teacher (T-R2.9).
  • The "oldest admin" subqueries are gone. LookupByCodeHash (the join preview) and ListMine (the student's classes) name each class's own teacher through classes.teacher_id, disabled or not, so the open preview can no longer reach ErrNoTeacher. In production it is the same person (D-23).
  • shared/visibility holds the two reach rules, each defined once as a subquery of ids, because a port cannot splice a predicate into another module's list or aggregate:
    • StudentIDs: a member of a class the teacher teaches, an account the teacher created, or an individual target of an assignment the teacher created. Archived classes count; draft assignments count.
    • AssignmentIDs: created by the teacher, or targeting a class the teacher teaches. It is T-R2.12e's definition, which reuses it.
  • A student is visible when student-like and reached (or with All). Lists, facets, get, update, reset and the delete lock use it; another teacher's student answers ErrStudentNotFound and is never written. The existing identity sites keep role = 'student' for T-R2.13 to swap; new code (add member) uses app.student_like_roles.
  • Inside a visible student, memberships name only classes the viewer teaches, a classId filter on another teacher's class matches nothing, and the figures (stats.Source, the roster's and the students table's) and "active in 7 days" cover only the assignments the viewer reaches.
  • createStudent: every classIds entry must be a class the actor teaches (any with All), locked FOR SHARE before the account is inserted, else 404 and nothing is written, before the email is checked. The contract gains that 404. It writes created_by = the actor.
  • addClassMember: the class is locked FOR SHARE in scope first; then one statement asks whether the target is an active, student-like account the actor reaches. Missing, disabled, staff and unreachable all answer the same 404 (it answered 400 for some of them), so a body id reveals nothing.
  • Attempt review names its student through a new, unscoped identity StudentAccount query (account fields, no memberships or figures): the attempt is the parent, and a scoped GetStudent would answer 500 for a student removed from the class after sitting it. T-R2.12e gates the attempt.
  • Leak review, recorded in the PR: /join/preview still returns exactly its three keys (TestTheSuccessBodyHasExactlyThreeKeys green), and the teacher's name is now the class's own teacher.
  • Deferred: ActiveCode has no consumer; T-R2.14b scopes it with getJoinCode.
  • scope_test.go and students_scope_test.go cover B on every one of A's class and student operations, B's createStudent into A's class, each reach rule granted and revoked, a student shared by A and B with each teacher seeing only their own class and figures, and the Admin path. Each predicate, reach arm and scope hand-off was mutated once, and a test failed each time.

T-R2.12e — Scoping: assignments and attempts

Depends on: T-R2.12a, T-R2.12d Touches:

  • server/internal/shared/visibility/visibility.go (TaughtClassIDs, AuthoredAssignmentIDs, Papers; one definition of each rule)
  • server/internal/modules/assignments/{domain,application,repositories,http}/** (assignments.go, write.go, delete.go, reopen.go)
  • server/internal/modules/attempts/{domain,application,repositories,http}/** (admin.go, monitor.go, review_byquestion.go, review_grade.go, review_review.go, integrity_store.go, http/admin_attempts.go)
  • server/internal/modules/dashboard/{domain/queries.go,repositories/postgres.go,http/handlers.go} (the attempt list, moved here from T-R2.12f)
  • server/internal/modules/assignments/repositories/tests/scope_test.go, attempts/application/tests/scope_test.go, and the scope_test.go of assignments/http/tests, attempts/http/tests and dashboard/http/tests (new)

Size: L Done when:

  • An assignment is reached (visibility.AssignmentIDs) when the viewer created it or it targets a class the viewer teaches, or with All. The list, the facets and the classId filter apply it in Go, only without All; a classId filter must also name a class the viewer teaches. Get, update, delete and reopen apply it in the row lock or the UPDATE itself. Another teacher's assignment answers exactly as a missing one, before delete's 409 or reopen's reasons can reveal its state.

  • Writes carry All beside ActorID (Request.All, Request.Scope()), as T-R2.12b to T-R2.12d do.

  • A write names only what the writer reaches: a version of a test the writer owns, classes the writer teaches, and individual targets who are active, student-like (app.student_like_roles) and reached (visibility.StudentIDs), or who are reached and are already targets of this assignment (#210). All are checked before replaceTargets deletes anything.

    • Decision D1: the answer is the one a missing id gets, not a new 404. An unreachable version answers 409 TEST_NOT_PUBLISHED, as a missing, deleted or unpublished one does. An untaught class or an unreached student answers 400 VALIDATION_FAILED naming the id, as a missing one does. The scope is folded into the same statement, so the answers are byte-identical and reveal nothing. That is T-R2.16's general rule for a body reference. It needs no contract change, where a 404 would have changed createAssignment's contract and three existing expectations. This amends the 404 this task and T-R2.16 first named; Thuong confirmed it on 2026-09-28.
  • Decision D2: mixed targets are filtered, never refused.

    • In R2 only a scope.all writer can create an assignment that targets another teacher's class or student.
    • The target classes it embeds, their names and the target count name only classes the reader teaches and students the reader reaches. So does the monitor roster.
    • An update replaces targets only within the writer's reach, and keeps the classes and students the writer cannot see. The web re-sends the filtered targets on publish and close, so a wholesale replace would have dropped them.
  • Decision D4: an update that keeps the assigned version skips the ownership check. The assignment is the parent. A teacher reaching an Admin's assignment through a class can therefore close or reschedule it, although they cannot read its test. A new version must belong to a test the writer owns.

  • Every teacher attempt operation is gated on the attempt's assignment (AssignmentIDs) in the statement or lock that finds it: the monitor and its overdue sweep, review, events, answers by question, grade, finish, note, flag, extend, reset and void. Another teacher's answers as a missing one, before any 409. The student /app/* paths are unchanged.

  • Lists of papers follow visibility.Papers: the answers by question and the attempt list (listAttempts, moved here from T-R2.12f because it lists attempt ids). They show every paper of an assignment the viewer created, including one by a student removed from the class after sitting it. Otherwise they show only the students the viewer reaches.

  • Canaries before and after, untouched: attempts/application/tests/events_test.go and tests/application/tests/publish_snapshot_test.go.

  • EXPLAIN (ANALYZE, BUFFERS) of the monitor, grading and timeline queries keeps one query for the parent and one for the children (20-data-model.md §14), recorded in the PR.

  • The scope tests cover:

    • B on every one of A's assignment and attempt operations, against a missing id, with A's rows unchanged;
    • B assigning A's version, class or student;
    • the embeds, the monitor and the lists of papers on a mixed assignment;
    • the class-arm close keeping hidden targets;
    • the creator and the Admin paths, including a scope.all reader who neither created nor reaches the assignment;
    • every HTTP handler handing the caller's scope or All to its command or query.

    Each predicate, reach arm, scope.all lift and hand-off was mutated once, and a test failed each time.

  • Residuals, recorded here and in the PR:

    • By-id attempt operations are gated on the assignment only. On a mixed assignment, a class-arm viewer holding the attempt id of a student they do not reach can open that paper, the student's name and email included, and grade, flag, extend, void or reset it. None of the lists scoped gives them such an id; the dashboard summary's recent attempts follow the same filter since T-R2.12f. Only a scope.all writer can create a mixed assignment. The attempt stays the parent so that a creator keeps reaching a student removed after sitting it.
    • Counts built from attempts are assignment-wide. These are the pending, submitted and flagged counts on a mixed assignment. They are numbers only, never names, as T-R2.12b's usedInTests is.
    • The web edit form cannot keep a version the viewer cannot read. The list of versions comes back empty, so a class-arm viewer editing an Admin's assignment must re-point it. The detail page's publish and close work, through D4. R4's rebuilt form reads the version from the assignment.
    • Class-arm writes follow the single rule. A teacher reaching an Admin's assignment through a class can also delete it while it is inactive, reopen it, or re-point it to their own test before any attempt exists. Their non-target edits (schedule, settings) apply to every target.
    • Reach can change between the check and the insert. A student removed from a class between the two stays targeted. The row names an account that was reachable a moment earlier, so nothing is revealed.
    • A disabled individual target blocks publish and close. This bug predates R2: the embed lists the disabled student, the web re-sends them and checkTargets refuses them. Filed as #210, not fixed here. Fixed in #210: a target already on the assignment stays; a new target must still be active, student-like and reached.

T-R2.12f — Scoping: dashboard and imports

Depends on: T-R2.12a, T-R2.12e Touches:

  • server/internal/shared/visibility/visibility.go (TaughtMemberIDs, which StudentIDs now builds on)
  • server/internal/modules/dashboard/{domain,application,repositories,http}/** (postgres.go: the summary; T-R2.12e scoped the attempt list)
  • server/internal/modules/imports/{domain,application,repositories,http}/** (store.go, list.go, sources.go, runs.go, drafts.go, application/command/scope.go, the ports)
  • server/internal/core/adapters/import_commit.go, and the internal owner of the tests and questions creates (tests/{domain/commands.go, application/command/create.go,repositories/store.go}, questions/{domain/input.go,application/internal/support/service.go, repositories/store.go})
  • api/openapi.yaml (descriptions only), server/gen/openapi/, web/src/lib/api/schema.d.ts
  • docs/plan/20-data-model.md (§31's owner table)
  • New tests: dashboard/repositories/tests/scope_test.go, and the scope_test.go of imports/repositories/tests, imports/application/tests and imports/http/tests. Extended: dashboard/http/tests/scope_test.go and core/adapters/tests/import_commit_test.go.

Size: M Done when:

  • The dashboard summary covers what the caller reaches.

    • Every condition is added in Go only without All, so the scope.all statements are exactly today's, role = 'student' included; T-R2.13 swaps that line. The fixed-dataset test proves it field by field against a verbatim copy of the pre-12f SQL, for a scope.all reader who reaches nothing on their own and for one who teaches classes and wrote assignments of their own.
    • Open and closing soon: the assignments visibility.AssignmentIDs gives.
    • Awaiting grading, flagged, waiting students and the oldest wait: those assignments' papers, as visibility.Papers shows them. Each then equals the grading queue its tile opens (listAttempts with the same filter), which the test checks.
    • Students (decision): the total counts the enabled, student-like members of the classes the caller teaches (visibility.TaughtMemberIDs), as the task named. The active count takes the same population, with an attempt started in the window on a reached assignment, so the "active / total" pair shares one population. It differs from the Students page, which counts every student the caller reaches (StudentIDs: also accounts they created and individual targets).
    • The next closing assignment: one the caller reaches. Its target count is filtered as the assignment's own embed is (taught classes, reached students), and its submitted count stays assignment-wide, as the embed's does (T-R2.12e's residual). The card therefore matches the row and the detail page it links to, which the test checks.
    • The recent attempts: the attempt list's filter.
    • Still three statements.
  • Imports belong to their creator.

    • The module has 14 operations:
      • 10 by id: get, upload, download, process (also the retry of a failed import), cancel (also the discard), review, save, adopt, source and commit;
      • 1 list, with its title and filename search;
      • create, keyed by its creator;
      • capabilities and limits, which name no import.
    • Get, Source, Draft (and its missing-draft probe) and the commit record read in a scope. The four row locks (reserve, finish, schedule, cancel) and touchUnderReview (save, adopt, record commit) carry Actor.Scope.All beside Actor.ID, as T-R2.12b to 12e do. The history filters created_by in Go only without All.
    • Another creator's import answers ErrNotFound, exactly as a missing one does. It comes before any 409, the 410 of removed files, a 422, and IMPORT_NOT_PROCESSED. Before this task, the commit replay's 409, the missing-draft probe, a cancelled import's cancel, the 410 and the 409s after unscoped locks all revealed another creator's import's state.
    • The worker, the retention sweep, the nudge, and the reads behind a scoped parent (the draft's run, the artifact set) stay system paths.
  • A commit belongs to the import's creator. The commit runs in the API, not the worker.

    • The Materializer takes the owner from the scoped draft (StoredDraft.CreatedBy).
    • The tests and questions creates take an internal OwnerID, which HTTP never sets.
    • The materialization reads under the owner's scope.
    • When an Admin commits a teacher's import, the test, its section groups and its bank questions therefore belong to the teacher, and T-R2.12a's one-owner draft check passes. The Admin stays created_by, the audit actor and committed_by.
  • Quotas are unchanged, and now stated exactly:

    • the import count and artifact bytes are per creator;
    • source bytes are per uploader;
    • worker leases are per requester;
    • per import: at most 50 runs, IMPORT_SOURCES_PER_ITEM (32) sources and IMPORT_ARTIFACT_SETS_PER_ITEM (200) artifact sets.

    The global caps and the single intake slot are installation-wide numbers that name nothing (residual).

  • Contract: the descriptions of Dashboard (and its activeStudents under scope.all), getDashboard, ClosingAssignment.targetCount, WordImport, listWordImports and downloadImportSource now say what the caller reaches. No response code changes.

  • The tests cover:

    • B's dashboard figure by figure against A's data (bystander rows for each gate: a reached student on an unreached assignment, an unreached student on a reached one, a student removed after sitting, a disabled student, a teacher-role member, an archived class);
    • B on every one of A's imports in four states against a missing id, with A's rows unchanged;
    • B's history and search;
    • the Admin acting on A's import;
    • the Admin's commit owned by the teacher, and B's commit refused with nothing created;
    • every read and write handler handing its scope or actor on.

    Each predicate, scope.all lift and hand-off was mutated once, and a test failed each time.

  • Residuals:

    • A commit's owner is the import's creator even if that account has since been disabled. R5's transfer must re-read it under RecordCommit's lock.
    • The e2e harness has no import storage, so every import operation there that names an import answers 501 (capabilities and limits still answer 200). T-R2.16 must configure MinIO and a work directory, or its suite would compare 501 with 501 and prove nothing for imports.

T-R2.13 — Guards that are not permissions

Depends on: T-R2.12d, T-R2.12e, T-R2.12f Touches:

  • server/internal/modules/identity/{domain,application,repositories,http}/** (student.go, errors.go, references.go, repository.go; support/students.go, the update, reset and delete commands, query/student_account.go; students_students.go, students_write.go, delete.go; students.go, delete.go)
  • server/internal/modules/classes/{domain/references.go,repositories/delete.go,http/delete.go} (classes/repositories/postgres.go verified: AddMember already reads the view)
  • server/internal/modules/dashboard/repositories/postgres.go
  • server/internal/core/maintenance/maintenance.go
  • server/internal/platform/db/context.go (ForeignKeyViolation, IsCheckViolation)
  • api/openapi.yaml (STUDENT_SHARED, the 403s on updateStudent, resetStudentPassword and deleteUser, the ReferencedBy enum and the 409 descriptions), server/gen/openapi/, web/src/lib/api/schema.d.ts
  • New tests: identity/application/tests/{escalation_test.go,subset_rule_test.go}, identity/repositories/tests/, classes/repositories/tests/, identity/http/tests/guards_test.go, classes/http/tests/references_test.go, classes/application/tests/delete_reference_test.go, platform/db/tests/foreign_key_test.go. Extended: identity/application/tests/delete_student_test.go, identity/http/tests/students_scope_test.go, core/maintenance/tests/maintenance_test.go, dashboard/repositories/tests/scope_test.go, platform/db/tests/expand_r2_test.go. Swept: the Admin fixtures in identity/application/tests/{students_scope_test.go,students_write_test.go} and access/application/tests/revocation_test.go.

Size: M Done when:

  • Strict student targets. Every Go statement that read users.role reads app.student_like_roles. The sites are:

    • identity's list and facets, get, update, reset (and its sharing check) and the delete lock;
    • the dashboard's scope.all student count;
    • the anonymiser.

    classes (AddMember, T-R2.12d), assignments (individual targets, T-R2.12e) and the dashboard's scoped count (T-R2.12f) already did. No Go write names role: every insert writes role_id, and 00056's trigger derives role. The PR's grep is empty.

  • StudentAccount, which names an attempt's sitter for review, reads the account whatever its role. A strict read would answer 500 for an Admin who sat a test. It returns domain.Account, which carries the legacy role derived as tokens derive it, so the review names a staff sitter admin, not student.

  • escalation_test.go covers two kinds of target that must never be a student:

    • an Admin with Take tests who joined A's class by code;
    • for each of the 17 matrix keys, a custom role granted that key after it was assigned. Only this order tells role = 'student' apart from the view.

    For each, A's and scope.all's get, update, disable, reset and delete answer 404, the list leaves them out, addClassMember and an individual target are refused, and the anonymiser refuses them. The Admin case runs in its own short transaction, because the grant locks the Admin role row until rollback. An emptied custom role, and one holding only Take tests, pass every one of those operations.

  • Guards on every student write. WriteRequest carries the actor's grants. The HTTP edge fills them from the principal, and a test through the real gate pins that. Each write first finds the student in scope (404). disabled then needs people.users.manage, and the student's permissions except learning.take_tests must be a subset of the actor's (access.CanActOn). Either refusal is 403 FORBIDDEN.

    • The subset rule covers update, reset and delete, as 70 §4.3 lists them.
    • On strict targets it cannot refuse in R2, so subset_rule_test.go drives it with fakes.
  • Shared students. Reset, and an email change, by an actor without people.users.manage need a student no one else reaches (decision above, confirmed by Thuong on 2026-09-28). Otherwise the answer is 403 STUDENT_SHARED and nothing is written. Sending the address the student already has is not a change: the web's edit form always sends email, so a rename of a shared student would otherwise be refused.

    • The check holds the row FOR NO KEY UPDATE, the mode the UPDATE takes. FOR UPDATE would deadlock against a refresh by the same student, which holds its token and then needs FOR KEY SHARE on the user.
    • Its order is: not student-like or unreached answers 404, and so does a disabled student for a reset, which never touches one. Only then can 403 answer. An email change finds a disabled student as a rename does, so a disabled shared student answers 404 to a reset and 403 STUDENT_SHARED to an email change.
    • The escalation test covers each arm (another class, another creator, another teacher's target, a classless student), the Admin's exemption, both answers for a disabled shared student, and a rename, which stays open to any teacher who reaches the student, with or without the unchanged email the form sends.
  • Last admin. The identity repository's exported UserWriteError maps users_last_admin (23514) to domain.ErrLastAdmin, for update and delete. No R2 identity write can reach an Admin row, so T-R2.2's destructive test drives the real trigger's error through the mapper on its scratch database. A shared-database test would be flaky, because parallel packages commit Admin fixtures. 409 LAST_ADMIN arrives with R5.

  • The Student role keeps "Take tests". T-R2.1's trigger is tested there.

  • Foreign-key refusals answer 409 with a reason.

    • db.ForeignKeyViolation reads the constraint of a 23503 or 23001.
    • identity maps every restricting key to app.users (21 today): as assignments, attempts or owned_content. audit comes from the pre-check.
    • classes maps its two keys (assignments, and members through the cascade to its join codes).
    • Anything unnamed is other, never a 500.
    • Both 409s always carry details.referencedBy.
    • Schema guard tests fail when a later migration adds a restricting key the maps do not name, or a deferrable one. Name it in the map in that migration's task.
    • A disabled student who owns a test is refused as owned_content.
  • Other hard deletes, none of which can hit an R2 foreign key:

    • deleteAssignment, deleteTestVersion and deleteTest already answer 409;
    • removeClassMember deletes a row nothing references;
    • revokeJoinCode, deleteQuestion and deleteMedia change no key.
    • deleteQuestionGroup passes an unexpected foreign-key error through as a 500, unreachable through the app. Filed as #213. Fixed: it answers 409 RESOURCE_REFERENCED; deleteTest and updateQuestionGroup had the same gap (#282), fixed too.
  • Residuals:

    • A reset concurrent with the student's own refresh can leave the new refresh token live (#198, pre-existing).
    • A teacher still sees Disable in the web and gets the server's 403 message until T-R2.17/R4.

T-R2.14a — Join codes encrypted: storage and lookup

Depends on: T-R2.2 Touches:

  • migrations/NNNNN_add_join_code_encryption.sql (new)
  • server/internal/platform/config/config.go, and its tests
  • server/internal/modules/classes/domain/joincode.go
  • server/internal/modules/classes/domain/joincode_keys.go (new)
  • server/internal/modules/classes/domain/tests/joincode_keys_test.go (new)
  • server/internal/modules/classes/repositories/{joincodes.go,lookup.go,enrol.go}
  • server/internal/modules/classes/application/** (the keys reach the commands and the preview)
  • server/internal/core/wiring/classes.go
  • server/internal/modules/classes/application/tests/joincode_encryption_test.go (new)
  • server/internal/platform/db/tests/expand_r2_test.go
  • .env.example, .github/workflows/ci.yml (live job env), .github/workflows/deploy.yml (preflight)
  • docs/setup/dns.md
  • Also, as built: api/openapi.yaml (the storage wording of previewJoinCode, JoinCodeInfo and rotateJoinCode), server/gen/openapi/, web/src/lib/api/schema.d.ts; platform/config/tests/deployment_test.go; platform/db/tests/constraints_test.go; web/tests/integration/bundle-secrets.test.ts; server/tests/{harness_test.go,join_code_key_flow_test.go}; the classes and identity application tests' construction; docs/plan/20-data-model.md; server/internal/core/wiring/wiring.go (Build derives the keys and refuses to start on an error); classes/domain/{commands.go,enrolment.go,repository.go} (RotateInput gains CodeID, Ciphertext and KeyID; EnrolInput carries Code JoinCodeLookup; LookupByCodeHash becomes LookupByCode)

Size: M Done when:

  • The migration adds to app.class_join_codes:
    • code_ciphertext bytea CHECK (code_ciphertext IS NULL OR length(code_ciphertext) = 36): a 12-byte nonce, the 8-byte code and a 16-byte tag.
    • key_id smallint CHECK (key_id IS NULL OR key_id <> 0).
    • lookup_scheme smallint NOT NULL DEFAULT 1 CHECK (lookup_scheme IN (1, 2)), where 1 is the legacy SHA-256 and 2 is the keyed HMAC.
    • class_join_codes_scheme_consistent CHECK ((lookup_scheme = 2) = (code_ciphertext IS NOT NULL) AND (lookup_scheme = 2) = (key_id IS NOT NULL)): scheme 2 needs both columns and scheme 1 allows neither.
    • As built, 00078_add_join_code_encryption.sql, with every check named: class_join_codes_ciphertext_length, class_join_codes_key_id_nonzero, class_join_codes_lookup_scheme_known and the consistency check. constraints_test.go violates each alone.
  • code_hash keeps its 32-byte UNIQUE column.
  • Down refuses while an unrevoked, unexpired scheme-2 code exists, because the previous binary could not look it up. Otherwise it drops the constraint and the columns.
  • Config:
    • JOIN_CODE_KEY is required in every environment: standard base64 of exactly 32 bytes; the error message says openssl rand -base64 32.
    • JOIN_CODE_KEY_PREVIOUS is optional, has the same format and must differ.
    • Neither value is ever logged.
    • .env.example documents both, the CI live job gets a throwaway key, deploy.yml preflight adds need JOIN_CODE_KEY, and dns.md's secrets list gains it.
  • Domain: JoinCodeKeys derives three keys from each raw key with the standard library's crypto/hkdf (SHA-256, no salt; no new dependency): - info quizzivy join-code aead v1: the AES-256 key; - info quizzivy join-code lookup v1: the HMAC key; - info quizzivy join-code key-id v1: its first two bytes as a non-zero int16.
  • Startup refuses equal ids for the current and previous keys, and a key whose id derives to zero. Both refusals come from NewJoinCodeKeys, which wiring.Build calls; the domain test pins a zero-id key and a colliding pair found by search.
  • Seal(classID, codeID, code): AES-256-GCM, a random 12-byte nonce, AAD = the 16 raw bytes of the class id followed by those of the code id. It stores nonce‖ciphertext‖tag.
  • Open picks the key by id. An unknown id gives ErrJoinCodeKeyUnavailable.
  • LookupHashes(code) returns the HMAC-SHA256 under the current lookup key, under the previous one when set, and the legacy SHA-256. As built it returns a JoinCodeLookup: Hashes() for the query, and Matches(StoredLookup) for the constant-time check against the candidate of the row's own scheme and key id.
  • Wiring passes the keys to the classes application. JoinCodeManager stays stateless for Generate, Format, Normalize and Hint.
    • Normalisation moves from the repository to the application (support.Enrolment.Lookup): EnrolInput carries the lookup, never the typed code.
  • Rotate:
    • generates the code id in Go (uuid.NewV7), because the AAD needs it before the insert;
    • writes code_hash as the current HMAC, code_ciphertext, key_id, lookup_scheme = 2 and the hint.
  • Revoke is unchanged.
  • Lookup (lookup.go), used by the preview, by joining and by the Google join:
    • WHERE jc.code_hash = ANY($1).
    • As built, ORDER BY jc.created_at DESC, jc.id DESC LIMIT 1, in the preview's LookupByCode and in the enrolment's claim. Two rows hold the same code only if a new code repeats an old one's plaintext; the newest is the one a teacher handed out.
    • The row found is compared in constant time with the candidate of its own scheme (JoinCodes.Equal).
    • A scheme-1 row matches only the SHA-256 candidate, a scheme-2 row only an HMAC candidate.
  • No log line and no audit row carries a code or its ciphertext; audit rows keep entity ids only.
  • Domain tests:
    • round trip;
    • a swapped class or code id in the AAD fails;
    • a flipped byte fails;
    • the previous key opens an old ciphertext;
    • pinned derivation test vectors.
  • Config tests refuse a missing, short or undecodable key, and equal current and previous keys.
  • joincode_encryption_test.go (integration):
    • A new code is stored as scheme 2, with a 36-byte ciphertext and a hash that is not SHA-256 of the code. A database dump alone no longer yields the code by hashing the code space.
    • A legacy scheme-1 row inserted the v0.7.0 way previews, joins and Google-joins.
    • A code issued under key K1 still previews and joins after a restart with current = K2 and previous = K1.
  • joincode_preview_test.go, joincode_enrol_test.go, joincode_rotate_test.go and joincode_audit_test.go stay green, with only their construction changed. The exceptions are the two rotate tests that expected the SHA-256: the retirement test finds the old row by the keyed hash, and the stored-form test expects the keyed hash and searches the ciphertext for the plaintext too.
  • As built, also:
    • joincode_encryption_test.go covers a keyed row that holds a code's legacy hash (refused, no use counted), two rows holding one code (the newest wins), and audit rows (none carries the code, grouped or not, nor its ciphertext or hash in hex or base64).
    • join_code_key_flow_test.go (e2e) boots the whole application three times: a code issued under K1 previews with current K2 and previous K1, and answers JOIN_CODE_INVALID with K2 alone. It is the only test that proves wiring passes the previous key.
    • expand_r2_test.go also proves Down refuses a live scheme-2 code and goes through once it has expired or been revoked.
  • Residuals:
    • During the deploy, the v0.7.0 machine cannot redeem a code the new binary issued. The new binary redeems every v0.7.0 code, so the window is the length of the rollout.
    • Local development needs JOIN_CODE_KEY in .env: the API refuses to start without it.
    • To T-R2.18: spec §13.3 (the code_hash comment "sha256(normalized code)") and §13.5 ("Join codes stored as SHA-256 hashes") describe only the legacy hash; §6.5's "a hash of the normalized code" stays true of the keyed lookup.
    • Legacy codes until R4. Every code active at the v0.8.0 deploy is a scheme-1 row: its SHA-256 beside the four-character hint is reversed in 2^20 guesses. The contract says so, and R4 rotates them (D5).
  • expand_r2_test.go: the old binary's insert (no new columns) is a consistent scheme-1 row.

T-R2.14b — Join codes: reading the code back, and rotating the key

Depends on: T-R2.14a, T-R2.7, T-R2.12d Touches:

  • api/openapi.yaml (getJoinCode, JoinCode, rotateJoinCode's description)
  • server/gen/openapi/, web/src/lib/api/schema.d.ts
  • server/internal/modules/classes/application/query/active_code.go
  • server/internal/modules/classes/http/joincode.go
  • server/internal/core/router/ratelimits.go
  • server/internal/core/router/tests/credential_limits_test.go
  • server/internal/core/maintenance/joincodes.go (new)
  • server/cmd/maintenance/main.go
  • server/internal/core/maintenance/tests/joincodes_test.go (new)
  • docs/setup/operations.md
  • Also, as built: classes/domain/{joincode.go,errors.go,repository.go,joincode_keys.go} (StoredCode, ActiveJoinCode, ErrNoActiveCode, PreviousID), classes/repositories/joincodes.go (the scoped ActiveCode), classes/application/app.go; core/maintenance/{cli.go,joincodes.go}; platform/config/config.go (the exported JoinCodeKeys loader, which Load and the rekey share); core/router/tests/{permissions_test.go,testdata/permissions.golden} (97 operations declare a permission; the Assistant is refused 33); tests in classes/{application,http}/tests/, core/maintenance/tests/cli_test.go and server/tests/join_code_key_flow_test.go

Size: M Done when:

  • GET /teacher/classes/{id}/join-code is getJoinCode, with x-permission: teaching.classes.write (T-R2.16 adds its x-resource entry, kind class):
    • 200 returns JoinCode {code: string | null (grouped XXXX-XXXX), hint, legacy: boolean, expiresAt, maxUses, usesCount}, with Cache-Control: no-store.
    • A legacy row returns code: null, legacy: true.
    • 404 when the class is not visible to the caller or has no active code.
    • Rate limit: 60/min and 600/h per address. The operation joins theCredentialMinters, because it reveals a bearer secret.
    • It is not audited: the deck shows the code on every card (D5).
    • As built, a class without an active code answers 404 with its own message ("Lớp này chưa có mã tham gia."); another teacher's class answers the missing-class 404.
    • Decision: a code sealed under a key the server no longer holds answers code: null, legacy: false rather than a 500. It can be neither read nor redeemed, and the description says to rotate it. A ciphertext that fails authentication is a 500: it means tampering or corruption, never a state to render.
  • rotateJoinCode's description changes: the code is stored encrypted and can be read again through getJoinCode. The "returned exactly once" and "if it is lost, rotate again" text goes (14a already replaced the SHA-256 wording). The response shape is unchanged.
  • maintenance rekey-join-codes [-apply], in the maintenance <command> [flags] form that R1's per-subcommand parsing gives every command (T-R1.12):
    • Refuses to run unless both JOIN_CODE_KEY and JOIN_CODE_KEY_PREVIOUS are set.
    • Every scheme-2 row under the previous key id is opened, then re-sealed and re-hashed under the current key. It works in batches of 500, one transaction each.
    • It reads app.class_join_codes and writes only code_hash, code_ciphertext and key_id. R5's quizzivy_maintenance grant (T-R5.17a) covers that, and its privileges_test.go pins it.
    • The dry run reports counts per key id, and the number of scheme-1 rows. Those cannot be re-keyed; R4 rotates them.
    • It prints a JSON report like the other commands.
    • As built, the report is {currentKeyId, previousKeyId, sealedByKey, legacy, moved, pending, unknown, unopened, applied}, counted when the command finishes. -batch (default 500, 1–10000) sets the batch size, as retain-integrity's does.
    • It walks the rows by an id cursor, and each batch's UPDATEs go as one pipelined pgx.Batch, so a batch holds its row locks for a few round trips, not one per row.
    • A row that does not open is listed in unopened (row id, class id, revoked) and skipped, so it never blocks the rows after it. An active one must have its class's code rotated before the old key is unset.
    • unknown counts active codes sealed under a key id that is neither key; -apply refuses while it is not 0, because one of the two keys given is then not the pair the API runs with. The API logs current_key_id and previous_key_id at startup (join code keys) for the operator to compare with the report.
  • getJoinCode tests:
    • as the owner, a scheme-2 code returns the full code;
    • a legacy row returns null and legacy: true;
    • another teacher gets 404, a student 403.
  • Rekey tests:
    • rows under the old id move;
    • the code still redeems with only the new key configured;
    • the dry run changes nothing.
    • As built, also: two batches (-batch 3 over four rows, a revoked one among them), a second run that moves nothing, a legacy row left alone, a row that does not open (listed, the rows after it moved, then listed as revoked once its class rotates), a code under a third key (reported by the dry run, -apply refused, nothing written), and the CLI refusing a missing or equal key without repeating it. The tests lock app.class_join_codes inside their rolled-back transaction and revoke other packages' codes there, so the unknown-key count is theirs alone.
    • To T-R2.18: spec §15's rotate line still says "(plaintext returned once)", and §15 has no getJoinCode line.
  • operations.md gains a "Join-code key" section:
    • Generate it with openssl rand -base64 32.
    • Store it as a Fly secret, with an offline copy in the owner's password manager.
    • Where the rekey runs: from the operator's machine, never from a scheduled workflow. The operator supplies JOIN_CODE_KEY and JOIN_CODE_KEY_PREVIOUS to cmd/maintenance from the offline copy, with MAINTENANCE_DATABASE_URL. No workflow environment holds the keys.
    • Rotate:
      1. set PREVIOUS to the old key and the current key to the new one;
      2. deploy;
      3. run the rekey dry run;
      4. run it with -apply;
      5. verify zero rows remain under the old id;
      6. unset PREVIOUS;
      7. deploy.
    • Loss: every scheme-2 code becomes unreadable and unredeemable, so rotate every class's code and tell the teachers.
    • The key must never appear in logs or tickets.

T-R2.15 — Join and sign-in limits sized for a classroom

Depends on: T-R2.7, T-R2.11 Touches:

  • server/internal/platform/ratelimit/{registry.go,bodykey.go,limiter.go} (Rules), and their tests
  • server/internal/platform/httpx/ratelimit.go, and httpx/tests/ratelimit_test.go (new)
  • server/internal/core/router/ratelimits.go
  • api/openapi.yaml (x-rate-limit blocks; logout's block and 429; an 8 KiB x-max-body-bytes and a 413 on login, Google sign-in, join preview and in-app join)
  • server/gen/openapi/, web/src/lib/api/schema.d.ts
  • server/internal/core/router/tests/{ratelimit_contract_test.go (new),group_authoring_test.go}
  • web/tests/e2e/support/live.ts (TEACHER)
  • server/internal/core/router/tests/{join_preview_test.go,router_test.go}
  • server/internal/core/router/tests/classroom_limits_test.go (new)
  • web/tests/e2e/join-classroom.live.spec.ts (new)

Size: M Done when:

  • A route can carry several keyed buckets. ratelimit.Compose(keys...) builds a composite key and yields "" (skip the bucket) when any part is empty.

    • As built, each keyed bucket carries the name its contract block uses (WithKey(name, key, capacity, rules...)), and the middleware records the client address on the request (ratelimit.WithAddress), so ratelimit.Address can be one part of a composite key.
  • Every limiter keeps its capacity and its eviction, so memory stays bounded.

  • The budgets:

    | Operation | Per address | Per address + key | Per key |
    |---|---|---|---|
    | `POST /join/preview` | 60/min, 600/h (was 10, 60) | + code: 10/min | code: 200/h (was 30) |
    | `POST /app/classes/join` | 60/min, 600/h (was 10, 60) | + code: 10/min | code: 200/h (was 30) |
    | `POST /auth/google` | 60/min, 600/h (was 10, 60) | — | join code: 200/h (was 30) |
    | `POST /auth/login` | 60/min, 600/h (was 10, 60) | + email: 10/min | email: 20/h (unchanged) |
    | `POST /auth/refresh` | 120/min, 1200/h (was 30, 200) | — | — |
    | `POST /auth/logout` | 120/min, 1200/h (was 30, 200) | — | — |
    
    • These numbers let a class of 40 behind one address make three tries at each step.
    • The per-address-and-code bucket stops one device hammering a code.
    • The per-email bucket still protects each account.
    • Guessing stays at or under 600 codes an hour per address. As built: 600 per address on each code-checking operation, so about 1,200 from one address with a student account (preview plus in-app join).
    • As built (decision above, Thuong confirms): per address 120/min and 600/h for the four join and sign-in operations, 120/min and 1,200/h for refresh and logout; per code 200/h; login per address and email 10/min, per email 20/h. The per-address-and-code bucket is not built: behind one school address it is shared by the whole class, so it refused the 11th student, against the classroom test below.
  • The beacon, POST /app/attempts/{id}/events, is reviewed and left at 120/min. It fires only on pagehide; the reason is written in the PR.

  • The x-rate-limit blocks match the registry.

  • The existing tests are re-pinned to the new boundaries, never deleted:

    • TestTheEleventhPreviewInAMinuteFromOneAddressIs429 becomes the 61st;
    • a new test: the 11th preview of one code from one address is 429;
    • TestTheThirtyFirstAttemptOnOneCodeIs429EvenAcrossAddresses becomes the 201st;
    • TestRespellingACodeDoesNotBuyAFreshAllowance is unchanged.
    • As built: the preview test is the 121st, the per-code test the 201st, and the respelling test needs 201 attempts to reach the 200-an-hour bucket; its fifth spelling is now K7M3 P9QR, within the schema's nine characters, since a longer one is refused by validation and fills no bucket. The 11th-preview-of-one-code test is replaced by its opposite in classroom_limits_test.go, which also pins the per-account bucket (the 11th login for one email from one address is 429). router_test.go's two preview tests move to 120 and 121.
    • ratelimit_contract_test.go (new) proves every x-rate-limit block equals its registry entry, bucket by bucket; logout gains its block and its 429.
  • classroom_limits_test.go: 40 students, one address, one code. 40 logins, 120 previews and 40 joins inside one minute see no 429.

  • join-classroom.live.spec.ts:

    • The seeded teacher rotates a code.
    • The thirty students from 07-dev-classroom.sql each sign in with their password, preview the code and join, concurrently, from the one test client address.
    • Every step succeeds, none gets 429, and the class lists thirty new members.
    • Why this belongs in the live suite (AGENTS): it is the deployed limiter configuration meeting the real join flow.
    • As built: the seeded teacher creates a fresh class each run and issues its first code, rather than rotating the seeded class's, so a rerun still sees thirty new members; each run leaves that class in the dev database.
  • A leak review of the four public operations is recorded in the PR: the bodies are unchanged, only the limits move. T-R2.18 updates spec §6.5's numbers.

    • As built: T-R2.18 also rewrites NFR-S05 in place in 50-nfr-registry.md (per address 120/min and 600/h, refresh and logout 120/min and 1,200/h; per code 200/h; login per address and email 10/min, per email 20/h; keyed bodies at most 8 KiB) rather than adding a second rate-limit row.
    • As built, after review: a body key is decoded as the handler decodes it (json.Decoder, so a trailing byte no longer hides the field), a value longer than its schema allows yields no key (validation refuses the request anyway), and the four keyed operations take at most 8 KiB, so an oversized body answers 413 before any handler. Before, a trailing byte or padding past 8 KiB skipped the per-email and per-code buckets while the handler still checked the password.

T-R2.16 — The two-teacher isolation suite

Depends on: T-R2.12a–f, T-R2.13, T-R2.14b, T-R2.11 Touches:

  • api/openapi.yaml (x-resource, x-resource-list)
  • server/gen/openapi/, web/src/lib/api/schema.d.ts
  • server/internal/core/router/tests/resource_contract_test.go (new)
  • server/tests/isolation_{world,runner,cases,flow}_test.go (new)
  • .github/workflows/ci.yml

Size: L Done when:

  • x-resource is a list with one entry per id the operation takes: [{in: path | query | body, name, kind}], where name is a JSON pointer for a body field.
    • The kinds today: test, test-version, section, question, question-group, media, import, import-source, class, assignment, attempt and student.
    • A uuid that names no owned resource, such as an option, a gap or an integrity session, is declared kind: none, so leaving it out is a decision that shows in the diff.
    • A fresh identity the caller supplies (a new group's, member's, stimulus' or recording's id in createQuestionGroup) is kind: none too. A duplicate answers 409 GROUP_CONFLICT and never overwrites, which tells B only that a uuid B already holds exists, never anything in it; the suite must not read that 409 as a leak.
    • A query-scoped id is an ordinary entry: listStudents?classId= declares {in: query, name: classId, kind: class}. There is no separate x-resource-param.
    • Each release adds its kinds to the enum in the PR that adds the operation: R4 notification and override; R7 question-set, share and attachment; R8 session and session-series; R10 word-list, word, course, unit and lesson.
  • Every /teacher/* list declares x-resource-list.
  • The suite fails on any uuid-typed path, query or body field under a scoped tree (/teacher/*, /app/*, /me/*) that names no kind.
  • The suite (tag e2e) first seeds, as teacher A, one resource of every kind:
    • a test with a draft and a published version;
    • a bank question and a bank group;
    • a media asset;
    • an import (the harness then needs import storage: a MinIO bucket and a private work directory, since without them every import operation answers 501 to everyone, T-R2.12f);
    • a class with an active code and a student;
    • an assignment with a submitted attempt.
  • Then, as teacher B holding the full Teacher set:
    • Every x-resource entry is exercised with a minimal valid body from a per-operation table. A's id goes into one parameter at a time, and B's own ids fill the others, so a second path id, a query filter and a body field are each tested alone.
    • Reads and writes alike answer as a missing id would: 404, or for a body reference the same error as an id that does not exist. A's rows are byte-for-byte unchanged afterwards.
    • For every list that declares x-resource-list, none of A's ids appears on any page, and the facets do not count A's rows.
  • B's own writes that reference A's resources answer as a missing id does: 404 where the operation answers 404 for a missing one, else the same error and body.
    • an assignment for A's version (409 TEST_NOT_PUBLISHED), A's class or A's student (400 VALIDATION_FAILED naming the id), per T-R2.12e's decision D1;
    • A's question in B's draft;
    • A's media bound in B's question;
    • A's student added to B's class;
    • a copy of A's bank group;
    • a new student created into A's class.
  • B's dashboard figures exclude A's data.
  • As the Admin, a sample of A's resources opens (200).
  • The same substitution runs for two students on /app/* (student B with student A's assignment and attempt ids) and for two users on /me/*. The /me/* table stays empty until R4 creates the tree.
  • The suite's table names every operation under /teacher/*, /app/* and /me/* exactly once, and the test fails naming any operation that is missing, or that has a uuid parameter without a kind. A new operation therefore cannot skip it: this is the scoping counterpart of the open-operations list.
  • It runs inside the Server job's end-to-end step in under a minute. The PR template's isolation box is ticked.
  • As built:
    • Files. The contract checks sit in core/router/tests/resource_contract_test.go: every scoped uuid, and every key of a map whose propertyNames is Uuid, names a kind, and the 12 lists name what they list. The suite is four files in server/tests/: isolation_world (seeding and the database scans), isolation_runner (reading the contract and building requests), isolation_cases (the per-operation table) and isolation_flow (the test). harness_test.go is unchanged. The Server job starts MinIO before its end-to-end step. The harness reads the same S3_* and IMPORT_S3_BUCKET variables the storage tests already read, and falls back to the compose endpoint, credentials and buckets.
    • Two more kinds. version-question and version-recording name a question and a group recording of a published version. Grading, the answers of one question (listAnswersForQuestion), and the student's autosave, event flush and plays take these ids, not bank ids.
    • A map's keys. saveAnswers' answers is keyed by the version question's id. The contract now says so with propertyNames: Uuid, and + in a pointer stands for every key. The request validator does not enforce propertyNames, so a key that is not a uuid is still a 500 (#220).
    • A polymorphic id. A section unit's id names a question or a group, as its kind says. It is declared as a question. The flow checks the group branch separately: B's draft naming A's section group or A's bank group answers as a missing group does, and naming its own group succeeds.
    • The oracle. Every slot whose kind is not none is sent with A's id and with a random uuid. The two answers must be equal: the status and the whole body, with uuids and timestamps masked and requestId dropped. A random id must not succeed unless the table excuses the slot with a written reason. The slot is then sent with B's own id, which must answer differently; that proves the case reaches the check. The table exempts a slot from this third send only with a written reason (blind). Twenty operations that change or consume their resource get a fresh one for each send: the deletes, the group archive, the attempt reset and void, the password reset, the member removal, the version operations, the test and group edits and copies, the import cancel and the review save.
    • Excused slots, where a random id may succeed:
      • listTestVersions, listAssignments?classId, listStudents?classId and listClassMembers: a filter or parent that does not exist lists nothing. B's own id lists B's rows.
      • tagQuestions tags only the questions the caller reaches. B's own question is tagged.
      • removeClassMember: removing someone who is not a member succeeds.
      • saveAnswers and flushEvents: an event naming a question outside the attempt's paper is kept without it, and an answer to one is dropped.
      • saveWordImportReview stores asset references unchecked.
    • Blind slots, where B's own id answers as a missing one does: removeClassMember's user, the two event questions, the answer key and the five review assets (an option holds one text paragraph, so an asset there is refused whatever it names). What proves these is the cross-reference scan and the commit check below.
    • Checks beyond the table.
      • Committing a review that holds A's image answers as a missing image does, and one holding B's own image commits. The commit resolves media in the import owner's scope.
      • The beacon (a text body with beaconToken), sent by an anonymous client to A's attempt, answers as a missing attempt does.
      • The cross-reference scan. No row in schema app holds both an id of A's and one of B's. B's ids are the seeded ones plus every resource a fresh helper made. The exception is word_import_drafts, where a save stores what the teacher typed. audit_log is included.
      • Rows naming A's ids. Every row in schema app that names one of A's ids, outside word_import_drafts, is identical before and after the table. So is the digest of A's owned rows.
      • The views. Each list, getDashboard, the student's listMy* views and the shared student's record are read before and after A seeds. A list with an optional id filter is read both unfiltered and filtered to B's own id. They must be byte-identical once the signed URLs' query strings are stripped.
    • Seeding.
      • Each teacher's bank question carries a tag, so a facet that counted another teacher's rows would change.
      • A reviewed import is seeded in SQL (a succeeded run and a draft), because processing runs in the worker.
      • The student side is a second student with an in-progress attempt on a listening test, so the attempt has a group recording.
      • A third student of B's joins A's class by its code once A has seeded, then sits and submits A's assignment. B's record of that shared student, and the stats in B's lists, must still show only B's classes and work.
      • The import quotas are raised for the fresh imports. The test cancels every import the two teachers left open, because the global quota counts every open import in the database.
      • /me/* has no operations yet. The table must name R4's when they arrive.
    • Result. No leak was found. The run takes about 10 to 17 seconds locally.

T-R2.17 — Web guards by permission, no visual change

Depends on: T-R2.6, T-R2.7 Touches:

  • web/src/features/auth/permissions.ts (new)
  • web/src/features/auth/home.ts
  • web/src/app/guards/RequireRole.tsx → RequireWorkspace.tsx
  • web/src/app/guards/HomeRedirect.tsx, web/src/app/router.tsx
  • web/src/features/join/pages/JoinPage.tsx
  • web/src/features/auth/components/SettingsPage.tsx
  • web/src/app/pages/ForbiddenPage.tsx, and whatever R1 left reading user.role
  • web/tests/units/auth/guards.test.tsx
  • web/tests/units/auth/permissions.test.ts (new)

Size: M Done when:

  • permissions.ts provides can(user, key) and hasWorkspace(user, workspace).
    • The hooks subscribe to derived booleans, not to the user object.
    • The Set is built once per user object.
  • homePathFor: the teacher or admin workspace goes to /admin, which is still the teacher console until R4; the app workspace goes to /app.
  • TeacherWorkspace (formerly AdminOnly) requires the teacher workspace and is still mounted at /admin.
  • StudentArea requires app. A staff user without it is redirected home as today, and a student on /admin/* still gets the 403 page.
  • Every other read of user.role is replaced:
    • JoinPage's link target;
    • SettingsPage's label: app only shows "Học viên", anything else "Giáo viên", which is today's text for both kinds of user.
  • A grep in the PR shows lib/api/client.typecheck.ts's legacy assertion as the only read of a user's role left in web/src.
  • No string is added, and no nav item or palette entry is added or removed.
  • /admin (dashboard) and /app (home) are captured at 1280 and 360 before and after, and the captures are identical.
  • guards.test.tsx covers Admin, Teacher, Assistant and Student fixtures carrying the 70 §4.1 sets: where each lands, which trees open, and where 403 appears. permissions.test.ts covers the helpers.
  • Canaries web/tests/integration/router-chunks.test.ts and web/tests/units/api/client.refresh.test.ts are untouched and green.
  • pnpm lint, typecheck, format:check, test:unit, test:integration, build and e2e are green.
  • As built:
    • permissions.ts also exports learnsOnly(user): the student app is the user's only workspace. JoinPage's three checks and SettingsPage's label use it rather than hasWorkspace(user, "app"), which keeps v0.7.0's behaviour: for the four built-in roles it names exactly the users whose legacy role is student. It is not the server's rule. The server accepts a self-join from anyone who holds learning.take_tests, an Admin with "Take tests" included, and never treats that Admin as a student target. Such an Admin cannot exist before R5 (open items).
    • The hooks are useCan(key) and useWorkspace(workspace). Each selects a boolean from the store.
    • StudentArea. It admits the app workspace, sends the teacher or admin workspace to /admin, and answers 403 for a user with neither. homePathFor sends that user to /app, so redirecting would loop.
    • An Admin with "Take tests" turned on lands on /admin and may also open /app, where v0.7.0 sent every admin away.
    • Fixtures. tests/support/fixtures.ts gains teacherUser and assistantUser with the 70 §4.1 sets.

T-R2.18 — Documents for this release

Depends on: T-R2.1 … T-R2.17 Touches:

Size: M Done when:

  • The spec, with its version bumped (§18):
    • §5: roles and the permission catalogue by reference to 70 §4; enforcement from x-permission; the session epoch; a disabled user refused on the next request.
    • §5.4: guards by workspace, and the guards that are not permissions (T-R2.13): strict student targets through app.student_like_roles, the subset rule on update, reset and delete, people.users.manage for disabled, the shared-student rule on reset and email, and the last Admin. §5.5: the docs gate on system.api_reference.
    • §6.1 and §6.5: encrypted storage, the keyed lookup, the key and its rotation, and T-R2.15's numbers. §6.4: phrased for the class's teacher.
    • §13: roles, permissions, role_permissions, users.role_id, session_epoch, created_by, the owner columns and the join-code columns; the §13.3 "plaintext never stored" line reversed by D5, and §13.5's "Join codes stored as SHA-256 hashes" rewritten for the sealed code and the keyed lookup (the SHA-256 stays for legacy rows until R4).
    • The Word import section's "Teacher scope is shared within this installation" becomes: an import, its sources, review and commit belong to its creator, and scope.all reaches every import; the test a commit creates belongs to the import's creator, with the committer recorded as the actor (T-R2.12f). 17-word-import.md's "single-organization admin scope" row is corrected to match.
    • §15: resetStudentPassword's 403 STUDENT_SHARED and FORBIDDEN, updateStudent's 403 (FORBIDDEN, and STUDENT_SHARED for email), deleteUser's 403, and deleteUser's and deleteClass's 409 RESOURCE_REFERENCED with details.referencedBy (T-R2.13).
    • §15: the prefix split; getCurrentUser, updateCurrentUser, linkGoogle, login and Google sign-in return CurrentUser (User's fields plus permissions and workspaces, self only), and User stays where a payload describes someone else.
    • §15: the join-code lines. GET /teacher/classes/:id/join-code (getJoinCode) returns {code, hint, legacy, expiresAt, maxUses, usesCount}, never cached; code is null for a legacy code or one under a key the server no longer holds. The rotate line drops "(plaintext returned once)" (T-R2.14b).
    • The "Availability: R2" markers from T-R0.3 are flipped.
  • AGENTS.md, "Authentication and authorization":
    • The "Until R2" prefix bullet is dropped, and the x-permission bullet is in the present tense.
    • Repositories take access.Scope, and another teacher's id answers 404.
    • Student targets go through app.student_like_roles and nothing else.
    • The subset rule lives in access.CanActOn.
    • A new /teacher/*, /app/* or /me/* operation needs an entry in the isolation suite, with a kind for every uuid it takes.
    • The /admin alias exists until v0.9.1 (T-R3.3).
  • AGENTS.md, other sections:
    • "Redesign in progress": R2 is done.
    • The repository map gains modules/access and shared/access.
    • The composition-root table gains wiring/access.go and Deps.Principals.
    • "High-risk areas" gains modules/access, users.role_id and the owner columns.
  • 20-data-model.md records every R2 file in merge order, with the deviations at the next free D-numbers:
    • reference data in a migration;
    • users.role_id replacing the enum (expand);
    • owner_id separate from created_by;
    • classes.teacher_id backfilled from the oldest active Admin, with no classes.created_by (D-23);
    • reversible join-code encryption with a keyed lookup;
    • the student_like_roles view;
    • the last-admin trigger, and why its function is SECURITY DEFINER.
  • 60-backend-architecture.md: eleven bounded contexts, availability (R1) and access among them, and the shared/access kernel.
  • 50-nfr-registry.md gains rows for:
    • a permission on every operation, asserted at startup;
    • the cross-owner 404 isolation suite;
    • join codes encrypted at rest with a documented rotation;
    • limits sized for a classroom, by correcting NFR-S05's numbers to T-R2.15's rather than adding a second rate-limit row.
  • 30-risks.md updates PR-1, PR-2 and PR-5 with the mitigations that landed.
  • 70-redesign-overview.md: §4.2 (the any-of form, the 10-second cache) and §4.3 (the subset rule without "Take tests") match what shipped; §6's R2 rows marked done.
  • deploy.md gains R2's rollout notes (forward only; do not rotate join codes during the deploy). server/README.md names the new module.
  • As built:
    • The spec is v0.45. No "Availability: R2" markers existed to flip: T-R0.3 added none. Beyond the listed sections, §1.1 and §1.3 (roles as data, several teachers), §7's types, §13.2, §13.7 (D-21) and the "single-teacher practice" phrases in §6.5 and §17 are corrected. The Word import section's API paths move to /teacher/imports.
    • Deviations. 20-data-model.md numbers them D-24 to D-27: student_like_roles, users.role_id, the last-admin trigger and the encrypted join codes. D-21 to D-23 already covered the reference data, the owner columns and classes.teacher_id.
    • Risks. 30-risks.md holds no PR-n rows. They live in 70 §9, which now records the R2 mitigations for PR-1, PR-2 and PR-5, and 30-risks.md points there.
    • NFRs. NFR-S16 to S18 are new, and NFR-S05 is rewritten in place. NFR-S07 keeps expiry, max_uses and rotation, and points to S18 for storage.
    • AGENTS.md also says that make test-api's end-to-end tier needs MinIO. docs/setup/dns.md already listed JOIN_CODE_KEY among the Fly secrets (T-R2.14a) and is unchanged.
    • Beyond the Touches list. 17-word-import.md and 18-word-import-ux.md give the capabilities API as /teacher/imports/capabilities, and 75-r5.md describes the last-admin lock as FOR NO KEY UPDATE in a SECURITY DEFINER function.
    • Not changed here. The contract's Role description still promises a third role "later". T-R3.1 removes the Role schema.

T-R2.19 — Verification

Depends on: T-R2.1 … T-R2.18 Touches: docs/setup/rehearsals/r2.sql (new) Size: M Done when:

  • Every CI job step passes locally, in order, judged by exit code (70 §8):
    • make lint, make gen-check;
    • make test-api, with docker compose up -d db minio;
    • goose up, down, up;
    • pnpm lint, typecheck, format:check, test:unit, test:integration, build;
    • pnpm e2e, pnpm e2e:content;
    • pnpm e2e:live, because auth and join changed;
    • node scripts/check-design-deck.mjs.
  • Per-suite test counts are recorded at the start and at the end of the release, and any drop is explained.
  • Security: the isolation suite, the escalation tests and the credential limit test are green. Thuong has read permissions.golden against 70 §4.1.
  • The old-tab check:
    1. The v0.7.0 web build (git checkout v0.7.0 -- web, in a scratch worktree) is served with vite preview against the R2 API.
    2. The owner signs in and opens the dashboard, tests, the builder, the bank, media, imports, a class and grading.
    3. A student opens and saves an attempt.
    4. Every call succeeds through the alias, and legacy_admin_path is logged.
  • No screen changed. The old console and the student app at 1280 and 360 match v0.7.0 captures. The dev server is confirmed fresh; Vite and the API are stopped afterwards.
  • docs/setup/rehearsals/r2.sql holds the verification queries from the release checklist below, including the count of draft references that cross owners, which must be 0 before deploy (T-R2.12a refuses them): a draft whose section names a question another owner owns. Also a committed import whose test's owner is not the import's creator, which must be 0 too (T-R2.12f): SELECT count(*) FROM app.word_import_commits c JOIN app.word_imports i ON i.id = c.import_id JOIN app.tests t ON t.id = c.test_id WHERE t.owner_id <> i.created_by.
  • As built (2026-09-29, on T-R2.15 to T-R2.18 combined):
    • CI steps. Every CI step passed locally, in order, on fresh databases: make lint, make contract, make gen-check, make test-api with TEST_DESTRUCTIVE=1, goose up, down and up, the seven pnpm steps, pnpm e2e:live and the deck check.

      • make test-api first failed four runs in four in TestAKeyPairTheAPIDoesNotRunWithIsRefused (#221). It deadlocked with another package's enrol. The rekey tests now take EXCLUSIVE on app.class_join_codes as their first statement, and two full runs then passed.
      • E2E 9 failed once and passed on retry: the known #179.
    • Counts, v0.7.0 → R2. No suite shrank.

      Suite v0.7.0 R2
      Go unit 524 641
      Go integration tier 1,006 1,230
      Go e2e 12 16
      Web unit 1,227 1,258
      Web integration 29 29
      E2E 61 61
      Content prototype 8 8
      Live E2E 10 11
    • The old-tab check.

      • The v0.7.0 web build ran against the R2 API on the seeded live database.
      • The owner signed in and opened 15 screens at 1280 and 360: the dashboard, tests, a test, the builder, the bank, media, imports, assignments, an assignment, its monitor, an attempt, grading, students, classes and a class. A student opened home, classes and an assignment intro, then started an attempt and saved an answer (200).
      • No API call failed, apart from the bootstrap 401 on /auth/me before the cookie refresh.
      • The API logged legacy_admin_path 102 times, across 17 patterns.
    • No screen changed.

      • The same 36 screens were captured from the R2 build against the same API and database.
      • 24 are pixel-identical. The other 12 differ only in relative times ("17 phút trước" against "20 phút trước"), countdowns, and a 9-pixel antialiasing difference on the avatar.
    • r2.sql.

      • It ran on a database migrated to 00053 and seeded with v0.7.0's seed, plus one legacy join code, then again after goose up (2.8 s).
      • Section A was identical in both passes, once the new roles table was excluded from A1.
      • Every "must be 0" check printed 0, the legacy code counted 1, the eight indexes are valid, and the catalogue holds 22 keys and 4 built-in roles.
    • Before main, done on 2026-10-03 once Thuong delegated the release and set up the Fly and Neon CLIs:

      • permissions.golden was read against 70 §4.1, all 97 operations, and agrees with it.
      • The Neon rehearsal ran with r2.sql (release checklist, item 2).
      • JOIN_CODE_KEY was generated and staged as a Fly secret, with a copy left in a file for Thuong's password manager.

Release checklist

70 §8 applies in full. For R2 specifically:

  1. Prerequisites before merging to main.
    • The JOIN_CODE_KEY Fly secret is set; the deploy preflight fails otherwise.
    • An offline copy of the key is stored.
    • JOIN_CODE_KEY_PREVIOUS is unset.
  2. Neon branch rehearsal (mandatory). On a branch of production, run goose up as quizzivy_migrate, then docs/setup/rehearsals/r2.sql, and record the timings. Delete the branch afterwards. The script must show:
    • no user without role_id;
    • every v0.7.0 admin as an Admin, and the Student count unchanged;
    • no NULL in any owner column, and every owner holding the Admin role;
    • the maximum updated_at per table unchanged from before the run;
    • the count of active legacy join codes (lookup_scheme = 1, unrevoked, unexpired), recorded because R4 rotates them;
    • pg_index.indisvalid true for all eight new indexes.
    • As run on 2026-10-03, on a Neon branch of production taken at 10:11 ICT and deleted afterwards:
      • goose up took 10.7 s for 00054 to 00078; the slowest file was 00054 at 1.5 s.
      • Section A was identical before and after. Every "must be 0" check printed 0, the eight indexes are valid, and the catalogue holds 22 keys and 4 built-in roles.
      • Production has two admins, not one, and three students. Both admins became Admins. One of them owns every test, question and media asset, and the class's teacher is the older of the two.
      • One legacy join code is active, for R4 to rotate.
      • No assignment window was open, and the one attempt still in_progress passed its deadline on 2026-09-22.
  3. Exam window. Deploy only when no assignment window has attempts in progress; check the monitor or ask Thuong. Scoping changes the attempt queries under a running exam.
  4. Deploy the API, then Pages. While old and new machines overlap, a join code rotated by the new binary cannot be previewed by an old machine. Do not rotate codes during the deploy. Do not reset student passwords or disable students either: a token issued by v0.7.0 carries no epoch, so v0.8.0 refuses it for a student whose epoch moved, and the student is signed out until the old machine is gone.
  5. Smoke on production:
    • /livez and /healthz answer, and the worker process group is running.
    • The owner signs in and lands in the old console at /admin.
    • Dashboard, the tests list, the builder, the bank, the media library, GET /teacher/imports/capabilities, an assignment monitor and grading all work.
    • A student signs in, and an assignment intro opens.
    • /join/preview with an existing legacy code answers.
    • GET /auth/me carries permissions and workspaces.
    • A tab left on v0.7.0 still works, and legacy_admin_path appears in the logs.
  6. Watch for 24 hours:
    • 401 and 403 rates: a mis-mapped permission shows as FORBIDDEN on a staff operation;
    • legacy_admin_path counts, which should fall;
    • Neon compute hours against the week before (the principal cache; PR-7).
  7. Release notes (docs(release): v0.8.0):
    • Nothing looks different.
    • Teaching API paths moved to /teacher/*; the old paths are served until the next release.
    • The permission and ownership model is in place; there is no second teacher yet.
    • New join codes are stored encrypted, and existing codes keep working until R4 rotates them.
    • Join and sign-in limits now fit a classroom behind one address.
    • Forward-only: v0.7.0 cannot read codes that v0.8.0 issued, so roll forward.
  8. Tag and close. Tag v0.8.0, back-merge main → develop by PR (the #151 pattern), and close the epic.
  9. Precondition for R3's contract steps. They merge only after v0.8.0 has been the only binary in production for at least a week, with legacy_admin_path hits near zero. R1's new-version prompt covers the tabs that remain.

Open items

  • Classroom size. The per-code budget of 200 an hour assumes classes of up to 40 and three tries per step (T-R2.15). Default: build as proposed. If Thuong runs larger sessions, such as a whole year group in a hall, raise the per-code bucket only.

  • Enforcing mustChangePassword on the server. Today only the SPA redirects a user with a temporary password; the API serves them in full. RequirePermission could refuse every operation but self until the password changes. That is not in R2, because it would change behaviour for sessions already in flight. Default: not enforced in R2. R5 takes the decision with its set-password link (D15, DG-05) and proposes 403 PASSWORD_CHANGE_REQUIRED (O-R5.4).

  • The two amendments to 70 §4: the any-of form of x-permission (§4.2), and the subset rule ignoring "Take tests" (§4.3, D13). 70 carries both. Default: build as written here. Thuong confirms the D13 reading.

  • The Admin's lists in the teacher workspace. They show everything in R2 (scope.all), which is no change while the owner owns everything. R4 decides the default (DG-53). The proposal: content lists show the caller's own rows, admins included; Students and Classes keep scope.all until R5.

  • Resetting or re-addressing a shared student. Default, as built in T-R2.13: a teacher's reset, or email change, is refused with 403 STUDENT_SHARED unless no one else reaches the student. That means every class is the actor's, no other creator, no other account's individual target, and, with no class, the actor created them. A holder of people.users.manage is exempt. Three points go beyond the plan's first text, which looked only at memberships and only at resets:

    • the email change, which Google sign-in's link-by-email turns into a takeover;
    • the created-by and individual-target arms of reach;
    • archived classes counting as memberships.

    It is the safer reading, because the alternative lets one teacher take over a student another teacher shares, and from R7 read their direct messages (D17). Thuong confirmed the stricter reading on 2026-09-28.

  • Self-join by an Admin who takes tests. From R5 an Admin can turn on "Take tests" (DG-51). The server then accepts that Admin's joinClass, because the operation needs only learning.take_tests, while rosters, resets and assignment targets never treat the Admin as a student. The web keeps v0.7.0's rule (T-R2.17): JoinPage tells that Admin joining is for students, although the student app now opens for them and links to /join. Default: unchanged in R2, where no Admin holds the key. R5 decides whether such an Admin may self-join, then aligns the server's enrol path, JoinPage and the student app's join links with the answer.


Generated from docs/plan/72-r2.md. Edit that file in the repository; changes made on this page are overwritten.

Home

Start here

Product

Implementation plan

Redesign (Phase R)

Setup and operations

Design

Team

Code READMEs

Clone this wiki locally