Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
106 changes: 106 additions & 0 deletions api/proto/meridian/control_plane/v1/manifest_history_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ service ManifestHistoryService {
get: "/v1/manifests/export"
};
}

// ReconcileManifest compares a stored manifest version against live service
// state and reports any drift. This is a read-only safety-net operation —
// no auto-repair is performed. With structural APIs removed from the public
// surface, drift should be impossible; reconciliation validates that invariant.
rpc ReconcileManifest(ReconcileManifestRequest) returns (ReconcileManifestResponse) {
option (google.api.http) = {
get: "/v1/manifests/reconcile"
};
}
}

// ========================================
Expand Down Expand Up @@ -314,3 +324,99 @@ message ExportManifestResponse {
// (e.g., a service was unreachable, partial data returned).
repeated string warnings = 5;
}

// ========================================
// Reconcile Manifest
// ========================================

// ReconcileManifestRequest specifies which stored manifest version to compare
// against live service state.
message ReconcileManifestRequest {
// version is the manifest version string to reconcile (e.g., "1.0").
// If empty, the most recently applied manifest version is used.
string version = 1 [(buf.validate.field).string.max_len = 50];

// include_sections filters which manifest sections to reconcile.
// If empty, all sections are reconciled. Valid values match ExportManifestRequest.
repeated string include_sections = 2 [(buf.validate.field).repeated = {
max_items: 20
items: {
string: {
min_len: 1
max_len: 64
}
}
}];
Comment on lines +339 to +349
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate include_sections against the supported section set.

This field currently accepts arbitrary strings, and the reconcile path silently drops unknown names. A typo like "instrument" instead of "instruments" will reconcile zero sections and return a clean result, which is a false negative for a drift detector. Please reject unknown values here, or fail the request when none of the requested sections resolve.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/proto/meridian/control_plane/v1/manifest_history_service.proto` around
lines 339 - 349, Validate the repeated string field include_sections (in
manifest_history_service.proto) against the canonical set of supported section
names (the same enum/strings used by ExportManifestRequest and the reconcile
logic) rather than accepting arbitrary strings: add proto-level validation if
possible (e.g., replace/augment repeated string with a repeated enum or use a
custom buf.validate rule) or enforce this in the server-side handler that
processes include_sections (e.g., the reconcile function) to reject the request
when an unknown name appears and return a clear validation error, and
additionally fail the request if none of the requested sections resolve so a
typo (like "instrument" vs "instruments") does not silently produce a no-op
success.

}

// ReconcileManifestResponse contains the drift report between the stored
// manifest and live service state.
message ReconcileManifestResponse {
// drift_items lists individual resource-level discrepancies found.
repeated DriftItem drift_items = 1;

// summary contains aggregate counts of the reconciliation.
ReconcileSummary summary = 2;

// reconciled_version is the manifest version string that was reconciled.
string reconciled_version = 3;

// reconciled_at is the timestamp when reconciliation was performed.
google.protobuf.Timestamp reconciled_at = 4;

// warnings contains non-fatal issues encountered during reconciliation
// (e.g., a live service was unreachable).
repeated string warnings = 5;
}

// DriftType classifies the nature of a drift discrepancy.
enum DriftType {
// DRIFT_TYPE_UNSPECIFIED is the default zero value.
DRIFT_TYPE_UNSPECIFIED = 0;

// DRIFT_TYPE_MISSING indicates a resource exists in the stored manifest
// but is absent from live service state.
DRIFT_TYPE_MISSING = 1;

// DRIFT_TYPE_MODIFIED indicates a resource exists in both the stored manifest
// and live state but their definitions differ.
DRIFT_TYPE_MODIFIED = 2;

// DRIFT_TYPE_EXTRA indicates a resource exists in live service state
// but is absent from the stored manifest.
DRIFT_TYPE_EXTRA = 3;
}

// DriftItem represents a single resource-level discrepancy between the
// stored manifest and live service state.
message DriftItem {
// resource_type is the category of the resource (e.g., "instrument", "saga").
string resource_type = 1;

// resource_code is the unique identifier of the resource within its type.
string resource_code = 2;

// drift_type classifies the nature of the discrepancy.
DriftType drift_type = 3;

// description is a human-readable summary of the drift.
string description = 4;
}

// ReconcileSummary contains aggregate statistics about a reconciliation run.
message ReconcileSummary {
// total_checked is the total number of resources compared.
int32 total_checked = 1;

// total_drifted is the number of resources with discrepancies.
int32 total_drifted = 2;

// missing is the number of resources in the manifest but absent live.
int32 missing = 3;

// modified is the number of resources present in both but differing.
int32 modified = 4;

// extra is the number of resources live but absent from the manifest.
int32 extra = 5;
}
43 changes: 40 additions & 3 deletions services/control-plane/internal/manifest/grpc_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ var ErrHistoryServiceRequired = errors.New("history service is required")
type HistoryHandler struct {
controlplanev1.UnimplementedManifestHistoryServiceServer

history *HistoryService
exporter *ExportService
logger *slog.Logger
history *HistoryService
exporter *ExportService
reconciler *ReconcileService
logger *slog.Logger
}

// NewHistoryHandler creates a new HistoryHandler.
Expand Down Expand Up @@ -48,6 +49,17 @@ func NewHistoryHandlerWithExport(history *HistoryService, exporter *ExportServic
return h, nil
}

// NewHistoryHandlerWithReconcile creates a HistoryHandler with export and reconcile support.
// The reconciler enables the ReconcileManifest RPC; when nil, the RPC returns Unimplemented.
func NewHistoryHandlerWithReconcile(history *HistoryService, exporter *ExportService, reconciler *ReconcileService, logger *slog.Logger) (*HistoryHandler, error) {
h, err := NewHistoryHandlerWithExport(history, exporter, logger)
if err != nil {
return nil, err
}
h.reconciler = reconciler
return h, nil
}

// GetCurrentManifest retrieves the most recently applied manifest for the tenant.
func (h *HistoryHandler) GetCurrentManifest(
ctx context.Context,
Expand Down Expand Up @@ -217,3 +229,28 @@ func (h *HistoryHandler) ExportManifest(

return result.ToProtoResponse(), nil
}

// ReconcileManifest compares a stored manifest against live service state
// and reports any drift as structured output.
func (h *HistoryHandler) ReconcileManifest(
ctx context.Context,
req *controlplanev1.ReconcileManifestRequest,
) (*controlplanev1.ReconcileManifestResponse, error) {
if h.reconciler == nil {
return nil, status.Error(codes.Unimplemented, "reconcile manifest not configured")
}

result, err := h.reconciler.Reconcile(ctx, req.GetVersion(), req.GetIncludeSections())
if err != nil {
if errors.Is(err, ErrVersionNotFound) {
return nil, status.Error(codes.NotFound, "manifest version not found")
}
h.logger.Error("failed to reconcile manifest",
"version", req.GetVersion(),
"error", err,
)
return nil, status.Error(codes.Internal, "failed to reconcile manifest")
}

return result.ToProtoResponse(), nil
}
Loading
Loading