Skip to main content

Documentation Index

Fetch the complete documentation index at: https://grantmaster.dev/llms.txt

Use this file to discover all available pages before exploring further.

BaseService & EventBus Architecture

StatusUpdatedCovered Files
🟢 Stable2026-02-21BaseService.ts, eventBus.ts, core/infrastructure.contracts.ts, packages/shared/src/events/*

The BaseService Pattern

BaseService<T> is an abstract base class that centralizes the “boring but critical” infrastructure for every feature service. It ensures that developers don’t forget validation, audit logging, or tenant scoping.

Key Responsibilities

  1. ServiceResult Wrapper: Instead of throwing errors for simple failures, methods can return { success, data, error } using withServiceResult.
  2. Standardized Logging:
    • logSuccess: Automatically emits an AUDIT_LOG_CREATED event.
    • logFailure: Sends error details to Sentry and registers the failure in the audit log.
  3. Runtime Validation: Integrates Zod schemas.
    • Write operations: Strict validation (throws ValidationError).
    • Read operations: Explicit intent pattern (Required, Optional, Fallback) to prevent ambiguous handling.
  4. Tenancy Enforcement: validateOrganizationId is a hard barrier to prevent data leaks.
  5. Quota Enforcement: validateQuota checks tier-based limits before create operations.

Dependency Injection

BaseService accepts injectable dependencies via constructor, defaulting to production singletons:
constructor(
  logger: ILogger = defaultLogger,
  eventBus: IEventBus = defaultEventBus,
  timeProvider: ITimeProvider = defaultTimeProvider,
  firestoreClient?: IFirestoreClient
)
In tests, inject test doubles:
const service = new MyService(
  new TestLogger(),
  new TestEventBus(),
  new FixedTimeProvider(new Date('2024-01-15'))
);

Injectable Interfaces

Defined in src/core/infrastructure.contracts.ts:
InterfaceProduction ImplTest ImplPurpose
ILoggerStructuredLoggerTestLoggerStructured logging (info/warn/error/debug)
IEventBusEventBusTestEventBusEvent emission and subscription
ITimeProviderSystemTimeProviderFixedTimeProviderCurrent time and Firestore timestamps
IFirestoreClientFirestoreClientFakeFirestoreClientDatabase operations (future)
IIdGeneratorCryptoIdGeneratorSequentialIdGeneratorUnique ID generation (future)

Implementation Guide

export class MyFeatureService extends BaseService<MyEntity> {
  protected collectionName = 'myCollection';
  protected serviceName = 'MyFeatureService';
  protected schema = myEntitySchema;       // Optional Zod schema
  protected creationSchema = myCreationSchema; // Optional
  protected updateSchema = myUpdateSchema;    // Optional

  async performAction(data: MyEntity, orgId: string, userId: string) {
    return this.withServiceResult(async () => {
      this.validateOrganizationId(orgId);
      this.validateInput(data, true); // Strict Zod validation
      await this.validateQuota(orgId, 'create'); // Tier-based quota check

      // Business logic here...

      await this.logSuccess('ACTION_PERFORMED', data.id, userId, {}, orgId);
    }, { operation: 'performAction' });
  }
}

Protected Methods

MethodPurpose
validateInput(data, isCreation?)Zod schema validation (strict for writes)
validateFirestoreDataRequired(data, context?)Strict read validation (throws on invalid read)
validateFirestoreDataOptional(data, context?)Optional read validation (returns null on invalid read)
validateFirestoreDataOrFallback(data, fallback, context?)Tolerant read validation with explicit fallback
validateOrganizationId(orgId)Throws if missing (tenant boundary)
validateEntityId(id, operation)Throws if missing (update/delete guard)
validateQuota(orgId, action?, count?)Checks tier limits via QuotaService
withErrorBoundary(fn, context)Catches, logs, re-throws errors
withServiceResult(fn, context)Catches errors, returns ServiceResult
logSuccess(action, resourceId, userId, metadata?, orgId?, agentContext?)Emits audit event
logFailure(action, resourceId, userId, error, orgId?, agentContext?)Emits audit event with error
executeNonCritical(fn, name, context)Runs non-critical operations (swallows errors)
assertExists(entity, name, id)Throws NotFoundError if null
assertBusinessRule(condition, message, context?)Throws BusinessLogicError if false
invalidateCache()Clears request cache for this collection
getResourceType()Maps collection to quota resource type
createContext(operation, extra?)Builds context object for error handling

Read Validation Policy (2026-02-27)

Service files under src/features/**/services and src/extensions/**/services must:
  1. Avoid raw validateFirestoreData(...) calls in business logic.
  2. Use one of:
    • validateFirestoreDataRequired(...)
    • validateFirestoreDataOptional(...)
    • validateFirestoreDataOrFallback(...)
  3. Avoid db! non-null assertions; use explicit guards (if (!db) throw) and narrowed references.
CI enforcement:
  • scripts/check-read-validation-policy.ts
  • scripts/check-no-db-non-null-services.ts
  • scripts/check-base-service-adoption.ts

AgentOperationContext

