Skip to content

Commit 6d90825

Browse files
feat(workspace): add explicit catalog practice adoption
1 parent 76b7bea commit 6d90825

36 files changed

Lines changed: 2313 additions & 22 deletions

.changeset/calm-practices-adopt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
Workspace administrators can now review an instance-catalog practice and its validation state before adopting an independent, editable copy. New workspaces choose what to adopt, and adopted practices start in Propose so reviews can collect observations while feedback remains held back.

docs/contributor/practice-catalogue.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,14 @@ for the architectural decision.
114114
freely; rename a slug only with an explicit remapping strategy.
115115
- **Definitions and order are independent.** Reordering does not create a definition override or an
116116
audit event. **Use Hephaestus order** removes custom positions.
117-
- **Workspace installation happens once.** Later instance changes never rewrite workspace copies.
117+
- **Legacy workspace installation happens once.** Startup repair preserves the snapshots created before
118+
explicit adoption shipped; later instance changes never rewrite those workspace copies. New workspaces
119+
start empty and choose what to adopt.
120+
- **Adoption is deliberate.** Workspace administrators can browse the entries the instance currently
121+
offers, inspect the complete effective definition and its validation fingerprint, and then adopt an
122+
independent copy. The preview's entity tag must still match when the copy is created.
123+
- **Adoption does not enable delivery.** A newly adopted practice receives its own `PROPOSE` tier: reviews
124+
may record observations, but feedback remains held back until a workspace administrator graduates it.
118125
- **Provenance is descriptive, not referential.** Matched workspace copies retain the source slug and
119126
comparison fingerprint without a foreign key. A bundled source may have no database row and may
120127
disappear in a later release.

