diff --git a/CHANGELOG.md b/CHANGELOG.md index a4fe394..3653253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## 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 +the forms that capture it), grounded in CDISC USDM v4 and timed to ICH +M11's adoption as a final guideline (November 2025). + +### Protocol-first build path (ADR-0012) +- Import a USDM v4 protocol package (JSON; Excel authoring via the + external usdm4-excel converter is documented) as an immutable, + versioned protocol artifact +- The compiler derives the build from the protocol: encounters → events + (with planned timing and visit windows carried as `edc:` extensions), + scheduled activities → forms, biomedical concepts → items/codelists + via a bundled mapping pack curated from the open COSMoS dataset + (MIT, pinned sha; no CDISC text or runtime CDISC Library dependency) +- A review screen shows the protocol's schedule of activities with + per-concept resolution status; surrogate/unmatched concepts become + draft items that must be completed before publish, so published + builds are always capture-ready +- Publishing runs through the same single build write path as ODM + import and the visual builder; per-field protocol traceability is + recorded relationally and in the exported ODM (`edc:UsdmRef`) +- New guide pages: "Why protocol-first?" (for readers new to USDM/M11) + and "Protocol import (USDM)" (including authoring the Excel workbook + properly) + +### Site form layouts (ADR-0013) +- Sites adapt form layout/workflow per site — regroup, reorder, relabel + — as append-only-versioned variants that only reference build fields +- Data-equivalence is validated structurally on every edit (exact + coverage of the governed items, no additions, mandatory flags may + only strengthen); submission is blocked until clean, so the sponsor's + approval queue reviews workflow, not data integrity +- Capture through an approved layout pins the variant version while + every value write keys on canonical build identifiers: captured data + is byte-identical in shape across sites regardless of layout +- Amendments revalidate approved layouts automatically: equivalent + layouts carry forward, the rest are marked stale with notifications + and capture falls back to the standard forms +- New permission `site.forms.manage` (site-scoped, seeded to the + coordinator role); sponsor decisions ride `study.manage` + ## v0.4.0 — Python workbench and blinding governance (2026-07) The analytics workbench speaks Python as well as R and SQL, the engines diff --git a/apps/api/src/db/schema/notifications.ts b/apps/api/src/db/schema/notifications.ts index b440d01..4a13781 100644 --- a/apps/api/src/db/schema/notifications.ts +++ b/apps/api/src/db/schema/notifications.ts @@ -35,6 +35,7 @@ export const notifications = pgTable( "form.awaiting_signature", "form.overdue", "security.anomaly", + "site_forms.stale", ], }).notNull(), title: text("title").notNull(), diff --git a/apps/api/src/routes/site-forms.test.ts b/apps/api/src/routes/site-forms.test.ts index f18f7ef..efb4276 100644 --- a/apps/api/src/routes/site-forms.test.ts +++ b/apps/api/src/routes/site-forms.test.ts @@ -153,8 +153,8 @@ describe.skipIf(!dbAvailable)("site form variants (integration)", () => { }); afterAll(async () => { + // The DB client is shared with the amendment suite below; it closes there. await server.close(); - await client.end(); }); const auth = (token: string) => ({ authorization: `Bearer ${token}` }); @@ -344,3 +344,182 @@ describe.skipIf(!dbAvailable)("site form variants (integration)", () => { expect(res.statusCode).toBe(400); }); }); + +describe.skipIf(!dbAvailable)("amendment integration for variants (integration)", () => { + // Reuses the DB from the suite above via fresh fixtures. + let server: FastifyInstance; + const suffix = randomUUID().slice(0, 8); + const fx = { managerToken: "", coordToken: "", studyId: "", siteId: "", versionId: "" }; + + beforeAll(async () => { + await runMigrations(); + server = await buildServer({ db }); + await server.ready(); + + const passwordHash = await hashPassword(PASSWORD); + const mkUser = async (username: string, isSystemAdmin = false) => { + const [user] = await db + .insert(users) + .values({ + username, + email: `${username}@example.com`, + fullName: username, + passwordHash, + isSystemAdmin, + }) + .returning(); + if (!user) throw new Error("fixture failed"); + return user; + }; + const manager = await mkUser(`amm-${suffix}`); + const coord = await mkUser(`amc-${suffix}`); + const admin = await mkUser(`amadm-${suffix}`, true); + const [study] = await db + .insert(studies) + .values({ oid: `ST.AM.${suffix}`, name: "Amendment Variants Study" }) + .returning(); + const [site] = await db + .insert(sites) + .values({ studyId: study?.id ?? "", oid: "SITE.AM", name: "Amendment Site" }) + .returning(); + if (!study || !site) throw new Error("fixture failed"); + fx.studyId = study.id; + fx.siteId = site.id; + + const roleId = async (name: string) => { + const [role] = await db.select().from(roles).where(eq(roles.name, name)); + if (!role) throw new Error(`seeded role ${name} missing`); + return role.id; + }; + await grantRole(db, { + userId: manager.id, + studyId: study.id, + roleId: await roleId("data_manager"), + grantedBy: admin.id, + }); + await grantRole(db, { + userId: coord.id, + studyId: study.id, + roleId: await roleId("data_entry"), + siteId: site.id, + grantedBy: admin.id, + }); + + const login = async (username: string) => { + const res = await server.inject({ + method: "POST", + url: "/auth/login", + payload: { username, password: PASSWORD }, + }); + return res.json().token as string; + }; + fx.managerToken = await login(`amm-${suffix}`); + fx.coordToken = await login(`amc-${suffix}`); + + const imported = await server.inject({ + method: "POST", + url: `/studies/${fx.studyId}/metadata-versions`, + headers: { authorization: `Bearer ${fx.managerToken}` }, + payload: { content: demoOdm, note: "baseline" }, + }); + if (imported.statusCode !== 201) throw new Error(`build import failed: ${imported.body}`); + + // Approved screening variant. + const created = await server.inject({ + method: "POST", + url: `/studies/${fx.studyId}/sites/${fx.siteId}/form-variants`, + headers: { authorization: `Bearer ${fx.coordToken}` }, + payload: { name: "Clinic flow", seedEventOids: ["SE.SCREENING"] }, + }); + if (created.statusCode !== 201) throw new Error(`variant create failed: ${created.body}`); + fx.versionId = created.json().versionId; + await server.inject({ + method: "POST", + url: `/studies/${fx.studyId}/sites/${fx.siteId}/form-variants/versions/${fx.versionId}/submit`, + headers: { authorization: `Bearer ${fx.coordToken}` }, + }); + const approved = await server.inject({ + method: "POST", + url: `/studies/${fx.studyId}/form-variants/versions/${fx.versionId}/approve`, + headers: { authorization: `Bearer ${fx.managerToken}` }, + payload: {}, + }); + if (approved.statusCode !== 200) throw new Error(`approve failed: ${approved.body}`); + }); + + afterAll(async () => { + await server.close(); + await client.end(); + }); + + const auth = (token: string) => ({ authorization: `Bearer ${token}` }); + + it("carries an equivalent variant forward across an untouched amendment", async () => { + const res = await server.inject({ + method: "POST", + url: `/studies/${fx.studyId}/metadata-versions`, + headers: auth(fx.managerToken), + payload: { content: demoOdm, note: "amendment without screening changes" }, + }); + expect(res.statusCode).toBe(201); + + const effective = await server.inject({ + method: "GET", + url: `/studies/${fx.studyId}/sites/${fx.siteId}/effective-forms?eventOid=SE.SCREENING`, + headers: auth(fx.coordToken), + }); + expect(effective.json().source).toBe("variant"); + + const variants = await server.inject({ + method: "GET", + url: `/studies/${fx.studyId}/sites/${fx.siteId}/form-variants`, + headers: auth(fx.coordToken), + }); + const latest = variants.json()[0].latest; + expect(latest.status).toBe("approved"); + expect(latest.decisionNote).toContain("carried forward"); + }); + + it("stales the variant when the amendment changes its events, falling back to standard forms", async () => { + const amended = demoOdm + .replace( + '', + '\n ', + ) + .replace( + '', + '\n Referring site\n \n ', + ); + const res = await server.inject({ + method: "POST", + url: `/studies/${fx.studyId}/metadata-versions`, + headers: auth(fx.managerToken), + payload: { content: amended, note: "amendment adding a screening item" }, + }); + expect(res.statusCode).toBe(201); + + const effective = await server.inject({ + method: "GET", + url: `/studies/${fx.studyId}/sites/${fx.siteId}/effective-forms?eventOid=SE.SCREENING`, + headers: auth(fx.coordToken), + }); + expect(effective.json().source).toBe("standard"); + + const variants = await server.inject({ + method: "GET", + url: `/studies/${fx.studyId}/sites/${fx.siteId}/form-variants`, + headers: auth(fx.coordToken), + }); + const versions = variants.json()[0].versions as { status: string }[]; + expect(versions.some((v) => v.status === "stale")).toBe(true); + + const notifications = await server.inject({ + method: "GET", + url: "/notifications", + headers: auth(fx.coordToken), + }); + expect(notifications.statusCode).toBe(200); + const items = notifications.json().items ?? notifications.json(); + expect(JSON.stringify(items).includes("needs an update")).toBe(true); + }); +}); diff --git a/apps/api/src/services/notifications.ts b/apps/api/src/services/notifications.ts index f987e98..e6114a5 100644 --- a/apps/api/src/services/notifications.ts +++ b/apps/api/src/services/notifications.ts @@ -5,6 +5,7 @@ import type { Db } from "../db/client.js"; import { notifications } from "../db/schema/index.js"; export type NotificationType = + | "site_forms.stale" | "query.opened" | "query.answered" | "form.awaiting_signature" diff --git a/apps/api/src/services/site-forms.ts b/apps/api/src/services/site-forms.ts index d837624..0cfbb7a 100644 --- a/apps/api/src/services/site-forms.ts +++ b/apps/api/src/services/site-forms.ts @@ -13,6 +13,7 @@ import { siteFormVariantVersions, studyMetadataVersions, } from "../db/schema/index.js"; +import { notifyPermissionHolders } from "./notifications.js"; import type { StudyBuildDefinition } from "./study-builds.js"; /** @@ -306,3 +307,127 @@ export async function effectiveFormsForEvent( forms: forms.map((f) => ({ oid: f.oid, name: f.name })), }; } + +/** + * Amendment integration: when a new build publishes, every approved variant + * is revalidated against it. Data-equivalent variants are carried forward + * automatically (a cloned approved version targeting the new build, audited + * as a system action); the rest are marked stale — capture falls back to the + * standard forms until the site submits an updated layout — and both the + * site and the sponsor are notified. Runs inside the build-publish + * transaction so the outcome commits atomically with the amendment; variant + * problems never block the amendment itself. + */ +export async function revalidateVariantsForBuild( + db: Db, + input: { studyId: string; newMetadataVersionId: string; actorId: string }, +): Promise<{ carried: number; staled: number }> { + const newMdv = await buildDefinition(db, input.newMetadataVersionId); + + const approvedRows = await db + .select({ + versionId: siteFormVariantVersions.id, + variantId: siteFormVariantVersions.variantId, + version: siteFormVariantVersions.version, + metadataVersionId: siteFormVariantVersions.metadataVersionId, + definition: siteFormVariantVersions.definition, + siteId: siteFormVariants.siteId, + name: siteFormVariants.name, + }) + .from(siteFormVariantVersions) + .innerJoin(siteFormVariants, eq(siteFormVariantVersions.variantId, siteFormVariants.id)) + .where( + and( + eq(siteFormVariants.studyId, input.studyId), + eq(siteFormVariantVersions.status, "approved"), + ), + ); + + let carried = 0; + let staled = 0; + for (const row of approvedRows) { + if (row.metadataVersionId === input.newMetadataVersionId) continue; + const definition = siteFormVariantDefinitionSchema.parse(row.definition); + const issues = validateVariantCoverage(newMdv, definition).filter( + (i) => i.severity === "error", + ); + + if (issues.length === 0) { + const [latest] = await db + .select({ version: siteFormVariantVersions.version }) + .from(siteFormVariantVersions) + .where(eq(siteFormVariantVersions.variantId, row.variantId)) + .orderBy(desc(siteFormVariantVersions.version)) + .limit(1); + const [carriedRow] = await db + .insert(siteFormVariantVersions) + .values({ + variantId: row.variantId, + version: (latest?.version ?? row.version) + 1, + metadataVersionId: input.newMetadataVersionId, + definition: row.definition as object, + status: "approved", + decidedBy: input.actorId, + decidedAt: new Date(), + decisionNote: "carried forward on amendment (still data-equivalent)", + createdBy: input.actorId, + }) + .returning(); + // The superseded approval is retired so only one approval is live. + await db + .update(siteFormVariantVersions) + .set({ status: "retired" }) + .where(eq(siteFormVariantVersions.id, row.versionId)); + await db.insert(auditEvents).values({ + actorId: input.actorId, + studyId: input.studyId, + action: "site_forms.carried_forward", + entityType: "site_form_variant_version", + entityId: carriedRow?.id ?? row.versionId, + newValue: { variantId: row.variantId, metadataVersionId: input.newMetadataVersionId }, + }); + carried++; + continue; + } + + await db + .update(siteFormVariantVersions) + .set({ status: "stale" }) + .where(eq(siteFormVariantVersions.id, row.versionId)); + await db.insert(auditEvents).values({ + actorId: input.actorId, + studyId: input.studyId, + action: "site_forms.staled", + entityType: "site_form_variant_version", + entityId: row.versionId, + newValue: { + variantId: row.variantId, + metadataVersionId: input.newMetadataVersionId, + issues: issues.map((i) => i.message).slice(0, 5), + }, + }); + const notification = { + studyId: input.studyId, + type: "site_forms.stale" as const, + title: `Site layout "${row.name}" needs an update`, + body: "A new study build changed the data this layout covers. Capture uses the standard forms until an updated layout is approved.", + payload: { variantId: row.variantId, siteId: row.siteId }, + dedupeKey: `site_forms.stale:${row.versionId}`, + }; + await notifyPermissionHolders(db, { + permission: "site.forms.manage", + scope: { studyId: input.studyId, siteId: row.siteId }, + excludeUserId: input.actorId, + notification, + }); + await notifyPermissionHolders(db, { + permission: "study.manage", + scope: { studyId: input.studyId }, + excludeUserId: input.actorId, + notification, + }); + staled++; + } + + return { carried, staled }; +} diff --git a/apps/api/src/services/study-builds.ts b/apps/api/src/services/study-builds.ts index 1b2f624..8298ef2 100644 --- a/apps/api/src/services/study-builds.ts +++ b/apps/api/src/services/study-builds.ts @@ -13,6 +13,7 @@ import { import { and, desc, eq, sql } from "drizzle-orm"; import type { Db } from "../db/client.js"; import { auditEvents, studies, studyMetadataVersions } from "../db/schema/index.js"; +import { revalidateVariantsForBuild } from "./site-forms.js"; /** What a studyMetadataVersions.definition jsonb column holds. */ export interface StudyBuildDefinition { @@ -113,6 +114,19 @@ export async function importStudyBuild( note: input.note ?? null, }, }); + + // Amendment integration for site form variants (BYOFW): approved + // layouts are carried forward when still data-equivalent to the new + // build, staled (with notifications) otherwise. Same transaction, so + // the amendment and its variant outcomes commit together; a stale + // variant never blocks the amendment — capture just falls back to the + // standard forms. + await revalidateVariantsForBuild(tx as unknown as Db, { + studyId: input.studyId, + newMetadataVersionId: row.id, + actorId: input.actorId, + }); + return row; }); diff --git a/docs/adr/0013-site-adaptable-forms.md b/docs/adr/0013-site-adaptable-forms.md new file mode 100644 index 0000000..b6884d9 --- /dev/null +++ b/docs/adr/0013-site-adaptable-forms.md @@ -0,0 +1,61 @@ +# ADR-0013: Sponsor-governed data, site-adaptable form layouts + +**Status:** accepted · 2026-07-18 + +## Context + +An EDC build fixes both what data is collected and how every site's forms look. +Sites work differently — clinic order, terminology, how many screens a visit +takes — and forcing one sponsor-designed layout on all of them optimises for one +stakeholder. The BYOFW idea ("bring your own forms and workflow", Bain 2026): +the sponsor governs the data, its structure, and its protocol relationship; the +site controls the presentation. The known failure mode is late-stage +sponsor/site cat-and-mouse over site-built forms and data managers facing a +different data shape per site. E6(R3) pulls in both directions: tools "designed +to capture the information required by the protocol" (§3.16.1(d)) and no +unnecessary burden on investigators (§3.1.4). + +## Decision + +- The governed layer is the published build itself, projected by + `governedRequirements(mdv)`: every item each event collects, with its + mandatory flag and canonical group. Computed from ODM, so it works for all + build paths (ADR-0003, ADR-0012), not just protocol-derived ones. +- A site form variant is a named, append-only-versioned definition that only + *references* build ItemDefs: regroup into different forms/sections, reorder, + relabel in site wording. It cannot define data. +- `validateVariantCoverage` makes data-equivalence structural, not reviewed: + per touched event the variant must cover exactly the governed items (no + omissions, no additions — additions are a sponsor amendment), may only + strengthen mandatory flags, cannot regroup items out of repeating groups, + and cannot touch events the sponsor flagged `edc:LayoutLocked`. Submission + is blocked while errors remain, so the sponsor's approval queue only ever + contains provably equivalent layouts — approval reviews workflow + suitability, which is what kills the cat-and-mouse dynamic. +- Lifecycle: draft → submitted → approved | changes_requested, plus retired + and stale. Site-scoped `site.forms.manage` authors; `study.manage` decides. + Every transition is audited; a new approval retires its predecessor. +- Capture pins variant instances (`V.*` form OIDs) to the approved variant + version, but every value write keys on the item's canonical build group. + `item_value_versions`, checks, coding, snapshots, exports, and the lake are + untouched and byte-identical in shape across sites — the one-canonical-shape + guarantee data managers rely on. The subject matrix intentionally stays on + the standard (canonical) layout as the oversight view. +- Amendments: inside the build-publish transaction, approved variants are + revalidated against the new build. Still-equivalent layouts carry forward + automatically (audited system action); the rest go stale, site and sponsor + are notified, and capture falls back to the standard forms until an updated + layout is approved. A variant can never block an amendment. + +## Consequences + +- Sites get real workflow control with zero data divergence; the sponsor + reviews far less (equivalence is machine-checked) but keeps explicit + approval and per-event layout locks. +- Variants are presentation-only by construction: no per-site edit checks, + no per-site data, no site-specific analytics shape. +- Repeating-group internals cannot be regrouped in v1; those items stay in + their canonical sections within variant events. +- The variant `definition` jsonb and the per-site `effective-forms` endpoint + are the seams for future eSource/device workflow layering (lab ordering, + device pulls scheduled around site workflow), which stays out of scope here. diff --git a/docs/regulatory-traceability.md b/docs/regulatory-traceability.md index 2b21ff1..59dc453 100644 --- a/docs/regulatory-traceability.md +++ b/docs/regulatory-traceability.md @@ -46,6 +46,8 @@ Status legend: 🟢 implemented · 🟡 in progress · ⚪ planned | E6-11 | Data transfer, exchange and migration: integrity, documented traceability, reconciliation to avoid loss and unintended modification (§4.2.5) | External data (lab CSV, RTSM assignments) lands through the standard audited write path with origin-tagged audit actions (`item_value.imported` / `item_value.integrated`); transfers never overwrite (identical replays idempotent, differing values reported as conflicts, nothing written); append-only `rtsm_events` records every RTSM POST including rejects as the reconciliation basis | 🟢 `lab-imports.test.ts`, `rtsm-intake.test.ts` | | E6-12 | Safeguard blinding in data governance: systems design, user accounts, data access, data transfers (§4.1.1) | Item-level blinding (ADR-0009) enforced at form reads, casebooks, audit, and structurally at lake publish; RTSM arm lands on a blinded item via write-only API-key principals; intake events listing masks arm/strata for viewers without study-wide `data.unblind` | 🟢 `blinding.test.ts`, `rtsm.test.ts`, `rtsm-intake.test.ts` | | E6-13 | Documentation of any planned or unplanned unblinding, including inadvertent or emergency unblinding (§4.1.4) | Explicit per-subject break-the-blind action (`data.unblind`-gated): category (planned / emergency / inadvertent / other) and reason required, recorded in append-only `subject_unblindings` and audited as `subject.unblinded`; surfaced in the audit trail, subject matrix, and casebook PDF; complements the audited `data.unblind` grant history (who *could* see unblinded data) | 🟢 `unblind.test.ts` | +| E6-14 | Data acquisition tools fit for purpose and "designed to capture the information required by the protocol" (§3.16.1(d)) | Protocol-first build path: a USDM protocol version compiles into the build through the single write path, with per-field traceability (`protocol_traceability` + `edc:UsdmRef` extensions in the exported ODM) recording which protocol element each event/form/item derives from; unresolved concepts block publish so builds are always capture-ready | 🟢 `protocols.test.ts`, `compile.test.ts` | +| E6-15 | Avoid unnecessary complexity and burden on participants and investigators; tools fit for purpose, clear, concise, consistent (§3.1.4) | Site form variants: sites adapt form layout/workflow to their own practice under structural data-equivalence validation (exact governed-item coverage, no additions or omissions) and sponsor approval, so reduced site burden never changes the collected data; amendments auto-carry equivalent layouts and stale the rest with notifications | 🟢 `site-forms.test.ts`, `variants.test.ts` | ## Data protection @@ -61,3 +63,4 @@ Status legend: 🟢 implemented · 🟡 in progress · ⚪ planned | SC-01 | CDISC ODM v2.0 (XML + JSON) | `packages/odm` import/export, tested against official CDISC examples with round-trips | 🟢 `parse.test.ts`, `study-builds.test.ts` | | SC-02 | CDISC Dataset-JSON v1.1 | Per-dataset export from pinned snapshots (item OIDs/labels/types from the snapshot manifest) | 🟢 `snapshots.test.ts` | | SC-03 | CDASH-aligned demo CRFs | `examples/demo-study.xml` (CDASH-aligned events/items/codelists/checks) + one-command `db:seed-demo` | 🟢 | +| SC-04 | CDISC USDM v4 protocol ingestion | `packages/usdm` parses/validates the USDM v4 API wrapper (field names verified against the DDF-RA v4.0.0 OpenAPI spec) and compiles it to the ODM build; demo package in `examples/demo-protocol-usdm.json` | 🟢 `parse.test.ts` (usdm), `protocols.test.ts` | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f8f53a..3bc0acd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: '@edc-core/schemas': specifier: workspace:* version: link:../../packages/schemas + '@edc-core/usdm': + specifier: workspace:* + version: link:../../packages/usdm '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 diff --git a/site/_quarto.yml b/site/_quarto.yml index c4dd291..55bf1f5 100644 --- a/site/_quarto.yml +++ b/site/_quarto.yml @@ -37,6 +37,7 @@ website: - guide/why-protocol-first.qmd - guide/study-builds.qmd - guide/protocol-import.qmd + - guide/site-forms.qmd - guide/data-capture.qmd - guide/lab-import.qmd - guide/rtsm-integration.qmd diff --git a/site/guide/site-forms.qmd b/site/guide/site-forms.qmd new file mode 100644 index 0000000..bbe836c --- /dev/null +++ b/site/guide/site-forms.qmd @@ -0,0 +1,72 @@ +--- +title: "Site form layouts" +--- + +The sponsor's build defines *what* every site collects. Site form layouts let +each site decide *how*: regroup the fields of a visit into the forms that +match its clinic flow, reorder them, and relabel questions in the wording its +staff actually use. The data never changes: a layout only rearranges +references to the build's fields, and the system proves that on every edit. + +This is the site half of the same idea as the +[protocol-first path](why-protocol-first.qmd): the protocol (or the sponsor's +build) governs the data; presentation is adaptable per stakeholder. + +## For site coordinators + +Open **site forms** from the study page (you need the site-scoped +`site.forms.manage` permission, seeded to the coordinator role). + +1. **Start from the standard layout.** A new layout is always a copy of the + sponsor's forms for your site, never a blank canvas. Name it after your + workflow ("Clinic flow", "Two-room screening"). +2. **Adapt it.** Reorder fields, rename forms, add site wording next to any + question. The panel above the editor tells you continuously whether the + layout is still *data-equivalent* to the sponsor's: collecting exactly the + same fields, with at least the same required flags. Anything that would + drop, add, or weaken a field is flagged immediately; you cannot submit + until it's clean. +3. **Submit for approval.** The sponsor sees your layout with the equivalence + check already passed, so the review is about workflow, not data. You'll + get it back approved or with a change note. +4. **Capture through it.** Once approved, the capture launcher on the same + page opens your layout's forms for your site's subjects. Data entry, + queries, signatures, and edit checks behave exactly as on standard forms. + +Some events may refuse layouts entirely: the sponsor can lock the layout of +sensitive events (consent, endpoint assessments). + +## For sponsors and data managers + +**Layout approvals** on the study page lists submitted layouts. Every entry +already passed structural data-equivalence validation (the badge is +machine-checked, not self-declared), so the decision is whether the workflow +is acceptable, with an optional note back to the site. + +The part that matters for data management: captured values are identical in +shape regardless of layout. Value rows key on the build's canonical field and +group identifiers, so review tools, exports, snapshots, coding, and analytics +see one data shape across all sites. The subject matrix deliberately keeps +the sponsor's standard layout as the oversight view; a variant only changes +what the site sees during entry. + +## Amendments + +When a new build publishes, every approved layout is rechecked against it +automatically: + +- Still equivalent: the layout is carried forward, marked with an audited + "carried forward on amendment" note. Nothing to do. +- No longer equivalent (the amendment changed the fields of an event the + layout covers): the layout is marked **stale**, the site and sponsor are + notified, and capture falls back to the standard forms for that site until + the site submits an updated layout and it is re-approved. A stale layout + never delays the amendment itself. + +## Audit trail + +Every step is recorded: each saved draft is a new immutable version, and each +transition (submit, approve, request changes, retire, carry-forward, stale) +writes an audit event with actor and note. Form instances record which layout +version captured them, so "what did the site see when this value was entered" +has an exact answer.