Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## Unreleased

### Dynamic and linked fields (ADR-0014)
- Skip logic: `CollectionExceptionConditionOID` on ItemRef and ItemGroupRef
is now enforced at runtime — skipped fields hide in capture, writes to
them are rejected on every intake path (UI, RTSM, lab import), and a
value already saved in a field that becomes skipped raises a system
query until the site clears it or the controlling answer changes back
- Dependent option lists: a new `edc:CollectionExceptionConditionOID`
vendor extension on CodeListItem withdraws an option when its condition
is true; plain-ODM consumers see the full list
- Derived values: `MethodDef` jsonata expressions now compute item values
server-side after every accepted write (audited as `item_value.derived`),
with a live read-only preview in form entry; direct writes to derived
items are rejected, and derivation cycles fail build validation
- Edit checks are skip-aware (not-collected fields cannot fire them), and
the client and server evaluate one shared pipeline, extending the
ADR-0007 dual-evaluation pattern
- The demo study now exercises all three: a pregnancy-test item skipped
for male subjects, a conditional "Not performed" option, and a BMI item
derived from height and weight

## v0.5.0 — Protocol-first builds and site form layouts (2026-07)

Two halves of one idea (BYOFW: the sponsor governs the data, sites adapt
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,11 @@ on one host (shared Keycloak SSO, one Postgres, one Caddy), start from
- Study builds from CDISC ODM v2.0 (file, API, or visual builder), versioned
and immutable, with mid-study amendment migration (build diff, impact
analysis, batch re-pointing of unsigned forms)
- Metadata-driven data capture with JSONata edit checks, repeating item
groups, a server-enforced entry workflow (in progress → complete → verified
→ signed → locked), and an audited subject lifecycle (screening → enrolled →
completed / withdrawn, with reinstate)
- Metadata-driven data capture with JSONata edit checks, dynamic fields
(skip logic, dependent code lists, server-computed derived values —
ADR-0014), repeating item groups, a server-enforced entry workflow (in
progress → complete → verified → signed → locked), and an audited subject
lifecycle (screening → enrolled → completed / withdrawn, with reinstate)
- Append-only audit trail (database-enforced), threaded query management,
Part 11 e-signatures, audit review UI
- Access control: unique accounts with per-study/per-site role scoping, admin
Expand Down
26 changes: 16 additions & 10 deletions apps/api/src/db/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ export interface ItemValueWrite {
/** Required when changing an existing value (clinical correction convention). */
reasonForChange?: string;
/** Set by machine write paths: the audit trail records the value as
* item_value.imported (lab import) or item_value.integrated (RTSM intake)
* so data origin is permanently distinguishable. */
origin?: "import" | "integration";
* item_value.imported (lab import), item_value.integrated (RTSM intake),
* or item_value.derived (MethodDef computation) so data origin is
* permanently distinguishable. */
origin?: "import" | "integration" | "derivation";
}

/**
Expand Down Expand Up @@ -69,13 +70,18 @@ export async function appendItemValue(db: Db, write: ItemValueWrite) {
await tx.insert(auditEvents).values({
actorId: write.actorId,
studyId: write.studyId,
action: latest
? "item_value.changed"
: write.origin === "import"
? "item_value.imported"
: write.origin === "integration"
? "item_value.integrated"
: "item_value.entered",
// Derivations stay item_value.derived even on change: the audit trail
// must always show the value as system-computed, never user-corrected.
action:
write.origin === "derivation"
? "item_value.derived"
: latest
? "item_value.changed"
: write.origin === "import"
? "item_value.imported"
: write.origin === "integration"
? "item_value.integrated"
: "item_value.entered",
entityType: "item_value",
entityId: inserted.id,
oldValue: latest ? { value: latest.value } : null,
Expand Down
15 changes: 13 additions & 2 deletions apps/api/src/routes/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ import {
writeItemValue,
} from "../services/capture.js";
import { generateSubjectCasebook } from "../services/casebook.js";
import { evaluateFormChecks } from "../services/checks.js";
import { ExportError } from "../services/exports.js";
import { assertWriteAllowed, loadFormState, runPostWritePipeline } from "../services/form-state.js";
import { listFormSignatures, SignatureError, signForm } from "../services/signatures.js";
import type { StudyBuildDefinition } from "../services/study-builds.js";

Expand Down Expand Up @@ -549,6 +549,17 @@ export const captureRoutes: FastifyPluginAsync = async (app) => {
}

try {
// Dynamic-field gate (ADR-0014): no writes to derived items, skipped
// fields (except clearing), or currently excluded code list options.
const formState = await loadFormState(app.db, context);
if (formState) {
assertWriteAllowed(formState, {
itemGroupOid: parsed.data.itemGroupOid,
itemGroupRepeatKey: parsed.data.itemGroupRepeatKey,
itemOid: parsed.data.itemOid,
value: parsed.data.value,
});
}
const version = await writeItemValue(app.db, context, {
itemGroupOid: parsed.data.itemGroupOid,
itemOid: parsed.data.itemOid,
Expand All @@ -559,7 +570,7 @@ export const captureRoutes: FastifyPluginAsync = async (app) => {
: {}),
...(parsed.data.reasonForChange ? { reasonForChange: parsed.data.reasonForChange } : {}),
});
const findings = await evaluateFormChecks(app.db, context, user.id);
const findings = await runPostWritePipeline(app.db, context, user.id);
return reply.code(201).send({ ...version, findings });
} catch (err) {
if (err instanceof Error && /reasonForChange is required/.test(err.message)) {
Expand Down
Loading
Loading