server/openapi.yaml

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ tags:
6666
name: Admin
6767
- description: Current user's Slack privacy preferences
6868
name: Slack User Preferences
69+
- description: Adopt instance catalog practices into a workspace
70+
name: Practice Catalog Adoption
6971
- description: Per-workspace Outline collection mirroring control plane
7072
name: Outline collections
7173
- description: Team management within workspace
@@ -4230,6 +4232,131 @@ paths:
42304232
summary: Set how much autonomy the system has over one area
42314233
tags:
42324234
- Practice Areas
4235+
/workspaces/{workspaceSlug}/practice-catalog/adoption:
4236+
get:
4237+
operationId: listAdoptablePractices
4238+
parameters:
4239+
- description: Workspace slug
4240+
in: path
4241+
name: workspaceSlug
4242+
required: true
4243+
schema:
4244+
type: string
4245+
pattern: "^[a-z0-9][a-z0-9-]{2,50}$"
4246+
responses:
4247+
"200":
4248+
content:
4249+
application/json:
4250+
schema:
4251+
type: array
4252+
items:
4253+
$ref: "#/components/schemas/CatalogPracticeSummary"
4254+
description: Available practices returned
4255+
"403":
4256+
content:
4257+
application/problem+json:
4258+
schema:
4259+
$ref: "#/components/schemas/ProblemDetail"
4260+
description: Workspace administrator access is required
4261+
summary: List practices available for adoption
4262+
tags:
4263+
- Practice Catalog Adoption
4264+
/workspaces/{workspaceSlug}/practice-catalog/adoption/{slug}:
4265+
get:
4266+
operationId: previewPracticeAdoption
4267+
parameters:
4268+
- description: Workspace slug
4269+
in: path
4270+
name: workspaceSlug
4271+
required: true
4272+
schema:
4273+
type: string
4274+
pattern: "^[a-z0-9][a-z0-9-]{2,50}$"
4275+
- in: path
4276+
name: slug
4277+
required: true
4278+
schema:
4279+
type: string
4280+
responses:
4281+
"200":
4282+
content:
4283+
application/json:
4284+
schema:
4285+
$ref: "#/components/schemas/CatalogPracticePreview"
4286+
description: Adoption preview returned
4287+
headers:
4288+
ETag:
4289+
description: Strong validator for If-Match
4290+
style: simple
4291+
"403":
4292+
content:
4293+
application/problem+json:
4294+
schema:
4295+
$ref: "#/components/schemas/ProblemDetail"
4296+
description: Workspace administrator access is required
4297+
"404":
4298+
content:
4299+
application/problem+json:
4300+
schema:
4301+
$ref: "#/components/schemas/ProblemDetail"
4302+
description: Practice is not offered
4303+
summary: Get a practice adoption preview
4304+
tags:
4305+
- Practice Catalog Adoption
4306+
post:
4307+
operationId: adoptPractice
4308+
parameters:
4309+
- description: Workspace slug
4310+
in: path
4311+
name: workspaceSlug
4312+
required: true
4313+
schema:
4314+
type: string
4315+
pattern: "^[a-z0-9][a-z0-9-]{2,50}$"
4316+
- in: path
4317+
name: slug
4318+
required: true
4319+
schema:
4320+
type: string
4321+
- in: header
4322+
name: If-Match
4323+
required: false
4324+
schema:
4325+
type: string
4326+
responses:
4327+
"201":
4328+
content:
4329+
application/json:
4330+
schema:
4331+
$ref: "#/components/schemas/Practice"
4332+
description: Practice adopted in Propose
4333+
"403":
4334+
content:
4335+
application/problem+json:
4336+
schema:
4337+
$ref: "#/components/schemas/ProblemDetail"
4338+
description: Workspace administrator access is required
4339+
"409":
4340+
content:
4341+
application/problem+json:
4342+
schema:
4343+
$ref: "#/components/schemas/ProblemDetail"
4344+
description: The practice slug already exists in the workspace
4345+
"412":
4346+
content:
4347+
application/problem+json:
4348+
schema:
4349+
$ref: "#/components/schemas/ProblemDetail"
4350+
description: Catalog or workspace state changed since preview
4351+
"428":
4352+
content:
4353+
application/problem+json:
4354+
schema:
4355+
$ref: "#/components/schemas/ProblemDetail"
4356+
description: The If-Match preview validator is required
4357+
summary: Adopt a practice in Propose
4358+
tags:
4359+
- Practice Catalog Adoption
42334360
/workspaces/{workspaceSlug}/practice-catalog/practices/{slug}:
42344361
get:
42354362
description: The current instance-catalog definition for comparison with a workspace
@@ -7630,6 +7757,21 @@ components:
76307757
- "null"
76317758
description: Destination area slug; omit or set to null for Unassigned
76327759
example: review-ready-work
7760+
CatalogAdoptionArea:
7761+
type: object
7762+
properties:
7763+
definition:
7764+
$ref: "#/components/schemas/CuratedAreaRequest"
7765+
disposition:
7766+
type: string
7767+
enum:
7768+
- UNASSIGNED
7769+
- REUSE_EXISTING_AREA
7770+
- CREATE_CATALOG_AREA
7771+
slug:
7772+
type: string
7773+
required:
7774+
- disposition
76337775
CatalogEntryStatus:
76347776
type: object
76357777
properties:
@@ -7679,6 +7821,66 @@ components:
76797821
- link
76807822
- slug
76817823
- sourceOffered
7824+
CatalogPracticePreview:
7825+
type: object
7826+
properties:
7827+
area:
7828+
$ref: "#/components/schemas/CatalogAdoptionArea"
7829+
availability:
7830+
type: string
7831+
enum:
7832+
- AVAILABLE
7833+
- ADOPTED
7834+
- SLUG_CONFLICT
7835+
definition:
7836+
$ref: "#/components/schemas/CuratedPracticeDefinition"
7837+
etag:
7838+
type: string
7839+
initialReviewTier:
7840+
type: string
7841+
enum:
7842+
- "OFF"
7843+
- PROPOSE
7844+
- DELIVER
7845+
slug:
7846+
type: string
7847+
sourceReviewRuleFingerprint:
7848+
type: string
7849+
required:
7850+
- area
7851+
- availability
7852+
- definition
7853+
- etag
7854+
- initialReviewTier
7855+
- slug
7856+
- sourceReviewRuleFingerprint
7857+
CatalogPracticeSummary:
7858+
type: object
7859+
properties:
7860+
areaName:
7861+
type: string
7862+
areaSlug:
7863+
type: string
7864+
artifactKind:
7865+
type: string
7866+
automatedReviewValidation:
7867+
$ref: "#/components/schemas/PracticeAutomatedReviewValidation"
7868+
availability:
7869+
type: string
7870+
enum:
7871+
- AVAILABLE
7872+
- ADOPTED
7873+
- SLUG_CONFLICT
7874+
name:
7875+
type: string
7876+
slug:
7877+
type: string
7878+
required:
7879+
- artifactKind
7880+
- automatedReviewValidation
7881+
- availability
7882+
- name
7883+
- slug
76827884
ChatMessage:
76837885
type: object
76847886
description: Mentor chat message in AI SDK UIMessage shape.

