Skip to content

Commit 003588f

Browse files
feat(server): enforce fail-closed instance silent mode (#1442)
1 parent 205a1f6 commit 003588f

112 files changed

Lines changed: 3358 additions & 589 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.

.changeset/safe-silent-egress.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"hephaestus": minor
3+
---
4+
5+
Instance Silent Mode now fails closed and enforces the brake at every GitHub, GitLab, and Slack
6+
delivery gateway. Suppressed feedback remains auditable but is never replayed when the brake is
7+
released, and stale admin pages can no longer release a newer incident response.
8+
9+
**Operators:** New installations and upgrades whose Silent Mode setting was never explicitly changed
10+
start engaged. On production, verify workspace delivery settings before releasing the brake from
11+
**Instance admin → Settings**; leave it engaged on staging clones and during disaster-recovery drills.
12+
13+
**API clients:** Replace `PUT /admin/settings/silent-mode` with `PATCH`; the `PUT` operation has been
14+
removed.

MIGRATION.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@ Entries exist only for releases that need operator action. Everything else is in
6565

6666
### Next release
6767

68+
#### 🔴 Untouched instances start with Silent Mode engaged
69+
70+
**Affected**: deployments where the instance Silent Mode setting has never been explicitly changed.
71+
72+
The upgrade engages the instance-wide outbound brake before any new GitHub, GitLab, or Slack delivery
73+
can leave the application. Detection, persistence, synchronization, webhooks, OAuth, and administration
74+
continue normally; suppressed feedback is recorded and is never replayed.
75+
76+
On production, verify each workspace's practice delivery settings and provider targets, then open
77+
**Instance admin → Settings** and release Silent Mode. Leave it engaged on staging clones and during
78+
disaster-recovery drills. Instances whose operator had already changed the setting keep that explicit
79+
choice.
80+
81+
**API clients:** The Silent Mode update operation is now
82+
`PATCH /admin/settings/silent-mode`. Replace calls to the removed `PUT` operation before upgrading.
83+
6884
#### 🔴 Practice-feedback delivery field renamed
6985

7086
**Affected**: API clients that read or write user settings, or consume account-export JSON.

docs/contributor/erd/schema.mmd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ erDiagram
438438
VARCHAR(500) silent_mode_reason
439439
TIMESTAMPTZ silent_mode_changed_at
440440
VARCHAR(255) silent_mode_changed_by
441+
BIGINT version "NOT NULL"
441442
}
442443

