Skip to main content

sumchain_primitives/
education.rs

1//! SRC-817 / SRC-818 Education-LMS suite — Phase 1 wire types only.
2//!
3//! This module defines the canonical bincode wire shapes and
4//! domain-separated commitment helpers for the education suite. It does
5//! NOT implement any executor behavior, storage, mempool admission,
6//! RPC, activation gate, or fee/nonce semantics — those are Phase 2+.
7//!
8//! Privacy model (frozen Phase 0 baseline, see
9//! `docs/SRC-81X-EDUCATION-SUITE.md`):
10//! - The submission itself happens in SNIP; the chain records only a
11//!   submission *receipt* (commitments + refs + audit fields).
12//! - The public chain tx sender is an authorized submitter
13//!   (institution / sponsor / relayer / LMS service account) and is
14//!   NEVER the student identity. There is no `submitter` field in any
15//!   wire payload — it is derived from the signed tx `from` in Phase 2.
16//! - Student identity is represented only by a scoped, salted,
17//!   non-reversible `student_commitment`; no raw student address, no
18//!   PII, no raw grades/submissions/answer keys on chain.
19//! - Every `SnipRef` carried in chain state is paired with a
20//!   `ContentAccessPolicy` (`ManagedSnipRef`) — no bare/dangling refs.
21
22use serde::{Deserialize, Serialize};
23
24use crate::Address;
25
26// ───────────────────────── Bounded-length constants ─────────────────────────
27//
28// Enforced by validators in Phase 2; asserted present by the Phase 1
29// fixtures so the limits are part of the locked wire contract.
30
31/// Max bytes for a catalog `course_code` (e.g. "CS101").
32pub const MAX_COURSE_CODE_BYTES: usize = 32;
33/// Max bytes for a catalog `department` string.
34pub const MAX_DEPARTMENT_BYTES: usize = 64;
35/// Max bytes for an offering `term` coordinate (e.g. "2026FA").
36pub const MAX_TERM_BYTES: usize = 32;
37/// Max bytes for an offering `section` coordinate (e.g. "A").
38pub const MAX_SECTION_BYTES: usize = 32;
39/// Max bytes for a plaintext course title.
40pub const MAX_TITLE_BYTES: usize = 256;
41/// Max bytes for the opaque per-operation `data` payload.
42pub const MAX_EDU_OP_DATA_BYTES: usize = 64 * 1024;
43/// Max bytes for any optional memo/metadata field.
44pub const MAX_MEMO_BYTES: usize = 1024;
45
46// ───────────────────────── Domain-separation tags ───────────────────────────
47
48const DOMAIN_CATALOG_ID: &[u8] = b"SRC817-CATALOG:v1:";
49const DOMAIN_OFFERING_ID: &[u8] = b"SRC818-OFFERING:v1:";
50const DOMAIN_STUDENT_COMMITMENT: &[u8] = b"SRC818-STUDENT:v1:";
51const DOMAIN_SUBMISSION_COMMITMENT: &[u8] = b"SRC818-SUBMISSION:v1:";
52const DOMAIN_GRADE_COMMITMENT: &[u8] = b"SRC818-GRADE:v1:";
53
54// ───────────────────────── Envelope ─────────────────────────────────────────
55
56/// Which education standard an `EducationTxData` targets. Append-only:
57/// future SRC-81X standards (810 transcript, 811 diploma, …) get new
58/// discriminants here, never a new `TxPayload` variant.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[repr(u8)]
61pub enum EducationStandard {
62    CourseCatalog = 0,
63    CourseOffering = 1,
64}
65
66/// Unified education transaction envelope. Carried by
67/// `TxPayload::Education` (the single education `TxPayload` variant).
68///
69/// `operation` is an explicit `u16` code (not a Rust enum variant tag)
70/// so the documented sparse operation codes are the wire truth and are
71/// stable regardless of Rust enum declaration order. See `catalog_op`
72/// and `offering_op`.
73///
74/// `recipient` keeps envelope parity with the other transaction
75/// families (`DocClass`/`Employment` etc.). Education v1 operations have
76/// no soulbound/token target, so `recipient` is set to `Address::ZERO`
77/// (the repo's existing no-target convention). It is reserved for a
78/// future operation that has a genuine target.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct EducationTxData {
81    pub standard: EducationStandard,
82    pub operation: u16,
83    pub data: Vec<u8>,
84    pub recipient: Address,
85}
86
87/// SRC-817 catalog operation codes (documented, wire-authoritative).
88pub mod catalog_op {
89    pub const CREATE_CATALOG_ENTRY: u16 = 0;
90    pub const UPDATE_CATALOG_ENTRY: u16 = 1;
91    pub const PUBLISH_CATALOG_CONTENT: u16 = 2;
92    pub const DEPRECATE_CATALOG_ENTRY: u16 = 3;
93    pub const SUPERSEDE_CATALOG_ENTRY: u16 = 4;
94    pub const ARCHIVE_CATALOG_ENTRY: u16 = 5;
95}
96
97/// SRC-818 offering operation codes (documented, wire-authoritative).
98pub mod offering_op {
99    pub const CREATE_OFFERING: u16 = 0;
100    pub const UPDATE_OFFERING: u16 = 1;
101    pub const PUBLISH_CONTENT: u16 = 2;
102    pub const ADD_ASSESSMENT: u16 = 3;
103    pub const UPDATE_ASSESSMENT: u16 = 4;
104    pub const OPEN_ENROLLMENT: u16 = 5;
105    pub const CLOSE_ENROLLMENT: u16 = 6;
106    pub const LINK_ENROLLMENT: u16 = 7;
107    pub const SUBMIT_ASSIGNMENT: u16 = 8;
108    pub const SUBMIT_EXAM: u16 = 9;
109    pub const GRADE_SUBMISSION: u16 = 10;
110    pub const FINALIZE_GRADE: u16 = 11;
111    pub const FINALIZE_COURSE: u16 = 12;
112    pub const ARCHIVE_OFFERING: u16 = 13;
113    pub const SUSPEND_OR_CANCEL_OFFERING: u16 = 14;
114}
115
116// ───────────────────────── Shared SNIP-ref + access policy ──────────────────
117
118/// Off-chain content pointer. No URL, no plaintext, no keys. The actual
119/// object lives in SNIP; the chain holds only this pointer + a
120/// commitment + an access policy.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct SnipRef {
123    pub content_root: [u8; 32],
124    pub snip_file_id: Option<[u8; 32]>,
125    pub size_bytes: u64,
126    pub schema_version: u32,
127}
128
129/// Audience class a `ContentAccessPolicy` grants access to.
130/// `IndividualStudent` carries a scoped `student_commitment` (never a
131/// raw address). It is **provisional**: legal/privacy must confirm a
132/// per-student commitment in on-chain policy is FERPA-safe, otherwise
133/// individual targeting moves entirely into SNIP ACL and chain policy
134/// stays audience-class-only. See `docs/SRC-81X-EDUCATION-SUITE.md`
135/// §3.2 / §6 Q9. Hard Phase-1-blocking question (tracked in docs).
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub enum AccessAudience {
138    Public,
139    EnrolledStudents,
140    InstructorsOnly,
141    StaffOnly,
142    IndividualStudent([u8; 32]),
143}
144
145/// Time-windowed access policy. The chain stores the schedule; SNIP
146/// enforces actual private object access within the window.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct ContentAccessPolicy {
149    pub opens_at: Option<u64>,
150    pub closes_at: Option<u64>,
151    pub grace_until: Option<u64>,
152    pub audience: AccessAudience,
153    pub revoke_on_course_archive: bool,
154}
155
156/// A `SnipRef` is never carried bare in education chain state — it is
157/// always paired with its `ContentAccessPolicy`. Using this wrapper in
158/// every payload makes a dangling content ref structurally impossible.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct ManagedSnipRef {
161    pub snip_ref: SnipRef,
162    pub access_policy: ContentAccessPolicy,
163}
164
165// ───────────────────────── Status / role enums ──────────────────────────────
166//
167// `#[repr(u8)]` documented discriminants. These are not serialized as
168// bincode enum tags on the wire where a stable code matters; payloads
169// that persist a status do so via the explicit numeric value.
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[repr(u8)]
173pub enum CatalogStatus {
174    Draft = 0,
175    Active = 1,
176    Deprecated = 2,
177    Archived = 3,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[repr(u8)]
182pub enum OfferingStatus {
183    Draft = 0,
184    Active = 1,
185    EnrollmentClosed = 2,
186    Completed = 3,
187    Archived = 4,
188    Suspended = 5,
189    Cancelled = 6,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[repr(u8)]
194pub enum AssessmentKind {
195    Assignment = 0,
196    Exam = 1,
197    Quiz = 2,
198    Project = 3,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[repr(u8)]
203pub enum CourseRole {
204    InstitutionAdmin = 0,
205    Instructor = 1,
206    TeachingAssistant = 2,
207    Grader = 3,
208    Student = 4,
209    Auditor = 5,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[repr(u8)]
214pub enum CourseLevel {
215    Undergraduate = 0,
216    Graduate = 1,
217    Doctoral = 2,
218    Professional = 3,
219    Continuing = 4,
220    Other = 5,
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224#[repr(u8)]
225pub enum ContentKind {
226    Syllabus = 0,
227    LectureMaterial = 1,
228    Reading = 2,
229    Resource = 3,
230    Other = 4,
231}
232
233/// Action carried by the combined Suspend/Cancel offering op
234/// (`offering_op::SUSPEND_OR_CANCEL_OFFERING`). `Suspend` is reversible
235/// (`Resume`); `Cancel` is terminal.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[repr(u8)]
238pub enum SuspendCancelAction {
239    Suspend = 0,
240    Resume = 1,
241    Cancel = 2,
242}
243
244// ───────────── Wire code <-> helper-enum conversions ───────────────────────
245//
246// Payload structs carry stable `u8` *code* fields on the wire (NOT
247// these Rust enums — `#[repr(u8)]` does not make serde/bincode encode
248// an enum as one byte; bincode tags enums as a u32 ordinal). These
249// enums are ergonomic helpers only. `EnumVariant as u8` yields the
250// code; `TryFrom<u8>` validates an inbound code. The one-byte wire
251// encoding is locked by the fixtures.
252
253/// Returned by `TryFrom<u8>` when a wire code has no enum variant.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub struct InvalidEducationCode(pub u8);
256
257impl core::fmt::Display for InvalidEducationCode {
258    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
259        write!(f, "invalid education wire code: {}", self.0)
260    }
261}
262
263impl TryFrom<u8> for CourseLevel {
264    type Error = InvalidEducationCode;
265    fn try_from(v: u8) -> Result<Self, Self::Error> {
266        Ok(match v {
267            0 => CourseLevel::Undergraduate,
268            1 => CourseLevel::Graduate,
269            2 => CourseLevel::Doctoral,
270            3 => CourseLevel::Professional,
271            4 => CourseLevel::Continuing,
272            5 => CourseLevel::Other,
273            other => return Err(InvalidEducationCode(other)),
274        })
275    }
276}
277
278impl TryFrom<u8> for ContentKind {
279    type Error = InvalidEducationCode;
280    fn try_from(v: u8) -> Result<Self, Self::Error> {
281        Ok(match v {
282            0 => ContentKind::Syllabus,
283            1 => ContentKind::LectureMaterial,
284            2 => ContentKind::Reading,
285            3 => ContentKind::Resource,
286            4 => ContentKind::Other,
287            other => return Err(InvalidEducationCode(other)),
288        })
289    }
290}
291
292impl TryFrom<u8> for AssessmentKind {
293    type Error = InvalidEducationCode;
294    fn try_from(v: u8) -> Result<Self, Self::Error> {
295        Ok(match v {
296            0 => AssessmentKind::Assignment,
297            1 => AssessmentKind::Exam,
298            2 => AssessmentKind::Quiz,
299            3 => AssessmentKind::Project,
300            other => return Err(InvalidEducationCode(other)),
301        })
302    }
303}
304
305impl TryFrom<u8> for CourseRole {
306    type Error = InvalidEducationCode;
307    fn try_from(v: u8) -> Result<Self, Self::Error> {
308        Ok(match v {
309            0 => CourseRole::InstitutionAdmin,
310            1 => CourseRole::Instructor,
311            2 => CourseRole::TeachingAssistant,
312            3 => CourseRole::Grader,
313            4 => CourseRole::Student,
314            5 => CourseRole::Auditor,
315            other => return Err(InvalidEducationCode(other)),
316        })
317    }
318}
319
320impl TryFrom<u8> for SuspendCancelAction {
321    type Error = InvalidEducationCode;
322    fn try_from(v: u8) -> Result<Self, Self::Error> {
323        Ok(match v {
324            0 => SuspendCancelAction::Suspend,
325            1 => SuspendCancelAction::Resume,
326            2 => SuspendCancelAction::Cancel,
327            other => return Err(InvalidEducationCode(other)),
328        })
329    }
330}
331
332// ───────────────────────── Operation payloads ──────────────────────────────
333//
334// Phase 1 defines the COMPLETE SRC-817/818 operation payload wire
335// surface (this section + the next). Three payloads
336// (CreateCatalogEntry, CreateOffering, SubmitAssignmentReceipt) also
337// carry canonical-byte fixtures as representative wire locks; the rest
338// are fully defined here so Phase 2 cannot wire-break the envelope.
339
340/// SRC-817 `CreateCatalogEntry` (operation = `catalog_op::CREATE_CATALOG_ENTRY`).
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct CreateCatalogEntryData {
343    pub catalog_id: [u8; 32],
344    pub institution_id: [u8; 32],
345    pub department: String,
346    pub course_code: String,
347    /// Plaintext title (default) — set exactly one of title/commitment.
348    pub course_title: Option<String>,
349    pub title_commitment: Option<[u8; 32]>,
350    /// `CourseLevel` code (see `CourseLevel as u8` / `TryFrom<u8>`).
351    pub course_level: u8,
352    /// Plaintext credit hours (default) — or commitment for
353    /// confidential programs. Set exactly one.
354    pub credit_hours: Option<u16>,
355    pub credit_commitment: Option<[u8; 32]>,
356    /// Count + root over prerequisite catalog_ids (bounded-collection
357    /// rule: no inline unbounded Vec on the primary record).
358    pub prerequisites_count: u32,
359    pub prerequisites_root: Option<[u8; 32]>,
360    pub version: u32,
361    pub supersedes: Option<[u8; 32]>,
362    pub nonce: u64,
363}
364
365/// SRC-818 `CreateOffering` (operation = `offering_op::CREATE_OFFERING`).
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367pub struct CreateOfferingData {
368    pub offering_id: [u8; 32],
369    /// REQUIRED ref to a non-Archived/Deprecated SRC-817 catalog entry.
370    pub catalog_id: [u8; 32],
371    pub term: String,
372    pub section: String,
373    /// Academic calendar — public, non-PII; bounds the default student
374    /// submission window and content access window.
375    pub instruction_start_at: u64,
376    pub instruction_end_at: u64,
377    pub final_grade_submission_deadline: u64,
378    pub nonce: u64,
379}
380
381/// SRC-818 `SubmitAssignment` / `SubmitExam` **receipt** payload
382/// (operation = `offering_op::SUBMIT_ASSIGNMENT` / `SUBMIT_EXAM`).
383///
384/// This is a receipt, not the work. There is NO `submitter` field —
385/// the authorized submitter is the signed tx `from`, recorded in the
386/// stored record in Phase 2. No raw student address, no raw work.
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct SubmitAssignmentReceiptData {
389    pub offering_id: [u8; 32],
390    pub assessment_id: [u8; 32],
391    /// Scoped, salted, non-reversible pseudonym — never a raw address.
392    pub student_commitment: [u8; 32],
393    pub submission_commitment: [u8; 32],
394    /// The submitted work lives in SNIP, referenced + access-policed.
395    pub work: ManagedSnipRef,
396    pub attempt: u16,
397    /// SRC-812 enrollment credential proving student authorization.
398    pub enrollment_ref: [u8; 32],
399    /// Optional commitment over a student-scoped signature / SNIP
400    /// submission authorization proven inside the private payload.
401    /// Optional in Phase 1; mandatory-vs-optional enforcement is a
402    /// Phase 2 executor/policy decision tied to legal Q9.
403    pub student_auth_commitment: Option<[u8; 32]>,
404}
405
406// ───────────────────────── Full op payload wire surface ────────────────────
407//
408// Phase 1 defines the complete SRC-817/818 operation payload wire
409// surface. Only the three payloads above carry canonical-byte fixtures
410// (representative); these additional payloads complete the wire
411// contract so Phase 2 cannot wire-break the envelope. No executor
412// behavior is implied by any of these — they are wire types only.
413
414// ---- SRC-817 catalog ----
415
416/// SRC-817 `UpdateCatalogEntry` (`catalog_op::UPDATE_CATALOG_ENTRY`).
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
418pub struct UpdateCatalogEntryData {
419    pub catalog_id: [u8; 32],
420    pub course_title: Option<String>,
421    pub title_commitment: Option<[u8; 32]>,
422    /// `CourseLevel` code, if changing it.
423    pub course_level: Option<u8>,
424    pub credit_hours: Option<u16>,
425    pub credit_commitment: Option<[u8; 32]>,
426    pub nonce: u64,
427}
428
429/// SRC-817 `PublishCatalogContent` (`catalog_op::PUBLISH_CATALOG_CONTENT`).
430/// Every ref is a `ManagedSnipRef` (ref + mandatory access policy).
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432pub struct PublishCatalogContentData {
433    pub catalog_id: [u8; 32],
434    pub description_ref: Option<ManagedSnipRef>,
435    pub learning_outcomes_ref: Option<ManagedSnipRef>,
436    pub default_syllabus_ref: Option<ManagedSnipRef>,
437    pub default_assessment_policy_ref: Option<ManagedSnipRef>,
438    pub nonce: u64,
439}
440
441/// SRC-817 `DeprecateCatalogEntry` (`catalog_op::DEPRECATE_CATALOG_ENTRY`).
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443pub struct DeprecateCatalogEntryData {
444    pub catalog_id: [u8; 32],
445    pub nonce: u64,
446}
447
448/// SRC-817 `SupersedeCatalogEntry` (`catalog_op::SUPERSEDE_CATALOG_ENTRY`).
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct SupersedeCatalogEntryData {
451    pub old_catalog_id: [u8; 32],
452    pub new_catalog_id: [u8; 32],
453    pub nonce: u64,
454}
455
456/// SRC-817 `ArchiveCatalogEntry` (`catalog_op::ARCHIVE_CATALOG_ENTRY`).
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458pub struct ArchiveCatalogEntryData {
459    pub catalog_id: [u8; 32],
460    pub nonce: u64,
461}
462
463// ---- SRC-818 offering ----
464
465/// SRC-818 `UpdateOffering` (`offering_op::UPDATE_OFFERING`).
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467pub struct UpdateOfferingData {
468    pub offering_id: [u8; 32],
469    pub term: Option<String>,
470    pub section: Option<String>,
471    pub instruction_start_at: Option<u64>,
472    pub instruction_end_at: Option<u64>,
473    pub final_grade_submission_deadline: Option<u64>,
474    pub nonce: u64,
475}
476
477/// SRC-818 `PublishContent` (`offering_op::PUBLISH_CONTENT`).
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479pub struct PublishContentData {
480    pub offering_id: [u8; 32],
481    pub content_id: [u8; 32],
482    /// `ContentKind` code (see `ContentKind as u8` / `TryFrom<u8>`).
483    pub kind: u8,
484    pub item: ManagedSnipRef,
485    pub content_commitment: [u8; 32],
486    pub nonce: u64,
487}
488
489/// SRC-818 `AddAssessment` (`offering_op::ADD_ASSESSMENT`).
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct AddAssessmentData {
492    pub offering_id: [u8; 32],
493    pub assessment_id: [u8; 32],
494    /// `AssessmentKind` code (see `AssessmentKind as u8` / `TryFrom<u8>`).
495    pub kind: u8,
496    pub instructions: ManagedSnipRef,
497    pub spec_commitment: [u8; 32],
498    pub opens_at: u64,
499    pub due_at: u64,
500    pub max_attempts: u16,
501    pub weight_bps: u16,
502    pub answer_key_commitment: Option<[u8; 32]>,
503    pub answer_key_access: Option<ContentAccessPolicy>,
504    pub nonce: u64,
505}
506
507/// SRC-818 `UpdateAssessment` (`offering_op::UPDATE_ASSESSMENT`).
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509pub struct UpdateAssessmentData {
510    pub offering_id: [u8; 32],
511    pub assessment_id: [u8; 32],
512    pub opens_at: Option<u64>,
513    pub due_at: Option<u64>,
514    pub max_attempts: Option<u16>,
515    pub weight_bps: Option<u16>,
516    pub instructions: Option<ManagedSnipRef>,
517    pub nonce: u64,
518}
519
520/// SRC-818 `OpenEnrollment` (`offering_op::OPEN_ENROLLMENT`).
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522pub struct OpenEnrollmentData {
523    pub offering_id: [u8; 32],
524    pub nonce: u64,
525}
526
527/// SRC-818 `CloseEnrollment` (`offering_op::CLOSE_ENROLLMENT`).
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529pub struct CloseEnrollmentData {
530    pub offering_id: [u8; 32],
531    pub nonce: u64,
532}
533
534/// SRC-818 `LinkEnrollment` (`offering_op::LINK_ENROLLMENT`). Binds a
535/// scoped `student_commitment` (never a raw address) backed by an
536/// SRC-812 enrollment credential reference.
537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
538pub struct LinkEnrollmentData {
539    pub offering_id: [u8; 32],
540    pub student_commitment: [u8; 32],
541    pub enrollment_ref: [u8; 32],
542    pub nonce: u64,
543}
544
545/// SRC-818 `SubmitExam` receipt (`offering_op::SUBMIT_EXAM`). The wire
546/// shape is identical to the assignment receipt — only the envelope
547/// `operation` code differs (8 vs 9). Modeled as a type alias so the
548/// two cannot drift apart.
549pub type SubmitExamReceiptData = SubmitAssignmentReceiptData;
550
551/// SRC-818 `GradeSubmission` (`offering_op::GRADE_SUBMISSION`). Only a
552/// grade *commitment* + optional encrypted-feedback ref — never the
553/// raw grade.
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555pub struct GradeSubmissionData {
556    pub offering_id: [u8; 32],
557    pub assessment_id: [u8; 32],
558    pub student_commitment: [u8; 32],
559    pub grade_commitment: [u8; 32],
560    pub feedback: Option<ManagedSnipRef>,
561    /// `CourseRole` code (see `CourseRole as u8` / `TryFrom<u8>`).
562    pub grader_role: u8,
563    pub nonce: u64,
564}
565
566/// SRC-818 `FinalizeGrade` (`offering_op::FINALIZE_GRADE`).
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct FinalizeGradeData {
569    pub offering_id: [u8; 32],
570    pub assessment_id: [u8; 32],
571    pub student_commitment: [u8; 32],
572    pub nonce: u64,
573}
574
575/// SRC-818 `FinalizeCourse` (`offering_op::FINALIZE_COURSE`).
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
577pub struct FinalizeCourseData {
578    pub offering_id: [u8; 32],
579    pub nonce: u64,
580}
581
582/// SRC-818 `ArchiveOffering` (`offering_op::ARCHIVE_OFFERING`).
583#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
584pub struct ArchiveOfferingData {
585    pub offering_id: [u8; 32],
586    pub nonce: u64,
587}
588
589/// SRC-818 combined `SuspendOffering` / `CancelOffering`
590/// (`offering_op::SUSPEND_OR_CANCEL_OFFERING`). `action` selects
591/// Suspend (reversible) / Resume / Cancel (terminal).
592#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
593pub struct SuspendOrCancelOfferingData {
594    pub offering_id: [u8; 32],
595    /// `SuspendCancelAction` code (see `… as u8` / `TryFrom<u8>`).
596    pub action: u8,
597    pub nonce: u64,
598}
599
600// ───────────────────────── Commitment helpers ───────────────────────────────
601//
602// Pure, domain-separated BLAKE3. **Length-safe**: the input tuple is
603// serialized with bincode (which length-prefixes every String / byte
604// slice) BEFORE hashing, so variable-length fields cannot be
605// re-segmented into a colliding byte stream
606// (`("CS","101")` ≠ `("C","S101")`). Same discipline as the
607// InferenceAttestation CF-key scheme. Fixed-size `[u8; N]` arrays are
608// unambiguous by construction; `String`/`&[u8]` get a u64 length
609// prefix from bincode.
610
611fn blake3_domain_bincode<T: Serialize>(domain: &[u8], value: &T) -> [u8; 32] {
612    // bincode serialization of these plain tuples cannot fail.
613    let inner = bincode::serialize(value)
614        .expect("commitment input is infallibly bincode-serializable");
615    let mut buf: Vec<u8> = Vec::with_capacity(domain.len() + inner.len());
616    buf.extend_from_slice(domain);
617    buf.extend_from_slice(&inner);
618    *blake3::hash(&buf).as_bytes()
619}
620
621/// `catalog_id = BLAKE3("SRC817-CATALOG:v1:" ‖ bincode(
622/// (institution_id, department, course_code, version, nonce)))`.
623/// Length-safe: `department`/`course_code` are bincode
624/// length-prefixed, so no string-boundary collision.
625pub fn catalog_id(
626    institution_id: &[u8; 32],
627    department: &str,
628    course_code: &str,
629    version: u32,
630    nonce: u64,
631) -> [u8; 32] {
632    blake3_domain_bincode(
633        DOMAIN_CATALOG_ID,
634        &(*institution_id, department, course_code, version, nonce),
635    )
636}
637
638/// `offering_id = BLAKE3("SRC818-OFFERING:v1:" ‖ bincode(
639/// (catalog_id, term, section, creator, nonce)))`. Length-safe:
640/// `term`/`section` are bincode length-prefixed.
641pub fn offering_id(
642    catalog_id: &[u8; 32],
643    term: &str,
644    section: &str,
645    creator: &Address,
646    nonce: u64,
647) -> [u8; 32] {
648    blake3_domain_bincode(
649        DOMAIN_OFFERING_ID,
650        &(*catalog_id, term, section, *creator.as_bytes(), nonce),
651    )
652}
653
654/// `student_commitment = BLAKE3("SRC818-STUDENT:v1:" ‖ bincode(
655/// (subject, offering_id, salt)))` — per-offering/per-context scoped,
656/// salted, non-reversible. A global/cross-offering student identifier
657/// is prohibited (Phase 0 FERPA rule). All inputs fixed-length.
658pub fn student_commitment(
659    subject: &[u8; 32],
660    offering_id: &[u8; 32],
661    salt: &[u8; 32],
662) -> [u8; 32] {
663    blake3_domain_bincode(
664        DOMAIN_STUDENT_COMMITMENT,
665        &(*subject, *offering_id, *salt),
666    )
667}
668
669/// `submission_commitment = BLAKE3("SRC818-SUBMISSION:v1:" ‖ bincode(
670/// (offering_id, assessment_id, student_commitment, attempt,
671/// work_hash, salt)))`.
672pub fn submission_commitment(
673    offering_id: &[u8; 32],
674    assessment_id: &[u8; 32],
675    student_commitment: &[u8; 32],
676    attempt: u16,
677    work_hash: &[u8; 32],
678    salt: &[u8; 32],
679) -> [u8; 32] {
680    blake3_domain_bincode(
681        DOMAIN_SUBMISSION_COMMITMENT,
682        &(
683            *offering_id,
684            *assessment_id,
685            *student_commitment,
686            attempt,
687            *work_hash,
688            *salt,
689        ),
690    )
691}
692
693/// `grade_commitment = BLAKE3("SRC818-GRADE:v1:" ‖ bincode(
694/// (offering_id, assessment_id, student_commitment, grade_value,
695/// salt)))`. Length-safe: `grade_value` is bincode length-prefixed.
696/// The raw grade is never on chain — only this commitment.
697pub fn grade_commitment(
698    offering_id: &[u8; 32],
699    assessment_id: &[u8; 32],
700    student_commitment: &[u8; 32],
701    grade_value: &[u8],
702    salt: &[u8; 32],
703) -> [u8; 32] {
704    blake3_domain_bincode(
705        DOMAIN_GRADE_COMMITMENT,
706        &(
707            *offering_id,
708            *assessment_id,
709            *student_commitment,
710            grade_value,
711            *salt,
712        ),
713    )
714}