server/src/main/java/de/tum/cit/aet/hephaestus/practices/DefaultPracticeCatalogSeeder.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import org.springframework.stereotype.Component;
2626
import org.springframework.transaction.support.TransactionOperations;
2727

28-
/** Installs the effective instance catalog once; workspace copies remain independent afterwards. */
28+
/** Repairs legacy workspace catalog snapshots once; new workspaces adopt practices explicitly. */
2929
@Component
3030
@ConditionalOnServerRole
3131
class DefaultPracticeCatalogSeeder {
@@ -97,10 +97,10 @@ public void onWorkspaceCreated(WorkspaceCreatedEvent event) {
9797
try {
9898
taskExecutor.execute(() -> {
9999
try {
100-
workspaceRepository.findById(event.workspaceId()).ifPresent(this::seedCatalogSafely);
100+
transactionOperations.executeWithoutResult(ignored -> markCatalogReady(event.workspaceId()));
101101
} catch (RuntimeException exception) {
102102
log.error(
103-
"Could not load workspace {} for default practice catalog seeding",
103+
"Could not initialize explicit practice catalog adoption for workspace {}",
104104
event.workspaceId(),
105105
exception
106106
);
@@ -115,6 +115,15 @@ public void onWorkspaceCreated(WorkspaceCreatedEvent event) {
115115
}
116116
}
117117

118+
private void markCatalogReady(Long workspaceId) {
119+
Workspace workspace = workspaceRepository.findByIdForUpdate(workspaceId).orElse(null);
120+
if (workspace == null || installationRepository.existsById(workspaceId)) {
121+
return;
122+
}
123+
Instant now = clock.instant();
124+
installationRepository.save(new PracticeCatalogInstallation(workspaceId, now, now));
125+
}
126+
118127
private void seedCatalogSafely(Workspace workspace) {
119128
try {
120129
transactionOperations.executeWithoutResult(ignored -> seedCatalog(workspace));

server/src/main/java/de/tum/cit/aet/hephaestus/practices/PracticeAreaService.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,25 @@ public PracticeArea createAreaFromCatalog(
159159
return practiceAreaRepository.save(area);
160160
}
161161

162+
@Transactional
163+
public PracticeArea adoptAreaFromCatalog(
164+
WorkspaceContext ctx,
165+
String slug,
166+
AreaDefinition definition,
167+
int displayOrder
168+
) {
169+
PracticeArea area = createAreaFromCatalog(ctx, slug, definition, displayOrder);
170+
configAudit.record(
171+
ConfigAuditEntry.created(
172+
ConfigAuditEntityType.PRACTICE_AREA,
173+
area.getId(),
174+
ctx.id(),
175+
PracticeAreaSnapshot.of(area)
176+
)
177+
);
178+
return area;
179+
}
180+
162181
/**
163182
* Sets — or clears — the tier this area imposes on the practices under it that hold no tier of their own.
164183
*

server/src/main/java/de/tum/cit/aet/hephaestus/practices/PracticeDefinitionSnapshot.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ record PracticeDefinitionSnapshot(
1717
String automatedReviewPolicySha256,
1818
@Nullable String whyItMatters,
1919
@Nullable String whatGoodLooksLike,
20-
@Nullable String areaSlug
20+
@Nullable String areaSlug,
21+
@Nullable String sourceCuratedSlug,
22+
@Nullable String sourceCuratedFingerprint
2123
) implements ConfigAuditSnapshot {
2224
static PracticeDefinitionSnapshot of(Practice practice, @Nullable Integer criteriaRevision) {
2325
return new PracticeDefinitionSnapshot(
@@ -31,7 +33,9 @@ static PracticeDefinitionSnapshot of(Practice practice, @Nullable Integer criter
3133
PracticeAutomatedReviewPolicyDigest.digest(practice.getAutomatedReviewPolicy()),
3234
practice.getWhyItMatters(),
3335
practice.getWhatGoodLooksLike(),
34-
practice.getArea() == null ? null : practice.getArea().getSlug()
36+
practice.getArea() == null ? null : practice.getArea().getSlug(),
37+
practice.getSourceCuratedSlug(),
38+
practice.getSourceCuratedFingerprint()
3539
);
3640
}
3741
}

0 commit comments

Comments
 (0)