When AI agents invoke service methods, they pass an AgentOperationContext for full attribution in audit logs:
interface AgentOperationContext {
  runId: string;       // Agent run ID for correlation
  agentType: string;   // e.g., 'Compliance Checker'
  stepId: string;      // Current step within the run
  triggeredBy: string; // userId who originally triggered the agent
}

How It Works

Both logSuccess and logFailure accept an optional agentContext parameter:
await this.logSuccess(
  'GENERATE_JOURNAL',
  journalId,
  userId,
  { entryCount: 5 },
  organizationId,
  agentContext   // ← Optional AgentOperationContext
);
When agentContext is provided:
  • Metadata enrichment: agentRunId, agentType, agentStepId are added to the audit log payload
  • Correlation: The event’s metadata.correlationId is set to agentContext.runId
  • User attribution: The audit event’s userId is set to agentContext.triggeredBy (the original human), not the service account
  • Agent tagging: metadata.agentType is added for filtering agent-initiated actions
This allows the audit trail to distinguish between human-initiated and agent-initiated operations while maintaining a clear chain of accountability.

ServiceOperationOptions

The agentContext field is also available on ServiceOperationOptions, which is passed to various service operation methods:
interface ServiceOperationOptions {
  silent?: boolean;               // Skip notifications
  skipAudit?: boolean;            // Skip audit logging
  skipCacheInvalidation?: boolean;
  auditAction?: AuditAction;
  agentContext?: AgentOperationContext; // Agent attribution
}

The EventBus Pattern

The EventBus is the nervous system of GrantMaster. It allows modules to communicate without direct dependencies.

Characteristics

  • In-Memory First: Fast, synchronous delivery for UI updates.
  • Optional Persistence: Only events marked via requiresPersistence() are saved to Firestore.
  • Error Resilient: If a listener fails, it is logged to Sentry, but other listeners still run, and the original business operation remains unaffected.

Event Anatomy

interface SystemEvent<T> {
  type: SystemEventType;        // e.g., 'EXPENSE_APPROVED'
  organizationId: string;       // Critical for scoping listeners
  userId: string;
  severity: EventSeverity;      // INFO, WARNING, ERROR, CRITICAL
  timestamp: Date;
  payload: T;                   // Typed data payload
  metadata?: {
    source?: string;            // Service/component that emitted
    version?: string;           // Event schema version
    correlationId?: string;     // For tracing related events
  };
}

Type-Safe Subscriptions

The IEventBus interface provides overloaded on() methods for compile-time payload safety:
// Single event type — payload is automatically typed via EventPayloadMap
eventBus.on(SystemEventType.EXPENSE_APPROVED, (event) => {
  // event.payload is ExpenseApprovedPayload
  console.log(event.payload.expenseId, event.payload.amount);
});

// Multiple event types — payload type must be specified manually
eventBus.on<ExpenseApprovedPayload | ExpenseRejectedPayload>(
  [SystemEventType.EXPENSE_APPROVED, SystemEventType.EXPENSE_REJECTED],
  handler
);

// Wildcard — receives all events
eventBus.onAny((event) => { /* event.payload is unknown */ });

Extension-Scoped Subscriptions

Extensions can register event subscriptions that are tracked and bulk-removable:
// Register subscription for an extension
eventBus.subscribeForExtension('impact', SystemEventType.PROJECT_CREATED, handler);
eventBus.subscribeForExtension('impact', SystemEventType.PROJECT_ARCHIVED, handler);

// On extension deactivation, remove all subscriptions at once
const count = eventBus.unsubscribeExtension('impact'); // returns 2

// Query subscription count
const active = eventBus.getExtensionSubscriptionCount('impact'); // 0
This prevents subscription leaks when extensions are installed/uninstalled. CI enforcement:
  • scripts/check-extension-eventbus-subscriptions.ts

EventBus Statistics

interface EventBusStats {
  emittedCount: number;
  persistedCount: number;
  errorCount: number;
  listenerCount: number;
  extensionMetrics?: Record<string, {
    emitCount: number;
    errorCount: number;
    listenerCount: number;
  }>;
}

Typical Usage Flow

  1. Service emits: eventBus.emit({ type: 'PROJECT_CREATED', ... }).
  2. Notification Hub listens: eventBus.on('PROJECT_CREATED', notifyTeam).
  3. Audit Hub listens: eventBus.onAny(updateLiveAuditTrail).
  4. Extension listens: eventBus.subscribeForExtension('impact', 'PROJECT_CREATED', syncIndicators).

Event Type Count

The SystemEventType enum contains 100 event types across 21 domain groups. See the Event Catalog for the complete reference.

Maintenance

Update this document when:
  • Adding global lifecycle hooks to BaseService (e.g., pre-commit, post-commit).
  • Changing the IEventBus interface.
  • Modifying the persistence logic in eventBus.ts.
  • Adding new injectable interfaces to infrastructure.ts.
  • Changing the AgentOperationContext interface.

Ramp-up Exercise

Check src/core/ExampleService.ts for a reference implementation of these patterns in action.