Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
929ed7a
docs: add design spec for NIST SP800-53 controls grouping
Marfuen May 21, 2026
494673d
docs: add implementation plan for NIST SP800-53 controls grouping
Marfuen May 21, 2026
fb89192
feat(db): add controlFamily to FrameworkEditorControlTemplate
Marfuen May 21, 2026
a799afa
feat(app): add controlTemplate to FrameworkInstanceWithControls type
Marfuen May 21, 2026
c398365
feat(api): include controlTemplate.controlFamily in framework control…
Marfuen May 21, 2026
9188ab0
feat(api): add controlFamily to control template DTOs and service
Marfuen May 21, 2026
f713e22
refactor(app): extract shared helpers from FrameworkControls
Marfuen May 21, 2026
aabe2fc
feat(app): add FrameworkControlsGrouped component with expand/collapse
Marfuen May 21, 2026
b4be0f9
feat(app): switch between flat and grouped controls view based on fam…
Marfuen May 21, 2026
931bd90
feat(framework-editor): add controlFamily column to control template …
Marfuen May 21, 2026
97971fd
feat(framework-editor): add ComboboxCell for control family selection
Marfuen May 22, 2026
d863b48
feat(app): default expand all families and add family filter
Marfuen May 22, 2026
45ebb36
fix(app): improve family header contrast and filter dropdown UX
Marfuen May 22, 2026
ab26667
fix(app): use bg-secondary for family header rows
Marfuen May 22, 2026
2f06113
fix(app): use default size for family filter button
Marfuen May 22, 2026
0c380bf
fix(app): use default size for collapse all button
Marfuen May 22, 2026
3332dfa
fix(app): use ghost variant for collapse all button
Marfuen May 22, 2026
8d2021f
fix(framework-editor): add clear option to ComboboxCell
Marfuen May 22, 2026
1fc7eb0
feat(framework-editor): add Manage Families dialog for rename and delete
Marfuen May 22, 2026
f1ea8b4
feat(framework-editor): add search and scroll to Manage Families dialog
Marfuen May 22, 2026
cc9572b
fix(framework-editor): use larger dialog for Manage Families
Marfuen May 22, 2026
730c183
refactor(framework-editor): use design system Dialog for Manage Families
Marfuen May 22, 2026
bf3b4e5
fix(framework-editor): use @trycompai/ui Dialog instead of design-system
Marfuen May 22, 2026
705ff4b
fix(framework-editor): transpile @trycompai/design-system and use DS …
Marfuen May 22, 2026
8fba938
fix(framework-editor): add design-system to tailwind content paths
Marfuen May 22, 2026
840dd5a
fix(framework-editor): hide built-in close button on Manage Families …
Marfuen May 22, 2026
253eee0
feat(framework-editor): show affected controls when deleting a family
Marfuen May 22, 2026
ebfb76b
feat(framework-editor): show framework names for each affected contro…
Marfuen May 22, 2026
c29dbd7
feat(framework-editor): scalable delete confirmation with framework i…
Marfuen May 22, 2026
6b44c00
fix(framework-editor): clean up delete family confirmation UI
Marfuen May 22, 2026
9931c85
feat(framework-editor): add double confirmation for family deletion
Marfuen May 22, 2026
535b189
feat(db): add controlFamily to Control model and versioning flow
Marfuen May 22, 2026
2ab438d
refactor(app): read controlFamily from Control instead of template
Marfuen May 22, 2026
a1e0d09
fix(api): use null instead of undefined for controlFamily in manifest
Marfuen May 22, 2026
1c45472
fix(api): allow null controlFamily in source loader type
Marfuen May 22, 2026
5231bbc
feat(framework-editor): show control family changes in version diff view
Marfuen May 22, 2026
454b98d
feat(app): show control family change details in framework update review
Marfuen May 22, 2026
6f55829
feat(app): persist control filters in URL using nuqs
Marfuen May 22, 2026
8843e1a
fix(app): use TableRow with data-state instead of raw tr
Marfuen May 22, 2026
1713ebd
fix(app): eliminate expand flash by tracking collapsed instead of exp…
Marfuen May 22, 2026
eb9aa5b
Merge remote-tracking branch 'origin/main' into mariano/nist-sp800-53…
Marfuen May 22, 2026
fc00951
fix(db): backfill framework-scoped control links after seeding
Marfuen May 22, 2026
f02953b
feat(app): show control count per family in filter dropdown
Marfuen May 22, 2026
4edced7
feat(db): scope controlFamily per framework instance via FrameworkCon…
Marfuen May 25, 2026
9aada65
refactor(api): use FrameworkControlFamily table in sync, fetch, and p…
Marfuen May 25, 2026
42f01c5
fix(api): record and restore FrameworkControlFamily changes during ro…
Marfuen May 25, 2026
053720c
fix(api): include controlFamily in framework export/import
Marfuen May 25, 2026
aa876e2
fix: address review findings — empty string normalization, perf, empt…
Marfuen May 25, 2026
696a89e
fix(app): use pipe separator for families URL param to support commas…
Marfuen May 25, 2026
e789dbd
chore(db): squash controlFamily migrations to avoid column churn
Marfuen May 25, 2026
dbb0e9b
refactor(framework-editor): extract useFamiliesManagement hook from C…
Marfuen May 25, 2026
295652f
fix(app): pass router params as props to GroupedControlRow, add Suspe…
Marfuen May 25, 2026
3c57e55
test(app): add tests for control family grouping, helpers, and change…
Marfuen May 25, 2026
ba10ff9
Merge branch 'main' into mariano/nist-sp800-53-readiness
Marfuen May 25, 2026
3bcf2c2
fix: address review violations — sync guard, client directive, Other …
Marfuen May 25, 2026
751a973
fix: numeric sort for control names, idempotent rollback for family r…
Marfuen May 25, 2026
b1b1026
fix: update tests for UNCATEGORIZED_FAMILY sentinel, update docs for …
Marfuen May 25, 2026
5d16b6f
fix(framework-editor): make ComboboxCell selection idempotent
Marfuen May 25, 2026
d0b9517
fix(framework-editor): fix ComboboxCell Clear button after idempotent…
Marfuen May 25, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export async function buildManifestForFramework(frameworkId: string): Promise<Fr
id: ct.id,
name: ct.name,
description: ct.description,
controlFamily: ct.controlFamily || null,
requirementIds: ct.requirements
.map((r) => r.id)
.filter((id) => ownRequirementIds.has(id)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export class ControlTemplateService {
data: {
name: dto.name,
description: dto.description ?? '',
controlFamily: dto.controlFamily || null,
},
});
this.logger.log(`Created control template: ${ct.name} (${ct.id})`);
Expand All @@ -120,6 +121,7 @@ export class ControlTemplateService {
data: {
name: dto.name,
description: dto.description ?? '',
controlFamily: dto.controlFamily || null,
},
});
await tx.frameworkEditorControlDocumentTypeLink.createMany({
Expand All @@ -144,6 +146,7 @@ export class ControlTemplateService {
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.controlFamily !== undefined && { controlFamily: dto.controlFamily || null }),
},
});
this.logger.log(`Updated control template: ${updated.name} (${id})`);
Expand All @@ -163,6 +166,7 @@ export class ControlTemplateService {
data: {
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.controlFamily !== undefined && { controlFamily: dto.controlFamily || null }),
},
});
await tx.frameworkEditorControlDocumentTypeLink.deleteMany({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export class CreateControlTemplateDto {
@MaxLength(5000)
description: string;

@ApiPropertyOptional({ example: 'AC - Access Control' })
@IsString()
@IsOptional()
@MaxLength(255)
controlFamily?: string;

@ApiPropertyOptional({ example: ['penetration-test', 'rbac-matrix'] })
@IsArray()
@IsString({ each: true })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ class ImportControlTemplateDto {
@MaxLength(5000)
description: string;

@ApiPropertyOptional({ example: 'AC - Access Control' })
@IsString()
@IsOptional()
@MaxLength(255)
controlFamily?: string;

@ApiPropertyOptional()
@IsArray()
@ArrayMaxSize(50)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ExportedFramework {
controlTemplates: Array<{
name: string;
description: string;
controlFamily: string | null;
documentTypes: string[];
requirementIndices: number[];
policyTemplateIndices: number[];
Expand Down Expand Up @@ -131,6 +132,7 @@ export class FrameworkExportService {
controlTemplates: controlTemplates.map((ct) => ({
name: ct.name,
description: ct.description,
controlFamily: ct.controlFamily ?? null,
documentTypes: ct.frameworkDocumentLinks.map((link) => link.formType),
requirementIndices: ct.requirements
.map((r) => reqIdToIndex.get(r.id))
Expand Down Expand Up @@ -225,6 +227,7 @@ export class FrameworkExportService {
data: {
name: ct.name,
description: ct.description,
controlFamily: ct.controlFamily ?? null,
requirements: {
connect: (ct.requirementIndices ?? []).map((i) => ({
id: createdRequirements[i].id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ function edgesFromControls<E>(
}

function controlEqual(a: ManifestControl, b: ManifestControl): boolean {
return a.name === b.name && a.description === b.description;
return a.name === b.name && a.description === b.description && (a.controlFamily ?? null) === (b.controlFamily ?? null);
}

function requirementEqual(a: ManifestRequirement, b: ManifestRequirement): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,30 @@ async function replayUndo(
});
}

// Restore control families — guarded so older undo payloads without
// this bucket don't break rollback.
if (ctx.undo.controlFamilies) {
for (const entry of ctx.undo.controlFamilies.created) {
await tx.frameworkControlFamily.deleteMany({
where: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId },
});
}
for (const entry of ctx.undo.controlFamilies.updated) {
await tx.frameworkControlFamily.upsert({
where: { frameworkInstanceId_controlId: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId } },
create: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId, controlFamily: entry.prevFamily },
update: { controlFamily: entry.prevFamily },
});
}
for (const entry of ctx.undo.controlFamilies.deleted) {
await tx.frameworkControlFamily.upsert({
where: { frameworkInstanceId_controlId: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId } },
create: { frameworkInstanceId: entry.frameworkInstanceId, controlId: entry.controlId, controlFamily: entry.prevFamily },
update: { controlFamily: entry.prevFamily },
});
}
}

// Revert framework instance version pointer
await tx.frameworkInstance.update({
where: { id: ctx.syncOp.frameworkInstanceId },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export async function applySync(
frameworkControlPolicyLinks: { connected: [], disconnected: [] },
frameworkControlTaskLinks: { connected: [], disconnected: [] },
frameworkControlDocumentTypeLinks: { connected: [], disconnected: [] },
controlFamilies: { created: [], updated: [], deleted: [] },
};
const summary: SyncSummary = {
controlsAdded: 0, controlsArchived: 0, controlsUpdatedApplied: 0, controlsUpdatedPreserved: 0,
Expand All @@ -83,6 +84,20 @@ export async function applySync(
ctlByTemplate.set(targetControl.id, created);
undo.controls.created.push(created.id);
summary.controlsAdded += 1;
// Per-instance family entry for the new control
if (targetControl.controlFamily) {
await tx.frameworkControlFamily.create({
data: {
frameworkInstanceId: ctx.instance.id,
controlId: created.id,
controlFamily: targetControl.controlFamily,
},
});
undo.controlFamilies!.created.push({
frameworkInstanceId: ctx.instance.id,
controlId: created.id,
});
}
}
for (const removed of diff.controls.removed) {
const inst = ctlByTemplate.get(removed.id);
Expand All @@ -96,6 +111,42 @@ export async function applySync(
for (const u of diff.controls.updated) {
const inst = ctlByTemplate.get(u.id);
if (!inst) continue;

// Sync family assignment regardless of whether the control content was edited.
// Family is structural metadata, not user-authored content.
const existingFamily = await tx.frameworkControlFamily.findUnique({
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
where: { frameworkInstanceId_controlId: { frameworkInstanceId: ctx.instance.id, controlId: inst.id } },
select: { controlFamily: true },
});
if (u.to.controlFamily) {
await tx.frameworkControlFamily.upsert({
where: { frameworkInstanceId_controlId: { frameworkInstanceId: ctx.instance.id, controlId: inst.id } },
create: { frameworkInstanceId: ctx.instance.id, controlId: inst.id, controlFamily: u.to.controlFamily },
update: { controlFamily: u.to.controlFamily },
});
if (existingFamily) {
undo.controlFamilies!.updated.push({
frameworkInstanceId: ctx.instance.id,
controlId: inst.id,
prevFamily: existingFamily.controlFamily,
});
} else {
undo.controlFamilies!.created.push({
frameworkInstanceId: ctx.instance.id,
controlId: inst.id,
});
}
} else if (existingFamily) {
await tx.frameworkControlFamily.deleteMany({
where: { frameworkInstanceId: ctx.instance.id, controlId: inst.id },
});
undo.controlFamilies!.deleted.push({
frameworkInstanceId: ctx.instance.id,
controlId: inst.id,
prevFamily: existingFamily.controlFamily,
});
}

if (isControlEdited(inst, u.from)) {
summary.controlsUpdatedPreserved += 1;
continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface ManifestControl {
id: string; // frk_ct_*
name: string;
description: string;
controlFamily?: string | null;
requirementIds: string[];
policyIds: string[];
taskIds: string[];
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/frameworks/framework-versioning/undo-payload.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface UndoPayload {
frameworkControlPolicyLinks?: ImplicitEdgeBucket;
frameworkControlTaskLinks?: ImplicitEdgeBucket;
frameworkControlDocumentTypeLinks?: ImplicitEdgeBucket;
// Per-instance control family assignments. Older syncs may not have this bucket.
controlFamilies?: ControlFamilyUndoBucket;
}

export interface EntityUndoBucket<Content> {
Expand Down Expand Up @@ -75,6 +77,17 @@ export interface ImplicitEdgeBucket {
disconnected: Array<{ controlId: string; otherId: string }>;
}

/**
* Tracks FrameworkControlFamily changes so rollback can restore prior state.
* `created` entries are deleted on rollback; `updated` entries are restored to
* prevFamily; `deleted` entries are recreated with prevFamily.
*/
export interface ControlFamilyUndoBucket {
created: Array<{ frameworkInstanceId: string; controlId: string }>;
updated: Array<{ frameworkInstanceId: string; controlId: string; prevFamily: string }>;
deleted: Array<{ frameworkInstanceId: string; controlId: string; prevFamily: string }>;
}

export interface SyncSummary {
controlsAdded: number;
controlsArchived: number;
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/frameworks/frameworks-source-loader.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface LoadedFrameworkSources {
id: string;
name: string;
description: string;
controlFamily?: string | null;
documentTypes: EvidenceFormType[];
}>;
policyTemplates: Array<{
Expand Down Expand Up @@ -133,6 +134,7 @@ export async function loadFrameworkSources({
id: c.id,
name: c.name,
description: c.description,
controlFamily: c.controlFamily,
documentTypes: (c.documentTypes ?? []) as EvidenceFormType[],
});
}
Expand Down Expand Up @@ -211,14 +213,15 @@ export async function loadFrameworkSources({

const liveControls = await tx.frameworkEditorControlTemplate.findMany({
where: { requirements: { some: { id: { in: fallbackRequirementIds } } } },
select: { id: true, name: true, description: true, documentTypes: true },
select: { id: true, name: true, description: true, controlFamily: true, documentTypes: true },
});
for (const lc of liveControls) {
if (!controlsMap.has(lc.id)) {
controlsMap.set(lc.id, {
id: lc.id,
name: lc.name,
description: lc.description,
controlFamily: lc.controlFamily ?? undefined,
documentTypes: lc.documentTypes,
});
}
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/frameworks/frameworks-upsert.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export async function upsertOrgFrameworkStructure({
const frameworkControlPolicyEntries: Prisma.FrameworkControlPolicyLinkCreateManyInput[] = [];
const frameworkControlTaskEntries: Prisma.FrameworkControlTaskLinkCreateManyInput[] = [];
const frameworkControlDocumentTypeEntries: Prisma.FrameworkControlDocumentTypeLinkCreateManyInput[] = [];
const frameworkControlFamilyEntries: Prisma.FrameworkControlFamilyCreateManyInput[] = [];
const controlTemplateById = new Map(controlTemplates.map((c) => [c.id, c]));

for (const relation of groupedRelations) {
Expand Down Expand Up @@ -360,6 +361,16 @@ export async function upsertOrgFrameworkStructure({
formType,
});
}

// FrameworkControlFamily: per-instance family grouping from the template.
const template = controlTemplateById.get(relation.controlTemplateId);
if (template?.controlFamily) {
frameworkControlFamilyEntries.push({
frameworkInstanceId,
controlId,
controlFamily: template.controlFamily,
});
}
}

if (requirementMapEntries.length > 0) {
Expand Down Expand Up @@ -397,6 +408,13 @@ export async function upsertOrgFrameworkStructure({
});
}

if (frameworkControlFamilyEntries.length > 0) {
await tx.frameworkControlFamily.createMany({
data: frameworkControlFamilyEntries,
skipDuplicates: true,
});
}

return {
processedFrameworks: frameworkEditorFrameworks,
controlTemplates,
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/frameworks/frameworks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ export class FrameworksService {
},
},
frameworkDocumentLinks: true,
frameworkControlFamilies: {
select: { frameworkInstanceId: true, controlFamily: true },
},
requirementsMapped: { where: { archivedAt: null } },
},
},
Expand All @@ -189,6 +192,7 @@ export class FrameworksService {
requirementsMapped: _,
frameworkPolicyLinks,
frameworkDocumentLinks,
frameworkControlFamilies,
...controlData
} = rm.control;
const policyLinks = rm.control.frameworkPolicyLinks.filter(
Expand All @@ -199,8 +203,13 @@ export class FrameworksService {
(link: { frameworkInstanceId: string }) =>
link.frameworkInstanceId === fi.id,
);
const familyEntry = (frameworkControlFamilies ?? []).find(
(f: { frameworkInstanceId: string }) =>
f.frameworkInstanceId === fi.id,
);
controlsMap.set(rm.control.id, {
...controlData,
controlFamily: familyEntry?.controlFamily ?? null,
policies: policyLinks.map(
(link: { policy: { id: string; name: string; status: string } }) =>
link.policy,
Expand Down Expand Up @@ -286,6 +295,11 @@ export class FrameworksService {
frameworkDocumentLinks: {
where: { frameworkInstanceId },
},
frameworkControlFamilies: {
where: { frameworkInstanceId },
select: { controlFamily: true },
take: 1,
},
},
},
},
Expand All @@ -307,10 +321,12 @@ export class FrameworksService {
requirementsMapped: _,
frameworkPolicyLinks,
frameworkDocumentLinks,
frameworkControlFamilies,
...controlData
} = rm.control;
controlsMap.set(rm.control.id, {
...controlData,
controlFamily: frameworkControlFamilies?.[0]?.controlFamily ?? null,
policies:
rm.control.frameworkPolicyLinks?.map((link) => link.policy) || [],
requirementsMapped: rm.control.requirementsMapped || [],
Expand Down Expand Up @@ -797,6 +813,11 @@ export class FrameworksService {
frameworkDocumentLinks: {
where: { frameworkInstanceId },
},
frameworkControlFamilies: {
where: { frameworkInstanceId },
select: { controlFamily: true },
take: 1,
},
},
},
},
Expand Down Expand Up @@ -849,10 +870,12 @@ export class FrameworksService {
const {
frameworkPolicyLinks,
frameworkDocumentLinks,
frameworkControlFamilies,
...control
} = relatedControl.control;
return {
...control,
controlFamily: frameworkControlFamilies?.[0]?.controlFamily ?? null,
policies: frameworkPolicyLinks.map((link) => link.policy),
controlDocumentTypes: frameworkDocumentLinks.map(
(documentType) => ({
Expand Down
Loading
Loading