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
| Status | Updated | Covered Files |
|---|
| 🟢 Stable | 2026-02-21 | BaseService.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
- ServiceResult Wrapper: Instead of throwing errors for simple failures, methods can return
{ success, data, error } using withServiceResult.
- Standardized Logging:
logSuccess: Automatically emits an AUDIT_LOG_CREATED event.
logFailure: Sends error details to Sentry and registers the failure in the audit log.
- Runtime Validation: Integrates Zod schemas.
- Write operations: Strict validation (throws
ValidationError).
- Read operations: Explicit intent pattern (
Required, Optional, Fallback) to prevent ambiguous handling.
- Tenancy Enforcement:
validateOrganizationId is a hard barrier to prevent data leaks.
- 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:
| Interface | Production Impl | Test Impl | Purpose |
|---|
ILogger | StructuredLogger | TestLogger | Structured logging (info/warn/error/debug) |
IEventBus | EventBus | TestEventBus | Event emission and subscription |
ITimeProvider | SystemTimeProvider | FixedTimeProvider | Current time and Firestore timestamps |
IFirestoreClient | FirestoreClient | FakeFirestoreClient | Database operations (future) |
IIdGenerator | CryptoIdGenerator | SequentialIdGenerator | Unique 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
| Method | Purpose |
|---|
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:
- Avoid raw
validateFirestoreData(...) calls in business logic.
- Use one of:
validateFirestoreDataRequired(...)
validateFirestoreDataOptional(...)
validateFirestoreDataOrFallback(...)
- 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
- Service emits:
eventBus.emit({ type: 'PROJECT_CREATED', ... }).
- Notification Hub listens:
eventBus.on('PROJECT_CREATED', notifyTeam).
- Audit Hub listens:
eventBus.onAny(updateLiveAuditTrail).
- 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.