443444
Issue {

docs/contributor/instance-admin.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,23 @@ All under `/admin`, all gated by `hasAuthority('app_admin')`:
4545
| `GET`/`PUT /admin/llm/settings` (`adminGetLlmSettings`, `adminUpdateLlmSettings`) | The instance LLM settings singleton: egress host allowlist and `allowWorkspaceConnections`, the switch that lets workspaces register their own provider connections. |
4646
| `GET /admin/llm/usage` (`adminGetLlmUsageReport`) | Cross-workspace monthly LLM usage and budget report, split by purse (shared models vs each workspace's own provider). |
4747
| `PUT /admin/workspaces/{workspaceSlug}/llm/budget` (`adminUpdateWorkspaceLlmBudget`) | Set **or clear** a workspace's monthly cap on **shared-model** spend — clearing is `PUT` with `monthlyBudgetUsd: null`, not `DELETE` (there is no `DELETE` mapping; it returns 405). The workspace's cap on its own provider is a different endpoint under `/workspaces/**`, set by the workspace's own admin. |
48+
| `GET /admin/settings` / `PATCH /admin/settings/silent-mode` | Read or change the instance-wide outbound delivery brake. Releasing requires `If-Match` with the ETag returned by `GET`, so a stale browser cannot release a newer incident response. |
49+
50+
## Instance Silent Mode
51+
52+
Silent Mode is an emergency and disaster-recovery brake, not a workspace rollout stage. It is
53+
**engaged by default** on new installs, when the singleton settings row is missing, and on upgrades
54+
whose seeded row was never explicitly changed. Detection, observation persistence, inbound webhook
55+
processing, synchronization, and admin access continue, but delivery writes to GitHub, GitLab, and
56+
Slack are refused at the provider gateway.
57+
58+
Suppression is prospective: a suppressed review is recorded as `SUPPRESSED(INSTANCE_SILENCED)` for
59+
audit and preview, but is never queued for replay. Releasing the brake therefore sends nothing by
60+
itself; only a new source event can deliver. A re-review that would have edited an existing comment
61+
records the attempted replacement without superseding the live delivered ledger unit.
62+
63+
OAuth/token lifecycle operations, webhook registration, and operator alerts remain available while
64+
Silent Mode is engaged.
4865

4966
## Impersonation time-box
5067

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/openapi.yaml

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,8 +1401,15 @@ paths:
14011401
tags:
14021402
- Instance Settings
14031403
/admin/settings/silent-mode:
1404-
put:
1404+
patch:
14051405
operationId: adminUpdateSilentMode
1406+
parameters:
1407+
- description: Current settings ETag; required when releasing Silent Mode
1408+
in: header
1409+
name: If-Match
1410+
required: false
1411+
schema:
1412+
type: string
14061413
requestBody:
14071414
content:
14081415
application/json:
@@ -1415,7 +1422,19 @@ paths:
14151422
application/json:
14161423
schema:
14171424
$ref: "#/components/schemas/InstanceSettings"
1418-
description: OK
1425+
description: Silent Mode updated
1426+
"412":
1427+
content:
1428+
application/json:
1429+
schema:
1430+
$ref: "#/components/schemas/ProblemDetail"
1431+
description: The supplied ETag is stale
1432+
"428":
1433+
content:
1434+
application/json:
1435+
schema:
1436+
$ref: "#/components/schemas/ProblemDetail"
1437+
description: If-Match is required when releasing Silent Mode
14191438
summary: Engage or release the instance-wide silent mode
14201439
tags:
14211440
- Instance Settings
@@ -8384,6 +8403,8 @@ components:
83848403
InstanceSettings:
83858404
type: object
83868405
properties:
8406+
etag:
8407+
type: string
83878408
silentModeChangedAt:
83888409
type: string
83898410
format: date-time
@@ -8394,6 +8415,7 @@ components:
83948415
silentModeReason:
83958416
type: string
83968417
required:
8418+
- etag
83978419
- silentModeEngaged
83988420
IntegrationCatalogEntry:
83998421
type: object

server/pom.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1010,9 +1010,10 @@
10101010
<!-- Tag filtering: run integration tests, exclude live tests -->
10111011
<groups>${failsafe.includedGroups}</groups>
10121012
<excludedGroups>${failsafe.excludedGroups}</excludedGroups>
1013-
<!-- Include *IntegrationTest.java pattern (project convention) -->
1013+
<!-- Failsafe-discovered integration test patterns -->
10141014
<includes>
10151015
<include>**/*IntegrationTest.java</include>
1016+
<include>**/*LiquibaseTest.java</include>
10161017
</includes>
10171018
</configuration>
10181019
<executions>

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/DiffNotePoster.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import de.tum.cit.aet.hephaestus.agent.handler.PracticeDetectionResultParser.DiffNote;
44
import de.tum.cit.aet.hephaestus.agent.handler.spi.JobDeliveryException;
5+
import de.tum.cit.aet.hephaestus.agent.handler.spi.JobDeliverySuppressedException;
56
import de.tum.cit.aet.hephaestus.agent.job.AgentJob;
7+
import de.tum.cit.aet.hephaestus.integration.core.egress.OutboundEgressSuppressedException;
68
import de.tum.cit.aet.hephaestus.integration.core.spi.FeedbackChannel;
79
import de.tum.cit.aet.hephaestus.integration.core.spi.FeedbackDeliveryException;
810
import de.tum.cit.aet.hephaestus.integration.core.spi.FindingAnchor;
@@ -74,6 +76,8 @@ DiffNoteResult reconcileInlineNotes(AgentJob job, List<DiffNote> diffNotes) {
7476
if (findings.isEmpty()) {
7577
try {
7678
channel.clearStaleFindings(target, HEPHAESTUS_MARKER);
79+
} catch (OutboundEgressSuppressedException e) {
80+
throw new JobDeliverySuppressedException(e.getMessage(), e);
7781
} catch (RuntimeException e) {
7882
log.warn(
7983
"Stale inline-note clear failed (best-effort), continuing: kind={}, jobId={}, error={}",
@@ -94,7 +98,15 @@ DiffNoteResult reconcileInlineNotes(AgentJob job, List<DiffNote> diffNotes) {
9498
result.failed(),
9599
job.getId()
96100
);
97-
return new DiffNoteResult(result.posted(), result.failed(), result.signals());
101+
return new DiffNoteResult(
102+
result.posted(),
103+
result.failed(),
104+
result.signals(),
105+
result.suppressed(),
106+
result.suppressedRecurrenceKeys()
107+
);
108+
} catch (OutboundEgressSuppressedException e) {
109+
throw new JobDeliverySuppressedException(e.getMessage(), e);
98110
} catch (FeedbackDeliveryException e) {
99111
throw new JobDeliveryException(e.getMessage(), e);
100112
}
@@ -124,5 +136,15 @@ private List<InlineFindingChannel.InlineFinding> mapFindings(List<DiffNote> diff
124136
return findings;
125137
}
126138

127-
record DiffNoteResult(int posted, int failed, List<InlineFindingChannel.DeliveredSignal> signals) {}
139+
record DiffNoteResult(
140+
int posted,
141+
int failed,
142+
List<InlineFindingChannel.DeliveredSignal> signals,
143+
boolean suppressed,
144+
List<String> suppressedRecurrenceKeys
145+
) {
146+
DiffNoteResult(int posted, int failed, List<InlineFindingChannel.DeliveredSignal> signals) {
147+
this(posted, failed, signals, false, List.of());
148+
}
149+
}
128150
}

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/FeedbackDeliveryService.java

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import de.tum.cit.aet.hephaestus.agent.handler.PracticeDetectionResultParser.DeliveryContent;
44
import de.tum.cit.aet.hephaestus.agent.handler.spi.ExistingDeliveryLookup;
55
import de.tum.cit.aet.hephaestus.agent.handler.spi.JobDeliveryException;
6+
import de.tum.cit.aet.hephaestus.agent.handler.spi.JobDeliverySuppressedException;
67
import de.tum.cit.aet.hephaestus.agent.job.AgentJob;
78
import de.tum.cit.aet.hephaestus.integration.core.spi.InlineFindingChannel;
89
import de.tum.cit.aet.hephaestus.integration.scm.domain.pullrequest.PullRequest;
@@ -87,6 +88,9 @@ void deliverFeedback(
8788

8889
try {
8990
doDeliverEligible(job, delivery, summaryComposer, decision.artifact());
91+
} catch (JobDeliverySuppressedException e) {
92+
log.info("Delivery suppressed at egress: jobId={}", job.getId());
93+
recordGateSuppressed(job, delivery, FeedbackSuppressionReason.INSTANCE_SILENCED);
9094
} catch (JobDeliveryException e) {
9195
if (job.getDeliveryCommentId() == null) {
9296
recordUndelivered(job, delivery);
@@ -97,6 +101,21 @@ void deliverFeedback(
97101
}
98102
}
99103

104+
private void recordPartialSummaryDelivery(AgentJob job, DeliveryContent delivery) {
105+
try {
106+
feedbackLedgerRecorder.recordWithoutConversation(
107+
job,
108+
delivery,
109+
WorkArtifact.PULL_REQUEST,
110+
List.of(),
111+
true,
112+
false
113+
);
114+
} catch (RuntimeException e) {
115+
log.warn("Partial delivery ledger record failed: jobId={}, error={}", job.getId(), e.getMessage());
116+
}
117+
}
118+
100119
private void doDeliverEligible(
101120
AgentJob job,
102121
DeliveryContent delivery,
@@ -110,13 +129,29 @@ private void doDeliverEligible(
110129
: null;
111130

112131
SummaryOutcome summaryOutcome = postSummaryNote(job, delivery, trend);
113-
DiffNotePoster.DiffNoteResult inlineResult = postDiffNotes(job, delivery);
132+
DiffNotePoster.DiffNoteResult inlineResult;
133+
try {
134+
inlineResult = postDiffNotes(job, delivery);
135+
} catch (JobDeliverySuppressedException e) {
136+
log.info("Inline delivery suppressed at egress: jobId={}", job.getId());
137+
if (summaryOutcome == SummaryOutcome.DELIVERED) {
138+
recordPartialSummaryDelivery(job, delivery);
139+
recordSuppressedRemainder(job, delivery, List.of());
140+
} else {
141+
recordGateSuppressed(job, delivery, FeedbackSuppressionReason.INSTANCE_SILENCED);
142+
}
143+
return;
144+
}
114145
List<InlineFindingChannel.DeliveredSignal> inlineSignals = inlineResult.signals();
115146

116-
if (summaryOutcome == SummaryOutcome.DELIVERED) {
147+
if (summaryOutcome == SummaryOutcome.DELIVERED && !inlineResult.suppressed()) {
117148
reEditSummaryWithSignals(job, summaryComposer, inlineSignals, trend);
118149
}
119150
boolean inlineDelivered = inlineResult.posted() > 0;
151+
if (inlineResult.suppressed() && summaryOutcome != SummaryOutcome.DELIVERED && !inlineDelivered) {
152+
recordGateSuppressed(job, delivery, FeedbackSuppressionReason.INSTANCE_SILENCED);
153+
return;
154+
}
120155
if (summaryOutcome == SummaryOutcome.SKIPPED_EMPTY && !inlineDelivered) {
121156
recordGateSuppressed(job, delivery, FeedbackSuppressionReason.EMPTY_AFTER_SANITIZE);
122157
return;
@@ -126,21 +161,52 @@ private void doDeliverEligible(
126161
}
127162

128163
try {
129-
feedbackLedgerRecorder.record(
130-
job,
131-
delivery,
132-
WorkArtifact.PULL_REQUEST,
133-
inlineSignals,
134-
summaryOutcome == SummaryOutcome.DELIVERED,
135-
inlineDelivered
136-
);
164+
if (inlineResult.suppressed()) {
165+
feedbackLedgerRecorder.recordWithoutConversation(
166+
job,
167+
delivery,
168+
WorkArtifact.PULL_REQUEST,
169+
inlineSignals,
170+
summaryOutcome == SummaryOutcome.DELIVERED,
171+
inlineDelivered
172+
);
173+
} else {
174+
feedbackLedgerRecorder.record(
175+
job,
176+
delivery,
177+
WorkArtifact.PULL_REQUEST,
178+
inlineSignals,
179+
summaryOutcome == SummaryOutcome.DELIVERED,
180+
inlineDelivered
181+
);
182+
}
137183
} catch (RuntimeException e) {
138184
log.warn(
139185
"Feedback ledger record failed (delivery unaffected): jobId={}, error={}",
140186
job.getId(),
141187
e.getMessage()
142188
);
143189
}
190+
if (inlineResult.suppressed()) {
191+
recordSuppressedRemainder(job, delivery, inlineResult.suppressedRecurrenceKeys());
192+
}
193+
}
194+
195+
private void recordSuppressedRemainder(
196+
AgentJob job,
197+
DeliveryContent delivery,
198+
List<String> suppressedRecurrenceKeys
199+
) {
200+
try {
201+
feedbackLedgerRecorder.recordSuppressedRemainder(
202+
job,
203+
delivery,
204+
FeedbackSuppressionReason.INSTANCE_SILENCED,
205+
suppressedRecurrenceKeys
206+
);
207+
} catch (RuntimeException e) {
208+
log.warn("Suppressed delivery ledger record failed: jobId={}, error={}", job.getId(), e.getMessage());
209+
}
144210
}
145211

146212
private void recordGateSuppressed(

0 commit comments

Comments
 (0)