Repository navigation
Plan 72 R2
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 VALIDconstraints and drops the fill triggers; - drops
users.role, its sync trigger,users_role_active_idxandapp.user_role; - removes the legacy
/adminalias; - stops issuing the legacy
roleclaim.
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) declaresx-permission, or is open.httpx.RequirePermissionreplaces the/admin/prefix gate. A short per-user principal cache supplies each request's role, permissions,disabled_atand 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.goand the web contract test agree: every operation declares a permission, the open operations declare none, and every key is in the catalogue. -
RequireRoleis gone.
- The startup assertion,
-
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.
- The isolation suite shows teacher B getting 404 for every
-
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-permissionis 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.teacheron 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. - Question and question-group writes serve both the bank
(
-
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 needspeople.students.reador a teaching key. Ownership scoping, not the permission, keeps another teacher's rows out. -
scope.alllifts 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 NULLcreated_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.allkeeps 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 keepscope.alllists until R5's Admin Classes and Users take them over. Either answer is theScopea 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 andcore/maintenanceuse 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. -
deleteStudentbecomesDELETE /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, andupdateStudentwith a newemail, 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 ofpeople.users.manage(T-R2.13). In R2 the only staff account is the Admin, which holds that key, so nothing visible changes. -
updateStudentwithdisabledalso needspeople.users.manage. The operation itself declarespeople.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 andforTestIdremove 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_adminserialises on the Admin role row and refuses to leave zero active Admins, whatever the path: R5's commands, a bulk action or a hand-writtenUPDATE. Its function isSECURITY DEFINER, because theFOR NO KEY UPDATEit takes needs UPDATE privilege onapp.roles, which T-R2.1 revokes from the app role. The409 LAST_ADMINcode 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-codereturns{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.
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_permissionscreatesapp.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, andUNIQUE (group_key, ordinal). - Rows: the eighteen matrix rows of 70 §4.1, in the deck's order
(
Quizzivy Admin.dc.html,PERMS), withpeople.students.createsecond in People (DG-03). - Rows: the four hidden keys (
scope.all,system.api_reference,system.data_export,system.leads) within_matrix = false.
- Columns:
-
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 NULLrevision bigint NOT NULL DEFAULT 1 CHECK (revision > 0)created_by uuid REFERENCES app.users ON DELETE SET NULLcreated_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 theroles_set_updated_attrigger.
- Columns:
-
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 within_matrix = falseis never granted, and the Admin role may hold onlylearning.take_tests. -
role_permissions_keep_student_take_tests(BEFORE DELETE): the built-in Student role cannot loselearning.take_tests. -
role_permissions_bump_revision(AFTER INSERT OR DELETE, FOR EACH ROW):roles.revision + 1for 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 onlyquizzivy_migratewrites grants, and from R5 the app role's UPDATE onrevisioncovers it.
-
-
As built:
- The hidden keys are in group
systemat ordinals 4–7, afterlearning.take_testsat 3. - The grant triggers also fire on UPDATE:
role_permissions_guardis BEFORE INSERT OR UPDATE,role_permissions_keep_student_take_testsBEFORE DELETE OR UPDATE, androle_permissions_bump_revisionAFTER INSERT OR UPDATE OR DELETE. An in-place UPDATE ofrole_idorpermission_keywould otherwise pass the Admin, hidden-key and Student guards and skip the revision bump. -
roles_builtin_key_immutableis BEFORE UPDATE OFbuiltin_key.
- The hidden keys are in group
-
Privileges:
REVOKE INSERT, UPDATE, DELETEon the three tables fromquizzivy_app, because00009's default privileges grant them. R5'sgrant_role_writes(T-R5.8) grants the role commands' writes: INSERT and UPDATE (name, description, icon, color, revision, updated_at) onroles, INSERT and DELETE onrole_permissions. DELETE onrolesis never granted (DG-52), andpermissionsstays read-only. -
Down drops the triggers, the functions and the three tables.
-
create_student_like_roles_viewcreatesapp.student_like_roles (id): the roles withbuiltin_key = 'student', or withbuiltin_key IS NULL AND NOT EXISTSa grant other thanlearning.take_tests.-
REVOKE INSERT, UPDATE, DELETEon 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.yamlgainscomponents.schemas.PermissionKey, a string enum of the 22 keys, documented with its matrix row. T-R2.6 references it fromCurrentUser.make genoutput is its own commit. -
shared/access(kernel; imports nothing outsideshared/) contains:-
Keyand constants for the 22 keys and the pseudo-keysself,workspace.teacherandworkspace.admin. -
SetwithHas,SubsetOfandWithout. -
Requirement(any-of; pseudo-keys evaluated as in 70 §4.1) andWorkspaces(Set). -
IsStudentLike(builtinKey, Set). -
CanActOn(actor, target Set): the subset rule withoutlearning.take_tests. -
Scope{UserID, All}andPrincipal{UserID, RoleID, BuiltinKey, Permissions, Epoch, Disabled}.
-
-
Unit tests in
shared/access/tests/:- Every pseudo-key;
workspace.teacheris 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.manageacting on an Admin with "Take tests" turned on; - false for a Teacher acting on an Admin.
-
IsStudentLikeis false for the Admin role withlearning.take_testsgranted, and true for an empty custom role.
- Every pseudo-key;
-
Integration,
core/tests/catalogue_test.go:- The contract's
PermissionKeyenum equalsaccess.All(), which equalsSELECT 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_testsraises; granting any rolescope.allraises; granting the Admin role any other key raises. - A grant insert and a grant delete each bump
revision.
- The contract's
-
20-data-model.mdgains 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).
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.goserver/internal/modules/identity/repositories/students_write.goserver/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 andstudent→ Student. The backfill runs withusers_set_updated_atdisabled, soupdated_atkeeps 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).
- Adds
- The same file adds the
users_sync_legacy_roletrigger, BEFORE INSERT OR UPDATE OFrole, role_id:- An insert with no
role_idtakes it fromrole; this is how the old binary inserts. - An insert with
role_idgetsrole = 'student'when the role is inapp.student_like_roles, and'admin'otherwise. This overrides the column default. - An update of
role_idre-derivesrole; an update ofrolealone re-derivesrole_id. - Down drops the trigger, the function, the constraint and the column.
- An insert with no
-
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 isDROP 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_guardadds theusers_last_admintrigger, AFTER UPDATE OFrole_id, disabled_atOR DELETE, FOR EACH ROW:- It acts when the old row was an active Admin and the new row is not.
- It takes
FOR UPDATEon the Admin row ofapp.roles, which serialises concurrent demotions. - It then raises
check_violationwithCONSTRAINT = 'users_last_admin'when no active Admin remains. - The function is
SECURITY DEFINER, owned byquizzivy_migrate, withSET search_path = app, pg_catalog.FOR UPDATEneeds UPDATE privilege onapp.roles, which T-R2.1 revokes fromquizzivy_app; as the invoker, R5's demote and disable would raise 42501 instead of the guard. It is the schema's firstSECURITY DEFINERfunction, and20-data-model.mdrecords why. - Down drops the trigger and its function.
- As built,
00059_add_users_last_admin_guard.sqldiffers 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 therole_idthatusers_sync_legacy_rolesets. - It takes
FOR NO KEY UPDATEon the Admin role row, notFOR UPDATE. That still serialises the guard against itself, and it does not wait on theFOR KEY SHAREa foreign-key check onusers.role_idtakes. - The function sets
search_path = pg_catalog, app, pg_temp, qualifies every type and relation, and has EXECUTE revoked from PUBLIC.
- The trigger is AFTER UPDATE OR DELETE with no column list. The v0.7.0
binary demotes by writing
- The new binary writes and reads
role_id, neverrole:-
students_write.goCreateandenrol.go's Google self-enrolment insertrole_idas the built-in Student, resolved bybuiltin_keyin the statement. -
store.go(sign-in, Google, refresh) derives the legacy role value fromrole_idthroughapp.student_like_roles, and selectssession_epoch. - The remaining reads of
rolemove in T-R2.12d and T-R2.13.
-
-
expand_r2_test.go(integration) first sets up the legacy state:-
goose.UpTothe version beforecreate_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 noupdated_atmoved. - As the app role, an insert with
roleonly getsrole_id. - An insert with
role_idset to Teacher getsrole = '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_adminfunction hasprosecdef, its owner isquizzivy_migrate, itsproconfigpinssearch_path, andquizzivy_apphas no UPDATE onapp.roles. Every other integration test connects asquizzivy_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.
- Every user has the right
- up/down/up green;
20-data-model.mdrecords the four files.
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 anaccess.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.
- The Admin gets every compiled key except
- 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)andCatalogue(ctx)complete the read side. - No
SELECT *.
-
- Application queries:
-
ResolvePrincipalreturns anaccess.Principal; a missing user givesErrUnknownUser. -
RolePermissionsis cached by(role_id, revision). -
Matrixreturns 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, andForget(userID)andForgetAll()for writes on this machine. -
wiring.Buildloads the catalogue and refuses to start when a compiled key is missing fromapp.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;
Forgetforces 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 afterForgetAllreturns the new set. - The epoch is read.
- A disabled user resolves as
-
core/tests/architecture_test.gois green, and the package comment names the context's model.
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.descriptionstates 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 amap[pattern]access.Requirement. An operation is open when it does not requirescheme(bearerAuth), byOpenRoutes' rule with the globalsecurityas 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.NewcallsPermissionRequirementsnext toAssertPublicRoutesLimited, and the process refuses to start on an error. -
permissions_test.go:-
TestEveryNonOpenOperationDeclaresAPermission,TestOpenOperationsDeclareNone,TestEveryDeclaredValueIsKnown. -
TestTheAssertionRefusesAnUndeclaredOperationruns on a mutated copy of the spec. -
TestThePermissionMapIsPinnedcompares againsttestdata/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 contractruns, adds a test: every operation that requires the bearer token declaresx-permission, no other declares one, and every value is inPermissionKeyor is a pseudo-key. -
RequireRolestill gates in this PR. Nothing enforces the declarations until T-R2.5, so no commit on the branch is ever ungated.
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.goserver/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.goserver/internal/modules/identity/http/docs.goserver/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:
-
ClaimsgainsEpoch intassep. -
Issue(userID, legacyRole string, epoch int). - A token without
sepreads as epoch 0. - The legacy
roleclaim is still issued (studentfor a student-like role,adminotherwise), so a v0.7.0 machine'sRequireRolekeeps working during the rolling deploy. - Sign-in, Google sign-in and refresh read
session_epochand put it in the token.
-
-
httpx.RequireAuthputs{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 UNAUTHORIZEDwithWWW-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.Principalreplaces 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.gopins the body limit before validation). -
RequireRole,RoleAdmin,AdminPathPrefixandIsAdminPatternare deleted;grep -rn "RequireRole\|IsAdminPattern\|RoleAdmin\|AdminPathPrefix" server --exclude-dir=genis empty. The generatedopenapi.RoleAdmin, from the contract'sRoleenum, stays until T-R3.1 retires the enum. -
router.DepsgainsPrincipals, the access resolver, wired incore.go.router.Newreturns an error when it is nil. - Docs session:
-
OpenDocsSessionissues the docs token with the epoch. -
RequireDocsSessionresolves the cookie's subject through the same resolver, and requiressystem.api_reference, an active account and the current epoch: 401 and 403 as spec §5.5 describes.
-
- Epoch bumps:
updateStudentwithdisabled: trueandresetStudentPasswordadd 1 tosession_epochand revoke the student's refresh families in their transaction (plan 70 §4.2), then call thePrincipals.Forgetport, 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.gouses 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
openDocsSessionanddeleteStudent. - An Admin passes every staff operation, and gets 403 on every
/app/*operation except the open beacon. -
selfoperations 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.
- A student gets 403 on every
-
role_test.go's five tests move intopermissions_test.gobefore 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 403FORBIDDENon the dashboard, aworkspace.teacheroperation. -
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/apptree needslearning.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.gocovers 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
Issuesignature, and every test router passes a fake resolver. - Canary
web/tests/units/api/client.refresh.test.tsis untouched and green.
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.goserver/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:
-
CurrentUserholdsUser's properties plus two required fields:-
permissions: PermissionKey[]: the effective set in catalogue order, including the hidden keys for the Admin. -
workspaces: Workspace[], from theteacher,adminandappworkspaces, derived byaccess.Workspaces.
-
-
CurrentUseris written flat, because a closed schema cannot be extended withallOf(issue #41). -
CurrentUserreplacesUserwherever the response describes the caller: getCurrentUser, updateCurrentUser, linkGoogle,AuthSuccessandGoogleSignInSuccess. -
Userstays where it describes someone else:getAttemptForReview'sstudent. A student's permissions never appear in a teacher's payload. -
rolekeeps 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
Principalsport (ResolvebesideForget) and carry them onmodel.Session;/auth/me, PATCH/auth/meand the Google link read them from the principal the gate resolved, so they cost no query. The table below lives inidentity/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.tsuseCurrentUser. The fixtures gain both fields in one mechanical commit, andweb/tests/units/contract/msw-contract.test.tsis green.
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/getsno-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.goserver/internal/core/router/tests/testdata/permissions.golden- the Go tests that name
/admin/API paths (23 files today) - the feature
api.tsmodules 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.routestubs, 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 exceptadmin-docsbecomesteacher-*. - Descriptions that say "admin" and mean the teacher are corrected, and
info.descriptionexplains the split into/teacher,/admin,/appand/auth, with/publicand/joinfor open operations and/mereserved for R4. -
deleteStudentbecomesDELETE /admin/users/{id}(deleteUser, tagadmin-users). Its handler is renamed; the command staysDeleteStudent, and the target stays strictly a student (T-R2.13). -
openDocsSessionstays at/admin/docs-session. - The path-tree rule is switched on in
PermissionRequirements, withTestPermissionTreesMatchPaths:-
/teacher/*accepts onlycontent.*,teaching.*,people.students.*andworkspace.teacher. -
/admin/*acceptspeople.users.manage,people.roles.manage,system.*,scope.allandworkspace.admin. -
/app/*acceptslearning.take_tests; the open beacon is exempt. -
/auth/*acceptsself; the open operations are exempt. -
/me/*accepts onlyself. 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
PermissionRequirementsat startup. -
TestPermissionTreesMatchPathspins 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
RequireAuthand 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.
-
-
TestRegistryHasNoStaleEntriesis green. -
credential_limits_test.gopinstheCredentialMinters: login, googleAuth, refreshSession, resetStudentPassword, createStudent, rotateJoinCode and openDocsSession. T-R2.14b adds getJoinCode.- Each has a registry entry.
- An operation whose 2xx body carries
temporaryPasswordoraccessToken, or a join code (an object with bothcodeandexpiresAt), at any depth, and is not on the list fails the test. The walk goes through arrays of objects (items[].temporaryPassword), maps (additionalProperties) andallOf/oneOf/anyOf. The next credential-minting operation, a bulk one included, cannot ship without a limit.
-
SecurityHeaders(hardening.go) setsCache-Control: no-storeon/teacher/as it does on/auth/,/app/and/admin/. Without it, createStudent's and resetStudentPassword'stemporaryPasswordand every student record become cacheable on the move. R4 adds/me/. -
TestSecurityHeadersOnSuccessFailureAndPreflightassertsno-storeon one path per private tree (/auth,/app,/teacher,/admin), and that/public/statuskeeps its ownpublic, max-age=30. - Commits, in this order:
- contract
make gen- Go: the handler rename, the limits, the tree rule, the
no-storeprefix, the tests - Go test path sweep (mechanical)
- web API-path sweep (mechanical)
-
pnpm typecheckpassing proves every call site matches the new schema. - The web sweep changes API paths only:
api.tsmodules,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-sessionand/admin/users/{id}. -
permissions.goldenis regenerated.make gen-check,pnpm typecheck,pnpm test:unit,pnpm test:integrationandpnpm e2eare green.
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/adminpath no longer in the spec never matches. - Its own
ServeMuxholds the 78 pinned pairs: the 77 renames, plusDELETE /admin/students/{id}→/admin/users/{id}. - A match rewrites
r.URL.Pathand rebuildsRawPathfrom the escaped segments, so an encoded%2Fstays inside its wildcard as it would on the new path (a clearedRawPathwould let/admin/tests/{id}land onDELETE /teacher/tests/{id}/versions/{version}). It logslegacy_admin_pathat 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-sessionandDELETE /admin/users/{id}are never rewritten. -
legacy_admin_test.go:- The table equals a hard-coded list of the v0.7.0
/adminoperations minusopenDocsSession, 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
OPTIONSpreflight to an old path is answered as the new path's would be. - One log line per rewritten request.
- The table equals a hard-coded list of the v0.7.0
- The release notes say the alias lasts one release;
73-r3.mdremoves it.
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.sqlandNNNNN_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.goserver/internal/modules/tests/repositories/{store.go,duplicate.go,version_draft.go,group_create.go}server/internal/modules/questions/repositories/store.goserver/internal/modules/media/repositories/store.go- the commands that carry the owner into those statements
server/internal/platform/db/tests/expand_r2_test.godocs/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, thenid), the teacher v0.7.0 shows for every class.app.classeshas never had acreated_by(an earlier draft of this plan assumed00006declared 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 functionapp.classes_fill_teacher(), applies the same rule when the old binary inserts without a teacher. It readsusers.role_id, notrole.
-
-
add_tests_owner,add_questions_ownerandadd_question_groups_owner:-
owner_id uuid REFERENCES app.users ON DELETE RESTRICT, backfilled fromcreated_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_ownerdoes the same fromuploaded_by, throughapp.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_attrigger for itsUPDATE. Otherwise every test'supdated_atwould move, and an open builder tab's next autosave would getSTALE_WRITEduring 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 onusers; v0.7.0's student creation (writesusers, then checksclassesthroughclass_members) or a user delete (writesusers, then checks the referencing tables) would deadlock with it and abort the release.expand_r2_test.godrives 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.goextended:- The legacy dataset backfills to the right owners:
created_byfor tests, questions and question groups,uploaded_byfor media, and the oldest active Admin for classes (D-23). - No backfilled
updated_atmoved. - Old-binary inserts without owner columns are filled by the triggers.
- The legacy dataset backfills to the right owners:
-
Duplicate and restore insert into
test_section_questions, a high-risk path: every such insert still callsquestions.LockForDraftUsefirst. 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 sharedconstraints_test.gofixture also runs at schemas before00065, so it namesteacher_idwhen the column exists. -
20-data-model.mdrecords the eleven files (§31) with D-22 (owner_idseparate fromcreated_by; provenance must survive R5's ownership transfer and R7's co-editing) and D-23 (classes.teacher_idfrom the oldest active Admin, noclasses.created_by). -
up/down/up green.
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 TRANSACTIONwith a singleCREATE INDEX CONCURRENTLY. Down isDROP 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.mdlists theDROP INDEX CONCURRENTLY IF EXISTSstatements to run before redeploying after an interrupted build of any of these orusers_role_id_active_idx, as it already does for00036and00038. -
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.
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-…).06and07are 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.sqlis idempotent; every user in it has the passwordquizzivy-dev, androle_idis looked up bybuiltin_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.sqlseeds thirty students,hocvien.lop01@quizzivy.com…hocvien.lop30@quizzivy.com, withmust_change_password = false, for T-R2.15's live test. -
99-assert.sqlasserts:- 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.
- every user has
-
thuong@quizzivy.com(Admin) andhocvien@quizzivy.comare unchanged.seed/README.mdlists 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 writerole_id.- As built, every Go test fixture that creates a user at the current schema
writes
role_id, so R3'sDROP COLUMN rolefinds none to fix.platform/db/testskeepsrolewhere it builds v0.7.0 data or runs at an older schema; its sharedconstraints_test.gofixture picks by which column exists. -
staff_roles_flow_test.gosigns in an Admin, a Teacher and an Assistant throughcreateStaff. For each it checks that the token's epoch matches the account's, and checks the workspaces,people.users.manageand the gate onDELETE /admin/users/{id}, all against the real database.
- As built, every Go test fixture that creates a user at the current schema
writes
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(ActorFromContextfills the scope;ScopeFromContext) server/internal/modules/tests/{application,repositories,http}/**-
server/internal/modules/tests/repositories/store.go(QuestionLocksgainsNotOwnedBy) -
server/internal/modules/questions/repositories/references.go(NotOwnedBy) -
server/internal/core/adapters/group_questions.go(NotOwnedBy, becauseGroupQuestionStoreembedsQuestionLocks) -
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:
-
ActorFromContextbuildsScope{UserID, All}from the principal, withAlltrue only forscope.all. Queries that serve teacher operations gain aScopefield. The plumbing lands with its first consumer here, becauseunparamforbids unused parameters. - Every read and write in the tests module filters
owner_id = scope.UserID, andAlllifts 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. -
copyQuestionGroupneeds 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:-
createQuestionGroupandcopyQuestionGrouprequirecontent.questions.writewhen the destination is the bank (noownerSectionId) andcontent.tests.writewhen it is a test section; -
updateQuestionGroupanddeleteQuestionGrouprequire the key that matches the stored group'sOwnerSectionID, checked in the repository after the group is readFOR UPDATEand 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.LockForDraftUsehas locked every referenced question, the same transaction asksQuestionLocks.NotOwnedByonce 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: 400VALIDATION_FAILEDonupdateTest, 404 onduplicateTest), 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. -
LockForDraftUseitself 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:
previewTestanswers 409TEST_NOT_PUBLISHEDandlistTestVersionsreturns no rows. - The scope comes from the resolved principal (
httpapi.ScopeFromContext, andActor.ScopefromActorFromContext), withAllonly forscope.all. A zero scope matches nothing. Single-row locks carry($x OR owner_id = $y); lists addowner_id = $nin Go only withoutAll, so their plans can use T-R2.10's owner indexes. - The narrowing's grants travel beside the scope (
Grantsoncommand.CreateGroup,model.GroupMutationand the domain inputs, not onActor, 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 readFOR UPDATE. The internal group creates of duplicate and restore passcontent.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 *.
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.AllandScope(),WriteInput.All,ListInput.Scope) -
server/internal/modules/questions/application/ports/(MediaKinds.Kindtakes the scope) server/internal/modules/tests/application/ports/group_media.go-
server/internal/modules/tests/application/internal/support/groups.goandcommand/{create_group,update_group}.go(Preparetakes 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 ofReadable) 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, andscope.alllifts it. The owner goes in as$1in Go only withoutAll, soquestions_owner_bank_idxserves 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
AllbesideActorID, not aScope, as media'sDeleteInputdoes (T-R2.12c): the owner compared isActorID, andWriteRequest.Scope()derives{ActorID, All}for reads and bindings. The canaries buildWriteRequest{ID, ActorID}; a zeroScopewould turn draftrace's delete into the not-found it accepts, andTestLockForDraftUseSerialisesAgainstDeletecould 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. -
createQuestionwrites 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'sforTestIdis the way to add to A's bank. - A question's "used in" list shows only tests the viewer can read.
Residual: the
usedInTestscount 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. -
updateQuestionanswers 404 for ErrNotFound, as the contract says; it answered 501. - Every media binding uses
media/repositories.Readableunder 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
MediaKindsport, whose adapter callsquery.Readableand 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 (
Prepareresolves only member questions), and their one check wasLockForVersionUse, which checks existence.lockGroupAssetscallsmedia.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. -
Preparepasses the scope too, so a member's unreadable asset is refused before the bundle is validated. - The test fakes of
MediaKindscallmediarepo.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.
- A question's asset goes through the
- 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.
-
lockGroupAssetslocks each asset beforeRequireReadablerefuses 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, unlockedReadablebefore the locks. - The OpenAPI descriptions of
bankTotaland of tagging'supdateddo not yet say "in the caller's scope", as T-R2.12c left media's.
-
scope_test.gocovers 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.
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.goandmedia/http/tests/scope_test.go(new); the library listings inlist_test.go,delete_test.goandversionref_test.gogain a scope
Size: S Done when:
-
The library list and its byte total filter
owner_id, andAlllifts 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 plainkind = $nrather than($1 IS NULL OR kind = $1), so a generic plan can usemedia_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 isDeleteInput.ActorID, andAllis a flag on the input rather than aScope: the canary file buildsDeleteInput{ID, ActorID}, and a zeroScopewould turn every one of its deletes into a not-found, leavingTestLockForVersionUseSerialisesAgainstDeletegreen 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.Readableis 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 holdsscope.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.Readableserves 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 byquestions_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.GetandSignedURLstay 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. -
LockForVersionUseis unchanged. Canarymedia/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 gainScope: 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.
Depends on: T-R2.12a Touches:
-
server/internal/shared/visibility/visibility.go(new:StudentIDs,AssignmentIDs) -
server/internal/shared/stats/stats.go(Sourcetakes the scope) -
server/internal/modules/attempts/repositories/studentstats.go,attempts/http/{attempts_handlers.go,admin_attempts.go},core/wiring/attempts.go(review readsStudentAccount) -
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, withAlllifting the filter: list and facets (in Go, only withoutAll, soclasses_teacher_idxserves 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, orErrClassNotFoundfor the codes, and before a delete's two 409s can reveal its state. - Writes carry
AllbesideActorUserID(oractor.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) andListMine(the student's classes) name each class's own teacher throughclasses.teacher_id, disabled or not, so the open preview can no longer reachErrNoTeacher. In production it is the same person (D-23). -
shared/visibilityholds 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 answersErrStudentNotFoundand is never written. The existing identity sites keeprole = 'student'for T-R2.13 to swap; new code (add member) usesapp.student_like_roles. - Inside a visible student, memberships name only classes the viewer
teaches, a
classIdfilter 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: everyclassIdsentry must be a class the actor teaches (any withAll), lockedFOR SHAREbefore the account is inserted, else 404 and nothing is written, before the email is checked. The contract gains that 404. It writescreated_by= the actor. -
addClassMember: the class is lockedFOR SHAREin 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
StudentAccountquery (account fields, no memberships or figures): the attempt is the parent, and a scopedGetStudentwould 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/previewstill returns exactly its three keys (TestTheSuccessBodyHasExactlyThreeKeysgreen), and the teacher's name is now the class's own teacher. - Deferred:
ActiveCodehas no consumer; T-R2.14b scopes it withgetJoinCode. -
scope_test.goandstudents_scope_test.gocover 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.
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 thescope_test.goofassignments/http/tests,attempts/http/testsanddashboard/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 withAll. The list, the facets and theclassIdfilter apply it in Go, only withoutAll; aclassIdfilter must also name a class the viewer teaches. Get, update, delete and reopen apply it in the row lock or theUPDATEitself. Another teacher's assignment answers exactly as a missing one, before delete's 409 or reopen's reasons can reveal its state. -
Writes carry
AllbesideActorID(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 beforereplaceTargetsdeletes 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 answers400 VALIDATION_FAILEDnaming 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 D1: the answer is the one a missing id gets, not a new 404.
An unreachable version answers
-
Decision D2: mixed targets are filtered, never refused.
- In R2 only a
scope.allwriter 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.
- In R2 only a
-
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.goandtests/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.allreader who neither created nor reaches the assignment; - every HTTP handler handing the caller's scope or
Allto its command or query.
Each predicate, reach arm,
scope.alllift 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.allwriter 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
usedInTestsis. - 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
checkTargetsrefuses 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.
-
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
Depends on: T-R2.12a, T-R2.12e Touches:
-
server/internal/shared/visibility/visibility.go(TaughtMemberIDs, whichStudentIDsnow 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 thescope_test.goofimports/repositories/tests,imports/application/testsandimports/http/tests. Extended:dashboard/http/tests/scope_test.goandcore/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 thescope.allstatements 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 ascope.allreader 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.AssignmentIDsgives. -
Awaiting grading, flagged, waiting students and the oldest wait:
those assignments' papers, as
visibility.Papersshows them. Each then equals the grading queue its tile opens (listAttemptswith 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.
- Every condition is added in Go only without
-
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) carryActor.Scope.AllbesideActor.ID, as T-R2.12b to 12e do. The history filterscreated_byin Go only withoutAll. - Another creator's import answers
ErrNotFound, exactly as a missing one does. It comes before any 409, the 410 of removed files, a 422, andIMPORT_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.
- The module has 14 operations:
-
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 andcommitted_by.
- The Materializer takes the owner from the scoped draft
(
-
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 andIMPORT_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 itsactiveStudentsunderscope.all),getDashboard,ClosingAssignment.targetCount,WordImport,listWordImportsanddownloadImportSourcenow 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.alllift 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.
- 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
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.goverified: AddMember already reads the view) server/internal/modules/dashboard/repositories/postgres.goserver/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, theReferencedByenum 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 inidentity/application/tests/{students_scope_test.go,students_write_test.go}andaccess/application/tests/revocation_test.go.
Size: M Done when:
-
Strict student targets. Every Go statement that read
users.rolereadsapp.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.allstudent 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 writesrole_id, and 00056's trigger derivesrole. 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 returnsdomain.Account, which carries the legacy role derived as tokens derive it, so the review names a staff sitteradmin, notstudent. -
escalation_test.gocovers 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.
WriteRequestcarries 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).disabledthen needspeople.users.manage, and the student's permissions exceptlearning.take_testsmust be a subset of the actor's (access.CanActOn). Either refusal is 403FORBIDDEN.- 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.godrives it with fakes.
-
Shared students. Reset, and an email change, by an actor without
people.users.manageneed a student no one else reaches (decision above, confirmed by Thuong on 2026-09-28). Otherwise the answer is 403STUDENT_SHAREDand nothing is written. Sending the address the student already has is not a change: the web's edit form always sendsemail, 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 UPDATEwould deadlock against a refresh by the same student, which holds its token and then needsFOR KEY SHAREon 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_SHAREDto 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.
- The check holds the row
-
Last admin. The identity repository's exported
UserWriteErrormapsusers_last_admin(23514) todomain.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_ADMINarrives 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.ForeignKeyViolationreads the constraint of a 23503 or 23001. - identity maps every restricting key to
app.users(21 today): asassignments,attemptsorowned_content.auditcomes from the pre-check. - classes maps its two keys (
assignments, andmembersthrough 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,deleteTestVersionanddeleteTestalready answer 409; -
removeClassMemberdeletes a row nothing references; -
revokeJoinCode,deleteQuestionanddeleteMediachange no key. -
deleteQuestionGrouppasses an unexpected foreign-key error through as a 500, unreachable through the app. Filed as #213. Fixed: it answers409 RESOURCE_REFERENCED;deleteTestandupdateQuestionGrouphad 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.
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 ofpreviewJoinCode,JoinCodeInfoandrotateJoinCode),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(Buildderives the keys and refuses to start on an error);classes/domain/{commands.go,enrolment.go,repository.go}(RotateInputgainsCodeID,CiphertextandKeyID;EnrolInputcarriesCode JoinCodeLookup;LookupByCodeHashbecomesLookupByCode)
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_knownand the consistency check.constraints_test.goviolates each alone.
-
-
code_hashkeeps its 32-byteUNIQUEcolumn. - 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_KEYis required in every environment: standard base64 of exactly 32 bytes; the error message saysopenssl rand -base64 32. -
JOIN_CODE_KEY_PREVIOUSis optional, has the same format and must differ. - Neither value is ever logged.
-
.env.exampledocuments both, the CI live job gets a throwaway key,deploy.ymlpreflight addsneed JOIN_CODE_KEY, anddns.md's secrets list gains it.
-
- Domain:
JoinCodeKeysderives three keys from each raw key with the standard library'scrypto/hkdf(SHA-256, no salt; no new dependency): - infoquizzivy join-code aead v1: the AES-256 key; - infoquizzivy join-code lookup v1: the HMAC key; - infoquizzivy join-code key-id v1: its first two bytes as a non-zeroint16. - Startup refuses equal ids for the current and previous keys, and a
key whose id derives to zero. Both refusals come from
NewJoinCodeKeys, whichwiring.Buildcalls; 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. -
Openpicks the key by id. An unknown id givesErrJoinCodeKeyUnavailable. -
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 aJoinCodeLookup:Hashes()for the query, andMatches(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.
JoinCodeManagerstays stateless for Generate, Format, Normalize and Hint.- Normalisation moves from the repository to the application
(
support.Enrolment.Lookup):EnrolInputcarries the lookup, never the typed code.
- Normalisation moves from the repository to the application
(
- Rotate:
- generates the code id in Go (
uuid.NewV7), because the AAD needs it before the insert; - writes
code_hashas the current HMAC,code_ciphertext,key_id,lookup_scheme = 2and the hint.
- generates the code id in Go (
- 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'sLookupByCodeand 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.goandjoincode_audit_test.gostay 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.gocovers 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 answersJOIN_CODE_INVALIDwith K2 alone. It is the only test that proves wiring passes the previous key. -
expand_r2_test.goalso 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_KEYin.env: the API refuses to start without it. -
To T-R2.18: spec §13.3 (the
code_hashcomment "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.
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.goserver/internal/modules/classes/http/joincode.goserver/internal/core/router/ratelimits.goserver/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 scopedActiveCode),classes/application/app.go;core/maintenance/{cli.go,joincodes.go};platform/config/config.go(the exportedJoinCodeKeysloader, whichLoadand the rekey share);core/router/tests/{permissions_test.go,testdata/permissions.golden}(97 operations declare a permission; the Assistant is refused 33); tests inclasses/{application,http}/tests/,core/maintenance/tests/cli_test.goandserver/tests/join_code_key_flow_test.go
Size: M Done when:
-
GET /teacher/classes/{id}/join-codeisgetJoinCode, withx-permission: teaching.classes.write(T-R2.16 adds itsx-resourceentry, kindclass):- 200 returns
JoinCode {code: string | null (grouped XXXX-XXXX), hint, legacy: boolean, expiresAt, maxUses, usesCount}, withCache-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: falserather 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.
- 200 returns
- 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 themaintenance <command> [flags]form that R1's per-subcommand parsing gives every command (T-R1.12):- Refuses to run unless both
JOIN_CODE_KEYandJOIN_CODE_KEY_PREVIOUSare 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_codesand writes onlycode_hash,code_ciphertextandkey_id. R5'squizzivy_maintenancegrant (T-R5.17a) covers that, and itsprivileges_test.gopins 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, asretain-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. -
unknowncounts active codes sealed under a key id that is neither key;-applyrefuses while it is not 0, because one of the two keys given is then not the pair the API runs with. The API logscurrent_key_idandprevious_key_idat startup (join code keys) for the operator to compare with the report.
- Refuses to run unless both
- getJoinCode tests:
- as the owner, a scheme-2 code returns the full code;
- a legacy row returns
nullandlegacy: 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 3over 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,-applyrefused, nothing written), and the CLI refusing a missing or equal key without repeating it. The tests lockapp.class_join_codesinside 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.mdgains 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_KEYandJOIN_CODE_KEY_PREVIOUStocmd/maintenancefrom the offline copy, withMAINTENANCE_DATABASE_URL. No workflow environment holds the keys. -
Rotate:
- set
PREVIOUSto the old key and the current key to the new one; - deploy;
- run the rekey dry run;
- run it with
-apply; - verify zero rows remain under the old id;
- unset
PREVIOUS; - deploy.
- set
- 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.
-
Generate it with
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, andhttpx/tests/ratelimit_test.go(new) server/internal/core/router/ratelimits.go-
api/openapi.yaml(x-rate-limitblocks; logout's block and 429; an 8 KiBx-max-body-bytesand 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), soratelimit.Addresscan be one part of a composite key.
- As built, each keyed bucket carries the name its contract block uses
(
-
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 onpagehide; the reason is written in the PR. -
The
x-rate-limitblocks match the registry. -
The existing tests are re-pinned to the new boundaries, never deleted:
-
TestTheEleventhPreviewInAMinuteFromOneAddressIs429becomes the 61st; - a new test: the 11th preview of one code from one address is 429;
-
TestTheThirtyFirstAttemptOnOneCodeIs429EvenAcrossAddressesbecomes the 201st; -
TestRespellingACodeDoesNotBuyAFreshAllowanceis 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 inclassroom_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 everyx-rate-limitblock equals its registry entry, bucket by bucket;logoutgains 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.sqleach 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.
- As built: T-R2.18 also rewrites NFR-S05 in place in
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-resourceis a list with one entry per id the operation takes:[{in: path | query | body, name, kind}], wherenameis 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) iskind: nonetoo. A duplicate answers 409GROUP_CONFLICTand 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 separatex-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 declaresx-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-resourceentry 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.
- Every
- 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_FAILEDnaming 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.
- an assignment for A's version (
- 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 whosepropertyNamesisUuid, names a kind, and the 12 lists name what they list. The suite is four files inserver/tests/:isolation_world(seeding and the database scans),isolation_runner(reading the contract and building requests),isolation_cases(the per-operation table) andisolation_flow(the test).harness_test.gois unchanged. The Server job starts MinIO before its end-to-end step. The harness reads the sameS3_*andIMPORT_S3_BUCKETvariables the storage tests already read, and falls back to the compose endpoint, credentials and buckets. -
Two more kinds.
version-questionandversion-recordingname 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'answersis keyed by the version question's id. The contract now says so withpropertyNames: Uuid, and+in a pointer stands for every key. The request validator does not enforcepropertyNames, so a key that is not a uuid is still a 500 (#220). -
A polymorphic id. A section unit's
idnames a question or a group, as itskindsays. 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
noneis 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 andrequestIddropped. 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?classIdandlistClassMembers: a filter or parent that does not exist lists nothing. B's own id lists B's rows. -
tagQuestionstags only the questions the caller reaches. B's own question is tagged. -
removeClassMember: removing someone who is not a member succeeds. -
saveAnswersandflushEvents: an event naming a question outside the attempt's paper is kept without it, and an answer to one is dropped. -
saveWordImportReviewstores 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
appholds 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 isword_import_drafts, where a save stores what the teacher typed.audit_logis included. -
Rows naming A's ids. Every row in schema
appthat names one of A's ids, outsideword_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'slistMy*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.
-
Files. The contract checks sit in
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.tsxweb/src/features/auth/components/SettingsPage.tsx-
web/src/app/pages/ForbiddenPage.tsx, and whatever R1 left readinguser.role web/tests/units/auth/guards.test.tsx-
web/tests/units/auth/permissions.test.ts(new)
Size: M Done when:
-
permissions.tsprovidescan(user, key)andhasWorkspace(user, workspace).- The hooks subscribe to derived booleans, not to the user object.
- The
Setis built once per user object.
-
homePathFor: the teacher or admin workspace goes to/admin, which is still the teacher console until R4; theappworkspace goes to/app. -
TeacherWorkspace(formerlyAdminOnly) requires the teacher workspace and is still mounted at/admin. -
StudentArearequiresapp. 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.roleis replaced:- JoinPage's link target;
- SettingsPage's label:
apponly 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'sroleleft inweb/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.tsxcovers Admin, Teacher, Assistant and Student fixtures carrying the 70 §4.1 sets: where each lands, which trees open, and where 403 appears.permissions.test.tscovers the helpers. - Canaries
web/tests/integration/router-chunks.test.tsandweb/tests/units/api/client.refresh.test.tsare untouched and green. -
pnpm lint,typecheck,format:check,test:unit,test:integration,buildande2eare green. - As built:
-
permissions.tsalso exportslearnsOnly(user): the student app is the user's only workspace. JoinPage's three checks and SettingsPage's label use it rather thanhasWorkspace(user, "app"), which keeps v0.7.0's behaviour: for the four built-in roles it names exactly the users whose legacyroleisstudent. It is not the server's rule. The server accepts a self-join from anyone who holdslearning.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)anduseWorkspace(workspace). Each selects a boolean from the store. -
StudentArea. It admits theappworkspace, sends the teacher or admin workspace to/admin, and answers 403 for a user with neither.homePathForsends that user to/app, so redirecting would loop. -
An Admin with "Take tests" turned on lands on
/adminand may also open/app, where v0.7.0 sent every admin away. -
Fixtures.
tests/support/fixtures.tsgainsteacherUserandassistantUserwith the 70 §4.1 sets.
-
Depends on: T-R2.1 … T-R2.17 Touches:
docs/quizzivy-spec-v0.3.mdAGENTS.mddocs/plan/{20-data-model.md,30-risks.md,50-nfr-registry.md,60-backend-architecture.md,70-redesign-overview.md}docs/setup/{deploy.md,dns.md}server/README.md
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.managefordisabled, the shared-student rule on reset and email, and the last Admin. §5.5: the docs gate onsystem.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.allreaches 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_SHAREDandFORBIDDEN, updateStudent's 403 (FORBIDDEN, andSTUDENT_SHAREDforemail), deleteUser's 403, and deleteUser's and deleteClass's409 RESOURCE_REFERENCEDwithdetails.referencedBy(T-R2.13). - §15: the prefix split;
getCurrentUser,updateCurrentUser,linkGoogle, login and Google sign-in returnCurrentUser(User's fields pluspermissionsandworkspaces, self only), andUserstays 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;codeis 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.
- §5: roles and the permission catalogue by reference to 70 §4; enforcement
from
-
AGENTS.md, "Authentication and authorization":- The "Until R2" prefix bullet is dropped, and the
x-permissionbullet is in the present tense. - Repositories take
access.Scope, and another teacher's id answers 404. - Student targets go through
app.student_like_rolesand 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
/adminalias exists until v0.9.1 (T-R3.3).
- The "Until R2" prefix bullet is dropped, and the
-
AGENTS.md, other sections:- "Redesign in progress": R2 is done.
- The repository map gains
modules/accessandshared/access. - The composition-root table gains
wiring/access.goandDeps.Principals. - "High-risk areas" gains
modules/access,users.role_idand the owner columns.
-
20-data-model.mdrecords every R2 file in merge order, with the deviations at the next free D-numbers:- reference data in a migration;
-
users.role_idreplacing the enum (expand); -
owner_idseparate fromcreated_by; -
classes.teacher_idbackfilled from the oldest active Admin, with noclasses.created_by(D-23); - reversible join-code encryption with a keyed lookup;
- the
student_like_rolesview; - the last-admin trigger, and why its function is
SECURITY DEFINER.
-
60-backend-architecture.md: eleven bounded contexts,availability(R1) andaccessamong them, and theshared/accesskernel. -
50-nfr-registry.mdgains 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.mdupdates 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.mdgains R2's rollout notes (forward only; do not rotate join codes during the deploy).server/README.mdnames 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.mdnumbers 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 andclasses.teacher_id. -
Risks.
30-risks.mdholds no PR-n rows. They live in 70 §9, which now records the R2 mitigations for PR-1, PR-2 and PR-5, and30-risks.mdpoints there. -
NFRs. NFR-S16 to S18 are new, and NFR-S05 is rewritten in place.
NFR-S07 keeps expiry,
max_usesand 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.mdalready listedJOIN_CODE_KEYamong the Fly secrets (T-R2.14a) and is unchanged. -
Beyond the Touches list.
17-word-import.mdand18-word-import-ux.mdgive the capabilities API as/teacher/imports/capabilities, and75-r5.mddescribes the last-admin lock asFOR NO KEY UPDATEin aSECURITY DEFINERfunction. -
Not changed here. The contract's
Roledescription still promises a third role "later". T-R3.1 removes theRoleschema.
-
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
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, withdocker 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.goldenagainst 70 §4.1. - The old-tab check:
- The v0.7.0 web build (
git checkout v0.7.0 -- web, in a scratch worktree) is served withvite previewagainst the R2 API. - The owner signs in and opens the dashboard, tests, the builder, the bank, media, imports, a class and grading.
- A student opens and saves an attempt.
- Every call succeeds through the alias, and
legacy_admin_pathis logged.
- The v0.7.0 web build (
- 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.sqlholds 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-apiwithTEST_DESTRUCTIVE=1, goose up, down and up, the sevenpnpmsteps,pnpm e2e:liveand the deck check.-
make test-apifirst failed four runs in four inTestAKeyPairTheAPIDoesNotRunWithIsRefused(#221). It deadlocked with another package's enrol. The rekey tests now takeEXCLUSIVEonapp.class_join_codesas 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/mebefore the cookie refresh. - The API logged
legacy_admin_path102 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
00053and seeded with v0.7.0's seed, plus one legacy join code, then again aftergoose up(2.8 s). - Section A was identical in both passes, once the new
rolestable 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.
- It ran on a database migrated to
-
Before
main, done on 2026-10-03 once Thuong delegated the release and set up the Fly and Neon CLIs:-
permissions.goldenwas 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_KEYwas generated and staged as a Fly secret, with a copy left in a file for Thuong's password manager.
-
-
70 §8 applies in full. For R2 specifically:
-
Prerequisites before merging to
main.- The
JOIN_CODE_KEYFly secret is set; the deploy preflight fails otherwise. - An offline copy of the key is stored.
-
JOIN_CODE_KEY_PREVIOUSis unset.
- The
-
Neon branch rehearsal (mandatory). On a branch of production, run
goose upasquizzivy_migrate, thendocs/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_atper 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.indisvalidtrue 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 uptook 10.7 s for00054to00078; the slowest file was00054at 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_progresspassed its deadline on 2026-09-22.
-
- no user without
- 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.
- 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.
-
Smoke on production:
-
/livezand/healthzanswer, 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/previewwith an existing legacy code answers. -
GET /auth/mecarriespermissionsandworkspaces. - A tab left on v0.7.0 still works, and
legacy_admin_pathappears in the logs.
-
-
Watch for 24 hours:
- 401 and 403 rates: a mis-mapped permission shows as
FORBIDDENon a staff operation; -
legacy_admin_pathcounts, which should fall; - Neon compute hours against the week before (the principal cache; PR-7).
- 401 and 403 rates: a mis-mapped permission shows as
-
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.
-
Tag and close. Tag
v0.8.0, back-mergemain→developby PR (the #151 pattern), and close the epic. -
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_pathhits near zero. R1's new-version prompt covers the tabs that remain.
-
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
mustChangePasswordon the server. Today only the SPA redirects a user with a temporary password; the API serves them in full.RequirePermissioncould refuse every operation butselfuntil 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 proposes403 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 keepscope.alluntil 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_SHAREDunless 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 ofpeople.users.manageis 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 onlylearning.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.
Generated from the repository's documentation on every push to develop.
Start here
Product
Implementation plan
- 00 Architecture overview
- 10 Phase 0: Scaffold and contract
- 11 Phase 1: Auth, join, shells
- 12 Phase 2: Admin authoring
- 13 Phase 3: Assign and take
- 14 Phase 4: Grade and results
- 15 Phase 5: Hardening
- 16 Phase 4.5: The student at a desk
- 17 Word import: milestone plan
- 18 Word import: teacher workflow
- 19 Versioned content contract
- 20 Data model
- 30 Risk register
- 40 Open items
- 50 Non-functional requirements
- 60 Backend architecture
- 91 September 2026 issue remediation
- 92 Student layouts and interaction rules
Redesign (Phase R)
- 70 Redesign overview
- 71 R1: Foundations (v0.7.0)
- 72 R2: Access (v0.8.0)
- 73 R3: Student console (v0.9.0)
- 74 R4: Teacher workspace (v0.10.0)
- 74d D4: Deck update (v0.11.0)
- 74e E1: Moving a question across a group
- 75 R5: Admin console and email (v0.12.0)
- 76 R6: Question types and scoring (v0.13.0)
- 77 R7: Collaboration (v0.14.0)
- 78 R8: Schedule (v0.15.0)
- 79 R9: Insights (v0.16.0)
- 80 R10: Learn (v0.17.0)
- 81 R11: Landing, leads, v1.0 (v1.0.0)
Setup and operations
- CI
- Deploying
- DNS for quizzivy.com
- Google OAuth client
- Production operations
- Cloudflare R2 bucket
- Word import worker
- R4 rehearsal on a Neon branch
Design
Team
- Agent team
- Verification environment
- Team ledger
- R4 readiness
- R4 verification strategy
- T-R4.51 browser verification
Code READMEs