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.