Skip to content

Spec 10 Integrity Monitoring

github-actions[bot] edited this page Oct 15, 2026 · 4 revisions

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next

10. Integrity monitoring (proctoring-lite)

First-class requirement. Lives in src/features/integrity/, consumed by take-test (capture) and attempts (review).

10.1 Signals

Every signal produces an append-only event { kind, occurredAt, clientSeq, questionId?, meta? }.

Kind Source Notes
tab_hidden / tab_visible document.visibilitychange Primary tab-switch signal; pair to compute away-duration.
window_blur / window_focus window blur/focus Catches alt-tab to another application — the Visibility API alone misses this.
fullscreen_enter / fullscreen_exit Fullscreen API Only when requireFullscreen is on.
copy / cut / paste listeners on document Always recorded; blocked only when blockCopyPaste is on.
context_menu contextmenu Recorded; blocking off by default (it breaks assistive tooling).
network_offline / network_online navigator.onLine + fetch failures Distinguishes cheating from bad wifi. Matters for fairness.
audio_play / audio_ended / audio_blocked / audio_seek player (§11.4) Gives the teacher listening behaviour. audio_seek is a jump the player put back on a recording that may not be skipped. The events only report: a question's play is counted by POST /app/attempts/:id/audio-play.
start server-side The attempt began. Written once, under the first session, at startedAt; it only gives that session a first row, so a flush it makes later is known to the server. It is no evidence that the tab is open.
resume server-side Re-entry into an in_progress attempt: reload, crash, device change.
session_takeover server-side Attempt opened in another tab/device. The teacher's timeline reads "Resumed in another tab or device". A tab that still holds the attempt's session never causes one: Continue in it reads the attempt with that session and resumes nothing (§9).
page_hide pagehide Best-effort final flush via navigator.sendBeacon.

Deliberately excluded: devtools-detection heuristics (window-size deltas, debugger timing). They false-positive on zoom, split-screen, and extensions, and are bypassed in seconds. Do not implement them.

Away-duration over count. A 2-second blur is a notification; a 90-second blur is a search. Store both endpoints so duration is visible, and only count a strike when an away episode exceeds minAwayMs (default 3000ms).

10.2 Student-facing behaviour

Announced, visible, never silent.

  • The intro page states the active rules in plain Vietnamese before starting, generated from the stored policy (§9). If requireFullscreen is on, the intro's click that starts or resumes the attempt is what enters fullscreen (browsers require a gesture): "Bắt đầu" in the "Bắt đầu ngay?" dialog, or the intro's "Tiếp tục làm bài". Home's "Tiếp tục làm bài" (§9) goes straight to the paper and does not ask for fullscreen: the engine opens outside it and shows the fullscreen bar described below. A browser with no fullscreen is told so and takes the test as normal.

  • Each counted absence opens the deck's alertdialog when the student returns: "Bạn vừa rời trang làm bài", one body and one button, "Quay lại bài làm". The body says where the student stands and what happens next, and only what the server does:

    • within the allowance, "Lần này được tính là lần n trong m lần được phép…";
    • past it under flag, that the teacher has been told and the answers are safe (D8);
    • past it under warn, the count and that the answers are safe, never naming the teacher;
    • with no absence allowed (-1), that leaving is not allowed, with no number;
    • under auto_submit, that the test is submitted after the allowance, and at the last one that the next absence submits it;
    • with no limit, that leaving is recorded, once a sitting.

    The body does not state the seconds away or that the timer keeps running: the timer is on screen. The dialog does not close from the backdrop and has no close button; the button and Esc acknowledge it.

  • A small persistent indicator shows remaining strikes when a limit is set: after the save line from 768, in the strip under the header below it (§9). Past the allowance it reads "Giáo viên đã được báo" under flag. maxFocusLoss = 0 retains the unlimited default; -1 permits no counted departure; positive values permit that many departures.

  • onLimitExceeded: warn = dialog only; flag = attempt marked for the admin, student told; auto_submit = immediate submission on exceeding the count, retaining answers for grading and recording the violation. There is no cancellation or extra strike. The final answer/event batch is saved before the server grades and closes. While offline, the attempt is locked locally, pending answers are retained and submission is retried with a visible notice.

  • Fullscreen exit shows a bar under the header with a "Quay lại toàn màn hình" button, or one sentence on a browser with no fullscreen. It is a bar, never a dialog. Never trap the student: Esc always works and there is always a visible way to leave and submit.

  • Once the attempt is submitted on an assignment that requires fullscreen, by the student, the timer or an auto-submit, the engine leaves fullscreen itself. That exit is the app's, so from then on no fullscreen_enter or fullscreen_exit is recorded, the student's own on the submitted screen included. A paper that is locked without a submit (taken over, already ended) stays as it is.

  • Under blockCopyPaste, a copy, cut or paste is stopped and recorded, and a danger toast says "Sao chép và dán đã bị tắt trong bài này": one toast, however often.

  • Under five minutes the timer takes the danger tones (D8). A polite live region says the time left once when it passes 5:00 and once when it passes 1:00.

  • While the maintenance or sign-in-again overlay (§9) covers the engine, no focus change is recorded. An away episode already open when one appears is dropped, so time on an overlay never counts against the student.

  • The engine adopts the server's deadline from every autosave (deadlineAt). It also adopts it from a timer submit that came too early (409 DEADLINE_NOT_REACHED; the server allows 5 s of grace). So a teacher's extension, or a maintenance window's, reaches an open attempt on its next autosave.

