package services // ViewService extension on EventService — runs a FilterSpec across the // 4 substrate sources (deadline, appointment, project_event, // approval_request) and returns a unified []ViewRow. // // Design: docs/design-data-display-model-2026-05-06.md §3 + §6.3. // // EventService is extended (not renamed) so the existing handlers // (/api/events, /api/events/summary) keep working unchanged. New // handlers (/api/views/{slug}/run, /api/user-views/...) call RunSpec. import ( "context" "encoding/json" "fmt" "slices" "sort" "strings" "time" "github.com/google/uuid" "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/models" ) // ViewRow is the unified row shape returned by RunSpec. Discriminated by // `Kind`; type-specific fields live under `Detail` as a per-source struct // marshalled via json.RawMessage. type ViewRow struct { Kind DataSource `json:"kind"` ID uuid.UUID `json:"id"` Title string `json:"title"` // Subtitle: one short context line (e.g. "Frist", "Termin", // "Genehmigung von …"). Optional; UIs render it under the title. Subtitle *string `json:"subtitle,omitempty"` // EventDate is the canonical sort key per row. Source-determined: // - deadline: due_date at 00:00 UTC // - appointment: start_at // - project_event: created_at // - approval_request: requested_at (or decided_at if status decided) EventDate time.Time `json:"event_date"` ProjectID *uuid.UUID `json:"project_id,omitempty"` ProjectTitle *string `json:"project_title,omitempty"` ProjectReference *string `json:"project_reference,omitempty"` ProjectType *string `json:"project_type,omitempty"` ActorID *uuid.UUID `json:"actor_id,omitempty"` ActorName *string `json:"actor_name,omitempty"` // Detail is the per-source typed payload as raw JSON. Frontend // type-narrows on Kind and parses Detail accordingly. Detail json.RawMessage `json:"detail"` } // ViewRunResult is the response shape of RunSpec — rows + a count of // projects that contributed zero rows because the caller can't see them // (Q17 fail-open attribution). type ViewRunResult struct { Rows []ViewRow `json:"rows"` InaccessibleProjectIDs []uuid.UUID `json:"inaccessible_project_ids,omitempty"` } // RunSpec executes the FilterSpec against the substrate and returns // merged rows sorted by EventDate (ascending for forward-looking, // descending if any sort hint says so). Visibility is enforced via // the per-source RLS predicates already used by the underlying tables; // `userID` is the caller for context propagation. // // Caller has run spec.Validate() before us. We trust the spec. func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService) (*ViewRunResult, error) { if approval == nil && slices.Contains(spec.Sources, SourceApprovalRequest) { // Approval source requires the approval service. Return a clear // error rather than silently skipping it — handlers always pass // the bundle's approval service. return nil, fmt.Errorf("RunSpec: approval source selected but ApprovalService is nil") } rows := make([]ViewRow, 0, 256) bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time) if spec.UnreadOnly && approval != nil { cursor, err := approval.InboxSeenAt(ctx, userID) if err != nil { return nil, fmt.Errorf("inbox cursor lookup: %w", err) } bounds.unreadOnly = true bounds.inboxSeenAt = cursor } for _, src := range spec.Sources { switch src { case SourceDeadline: batch, err := s.runDeadlines(ctx, userID, spec, bounds) if err != nil { return nil, err } rows = append(rows, batch...) case SourceAppointment: batch, err := s.runAppointments(ctx, userID, spec, bounds) if err != nil { return nil, err } rows = append(rows, batch...) case SourceProjectEvent: batch, err := s.runProjectEvents(ctx, userID, spec, bounds) if err != nil { return nil, err } rows = append(rows, batch...) case SourceApprovalRequest: batch, err := s.runApprovalRequests(ctx, userID, spec, approval, bounds) if err != nil { return nil, err } rows = append(rows, batch...) } } // Default sort: ascending. Per-source sort hints don't apply here — // Render-side sort (RenderSpec.List/Cards.Sort) is the user-facing // knob. We give the substrate a stable shape; the renderer flips it. sort.SliceStable(rows, func(i, j int) bool { if rows[i].EventDate.Equal(rows[j].EventDate) { // Tiebreaker: kind alphabetical, then title — deterministic. if rows[i].Kind != rows[j].Kind { return rows[i].Kind < rows[j].Kind } return rows[i].Title < rows[j].Title } return rows[i].EventDate.Before(rows[j].EventDate) }) out := &ViewRunResult{Rows: rows} // Q17 fail-open attribution: if the caller specified explicit // project IDs, surface the ones they couldn't see. We do that with // one cheap check against can_see_project (via RLS-aware visibility // predicate), batched per call. if spec.Scope.Projects.Mode == ScopeExplicit { inaccessible, err := s.filterInaccessibleProjects(ctx, userID, spec.Scope.Projects.IDs) if err != nil { return nil, err } if len(inaccessible) > 0 { out.InaccessibleProjectIDs = inaccessible } } return out, nil } // viewSpecBounds carries the resolved [from, to) window the spec // translates into. Either bound can be nil (open-ended). // // inboxSeenAt is set by RunSpec when spec.UnreadOnly=true: the caller's // users.inbox_seen_at cursor pre-resolved once so each source-runner can // overlay it without re-querying the users table. nil means "never // visited" — every row is unread. type viewSpecBounds struct { from *time.Time to *time.Time unreadOnly bool inboxSeenAt *time.Time } func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds { now = now.UTC() day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) tomorrow := day.AddDate(0, 0, 1) switch ts.Horizon { case HorizonNext1d: from := day to := day.AddDate(0, 0, 1) return viewSpecBounds{from: &from, to: &to} case HorizonNext7d: from := day to := day.AddDate(0, 0, 7) return viewSpecBounds{from: &from, to: &to} case HorizonNext14d: from := day to := day.AddDate(0, 0, 14) return viewSpecBounds{from: &from, to: &to} case HorizonNext30d: from := day to := day.AddDate(0, 0, 30) return viewSpecBounds{from: &from, to: &to} case HorizonNext90d: from := day to := day.AddDate(0, 0, 90) return viewSpecBounds{from: &from, to: &to} case HorizonNextAll: // One-sided unbounded — from today onwards, no upper bound. // Distinct from HorizonAll (bidirectional unbounded) and // HorizonAny (no time filter at all). from := day return viewSpecBounds{from: &from} case HorizonPast1d: from := day.AddDate(0, 0, -1) return viewSpecBounds{from: &from, to: &tomorrow} case HorizonPast7d: from := day.AddDate(0, 0, -7) return viewSpecBounds{from: &from, to: &tomorrow} case HorizonPast14d: from := day.AddDate(0, 0, -14) return viewSpecBounds{from: &from, to: &tomorrow} case HorizonPast30d: from := day.AddDate(0, 0, -30) return viewSpecBounds{from: &from, to: &tomorrow} case HorizonPast90d: from := day.AddDate(0, 0, -90) return viewSpecBounds{from: &from, to: &tomorrow} case HorizonPastAll: // One-sided unbounded — up to and including today, no lower bound. return viewSpecBounds{to: &tomorrow} case HorizonAny, HorizonAll: return viewSpecBounds{} case HorizonCustom: return viewSpecBounds{from: ts.From, to: ts.To} } return viewSpecBounds{} } // runDeadlines projects DeadlineWithProject rows from the existing // DeadlineService.ListVisibleForUser onto ViewRow, applying spec narrowing. func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) { df := ListFilter{} if spec.Scope.PersonalOnly { uid := userID df.CreatedBy = &uid } if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil { dp := preds.Deadline // Status: ListFilter has DeadlineStatusFilter (single-value filter). // If the spec asks for both pending+completed → no narrowing; if // only pending → DeadlineFilterPending; only completed → Completed. switch { case len(dp.Status) == 1 && dp.Status[0] == "pending": df.Status = DeadlineFilterPending case len(dp.Status) == 1 && dp.Status[0] == "completed": df.Status = DeadlineFilterCompleted default: df.Status = DeadlineFilterAll } df.EventTypeIDs = dp.EventTypeIDs df.IncludeUntyped = dp.IncludeUntyped } if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 { // DeadlineService takes one project id; we filter post-load when // spec selects multiple projects (the visibility predicate already // bounds to the caller's set, and explicit IDs are a refinement). } rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df) if err != nil { return nil, err } out := make([]ViewRow, 0, len(rows)) allowedProjects := explicitProjectSet(spec) for _, r := range rows { eventDate := time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC) if !inSpecWindow(eventDate, bounds) { continue } if allowedProjects != nil && !allowedProjects[r.ProjectID] { continue } // Approval-status narrowing (entity-side pill). if !approvalStatusMatches(r.ApprovalStatus, spec, SourceDeadline) { continue } detail, _ := json.Marshal(map[string]any{ "due_date": r.DueDate.Format("2006-01-02"), "status": r.Status, "approval_status": r.ApprovalStatus, "source": r.Source, "rule_id": r.RuleID, "rule_code": r.RuleCode, "rule_name": r.RuleName, "event_type_ids": r.EventTypeIDs, "description": r.Description, "completed_at": r.CompletedAt, }) pid := r.ProjectID pt := r.ProjectTitle ptype := r.ProjectType out = append(out, ViewRow{ Kind: SourceDeadline, ID: r.ID, Title: r.Title, EventDate: eventDate, ProjectID: &pid, ProjectTitle: &pt, ProjectReference: r.ProjectReference, ProjectType: &ptype, ActorID: r.CreatedBy, Detail: detail, }) } return out, nil } // runAppointments projects AppointmentWithProject onto ViewRow. func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) { af := AppointmentListFilter{} if spec.Scope.PersonalOnly { uid := userID af.CreatedBy = &uid } af.From = bounds.from af.To = bounds.to if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil { ap := preds.Appointment // AppointmentListFilter takes a single Type today; narrow to first // listed value, fall back to all if multiple. if len(ap.AppointmentTypes) == 1 { t := ap.AppointmentTypes[0] af.Type = &t } } rows, err := s.appointments.ListVisibleForUser(ctx, userID, af) if err != nil { return nil, err } out := make([]ViewRow, 0, len(rows)) allowedProjects := explicitProjectSet(spec) allowedTypes := allowedAppointmentTypes(spec) for _, r := range rows { if !inSpecWindow(r.StartAt, bounds) { continue } if r.ProjectID != nil && allowedProjects != nil && !allowedProjects[*r.ProjectID] { continue } if r.ProjectID == nil && allowedProjects != nil { continue } if !approvalStatusMatches(r.ApprovalStatus, spec, SourceAppointment) { continue } if allowedTypes != nil { if r.AppointmentType == nil || !allowedTypes[*r.AppointmentType] { continue } } detail, _ := json.Marshal(map[string]any{ "start_at": r.StartAt, "end_at": r.EndAt, "location": r.Location, "appointment_type": r.AppointmentType, "approval_status": r.ApprovalStatus, "description": r.Description, }) out = append(out, ViewRow{ Kind: SourceAppointment, ID: r.ID, Title: r.Title, EventDate: r.StartAt, ProjectID: r.ProjectID, ProjectTitle: r.ProjectTitle, ProjectReference: r.ProjectReference, ProjectType: r.ProjectType, ActorID: r.CreatedBy, Detail: detail, }) } return out, nil } // runProjectEvents queries paliad.project_events with the visibility // predicate. The audit table doesn't have a service wrapper today; we // run our own SQL bounded by the spec. // // Inbox semantics (t-paliad-249) kick in when bounds.unreadOnly is set: // the caller's own events are excluded (you don't notify yourself) and // rows older than bounds.inboxSeenAt are dropped. Cursor lookup happens // once in RunSpec — runProjectEvents only consumes the resolved value. func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) { conds := []string{visibilityPredicatePositional("p", 1)} args := []any{userID} allowedKinds := allowedProjectEventKinds(spec) if len(allowedKinds) > 0 { args = append(args, pq.Array(allowedKinds)) conds = append(conds, fmt.Sprintf("pe.event_type = ANY($%d)", len(args))) } if bounds.from != nil { args = append(args, *bounds.from) conds = append(conds, fmt.Sprintf("pe.created_at >= $%d", len(args))) } if bounds.to != nil { args = append(args, *bounds.to) conds = append(conds, fmt.Sprintf("pe.created_at < $%d", len(args))) } if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 { args = append(args, spec.Scope.Projects.IDs) conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args))) } if bounds.unreadOnly { // Inbox mode: hide the caller's own actions (no self-notify). conds = append(conds, "pe.created_by IS DISTINCT FROM $1") if bounds.inboxSeenAt != nil { args = append(args, *bounds.inboxSeenAt) conds = append(conds, fmt.Sprintf("pe.created_at > $%d", len(args))) } } q := ` SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description, pe.event_date, pe.created_by, pe.created_at, p.title AS project_title, p.type AS project_type, p.reference AS project_reference, u.display_name AS actor_name FROM paliad.project_events pe JOIN paliad.projects p ON p.id = pe.project_id LEFT JOIN paliad.users u ON u.id = pe.created_by WHERE ` + strings.Join(conds, " AND ") + ` ORDER BY pe.created_at DESC LIMIT 500` type row struct { ID uuid.UUID `db:"id"` ProjectID uuid.UUID `db:"project_id"` EventType *string `db:"event_type"` Title string `db:"title"` Description *string `db:"description"` EventDate *time.Time `db:"event_date"` CreatedBy *uuid.UUID `db:"created_by"` CreatedAt time.Time `db:"created_at"` ProjectTitle string `db:"project_title"` ProjectType string `db:"project_type"` ProjectReference *string `db:"project_reference"` ActorName *string `db:"actor_name"` } var dbRows []row if err := s.db.SelectContext(ctx, &dbRows, q, args...); err != nil { return nil, fmt.Errorf("project_events query: %w", err) } out := make([]ViewRow, 0, len(dbRows)) for _, r := range dbRows { detail, _ := json.Marshal(map[string]any{ "event_type": r.EventType, "description": r.Description, "event_date": r.EventDate, }) pid := r.ProjectID pt := r.ProjectTitle ptype := r.ProjectType out = append(out, ViewRow{ Kind: SourceProjectEvent, ID: r.ID, Title: r.Title, EventDate: r.CreatedAt, ProjectID: &pid, ProjectTitle: &pt, ProjectReference: r.ProjectReference, ProjectType: &ptype, ActorID: r.CreatedBy, ActorName: r.ActorName, Detail: detail, }) } return out, nil } // runApprovalRequests projects approval_request rows via the existing // ApprovalService inbox queries. ViewerRole picks which underlying // query runs. func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) { preds := spec.Predicates[SourceApprovalRequest] role := "approver_eligible" if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" { role = preds.ApprovalRequest.ViewerRole } filter := InboxFilter{} if preds.ApprovalRequest != nil { // InboxFilter takes a single status today. If the spec says // only one, narrow; if multiple, leave open. if len(preds.ApprovalRequest.Status) == 1 { filter.Status = preds.ApprovalRequest.Status[0] } if len(preds.ApprovalRequest.EntityTypes) == 1 { filter.EntityType = preds.ApprovalRequest.EntityTypes[0] } } if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 { pid := spec.Scope.Projects.IDs[0] filter.ProjectID = &pid } var rows []ApprovalRequestView var err error switch role { case "approver_eligible": rows, err = approval.ListPendingForApprover(ctx, userID, filter) case "self_requested": rows, err = approval.ListSubmittedByUser(ctx, userID, filter) case "any_visible": // any_visible is the broadest read — RLS bounds it. The existing // ApprovalService doesn't have a "list all visible" call; we // approximate by running both inbox queries and de-duping. Future // optimization: dedicated service method. a, errA := approval.ListPendingForApprover(ctx, userID, filter) if errA != nil { return nil, errA } b, errB := approval.ListSubmittedByUser(ctx, userID, filter) if errB != nil { return nil, errB } seen := make(map[uuid.UUID]bool, len(a)+len(b)) for _, r := range a { if !seen[r.ID] { rows = append(rows, r) seen[r.ID] = true } } for _, r := range b { if !seen[r.ID] { rows = append(rows, r) seen[r.ID] = true } } default: return nil, fmt.Errorf("%w: approval_request.viewer_role %q", ErrInvalidInput, role) } if err != nil { return nil, err } out := make([]ViewRow, 0, len(rows)) allowedStatuses := allowedRequestStatuses(spec) allowedEntityTypes := allowedRequestEntityTypes(spec) allowedProjects := explicitProjectSet(spec) for _, r := range rows { // Spec status filter (when the inbox query received broad results). if allowedStatuses != nil && !allowedStatuses[r.Status] { continue } if allowedEntityTypes != nil && !allowedEntityTypes[r.EntityType] { continue } if allowedProjects != nil && !allowedProjects[r.ProjectID] { continue } // Sort key: decided_at if decided, else requested_at. eventDate := r.RequestedAt if r.DecidedAt != nil { eventDate = *r.DecidedAt } if !inSpecWindow(eventDate, bounds) { continue } // Inbox unread-only carve-out (t-paliad-249, design §3): // pending requests always survive; decided rows are subject to // the cursor like any other audit-style item. if bounds.unreadOnly && r.Status != RequestStatusPending { if bounds.inboxSeenAt != nil && !eventDate.After(*bounds.inboxSeenAt) { continue } } title := approvalRowTitle(r) subtitle := approvalRowSubtitle(r) detail, _ := json.Marshal(r) // request view already carries everything the UI needs actorID := r.RequestedBy actorName := r.RequesterName pid := r.ProjectID pt := r.ProjectTitle out = append(out, ViewRow{ Kind: SourceApprovalRequest, ID: r.ID, Title: title, Subtitle: &subtitle, EventDate: eventDate, ProjectID: &pid, ProjectTitle: &pt, ActorID: &actorID, ActorName: &actorName, Detail: detail, }) } return out, nil } // approvalRowTitle returns a one-line title describing the approval // request — used as the ViewRow.Title. func approvalRowTitle(r ApprovalRequestView) string { if r.EntityTitle != nil && *r.EntityTitle != "" { return *r.EntityTitle } return fmt.Sprintf("%s %s", r.EntityType, r.LifecycleEvent) } // approvalRowSubtitle returns a one-line context for the request. func approvalRowSubtitle(r ApprovalRequestView) string { switch r.Status { case "pending": return fmt.Sprintf("Genehmigung angefragt von %s", r.RequesterName) case "approved": if r.DeciderName != nil { return fmt.Sprintf("Genehmigt von %s", *r.DeciderName) } return "Genehmigt" case "rejected": if r.DeciderName != nil { return fmt.Sprintf("Abgelehnt von %s", *r.DeciderName) } return "Abgelehnt" case "revoked": return "Widerrufen" case "changes_requested": if r.DeciderName != nil { return fmt.Sprintf("Abgelehnt mit Vorschlag von %s", *r.DeciderName) } return "Abgelehnt mit Vorschlag" } return r.Status } // inSpecWindow returns true when ts is within [from, to). nil bounds // are open-ended. func inSpecWindow(ts time.Time, b viewSpecBounds) bool { if b.from != nil && ts.Before(*b.from) { return false } if b.to != nil && !ts.Before(*b.to) { return false } return true } // explicitProjectSet returns nil when the scope isn't explicit, otherwise // a set membership map for fast filtering. func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool { if spec.Scope.Projects.Mode != ScopeExplicit { return nil } out := make(map[uuid.UUID]bool, len(spec.Scope.Projects.IDs)) for _, id := range spec.Scope.Projects.IDs { out[id] = true } return out } // approvalStatusMatches checks the entity-side approval_status filter. // Returns true when the row passes (no filter set → always true). func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool { preds, ok := spec.Predicates[src] if !ok { return true } var allowed []string switch src { case SourceDeadline: if preds.Deadline != nil { allowed = preds.Deadline.ApprovalStatus } case SourceAppointment: if preds.Appointment != nil { allowed = preds.Appointment.ApprovalStatus } } if len(allowed) == 0 { return true } return slices.Contains(allowed, rowStatus) } // allowedAppointmentTypes returns nil when the filter is open, otherwise // a set of legal appointment_type values. func allowedAppointmentTypes(spec FilterSpec) map[string]bool { preds, ok := spec.Predicates[SourceAppointment] if !ok || preds.Appointment == nil { return nil } if len(preds.Appointment.AppointmentTypes) <= 1 { return nil // single-value already pushed down via AppointmentListFilter.Type } out := make(map[string]bool, len(preds.Appointment.AppointmentTypes)) for _, t := range preds.Appointment.AppointmentTypes { out[t] = true } return out } // allowedProjectEventKinds returns the slice of project_event.event_type // values the spec narrows to, or nil for "all known kinds". // // Inbox de-dup (t-paliad-249): when the spec also fans out // SourceApprovalRequest, every `*_approval_*` audit event is dropped // — the approval_request row itself is the canonical signal, and we // don't want both rows showing up side-by-side. The drop applies to // both the explicit caller list and the implicit "all kinds" path. func allowedProjectEventKinds(spec FilterSpec) []string { preds, ok := spec.Predicates[SourceProjectEvent] dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest) var requested []string switch { case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0: requested = preds.ProjectEvent.EventTypes case dedupApprovals: // No explicit narrowing, but ApprovalRequest is in sources — // rebuild the implicit "all" list so we can subtract approvals. requested = KnownProjectEventKinds default: return nil } if !dedupApprovals { return requested } filtered := make([]string, 0, len(requested)) for _, k := range requested { if isApprovalAuditKind(k) { continue } filtered = append(filtered, k) } return filtered } // isApprovalAuditKind matches the `*_approval_*` audit event_types that // every approval mutation emits alongside the approval_request row. // Dropped from inbox project_event reads (see allowedProjectEventKinds). func isApprovalAuditKind(kind string) bool { return strings.Contains(kind, "_approval_") || kind == "approval_decided" } // allowedRequestStatuses returns nil for "no narrowing" (or "single value // already pushed into InboxFilter.Status"). func allowedRequestStatuses(spec FilterSpec) map[string]bool { preds, ok := spec.Predicates[SourceApprovalRequest] if !ok || preds.ApprovalRequest == nil { return nil } if len(preds.ApprovalRequest.Status) <= 1 { return nil } out := make(map[string]bool, len(preds.ApprovalRequest.Status)) for _, s := range preds.ApprovalRequest.Status { out[s] = true } return out } func allowedRequestEntityTypes(spec FilterSpec) map[string]bool { preds, ok := spec.Predicates[SourceApprovalRequest] if !ok || preds.ApprovalRequest == nil { return nil } if len(preds.ApprovalRequest.EntityTypes) <= 1 { return nil } out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes)) for _, t := range preds.ApprovalRequest.EntityTypes { out[t] = true } return out } // filterInaccessibleProjects returns the subset of `requested` that the // caller cannot see. Implementation: SELECT id FROM paliad.projects // WHERE id = ANY(...) (RLS filters the visible ones); the missing ones // are inaccessible. One DB hit per RunSpec when scope is explicit. func (s *EventService) filterInaccessibleProjects(ctx context.Context, userID uuid.UUID, requested []uuid.UUID) ([]uuid.UUID, error) { if len(requested) == 0 { return nil, nil } q := `SELECT p.id FROM paliad.projects p WHERE p.id = ANY($1) AND ` + visibilityPredicatePositional("p", 2) var visible []uuid.UUID if err := s.db.SelectContext(ctx, &visible, q, requested, userID); err != nil { return nil, fmt.Errorf("filter inaccessible projects: %w", err) } visibleSet := make(map[uuid.UUID]bool, len(visible)) for _, id := range visible { visibleSet[id] = true } out := make([]uuid.UUID, 0) for _, id := range requested { if !visibleSet[id] { out = append(out, id) } } return out, nil } // Compile-time guards: the substrate's source loaders read fields off // known model shapes. If a model rename breaks this, the build fails // here rather than at runtime in production. var ( _ = models.DeadlineWithProject{} _ = models.AppointmentWithProject{} _ = models.ProjectEvent{} )