event-shape.md
c133 Phase 2: the record's event shape (a proposal for Tyler)
Written: 2026-11-11. Reader: Tyler first (plan Phase 2: agree the event shape), then c134's executor (the plane keeps it), c128 (Reports reads it), c137 (the message that carries it back). Status: a proposal in the wire shape, drawn from the lab mock; nothing is implemented in qcontrol or the plane. Field names are snake_case on the wire, as c137's bones; timestamps are RFC 3339 in UTC.
2026-11-11, later: Mark walked §6 and set the assumptions in §8, to validate with Tyler; §2 and §3 are read with §8's amendments.
Rulings it follows: R4 (the record is proof, not surveillance), D3 (all right with retake; only the final result), D8 (completion travels as training-completed in the assignment's thread), D9 (completion changes no status), D10 (the person's name on the finding and in the audit log, counts only in Reports), D13 (the plane learns nothing between assignment and record), D14 (the person sees the record before it is written), D16 (the history row now, a Corrections block once this shape is agreed).
1. What happens, in order
- A policy with a
trainingfield detects a finding; the plane sends the person atraining-assignmentmessage (c137 bones §2, §10 E7) and writesmessage.sentto the audit log (bones §9). That send is the only assignment record: Reports count assignments from it. - The person opens the training on the web app, reads the lesson, passes the check, and presses Finish (D14). Nothing reaches the plane before that press (D13).
- The web app sends one
training.completedevent to the plane. The plane writes the record to the audit log, ends the assignment message (message.done, bones §5), and answers the thread with atraining-completedmessage to the operator's inbox, quiet (bones §2). - The finding's history shows the record as a row (today); a Corrections block on the finding and a Corrections section in Reports read the same record (D16, D10).
2. The event
In qevents' pattern (a tagged enum, type and payload, dotted type names):
#[serde(tag = "type", content = "payload")]
pub enum TrainingEvent {
#[serde(rename = "training.completed")]
Completed(TrainingCompleted),
}
pub struct TrainingCompleted {
record_id: RecordId, // TR-<ulid>: the plane assigns it; the person sees it on the result page
training_id: TrainingId, // T-MCP-1
training_version: u32, // the content's revision at the time; a later edit never rewrites a pass
finding_id: FindingId, // the finding it answers
policy_id: PolicyId, // the policy that assigned it (D2)
person: { id, name }, // as the finding names them
endpoint: { id, name }, // the machine the finding was on
result: "passed", // the only value: a training not passed has no record (D3)
check: { right: u8, total: u8 }, // the final result, n of n
completed_at: Timestamp, // when Finish was pressed, UTC
assigned_by: "policy" | "operator" | "hq", // D2
message_id: MessageId, // the training-assignment this answers (D8)
}
What is not in the event, by rule (R4): how long the person took, how many tries, which answers were wrong, where they took it (the browser, the device), the delivery channel of the assignment. The event carries nothing a later reader could turn into a score.
3. The audit log row
The plane keeps one row per event, in the shape its audit log already uses for messages (bones §9):
| time | event | actor | subject | links |
|---|---|---|---|---|
completed_at | training.completed | the person (person.name) | training_id v<version> · finding_id | record, finding, training, message |
Beside it, the two message rows the assignment already produces: message.sent when the policy assigned it, message.done when training.completed ended it. The lab's audit log mock shows exactly these three rows.
4. The projections
- The finding's history (today, D16): one row,
training completed → <status unchanged>, Manual, the person as actor, the record as the cause. The lab adds an event kindtraining-completedto the finding's lifecycle for it; the plane need not store it twice if the history view can read the audit log byfinding_id. - The Corrections block on the finding (once this shape is agreed, D16): the record as the audit log keeps it, the three audit rows, a link to the training's record page. Shown in the lab behind
?v.corrections=block. - Reports (c128's Findings report, D10): a Corrections section, counts only, as of the data cutoff: assigned (findings under a policy with a training), completed (with a record), waiting; per training. Assigned comes from
message.sentof typetraining-assignment; completed fromtraining.completed. No names. - The operator's inbox (c137): the
training-completedmessage in the assignment's thread, quiet.
5. Who may read it (D10)
The person's name is on the finding (operators who can open the finding) and in the audit log (its readers). Reports show counts. Retention follows the finding's and the audit log's; the lab does not propose a shorter one. The first question for Peterson stands: does a completed training count as personnel data in the organisations he knows? If it does, the plane setting would be who may read training rows, not whether they exist.
6. Questions for Tyler
- Where it lives. One row in the plane's audit log, read back by
finding_idfor the finding and summed for Reports; or atraining_recordstable beside the audit row? The lab assumes the audit log is the store and the rest are reads. - Whose event.
training.completedis produced by the web app on the plane's side, never by an endpoint; does it belong in qevents' public contracts (the generated schemas and TypeScript) or in the plane's own API types (c134'stypes/api/)? The lab wrote it in qevents' pattern so it can go either way. - The assignment. Count assignments from
message.sent(typetraining-assignment), or addtraining.assignedso Reports never read the message log? The lab reads the message. - Idempotency. One record per (training, finding, person); a second Finish for the same finding is a no-op and returns the existing record. Agreed?
- Version.
training_versionas an integer the content carries (the lab), or a content hash so the plane can prove which text was passed? - Operator and HQ assignment (D2 second and third): the same event, with
assigned_byset andmessage_idpointing at their message. Nothing else changes. Agreed?
7. What the lab shows
/pages/phase4/mock/training?findingId=F-A&at=recorded (the record and the three rows), /pages/phase4/mock/finding-detail?findingId=F-A&at=recorded (the history row; &v.corrections=block for the block), /pages/phase4/mock/reports/findings?findingId=F-A&at=recorded (the Corrections section). app/data/trainings/types.ts holds the TypeScript twin of §2 (TrainingRecord, AuditEvent).
8. Mark's assumptions (2026-11-11), to validate with Tyler
Walked question by question that night; recorded in plan.md as D22–D27. Where an assumption changes §2 or §3, the change is stated.
- The store: the audit log, one row per completion; the finding reads it by finding id and Reports sum it; a table of its own only if a read needs it (D22).
- The contract: the existing event pattern as the base (qevents': a tagged enum,
typeandpayload, dotted names); the event stays small; the plane's richer record object consumes it and adds the metadata (D23). Which crate declares it is Tyler's to say. So §2'sTrainingCompletedis the proto; the plane's record object wraps it with the links and the finding's context. - Assignment and completion are both audit rows (D24). A completion links to the finding that triggered it, and the plane associates it with the person's earlier findings under the same policy: the Corrections block on each of those findings shows the training taken after a later one, and the association drives what remediation comes next (the policy's next step after a repeat). That read is c134's and Phase 5's; the event carries only
finding_id. - Every completion is a row (D25): no de-duplication, no one-row-per-action. One grouped object per completion, in Mark's words:
type: training · completed: <date> · training: { id, name } · proof of understanding: true. §2 stands, with two amendments: the record carries the training'stitlebesidetraining_id, andresult: "passed"may be namedproof_of_understanding: true, which is R3's meaning. Reports count a finding as answered once. - Version and a hash (D26): the published training carries
versionandcontent_hash(a hash of its published JSON); the record names both. The publish step (Phase 3) computes the hash; the API serves both. - Separate events per assigner (D27): the completion after a policy's, an operator's or HQ's assignment is its own type, so the audit log reads who asked without opening the message. Proposed names, Tyler's to adjust:
training.completed.policy,training.completed.operator,training.completed.hq; the payload is the sameTrainingCompleted,assigned_bydropped since the type carries it,message_idkept.
What Tyler still decides: the crate (2), the type names (6), retention of the audit rows against report windows (3 of §6), and whether the plane or the app ends the assignment message on completion (bones §5 says the app).