10.3 Policy defaults (per assignment)

requireFullscreen: false, blockCopyPaste: true, maxFocusLoss: 0, onLimitExceeded: 'flag'. Conservative on purpose — the teacher opts into stricter modes per test.

10.4 Teacher review

What the teacher sees, by D8 (R4). One tone marks "Flagged" everywhere, the danger tokens (features/integrity/tones.ts): the dashboard's figure and its Recent activity marker, the assignment's Flagged count, the roster's row, the attempt sheet and the attempt page, and the flagged notification.

  • The Flagged count on Assignment detail's progress card (T-D4.22) counts the flagged attempts that are not voided: exactly the rows its filter shows, over the whole roster whatever the search. A flagged attempt that is later voided leaves the count.

  • The roster (the assignment's Students tab) shows Focus lost as "—" or "{n}×", in danger ink after a flag icon once the attempt is flagged, on a row in the danger's soft tone. Where the column is hidden, the same mark sits under the student's name.

  • The attempt sheet draws a compact timeline from the start (info) to "Submitted" (success, once handed in). Leaving the test and an automatic submission are danger, with the time away on the label ("Left fullscreen · 14 s"); every other kind keeps a neutral dot.

  • The rules keep §10.5's honest limits beside the integrity policy in the assignment wizard.

/teacher/attempts/:id → Integrity tab: ordered timeline with event kind, wall-clock time, offset from attempt start, duration for paired events, and the question on screen. Summary strip: total away-time, away episodes ≥ minAwayMs, paste count, resume count, audio replays. Neutral text — no red banners, no "CHEATING DETECTED". The teacher judges; the app reports. An audio play with no recorded end shows no duration and is not called ongoing.

Sessions are ordered by their earliest receivedAt, with sessionId breaking ties. Within a session, start and resume come first and session_takeover last. Within each priority, two events with different non-null clientSeq values compare by clientSeq; otherwise by occurredAt and then event id. This order is not a guarantee of wall-clock chronology across tabs or sessions.

The timeline draws no row for start: the synthetic "Started" row already marks it, and an attempt whose only recorded event is start shows the empty state.

10.5 Honest limits — say this in the UI help text

Browser monitoring detects this tab losing focus. It cannot see a second device, a phone beside the laptop, a person in the room, or a printed sheet. The signals are evidence for a conversation, not proof. Do not build auto-zero or auto-ban features that assume otherwise.

10.6 Client implementation

  • One useIntegrityMonitor hook owns every DOM listener that records a signal or stops one, registered and torn down in a single useEffect. Four signals are recorded outside it, by a question's audio player through recordAudioEvent (§11.4): audio_play from its play callback, audio_ended when playback reaches the end, audio_blocked when the browser refuses to start it, and audio_seek when the player puts a jump back. None changes a play count. A shared recording's play is written by the server (§11.5); the browser records its end and block with scope: group and recordingId in meta, without a question id. A known end duration is elapsed device wall time since the latest start, clamped to zero if the clock moved backwards; it is not total listening time. A resume overwrites that start; an end or block clears it. The timeline pairs the latest open play once, by question id first or otherwise by recording id. It accepts numeric durationMs from 0 through 86,400,000, truncating fractions to whole milliseconds; missing, malformed or out-of-range durations fall back to the nonnegative span between event clocks. Delayed play counts or clock skew can leave events unpaired. Overlapping sessions have no play-id matching. Other hooks listen to some of the same events and record and stop nothing: useClipboardNotice has its own copy, cut and paste listeners on document, only to show §10.2's toast; useSaveStatus listens to online and offline on window, only for the save line (§9); useVersionWatch listens to visibilitychange on document, only to look for a newer build (§9). The monitor records and stops whether or not they run.
  • Events buffer in memory + sessionStorage, flush with the autosave batch, and immediately on pagehide via sendBeacon. Leaving the engine while the attempt goes on sends what is buffered through the same beacon and keeps the sequence in sessionStorage, so a return in the same session numbers on from it; the buffer is forgotten once the attempt has ended. A tab that begins a new session on an attempt whose stored buffer belongs to another session still holding events sends those events first, through the bearer POST /app/attempts/:id/events under their own session id, which the server accepts as one the attempt has recorded events under, in slices of at most 500, one after another; a refusal or a failure drops the rest quietly, and the new session is not held back. No request carries more than the contract's 500 events: an autosave or the pagehide beacon takes the oldest 500 and leaves the rest buffered, and an autosave that leaves events schedules the next one. The pagehide beacon takes the oldest events that fit about 60 kB, because sendBeacon refuses a body above 64 KiB, and removes them from the buffer only when the browser took them. Submitting, manually or by auto_submit, flushes again while events wait, so none is dropped when the attempt ends. A page that is unloading, not one that may return from the back/forward cache, records nothing after page_hide, so a reload leaves no open away episode.
  • clientSeq uses an attempt-relative millisecond offset, calibrated by getAttempt from startedAt and serverTime, and is never below the tab's stored next sequence. It increases within a tab. The offset component caps at 2,000,000,000; subsequent local increments can exceed that value. Response latency, clock changes and local increments can cause reordering or collisions across tabs, even for events in different milliseconds. Cross-tab chronology and uniqueness are not guaranteed. Older builds still send counters from zero, which the server accepts. The append-only dedup key remains (attempt, session, clientSeq); a repeated key is treated as a retry and not stored. A clientSeq above 2,147,483,647 or below 0 answers 400 on every path that takes events, the beacon included, and writes nothing.
  • A flush names the session it was written under. The server accepts the attempt's current session, or one that began with a start or resume and so has a row of its own (a superseded tab finishing its flush), and answers 403 FORBIDDEN for any other id without writing. A flush never changes which session is current.
  • Failed background event flushes do not block answering or manual submission. The immediate auto_submit policy retries its final answer/event batch before confirming submission so the violation is retained with the answers.
  • An event transport failure alone does not block input. The explicit auto_submit policy locks further answering once exceeded, while preserving and retrying the pending answers.

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next


Generated from docs/quizzivy-spec-v0.3.md. Edit that file in the repository; changes made on this page are overwritten.

Home

Start here

Product

Implementation plan

Redesign (Phase R)

Setup and operations

Design

Team

Code READMEs

Clone this wiki locally