Skip to content

Commit b2f4cb0

Browse files
committed
feat(plugins)!: guard final designs and resolve gaps in place
Check merged unit evidence and dependencies before final design or regeneration. Keep explicit drafts marked, preserve reviewed answers in their original documents, and protect reviewed corrections against concurrent changes. Scope build records to their application and constrain generated design outputs. BREAKING CHANGE: incomplete designs require explicit draft mode; final generation requires current merged evidence. Multi-application build records must name their application. Install the updated Modernize member plugin before recording them.
1 parent 253631e commit b2f4cb0

92 files changed

Lines changed: 1631 additions & 131 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/api/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ Responsibilities beyond the feature modules below:
3939
items, the traceability matrix, the AS-IS synthesis documents) with the parsers of
4040
`@kintsugi/agents/reader` (default ref: the stage's latest successful run, else the default
4141
branch); `GET …/applications/:appId/results/headlines` gives the overview's headline numbers.
42+
- Delivery → gap answers: `POST …/applications/:appId/understand/gaps/answer` (`documents.resolve`) proposes
43+
a correction to the named gap's row in its original specification. It checks the document the user read and the
44+
writer checks its old hash again; the correction is always reviewed in a PR. A merged answer with evidence can
45+
clear a blocking gap; draft designs remain non-final. See [ADR-0082](../../docs/decisions/0082-design-readiness-drafts-and-in-place-gap-answers.md).
4246
- Cross-project reads (session only): `GET /api/document-writes`
4347
(`?status=queued,failed&projectId`); `GET /api/monitoring/overview` (counts, what needs
4448
attention, recent activity, the worker's pg-boss queue counts — `null` when unreadable) and
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- A portal answer proposes a correction to the existing specification; a Git review/merge accepts it.
2+
insert into core.permission (code, group_name, description) values
3+
('documents.resolve', 'Delivery', 'Propose answers and reopen gaps in original specifications')
4+
on conflict (code) do nothing;
5+
insert into core.role_permission (role_id, permission_code) values ('admin', 'documents.resolve')
6+
on conflict do nothing;

‎apps/api/src/common/audit/audit-trail.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const AUDIT_ACTIONS = {
1717
sandboxUpdate: "sandbox.update",
1818
telemetryExportUpdate: "telemetry-export.update",
1919
manifestPublish: "manifest.publish",
20+
documentCorrection: "document.correction",
2021
webSearchConfigure: "web-search.configure",
2122
projectMemoryConfigure: "project-memory.configure",
2223
projectMemoryReset: "project-memory.reset",

‎apps/api/src/modules/admin/document-writes/application/document-writes.service.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { AUDIT_ACTIONS, AuditTrail, auditRecord } from "#common/audit/audit-trai
99
import type { Actor } from "#common/auth/decorators.js";
1010
import { newId } from "#common/domain/ids.js";
1111
import { ProjectsService } from "#modules/admin/projects/index.js";
12-
import type { DocumentWriteFilter, DocumentWriteRecord, NewDocumentWrite } from "../domain/document-write.js";
12+
import type { DocumentWriteFile, DocumentWriteFilter, DocumentWriteRecord, NewDocumentWrite } from "../domain/document-write.js";
1313
import { DocumentRepoSkeleton, DocumentWriteQueue, DocumentWriteStore } from "../domain/document-write.ports.js";
1414

1515
@Injectable()
@@ -54,6 +54,16 @@ export class DocumentWritesService {
5454
return write;
5555
}
5656

57+
/** A bounded caller supplies corrected documents; the writer proposes them for review and checks their old hashes. */
58+
async proposeCorrection(projectId: string, message: string, files: DocumentWriteFile[], actor: Actor): Promise<DocumentWriteRecord> {
59+
const project = await this.projects.getProject(projectId);
60+
if (project.documentRepository === null) throw new ConflictException(`Project ${projectId} has no document repository`);
61+
if (files.length === 0 || files.some((file) => !file.path.endsWith(".md") || !/^[0-9a-f]{64}$/.test(file.expectedHash ?? ""))) throw new ConflictException("A correction needs Markdown documents and their previous hashes");
62+
const write = await this.#enqueue({ id: newId(), projectId, documentRepositoryId: project.documentRepository.repositoryId, kind: "state-change", message, files, reviewAll: true, actor: actor.username ?? actor.id });
63+
await this.audit.recordChange(auditRecord(actor, AUDIT_ACTIONS.documentCorrection, { projectId, subject: write.id, detail: { paths: files.map((file) => file.path) } }));
64+
return write;
65+
}
66+
5767
async list(projectId: string, query: PageQuery): Promise<Page<DocumentWriteRecord>> {
5868
await this.projects.getProject(projectId); // 404 for an unknown project
5969
const { items, total } = await this.store.listForProject(projectId, query.limit, query.offset);

‎apps/api/src/modules/admin/document-writes/domain/document-write.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export interface DocumentWriteFile {
4343
content: string;
4444
/** Written only when the default branch does not have the file yet. */
4545
createOnly?: boolean;
46+
/** SHA-256 of the document the person edited, checked by the writer before committing. */
47+
expectedHash?: string;
4648
}
4749

4850
/** A write to enqueue: the files plus the provenance that becomes commit trailers. */

‎apps/api/src/modules/admin/document-writes/infrastructure/pg-boss-document-write.queue.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class PgBossDocumentWriteQueue extends DocumentWriteQueue implements OnAp
2424
projectId: write.projectId,
2525
kind: write.kind,
2626
message: write.message,
27-
files: write.files.map((file) => ({ path: file.path, content: file.content, ...(file.createOnly === true ? { createOnly: true } : {}) })),
27+
files: write.files.map((file) => ({ path: file.path, content: file.content, ...(file.createOnly === true ? { createOnly: true } : {}), ...(file.expectedHash === undefined ? {} : { expectedHash: file.expectedHash }) })),
2828
...(write.reviewAll === true ? { reviewAll: true } : {}),
2929
...(write.actor === undefined ? {} : { actor: write.actor }),
3030
});

‎apps/api/src/modules/delivery/build/application/build.service.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,14 @@ export class BuildService {
101101
}
102102

103103
/** The newest `build.*` record of a member for each unit (the summary of a record is the unit key), the merge request one first. */
104-
async memberBuilds(projectId: string, withUser: boolean): Promise<Map<string, MemberBuild>> {
104+
async memberBuilds(projectId: string, withUser: boolean, scope: { applicationId: string; allowLegacy: boolean }): Promise<Map<string, MemberBuild>> {
105105
const page = await this.activity.list({ projectId, pluginName: MEMBER_PLUGIN, limit: RECENT_ACTIVITY, offset: 0 });
106106
const latest = new Map<string, MemberBuild>();
107107
const tests = new Map<string, MemberBuild>();
108108
for (const record of page.items) {
109109
if (!record.kind.startsWith("build.")) continue;
110+
const application = record.detail["application"];
111+
if (application === undefined ? !scope.allowLegacy : application !== scope.applicationId) continue;
110112
const build = memberBuildOf(record, withUser);
111113
if (record.kind === "build.mr" && build.mr !== undefined && build.commit !== undefined && !latest.has(record.summary)) latest.set(record.summary, build);
112114
else if (record.kind === "build.test" && !tests.has(record.summary)) tests.set(record.summary, build);
@@ -123,14 +125,17 @@ export class BuildService {
123125
async overview(projectId: string, applicationId: string, options: { memberUsers: boolean }): Promise<BuildOverviewView> {
124126
const [design, context] = await Promise.all([this.design.overview(projectId, applicationId, undefined), this.documents.context(projectId)]);
125127
const commit = design.ref.commit;
126-
const [targets, active, member] = await Promise.all([this.targets(projectId, applicationId), this.activeReviews(projectId, applicationId, context), this.memberBuilds(projectId, options.memberUsers)]);
128+
const [targets, active, member] = await Promise.all([this.targets(projectId, applicationId), this.activeReviews(projectId, applicationId, context), this.memberBuilds(projectId, options.memberUsers, { applicationId, allowLegacy: context.project.applications.length === 1 })]);
127129

128130
const rows: UnitBuildRow[] = [];
129131
for (const group of chunk(design.units, READ_AT_ONCE)) {
130132
rows.push(
131133
...(await Promise.all(
132134
group.map(async (unit): Promise<UnitBuildRow> => {
133-
const designed = Object.values(unit.sets).some((set) => set.files.total - set.files.missing > 0);
135+
const summaries = Object.values(unit.sets);
136+
// A draft or a failed conformance check is not the design a member is authorized to build.
137+
const designed = summaries.some((set) => set.files.conformant > 0 && set.files.missing === 0 && set.files.problems === 0 && (set.blockers?.length ?? 0) === 0)
138+
&& summaries.every((set) => set.files.problems === 0);
134139
const recorded = member.get(unit.key);
135140
const busy = active.get(unit.key);
136141
const published = frontMatter(await this.documents.readText(context, commit, unitCodeReviewPath(applicationId, unit.key)));

‎apps/api/src/modules/delivery/design/application/design-runs.service.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ export interface DesignSelection {
2424
resumeOf?: string;
2525
/** Write the set again for units that already have it, over their design (ADR-0081). */
2626
regenerate?: boolean;
27+
/** Explicitly produce a non-final design when the evidence is incomplete. */
28+
draft?: boolean;
2729
}
2830

29-
type Conflict = { unit: string; state: "running" | "in-pr"; runId: string } | { unit: string; state: "written" } | { unit: string; state: "unwritten" } | { unit: string; state: "needs"; needs: string[] };
31+
type Conflict = { unit: string; state: "not-ready"; reasons: string[] } | { unit: string; state: "running" | "in-pr"; runId: string } | { unit: string; state: "written" } | { unit: string; state: "unwritten" } | { unit: string; state: "needs"; needs: string[] };
3032

3133
const reasonOf = (conflict: Conflict): string => {
3234
switch (conflict.state) {
@@ -38,6 +40,8 @@ const reasonOf = (conflict: Conflict): string => {
3840
return "has this set already";
3941
case "unwritten":
4042
return "has no design of this set to regenerate";
43+
case "not-ready":
44+
return conflict.reasons.join("; ");
4145
case "needs":
4246
return `needs ${conflict.needs.join(", ")} first`;
4347
}
@@ -61,7 +65,8 @@ export class DesignRunsService {
6165
const wanted = [...new Set(selection.units ?? [])].sort();
6266
if (set === "" || wanted.length === 0) throw new BadRequestException("Name the design set and at least one unit");
6367

64-
const [sets, overview] = await Promise.all([this.design.sets(projectId, undefined, applicationId), this.design.overview(projectId, applicationId, undefined)]);
68+
const overview = await this.design.overview(projectId, applicationId, undefined);
69+
const sets = await this.design.sets(projectId, overview.ref.commit, applicationId);
6570
if (!sets.sets.some((entry) => entry.id === set)) throw new BadRequestException({ message: `The project defines no design set "${set}" (${designSetDir(set)}/)`, details: { set } });
6671
const faults = sets.problems.filter((problem) => problem.path.startsWith(`${designSetDir(set)}/`));
6772
if (faults.length > 0) {
@@ -86,16 +91,19 @@ export class DesignRunsService {
8691
}
8792

8893
const regenerate = selection.regenerate === true;
94+
const draft = selection.draft === true;
8995
const busy = new Map((await this.activity(projectId, applicationId)).items.filter((item) => item.set === set).map((item) => [item.unit, item]));
9096
const needs = overview.sets.find((entry) => entry.id === set)?.needs ?? [];
9197
const conflicts = wanted.flatMap((key): Conflict[] => {
9298
const active = busy.get(key);
9399
if (active !== undefined) return [{ unit: key, state: active.state, runId: active.runId }];
94100
const summary = forApplication ? overview.application.sets[set] : byKey.get(key)!.sets[set];
95101
if (summary === undefined) return [];
102+
if (summary.draftAllowed === false) return [{ unit: key, state: "not-ready", reasons: ["The unit is not active in the merged catalog; assess and merge its scope first."] }];
103+
if (!draft && (summary.blockers?.length ?? 0) > 0) return [{ unit: key, state: "not-ready", reasons: summary.blockers! }];
96104
// A regeneration needs a design to replace; a first design needs the sets it builds on and no design yet.
97105
if (regenerate) return summary.files.conformant + summary.files.problems > 0 ? [] : [{ unit: key, state: "unwritten" }];
98-
if (summary.ready) return [];
106+
if (summary.ready || (draft && summary.files.conformant + summary.files.problems === 0)) return [];
99107
return [summary.state === "missing" ? { unit: key, state: "needs", needs } : { unit: key, state: "written" }];
100108
});
101109
if (conflicts.length > 0) {
@@ -108,7 +116,7 @@ export class DesignRunsService {
108116
});
109117
}
110118
// Pinned: the default branch tip the overview was read at.
111-
return this.runs.startRun(projectId, applicationId, DESIGN, actor, { inputs: { scope: { items: wanted, files: [], set, ...(regenerate ? { regenerate: true } : {}) }, documentsCommit: overview.ref.commit } });
119+
return this.runs.startRun(projectId, applicationId, DESIGN, actor, { inputs: { scope: { items: wanted, files: [], set, ...(regenerate ? { regenerate: true } : {}), ...(draft ? { draft: true } : {}) }, documentsCommit: overview.ref.commit } });
112120
}
113121

114122
/** The units a run is generating or a pull request carries, per design set: what a new selection cannot take. */

‎apps/api/src/modules/delivery/design/application/design.service.ts‎

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
type DesignSetSource,
1919
type ResolvedDesignFile,
2020
checkDesignDocument,
21+
designInputBlockers,
22+
type ItemCatalogItem,
2123
designBaseFiles,
2224
designCoverage,
2325
designFilesFor,
@@ -54,10 +56,14 @@ import {
5456
parseRequirementsMarkdown,
5557
unitDesignFilePath,
5658
unitRulesPath,
59+
unitAsIsPath,
60+
unitUnderstandingPath,
61+
unitRulesReviewPath,
5762
unitStatePath,
5863
} from "@kintsugi/domain";
5964
import { parseKintsugiYaml } from "@kintsugi/manifest";
6065
import { chunk } from "#common/util/async.js";
66+
import { CatalogService } from "#modules/delivery/catalog/index.js";
6167
import { type DocumentContext, DocumentsService } from "#modules/delivery/documents/index.js";
6268
import type {
6369
CommonComponentView,
@@ -209,7 +215,7 @@ export class DesignService {
209215
private readonly projectSets = new Map<string, Promise<ProjectSets>>();
210216
private readonly scans = new Map<string, Promise<AppScan>>();
211217

212-
constructor(private readonly documents: DocumentsService) {}
218+
constructor(private readonly documents: DocumentsService, private readonly catalog: CatalogService) {}
213219

214220
/** The design sets of the project at a ref, with the faults of their definitions; with an application, how its units stand with each. */
215221
async sets(projectId: string, ref: string | undefined, applicationId: string | undefined): Promise<DesignSetsView> {
@@ -485,12 +491,24 @@ export class DesignService {
485491
if (keys.length < MAX_UNITS) keys.push({ module, unit });
486492
}
487493
}
494+
const catalog = await this.catalog.load(context.project.id, applicationId, commit).catch((error: unknown) => {
495+
if (error instanceof NotFoundException) return undefined;
496+
throw error;
497+
});
498+
const active = new Map((catalog?.catalog.items ?? []).filter((item) => item.status === "active").map((item) => [item.id, item]));
499+
const knownKeys = new Set(keys.map((key) => `${key.module}/${key.unit}`));
500+
for (const item of active.values()) {
501+
if (!knownKeys.has(item.id) && keys.length < MAX_UNITS) {
502+
const [module, unit] = item.id.split("/") as [string, string];
503+
keys.push({ module, unit });
504+
}
505+
}
488506
const shared: Omit<DesignContext, "items"> = { ruleIds: [], requirementIds, componentIds: components.map((component) => component.id) };
489507
// The application's own design first: a unit's design may point to the items of the sets of the application it needs.
490508
const application = await this.scanApplication(context, commit, applicationId, project, shared);
491509
const units: UnitScan[] = [];
492510
for (const group of chunk(keys, READS)) {
493-
const scanned = await Promise.all(group.map((key) => this.scanUnit(context, commit, applicationId, key.module, key.unit, project, shared, application)));
511+
const scanned = await Promise.all(group.map((key) => this.scanUnit(context, commit, applicationId, key.module, key.unit, project, shared, application, active.get(`${key.module}/${key.unit}`))));
494512
units.push(...scanned.filter((unit): unit is UnitScan => unit !== undefined));
495513
}
496514
return { project, application, units, components };
@@ -541,25 +559,31 @@ export class DesignService {
541559
}
542560
for (const detail of details) {
543561
const summary = summaries[detail.set] as UnitSetSummary;
544-
const needsMet = detail.needs.every((need) => summaries[need] === undefined || finished((summaries[need] as UnitSetSummary).state));
545-
summary.ready = needsMet && summary.state === "missing" && summary.files.conformant + summary.files.problems === 0;
562+
summary.blockers = detail.needs.filter((need) => summaries[need] === undefined || !finished(summaries[need]!.state)).map((need) => `Needs the merged, conformant ${need} design first.`);
563+
summary.ready = summary.blockers.length === 0 && summary.state === "missing" && summary.files.conformant + summary.files.problems === 0;
546564
}
547565
return { summaries, details, unresolved, componentRefs, anyStale, items };
548566
}
549567

550568
/** One unit: its state file, its rules, its design documents, and what the checks say. Undefined without a unit file. */
551-
private async scanUnit(context: DocumentContext, commit: string, app: string, module: string, unit: string, project: ProjectSets, shared: Omit<DesignContext, "items">, application: ApplicationScan): Promise<UnitScan | undefined> {
569+
private async scanUnit(context: DocumentContext, commit: string, app: string, module: string, unit: string, project: ProjectSets, shared: Omit<DesignContext, "items">, application: ApplicationScan, catalogItem?: ItemCatalogItem): Promise<UnitScan | undefined> {
552570
const stateText = await this.documents.readText(context, commit, unitStatePath(app, module, unit), MAX_DESIGN_BYTES);
553-
const summary = stateText === undefined ? null : parseWorkItem(stateText);
554-
if (stateText === undefined || summary === null || summary.kind === undefined) return undefined;
555-
const data = documentOf(stateText).data;
571+
const summary = stateText === undefined ? (catalogItem === undefined ? null : { kind: catalogItem.kind, title: catalogItem.name, assignee: undefined }) : parseWorkItem(stateText);
572+
if (summary === null || summary.kind === undefined) return undefined;
573+
const data = documentOf(stateText ?? "").data;
556574
const waived = new Map<string, string>();
557575
for (const entry of Array.isArray(data["waived"]) ? data["waived"] : []) {
558576
const waiver = typeof entry === "string" ? parseDesignWaiver(entry) : undefined;
559577
if (waiver !== undefined) waived.set(`${waiver.set}/${waiver.file}`, waiver.reason);
560578
}
561579
const rulesText = await this.documents.readText(context, commit, unitRulesPath(app, module, unit), MAX_RULES_BYTES);
562580
const rules = rulesText === undefined ? [] : ruleCardsOf(rulesText);
581+
const [specification, understanding, review] = await Promise.all([
582+
this.documents.readText(context, commit, unitAsIsPath(app, module, unit), MAX_DESIGN_BYTES),
583+
this.documents.readText(context, commit, unitUnderstandingPath(app, module, unit), MAX_DESIGN_BYTES),
584+
this.documents.readText(context, commit, unitRulesReviewPath(app, module, unit), MAX_RULES_BYTES),
585+
]);
586+
const evidenceBlockers = designInputBlockers({ specification, understanding, rules: rulesText, review, catalogDigest: catalogItem?.digest });
563587
const kind = summary.kind;
564588
const applicable = project.sets.filter((loaded) => designFilesFor(loaded, kind).length > 0);
565589

@@ -621,11 +645,15 @@ export class DesignService {
621645
}
622646
for (const detail of details) {
623647
const summary = sets[detail.set] as UnitSetSummary;
624-
const needsMet = detail.needs.every((need) => {
648+
summary.draftAllowed = catalogItem !== undefined;
649+
summary.blockers = [...evidenceBlockers, ...detail.needs.filter((need) => {
625650
const target = sets[need] ?? application.summaries[need];
626-
return target === undefined || finished(target.state);
627-
});
628-
summary.ready = needsMet && summary.state === "missing" && summary.files.conformant + summary.files.problems === 0;
651+
// A unit dependency deliberately inapplicable to this kind has nothing to generate.
652+
const definition = project.sets.find((loaded) => loaded.set.id === need);
653+
const inapplicable = definition !== undefined && definition.set.scope !== "application" && designFilesFor(definition, kind).length === 0;
654+
return !inapplicable && (target === undefined || !finished(target.state));
655+
}).map((need) => `Needs the merged, conformant ${need} design first.`)];
656+
summary.ready = summary.blockers.length === 0 && summary.state === "missing" && summary.files.conformant + summary.files.problems === 0;
629657
}
630658
const coverage: UnitCoverageRow[] = rules
631659
.map((rule) => ({ rule: rule.id, title: rule.title, coveredBy: coveredBy.get(rule.id) ?? [] }))

0 commit comments

Comments
 (0)