Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5793c3d
feat(processor): record policy-run failures as durable, actionable ev…
EthanHealy01 Aug 3, 2026
bc87bf9
test: drop the permit-all chain in favour of excluding Spring Security
EthanHealy01 Aug 3, 2026
ba905e1
Merge branch 'main' into feature/file-run-info-transit-and-notifications
EthanHealy01 Aug 4, 2026
a5ed735
fix(failure): make incident folding and status transitions concurrenc…
EthanHealy01 Aug 4, 2026
4477b7a
fix(failure): drop the unlock label from the password-protected kind
EthanHealy01 Aug 5, 2026
ac2ec76
fix(failure): fold repeats on the document, and keep the view out of …
EthanHealy01 Aug 5, 2026
a44c173
fix(failure): only stop polling on a refusal that cannot change
EthanHealy01 Aug 5, 2026
bd452de
refactor(failure): make origin the run type, not the place it started
EthanHealy01 Aug 5, 2026
e83abbe
Merge remote-tracking branch 'origin/main' into feature/file-run-info…
EthanHealy01 Aug 5, 2026
1bf3154
fix(failure): redact the shapes a document name actually takes
EthanHealy01 Aug 6, 2026
bfaed76
docs(failure): mark the redaction gap as a known limitation
EthanHealy01 Aug 6, 2026
49baf64
docs(failure): trim the redaction comment, and name the real fix
EthanHealy01 Aug 6, 2026
b24ab78
refactor(common): make filename redaction a shared utility
EthanHealy01 Aug 6, 2026
024899f
revert(failure): store failure messages verbatim
EthanHealy01 Aug 6, 2026
d0a8b30
Merge branch 'main' into feature/file-run-info-transit-and-notifications
EthanHealy01 Aug 6, 2026
8bb5d57
Merge branch 'main' into feature/file-run-info-transit-and-notifications
EthanHealy01 Aug 7, 2026
26a2bf4
Merge branch 'main' into feature/file-run-info-transit-and-notifications
EthanHealy01 Aug 10, 2026
4e1a375
test(a11y): baseline the Documents violations this branch uncovers
EthanHealy01 Aug 10, 2026
0618842
Merge branch 'main' into feature/file-run-info-transit-and-notifications
EthanHealy01 Aug 10, 2026
a4b8513
fix(a11y): fix three of the Documents violations instead of baselinin…
EthanHealy01 Aug 10, 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
@@ -0,0 +1,35 @@
package stirling.software.proprietary.failure;

import java.util.Map;

import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;

