Structural audit contract — recordStructuralChange (temporal program, spec D)¶
1. Problem distillation¶
- The temporal program (program contract §1) puts valid time in domain tables and transaction time in
audit_logs. The transaction-time half has today exactly one writer,AuditService.record(src/audit-log/audit.service.ts), and it is a value-audit primitive: free-formdata, consumer-owned strings, actor always a request user. Nothing in the tree records a structural change — "who moved this student to that homeroom, when did we act, and from which date does it apply". - Six sibling specs (A, C1, C2, C3, R, G) each need to append structural events inside their own write transaction. Written in parallel against the free-string convention of chapter 20 §3 ("naming convention by consumers, not enforced"), they would mint six divergent envelope shapes and verb vocabularies. This spec fixes the primitive once: the
recordStructuralChangesignature (contract §9, verbatim), the stored envelope{ v: 1, before, after, effectiveOn }, and a reserved-names registry of everyentityType/actionthe program is allowed to write. - The primitive is deliberately boring: no schema migration (the envelope rides the existing
audit_logs.dataJSON), no new endpoint, no new RBAC, no product impact. It is the D row of contract §2 — the one spec every other spec depends on and the only one that can ship before any interval column exists. before/afterare full value snapshots of the affected row. The precedent in the tree is partial: grades'.deletedevent captures a before-image inside the tx just before the delete (src/grades/grades.service.ts~508–525), but — like attendance's{ status, time, note }events — it stores a meaningful-values projection (ch20 §3: "full snapshot of the meaningful values"), not the full column set. The structural rule (§4: full column set,idincluded, viatoJsonSnapshot) is deliberately stricter than both shipped value-audit consumers — it aligns with theAuditLogmodel doc comment (prisma/schema.prismaline ~503: "datais a full value snapshot per event"), not with shipped consumer practice.
Success criteria (observable behavior that proves this works):
- A consumer calls audit.recordStructuralChange(tx, input) inside its $transaction; exactly one audit_logs row exists after commit, and none if the transaction rolls back — identical atomicity to record() today.
- The stored data column is exactly { v: 1, before, after, effectiveOn } — all four keys always present, effectiveOn: null when the input omits it.
- Every program consumer's entityType/action string resolves from one constants file (src/audit-log/structural-audit.constants.ts); an action not prefixed <entityType>. throws at the call site (programmer-error assert).
- The result of the existing resolveActor(tx, ctx) is passable as input.actor with no adapter (structural assignability — see §5).
- Envelope rows render in the existing admin GET /audit-log with zero controller/DTO/Swagger change, filterable by entityType/entityId as any other row.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No call sites. A, C1–C3, R, G own their own emission points; this spec ships the primitive, the vocabulary, and the tests of the primitive.
- No change to the existing value-audit trails (attendance_record, grade_entry) — they are not migrated to envelope v1; their history endpoints keep their current data shapes.
- No new read surface, no history-query helper beyond the existing historyFor (which already answers structural history via (entityType, entityId)).
- No runtime enum validation that entityType is in the registry (compile-time convention + review; only the <entityType>. prefix assert is runtime).
- No base-service auto-hook (chapter 20 §8 — still explicitly deferred).
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/audit-log/audit.service.ts (record / recordMany) |
The whole write discipline: append via tx.auditLog.create on the caller's transaction handle, denormalized actor snapshot, requestId lifted from CLS, recordMany batching via one createMany. recordStructuralChange is a typed specialization of record. |
record() takes free-form data: unknown and an AuditActor with a mandatory userId; structural events need a fixed, versioned envelope and a nullable userId (contract §9 actor shape). |
docs/superpowers/specs/2026-06-25-audit-log-design.md + docs/20-audit-log.md §3 |
entityType/action as free VarChar columns, soft entityId, the <entityType>.<verb> namespacing, "full snapshot, not a diff". |
Its vocabulary ownership rule — "the convention lives in the consumers" — is exactly what a 7-consumer parallel program cannot afford. This spec inverts ownership: the audit-log module owns a reserved-names registry; consumers import constants. |
src/attendance/attendance.service.ts (ATTENDANCE_ENTITY_TYPE at line 68; verbs .created/.updated/.admin_override) and src/grades/grades.service.ts (GRADE_ENTITY_TYPE at line 59; .created/.updated/.admin_override/.deleted) |
The per-module const + template-literal action pattern, and the grades .deleted event as the "capture a before-image inside the tx prior to destruction" precedent for before. |
Per-module constants cannot prevent cross-module divergence (each module can only see its own); neither trail carries a valid-time application date — createdAt is their only time axis; and both store meaningful-values projections (grades: 4 fields, no id; attendance: { status, time, note }), so neither is precedent for §4's stricter full-column-set rule. |
src/audit-log/audit-log.queries.ts (toAuditLogEntryResponse, buildAuditLogWhere) |
Pure named functions in queries.ts for mappers and where-builders, unit-tested in audit-log.queries.spec.ts. The envelope builder follows the same shape. |
Fits cleanly. |
src/common/constants/entity-keys.ts (frozen as const string registry consumed everywhere) |
The "one as const object is the registry; nobody inlines the string" discipline for the reserved-names file. |
Entity keys feed the RBAC seed; the structural registry feeds nothing at seed time — it is a pure compile-time vocabulary. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | input.tenantId written to AuditLog.tenantId exactly as record() does; RLS already covers the table (src/prisma/rls-coverage.ts line 74 lists audit_logs) and the model is in STRICTLY_TENANTED_MODELS (src/prisma/tenanted-models.ts line 20). No delta to either. |
Existing table, existing coverage. |
| Academic-year scope | no | n/a — the trail is not AY-scoped (2026-06-25 spec §3 decided this); consumers that care put academicYearId inside the before/after row snapshots, where it already lives on interval rows. |
effectiveOn is valid time, not an AY filter. |
| RBAC entity key | existing AUDIT_LOG |
No delta to src/common/constants/entity-keys.ts. |
No new surface to gate. |
| Scopes | none new | The single descriptor-only configuration scope on audit_log is untouched. |
Read surface unchanged. |
| Actions | none | No new action keys (and read/update are never action names). There is no HTTP write surface — emission stays in-process. |
Mirrors the existing module: "no write action exists because there is no write endpoint" (ch20 §6). |
| Service base | custom — extend AuditService |
New methods on the existing injectable; not a sibling service (decision argued in §5). Not BaseTenantedCrudService — this is not entity CRUD. |
One injection point for consumers that write both value and structural events (attendance under R does exactly that). |
queries.ts shape |
named pure functions | buildStructuralAuditData(input) (envelope mapper) and toJsonSnapshot(value) (row → JSON-safe snapshot) join toAuditLogEntryResponse in audit-log.queries.ts. The tx.auditLog.create itself stays inline in the service, matching record()/recordMany() exactly. |
Convention: named functions, no repository classes. |
| Error codes | none new | The prefix assert throws a plain Error (programmer error → 500 via AllExceptionsFilter), not an AppException: a malformed action string is a bug in a consumer, never a user-facing condition, and none of §11's reserved codes apply to this spec. |
Chapter 06 codes are domain errors; this is an invariant assert. |
| DTO conventions | none new | No new DTOs. AuditLogEntryResponseDto.data is already documented FE-facing as "Full value snapshot; shape is per-entityType" — the envelope is one more per-entityType shape. |
Read contract unchanged. |
| File-backed sub-resources | n/a | No files. | |
| Custom fields | no | Infrastructure table, not in CUSTOM_FIELD_ENTITY_KEYS. |
|
| Profile completeness | no | n/a — no person fields. |
4. Data model plan¶
Schema deltas¶
- None. The envelope lives inside the existing
audit_logs.dataJson(NOT NULL) column onmodel AuditLog(prisma/schema.prisma~509–527). No new model, no new column, no new enum.
Stored payload (the one data-shaped deliverable)¶
// audit_logs.data for every structural event — contract §9, binding
{
"v": 1, // envelope version, literal number
"before": { /* full row snapshot */ } | null, // null on create/open
"after": { /* full row snapshot */ } | null, // null on hard removal (rare — contract §10)
"effectiveOn": "YYYY-MM-DD" | null // valid-time application date; null when the event is not effective-dated
}
- All four keys are always present (
effectiveOn: input.effectiveOn ?? null) — a fixed shape is what makes the envelope machine-readable without per-key existence checks, and it is what contract §15 item 5 verifies across specs. - A row snapshot is the affected row's full column set,
idincluded, passed throughtoJsonSnapshot(see §5) soDatecolumns serialize as ISO-8601 strings. It is the value the consumer's transaction read/wrote — never re-fetched, never trimmed to a diff. The two defined exceptions (thestudent_enrolmentandtimetableprojections) are ledgered in §7 (D4, D5). vexists so a future semantic change bumps to2while old rows stay readable. Additive-key changes do not bumpv(governance note; no v2 is designed here).- JSON nuance: the payload root is always an object, so the Prisma
DbNull/JsonNullambiguity never arises;before: null/after: nullare plain JSON nulls inside the object.
Migration shape¶
- No migration. Nothing for
npx prisma migrate devto generate. - Chapter 12 hazard checklist, run explicitly and vacuously: no uncommitted migration to fold; no
migration.sqlto audit; no destructive/rename/type-change/enum hazard; no partial-index orWHERE-predicate trap; no RLS delta. - New-tenant-bearing-model drift guards — the program-mandated five (rls-coverage, tenanted-models, tenant-reset coverage, label-coverage, db-constraints e2e): no new obligations, because there is no new model. Existing coverage for
AuditLogverified in the tree:src/prisma/rls-coverage.ts:74('audit_logs') andsrc/prisma/tenanted-models.ts:20('AuditLog').
Indexes and uniqueness¶
- Existing indexes suffice:
(tenantId, entityType, entityId, createdAt)backs bothhistoryForand the entity-filtered admin list, and the §5 anchor-id rule is chosen so structural history queries hit exactly this index. - No JSONB index on
data. G's divergence worklist may later wantdata->>'effectiveOn'queries; adding a GIN/expression index is G's call when it has a real query (deferred, §9).
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| — | none — no new or changed HTTP endpoints | n/a | n/a | n/a |
Admin GET /audit-log renders envelope rows acceptably as-is — recommendation: no API change. The controller (src/audit-log/audit-log.controller.ts) is untouched; ListAuditLogQueryDto.entityType/entityId filter structural rows like any others; AuditLogEntryResponseDto.data is typed unknown with FE-facing copy "shape is per-entityType", which the envelope satisfies. An admin filtering entityType=homeroom_membership&entityId=<studentId> gets the student's homeroom history newest-first with no new code.
Swagger considerations¶
- None. No decorator, DTO, or
audit-log.swagger.tschange. (The reserved-names registry is backend-internal vocabulary and must not be pasted into Swagger/JSDoc copy; if the FE ever needs the vocabulary, that is an FE-guide, not OpenAPI.)
5.1 Programmatic surface — the actual contract¶
Signature verbatim from contract §9 (binding — parameter names, types, optionality):
recordStructuralChange(tx: Prisma.TransactionClient, input: {
tenantId: string;
actor: { userId: string | null; name: string; role: string };
entityType: string; // snake_case domain noun, e.g. 'homeroom_membership'
entityId: string;
action: string; // '<entityType>.<past-tense-verb>', e.g. 'homeroom_membership.moved'
before: Prisma.JsonValue | null; // null on create
after: Prisma.JsonValue | null; // null on hard removal (rare — contract §10)
effectiveOn?: string; // IsoDate — the valid-time application date, when effective-dated
requestContext?: Prisma.JsonValue;
}): Promise<void>
Where it lives — decision: a method on AuditService, not a sibling service. Survey of the module (all 11 files read): AuditService is a stateless writer whose only dependencies (PrismaService for reads, ClsService for requestId) are exactly what the new method needs; a sibling StructuralAuditService would duplicate that wiring, add a second provider/export to audit-log.module.ts, and force consumers that emit both kinds of event (attendance under spec R writes attendance_record.* value events and attendance_register.* lifecycle events in one transaction) to inject two services. Extending keeps chapter 20 §7's one-line consumer recipe intact: import AuditLogModule, inject AuditService.
Semantics (D refines everything the contract left open):
- Row mapping. One
tx.auditLog.createon the caller's tx — never its own transaction, no fail-soft branch, exactly likerecord():tenantId ← input.tenantId;actorUserId ← input.actor.userId(column is nullable — schema line 513);actorName/actorRole ← input.actor;entityType/entityId/actionverbatim;data ← buildStructuralAuditData(input);createdAtdefaults tonow()and is the transaction-time stamp — no second timestamp anywhere. - Prefix assert.
actionmust start withinput.entityType + '.'and carry a non-empty verb; otherwise thrownew Error(...)before writing. Both shipped trails already satisfy this shape (attendance_record.created,grade_entry.deleted), but the assert applies only to the new method — existingrecord()behavior is untouched. - Actor.
AuditActor(fromresolveActor(tx, ctx)) is structurally assignable to the actor parameter: itsuserId: stringnarrowsstring | null, and its extratenantIdis legal on a non-literal value. Request-driven consumers therefore doconst actor = await this.audit.resolveActor(tx, ctx)once and passactorstraight in — no adapter, no second lookup. For the rare non-request writer (ops tooling, an app-level backfill), exportSTRUCTURAL_SYSTEM_ACTOR = { userId: null, name: 'system', role: 'system' } as constso nobody mints'SYSTEM'/'automated'variants. (No program consumer needs it for correctness today — H never writes, per contract §2 — it exists to close the vocabulary.) requestContext. When omitted →{ requestId }from CLS when present, else columnnull— identical torecord()today. When provided as a JSON object → shallow-merged over{ requestId }, caller keys winning (G will want to attach amendment context without losing the request trace). A non-objectPrisma.JsonValueis stored verbatim (legal per the signature, discouraged in the doc comment).- Batch variant.
recordStructuralChanges(tx, inputs: StructuralChangeInput[]): Promise<void>— onecreateMany, empty-list no-op, CLS read once, per-row shape identical to the singular. MirrorsrecordMany(precedent: attendance's batched take). The contract fixes only the singular; the plural is a D-owned refinement so bulk interval writers (C2's setup full-replace paths) don't invent it (open question Q1). - Snapshot helper.
toJsonSnapshot(value: unknown): Prisma.JsonValueinaudit-log.queries.ts— the sanctioned row→JSON conversion (JSON.parse(JSON.stringify(value))): PrismaDatefields become ISO-8601 strings (a@db.Datecolumn serializes as...T00:00:00.000Z, kept verbatim —effectiveOnis the clean date-only field),undefinedkeys drop. Without this, six consumers hand-roll date serialization and the snapshots drift. Note: a Prisma row type containingDateis not assignable toPrisma.JsonValue, so consumers cannot accidentally skip the conversion — the compiler routes them to the helper.
File layout (all inside src/audit-log/ — the module boundary this spec owns):
| File | Delta |
|---|---|
audit.service.ts |
+ recordStructuralChange, recordStructuralChanges, private prefix assert |
structural-audit.constants.ts |
new — STRUCTURAL_ENTITY_TYPES, STRUCTURAL_ACTIONS, STRUCTURAL_SYSTEM_ACTOR (+ registry doc comment) |
interfaces/audit.interface.ts |
+ StructuralAuditActor, StructuralChangeInput, StructuralAuditEnvelope types |
audit-log.queries.ts |
+ buildStructuralAuditData, toJsonSnapshot named pure functions |
index.ts |
re-export the new symbols (consumers import from the barrel, per ch20 §7) |
docs/20-audit-log.md |
post-implementation: new "Structural events" section (doc update lands with the code) |
5.2 Reserved-names registry — the program vocabulary¶
entityType and action remain string in the signature (contract-verbatim; not narrowed to a union type). The registry closes the vocabulary anyway: every program consumer imports these constants; an inline structural entityType/action string in a program PR is a review reject. Adding a name = a PR editing structural-audit.constants.ts (the single registry; this table is its birth certificate). Seam check: contract §15 item 5.
Interval verb triplet — uniform across every interval-bearing entityType, mapping 1:1 onto contract §7's command taxonomy: .opened (MEMBERSHIP_ADD / admission / setup — a new interval row; before: null), .moved (MEMBERSHIP_MOVE — one logical event per command, not per row: before = the outgoing row as it stood pre-command with validUntil still NULL, after = the incoming row as created, effectiveOn = the single shared boundary X of contract §3's move rule; the outgoing row's post-close state is derivable as before + validUntil = effectiveOn), .closed (MEMBERSHIP_REMOVE / LEAVING / §10 delete-converted-to-close — before = the row pre-close, after = the row post-close with validUntil set). after: null is reserved for genuine app-level hard removals, which contract §10 makes rare; DB-level ON DELETE CASCADE paths emit nothing — that history loss is accepted per-cascade in each C-spec's Divergence ledger (contract §10), not papered over here.
C2's three membership entity types additionally reserve .updated for an
amendment to the one not-yet-effective pending plan (§2b #24): a future start or
end is rescheduled/cancelled without changing a governed day. before/after
are the captured row states and effectiveOn is the resulting pending date (or
null when cancellation removes the future effect). This is distinct from
rewriting effective history, which remains forbidden. Timetable lifecycle verbs
remain the other actions outside the interval triplet.
entityType |
Owner | Reserved actions | entityId anchor (see divergence D1) |
before/after rows |
effectiveOn |
|---|---|---|---|---|---|
timetable |
A | .published, .republished, .unpublished |
the lineage's stable published Timetable id (retain-then-swap keeps it across republish — publishRevision, src/timetables/timetables.service.ts ~399–424; the retained ARCHIVED copy gets a new id), so historyFor reads the whole lineage story |
version-metadata projections, not full rows (ledgered, §7 D5; exact shapes fixed in A §4): .published (publish out of DRAFT) — before: null, after = incoming version metadata; .republished — one event per republish command, not a close+publish pair (the same one-event-per-logical-command rule as .moved): before = outgoing version metadata incl. the retained copy's new archiveId and its — possibly empty [X, X) — closed interval, after = incoming version metadata; .unpublished — before = the retained archive's metadata, after: null meaning the lineage no longer governs (a withdrawal — an A-defined refinement of after: null beyond §10's hard-removal case) |
incoming version's effectiveFrom = the boundary F (.published, .republished); unpublish boundary U (.unpublished) |
student_placement |
C1 | .opened, .moved, .closed |
studentId |
StudentPlacement rows |
interval boundary |
student_enrolment |
C1 | .exited, .readmitted |
studentId |
enrolment projection of the Student row — { id, enrollmentDate, exitDate, status } — not the full row (ledgered, §7 D4); on .readmitted the before/after pair distinguishes un-close (a pending exit reverted) from a new interval (C1 §4.4). An exit/readmission command also emits the paired student_placement.closed/.opened event for the placement interval it closes/opens (contract §9, review correction 2026-07-26 — the enrolment event alone does not audit the changed interval) |
the exit date (.exited); the reopen boundary (.readmitted) |
homeroom_membership |
C2 | .opened, .moved, .closed, .updated |
studentId |
HomeroomAssignment interval rows (homeroom id/name inside snapshots); .updated is pending-boundary amendment only |
interval boundary; amended date or null for cancelled pending effect |
subject_group_membership |
C2 | .opened, .moved, .closed, .updated |
studentId |
SubjectGroupAssignment interval rows (subjectGroupId inside snapshots); .updated is pending-boundary amendment only |
interval boundary; amended date or null for cancelled pending effect |
curriculum_selection |
C2 | .opened, .moved, .closed, .updated |
studentId |
StudentCurriculumSelection aggregate versions, including their choice sets. .moved replaces a currently-effective aggregate at one boundary even when only choices changed. .updated amends the not-yet-effective pending aggregate in place so v1 keeps one pending transition; before/after include the full choice set |
interval boundary; for .updated, the pending aggregate's validFrom — the date the amended plan will govern |
subject_group_teacher |
C3 | .opened, .closed |
subjectGroupId (the expectation/authorization anchor; teacherId inside the snapshots) |
SubjectGroupTeacher interval rows |
interval boundary |
combined_class_membership |
C3 | .opened, .closed |
combinedClassId (the meeting anchor; member subjectGroupId inside the snapshots) |
the membership link snapshot (SubjectGroup.combinedClassId temporality — C3 defines the row) |
interval boundary |
attendance_register |
R (DEFERRED, contract §2a — reserved-unused this version; no consumer ships) | .submitted, .reopened, .locked |
R's register row id (a register is itself a stable aggregate — its own id is its history key) | register lifecycle row pre/post transition | the register's covered date (recommended; R decides) |
| (every entityType above) | G (DEFERRED, contract §2a — verb stays reserved, no writer ships) | .amended — G-exclusive verb; no other spec may write it |
same anchor as the fact being amended, so amendments interleave chronologically with the original events in one historyFor query |
amended row pre/post correction | the corrected valid-time date |
Collision fence — pre-existing, non-envelope types. attendance_record (src/attendance/attendance.service.ts:68) and grade_entry (src/grades/grades.service.ts:59) are already in production audit_logs rows with consumer-shaped data (no v key). They are listed in the constants file's doc comment as reserved-but-legacy: no program spec may reuse them for structural events, and they are not migrated to envelope v1 (§1 non-goals). A reader distinguishes envelope rows by data.v === 1.
G's concrete constants (HOMEROOM_MEMBERSHIP_AMENDED, …) and any additional entityType G's worklist needs are appended by G via the registry procedure — reserved here as a verb rule so no other spec squats on .amended (open question Q2).
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none |
*_SCOPES runtime constant |
none — no change to src/common/constants/scope-fields.ts |
Zero RBAC delta by mandate: emission is in-process infrastructure; the only read surface (GET /audit-log) keeps its existing @RequireScopes(AUDIT_LOG, 'read') + @RequireRoles('admin') gate.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
D1 — ch20 §9: "entityId — the UUID of the row you mutated" |
Structural events anchor entityId on the stable domain anchor (studentId / subjectGroupId / combinedClassId / lineage id / register id — §5.2 table), not on the interval row id |
Interval rows churn by design — one student's homeroom history spans many HomeroomAssignment rows; anchoring per-row shatters one fact's history across ids, making historyFor and G's divergence worklist useless. The anchor makes (tenantId, entityType, entityId, createdAt) — the existing index — the exact history key. Row ids are preserved inside every snapshot, so nothing is lost |
The admin list can't filter by interval-row id directly (it lives in data); entityId stays a UUID (all anchors are UUIDs), so the column contract holds |
| D2 — "new columns for new queryable facts" (the default schema instinct) | before/after/effectiveOn live inside data as the { v: 1, ... } envelope — no schema migration (contract §9 mandate, justified here) |
First-class columns would mean a migration on a growing append-only table, three nullable columns meaningless for every value-audit row, and dual rendering paths in the admin list. The JSON envelope keeps the writer untouched, is versioned via v, renders through the existing surface, and PostgreSQL JSONB expression indexes remain available the day G needs a real query |
No DB-level shape check on the envelope — mitigated by the single buildStructuralAuditData builder + its unit tests; effectiveOn range queries are not index-backed until someone adds an expression index |
D3 — ch20 §3: AuditRecordParams.data is free-form, "the consumer decides its shape"; vocabulary "lives in the consumers, not enforced" |
Structural data is builder-produced, never caller-shaped, and the vocabulary is a module-owned registry with a runtime prefix assert |
Seven parallel consumers; contract §15 item 5 requires the envelope identical everywhere, which consumer-shaped payloads cannot guarantee | Consumers lose payload flexibility (extra context goes in requestContext or inside the row snapshots, not as new envelope keys) |
| D4 — "before/after are full value snapshots of the affected row" | student_enrolment events snapshot the enrolment projection { id, enrollmentDate, exitDate, status }, not the full Student row |
The Student row spans 8 RBAC scopes including health; copying it wholesale into a trail readable via the aggregate admin list bypasses field-level filtering for zero forensic value — the affected fact is enrolment. Every other program entityType's affected row is a small interval/lifecycle row, snapshotted in full |
An enrolment event is not a full-row restore point; anyone needing more joins the live Student row by the snapshotted id |
| D5 — "before/after are full value snapshots of the affected row" | timetable events snapshot a version-metadata projection (ids, revisionNumber, effectiveFrom/effectiveUntil; exact shapes fixed in A §4), not the full Timetable row |
The affected fact is the governance interval; the schedule-content evidence is the retained archive rows (every version keeps its full ScheduledLesson/ScheduledBreak/ScheduledActivity set keyed by timetableId), which no Timetable-row snapshot could carry anyway |
A timetable event is not a row restore point; content forensics resolve the archived version by the snapshotted ids |
8. Pushback log¶
| US says (here: program contract) | Conflicts with | Proposed instead | Status |
|---|---|---|---|
Contract §9 actor is { userId, name, role } while tenantId rides top-level on the input — but the existing AuditActor bundles tenantId inside the actor |
Two near-identical actor shapes now coexist in one module | Keep the contract shape verbatim (actor = identity, tenant = addressing — arguably the cleaner split); structural assignability means resolveActor's result passes unchanged, so no consumer feels the difference. No contract amendment requested |
Resolved — designed to contract |
Contract §9's lone example homeroom_membership.moved leaves open whether a move is one event or a per-row pair (.closed + .opened) |
A parallel C-spec could read it per-row, and then D and C2 would disagree on event counts for the same command | D fixes one event per logical command (§5.2); the registry table is the disambiguation the contract example lacks. Contract stays as-is; contract §15 item 5 catches any sibling that diverged | Resolved — registry disambiguates |
Contract §9 gives the caller a requestContext?: Prisma.JsonValue input, but the shipped record() sources requestContext exclusively from CLS |
Two sourcing rules for one column | Merge rule in §5.1 item 4: CLS requestId is never lost, caller keys layer on top. record() untouched |
Resolved — designed to contract |
9. Deferrals¶
- JSONB / expression index on
data(e.g. ondata->>'effectiveOn') — no consumer query exists yet — follow-up: spec G's divergence worklist, which owns the first such query. - Retrofit of
attendance_record/grade_entryvalue events to envelope v1 — theirdatashapes are load-bearing for the shipped{ current, history }endpoints (attendance ch19 §12, grades) — follow-up: revisit only if a unified audit viewer ever needs shape uniformity. - Runtime registry validation (throw when
entityTypeis not a known structural type) — would couple the service to the registry and break the legacy value-audit callers' freedom; the prefix assert plus review discipline suffice — follow-up: revisit if a divergent string ever reaches a tree. - Base-service auto-hook (ch20 §8) — unchanged, still not built — follow-up: existing deferral in the 2026-06-25 spec.
- Envelope v2 rules — only the governance note in §4 (additive keys don't bump
v; semantic changes do); no v2 is designed — follow-up: whichever future spec first needs a semantic change.
10. Open questions¶
- Q1 — ship the batch variant
recordStructuralChangeswith D, or defer to its first bulk consumer? Recommended: ship with D. It is a mechanical mirror of the existingrecordMany(onecreateMany, empty-list no-op), and deferring it invites C2's bulk paths (setup full-replace, bulk curriculum assignment) to invent a divergent one mid-flight. Resolved at review, 2026-07-26: ship it (as recommended). - Q2 — pre-seed G's
.amendedconstants instructural-audit.constants.tsnow, or reserve the verb and let G append? Recommended: reserve the verb rule here (G-exclusive, §5.2) and let G append its concrete constants via the registry procedure — G's spec owns amendment semantics, and constants without a shipped writer tend to drift from the design that finally lands. Resolved at review, 2026-07-26: G constants remain deferred — the verb stays reserved here; G (itself deferred, contract §2a) appends its concrete constants on revival.
11. Verification plan¶
- Unit specs:
src/audit-log/audit.service.spec.ts(extend — same constructed-service/mocked-txstyle as the existingrecord/recordManycases):recordStructuralChangewrites exactly onetx.auditLog.createwithdataexactly{ v: 1, before, after, effectiveOn }; omittedeffectiveOnstoresnull;actor.userId: null→actorUserId: null; anAuditActor-shaped value (with its extratenantId) is accepted asinput.actor(compile-level assertion in the spec); prefix assert throws onaction: 'homeroom_membership'and onaction: 'student_placement.moved'underentityType: 'homeroom_membership';requestContextrules — omitted →{ requestId }, object → merged with caller keys winning, no CLS requestId →null;recordStructuralChangesemits onecreateManywith per-row parity to the singular and no-ops on[].src/audit-log/audit-log.queries.spec.ts(extend):buildStructuralAuditDataalways emits all four keys;toJsonSnapshotturnsDatefields into ISO strings and dropsundefinedkeys.- E2E specs: none for this spec — there is no HTTP surface change, and the write path is exercised end-to-end by the first consumer's e2e (spec A's publish flow asserts an envelope row lands and rolls back with the transaction). The existing admin-list behavior is already covered by the module's shipped tests; structural rows add no branch to it.
- Manual verification: after the first consumer lands,
GET /audit-log?entityType=timetableas a tenant admin and eyeball one envelope row rendering through the unchanged DTO.
Patterns: chapter 09 (testing) and feedback_e2e_isolation_patterns.md for the consumer-side e2e discipline (not exercised here).
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-07-26
- Chat reference: 2026-07-26 temporal-program review conversation — batch approval of the reviewed program ("approved, but proceed one spec at a time from now on, approve the first and craft its plan"); D is the first spec in §2's order. Program contract ratified in the same message (amendment batch §2b #1–#20 folded).
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.