/**
* "Seen, and I own it." Moves {@code NEW} to {@code ACKNOWLEDGED} so the row stops counting as
* unread while staying in the open list. Touches only the status, not the document, ledger or run.
*
* <p>Re-acknowledging is a no-op that keeps the original actor and timestamp, so the first person
* to pick it up stays credited.
*/
@Component
@RequiredArgsConstructor
public class AcknowledgeAction implements FailureAction {

private final FileRunEventStore store;

@Override
public FailureActionId id() {
return FailureActionId.ACKNOWLEDGE;
}

@Override
public FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor) {
if (event.status() == FileRunEventStatus.ACKNOWLEDGED) {
return event;
}
return store.applyStatus(
event.id(), event.teamId(), FileRunEventStatus.ACKNOWLEDGED, actor);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package stirling.software.proprietary.failure;

import java.util.Map;

import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;

/**
* "No remediation will happen; close it." Terminal. Named Dismiss rather than Reject because it
* closes the incident, not the document: nothing is deleted or delivered, the ledger row is left
* alone, and a different file failing the same way still opens its own incident.
*
* <p>Recurrences of this exact failure fold onto the dismissed row, which is what makes "stop
* showing me this" hold for a source that re-lists the same failing file each sweep.
*/
@Component
@RequiredArgsConstructor
public class DismissAction implements FailureAction {

private final FileRunEventStore store;

@Override
public FailureActionId id() {
return FailureActionId.DISMISS;
}

@Override
public FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor) {
return store.applyStatus(event.id(), event.teamId(), FileRunEventStatus.DISMISSED, actor);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package stirling.software.proprietary.failure;

import java.util.Map;

/**
* The behaviour behind one {@link FailureActionId}. Implementations are Spring beans injected as a
* {@code List} and resolved by id, the pattern already used for {@code InputSource}, {@code
* PolicyOutputSink} and {@code PolicyTrigger}.
*
* <p>Keeping behaviour out of the registry keeps that pure data, so a new kind ships by declaring
* an action id that already has a handler.
*/
public interface FailureAction {

FailureActionId id();

/**
* Apply the action and return the updated event. {@code inputs} carries whatever the action
* declared it needs, which is nothing for the two that exist today. Implementations leave the
* document, ledger, run and output destinations alone unless that is the action's purpose.
*/
FileRunEvent execute(FileRunEvent event, Map<String, String> inputs, String actor);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package stirling.software.proprietary.failure;

import lombok.Getter;

/**
* Why an action could not be dispatched. Carries a {@link Reason} rather than an HTTP status, so
* the service stays web-agnostic and the controller owns the mapping.
*/
@Getter
public class FailureActionException extends RuntimeException {

public enum Reason {
/**
* No such event, it belongs to another team, or the caller's team did not resolve. One
* reason for all three, so the response does not vary with which it was. Unrelated to
* {@link FailureKind#UNKNOWN}, which is an unclassified failure rather than a refused
* action.
*/
EVENT_NOT_FOUND,

/** The action id is not in the vocabulary at all, or has no registered handler. */
ACTION_NOT_RECOGNISED,

/**
* The action exists but this kind does not declare it, so an incoherent pairing (releasing
* a document whose destination is what failed) cannot be dispatched even by hand.
*/
ACTION_NOT_DECLARED,

/** The event is already closed, so no further transition is possible. */
ALREADY_CLOSED
}

private final transient Reason reason;

public FailureActionException(Reason reason, String message) {
super(message);
this.reason = reason;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package stirling.software.proprietary.failure;

/**
* The actions a {@link FailureKind} may declare. Both are incident dispositions: they change how
* the event is shown and touch nothing else, which is what makes them valid for every kind
* including {@link FailureKind#UNKNOWN}, and why there is no {@code APPROVE} yet.
*/
public enum FailureActionId {
ACKNOWLEDGE,
DISMISS
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package stirling.software.proprietary.failure;

import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;

import lombok.extern.slf4j.Slf4j;

/**
* Resolves a {@link FailureActionId} to the bean that implements it. The startup check is the
* point: because kinds declare action ids as data, one could name an action nobody implements,
* which would otherwise show up as a button that 400s rather than as a failed boot.
*/
@Slf4j
@Service
public class FailureActionRegistry {

private final Map<FailureActionId, FailureAction> byId = new EnumMap<>(FailureActionId.class);

public FailureActionRegistry(List<FailureAction> actions) {
for (FailureAction action : actions) {
FailureAction clash = byId.put(action.id(), action);
if (clash != null) {
throw new IllegalStateException(
"Two handlers registered for action "
+ action.id()
+ ": "
+ clash.getClass().getName()
+ " and "
+ action.getClass().getName());
}
}
}

/**
* Fail fast if any kind declares an action with no handler, naming every gap rather than the
* first, so one boot tells you everything that is missing.
*/
@PostConstruct
void verifyEveryDeclaredActionHasAHandler() {
List<String> gaps =
java.util.Arrays.stream(FailureKind.values())
.flatMap(
kind ->
kind.getActions().stream()
.filter(action -> !byId.containsKey(action))
.map(action -> kind.getId() + " -> " + action))
.toList();
if (!gaps.isEmpty()) {
throw new IllegalStateException(
"Failure kinds declare actions with no registered handler: " + gaps);
}
log.debug(
"Failure action registry initialised with {} handler(s) for {} kind(s)",
byId.size(),
FailureKind.values().length);
}

public Optional<FailureAction> find(FailureActionId id) {
return Optional.ofNullable(byId.get(id));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package stirling.software.proprietary.failure;

import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientResponseException;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import stirling.software.common.util.ExceptionUtils;

import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;

/**
* Maps a thrown failure onto a {@link FailureKind}. A tool's 4xx arrives as a {@link
* RestClientResponseException} whose body is the Problem Details document carrying {@code
* errorCode}, so this matches on codes rather than exception messages.
*
* <p>Anything unrecognised becomes {@link FailureKind#UNKNOWN}, so every failed run still gets a
* record.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FailureClassifier {

/** Set by {@code GlobalExceptionHandler#createProblemDetailResponse}. */
private static final String ERROR_CODE_PROPERTY = "errorCode";

private final ObjectMapper objectMapper;

/**
* Depth bound on the cause chain. The JDK forbids self-causation but not a longer cycle (A
* caused by B caused by A), which an unbounded walk would spin on. Real chains are a handful
* deep.
*/
private static final int MAX_CAUSE_DEPTH = 16;

/** Never null, never throws. A classifier that can fail would lose the failure it describes. */
public FailureKind classify(Throwable throwable) {
Throwable current = throwable;
for (int depth = 0; current != null && depth < MAX_CAUSE_DEPTH; depth++) {
FailureKind resolved = classifyOne(current);
if (resolved != FailureKind.UNKNOWN) {
return resolved;
}
Throwable cause = current.getCause();
current = cause == current ? null : cause;
}
return FailureKind.UNKNOWN;
}

private FailureKind classifyOne(Throwable throwable) {
// A tool step's 4xx/5xx: the Problem Details body names the error code.
if (throwable instanceof RestClientResponseException responseException) {
String code = errorCodeFromBody(responseException);
if (code != null) {
return FailureKind.byErrorCode(code).orElse(FailureKind.UNKNOWN);
}
}
// Thrown in-process (not over loopback): the exception carries its own code.
if (throwable instanceof ExceptionUtils.ErrorCodeProvider provider) {
return FailureKind.byErrorCode(provider.getErrorCode()).orElse(FailureKind.UNKNOWN);
}
return FailureKind.UNKNOWN;
}

/**
* Pull {@code errorCode} from a Problem Details body, or null when the body is absent, not
* JSON, or has no such property (an entitlement sentinel has {@code error} instead).
*/
private String errorCodeFromBody(RestClientResponseException exception) {
String body = exception.getResponseBodyAsString();
if (body == null || body.isBlank()) {
return null;
}
try {
JsonNode root = objectMapper.readTree(body);
JsonNode code = root.get(ERROR_CODE_PROPERTY);
if (code == null || !code.isTextual()) {
return null;
}
String text = code.asString();
return text.isBlank() ? null : text;
} catch (JacksonException e) {
log.debug("Downstream error body was not JSON; classifying as UNKNOWN");
return null;
}
}
}
Loading
Loading