[FEATURE] Phase 2 — Metering hybrid enhancements (recompute, COUNT(DISTINCT), lifecycle)
Repository: granit-fx/granit-dotnet
Author: jfmeyers
## Description
**Problem.** `Granit.Metering` aggregates events at ingestion time via a hourly background job (`MeteringAggregationJob`) into pre-computed `UsageAggregate` rows. This works for hot-path reads (quota checks, dashboards) but rules out:
- **Retroactive recompute** when a meter formula or a historical event changes
- **`COUNT(DISTINCT)`** on a property in `Metadata` (e.g., bill per *unique active user*, not per *request*)
- **Lifecycle**: a meter is binary `Activated` (bool) — no Draft state for testing, no Archived state for sunset
- **Backfill**: events with `Timestamp` older than 7 days are rejected
- **Soft event deprecation**: no way to neutralize a fraudulent or bogus event without DELETE
ORB solves this by storing raw events immutably and computing on-demand. We adopt the **hybrid** approach: keep `UsageAggregate` for the hot path, add a cold-path on-demand recompute layer.
**Solution.** Augment `Granit.Metering` along seven axes (see User Stories). Critical addition: PostgreSQL **advisory locking** on `(MeterDefinitionId, TenantId)` to co-exclude the recompute endpoint and the hourly job — without this, concurrent execution corrupts aggregates.
**Alternatives considered.**
- *Pure query-based metering (full ORB parity)*: rejected — would require ClickHouse / TimescaleDB; scope explosion. The hybrid covers ~95% of B2B SaaS use cases on PostgreSQL.
- *Inline SQL ad-hoc metric definitions*: deferred (security design needed — see Spike story)
## User Stories
- #1165 - Replace MeterDefinition.Activated with WorkflowLifecycleStatus
- #1166 - Add CountDistinct aggregation type with DistinctProperty
- #1167 - Add on-demand recompute endpoint with PostgreSQL advisory locking
- #1168 - Add events backfill API for historical timestamps
- #1169 - Add soft event deprecation with auto-recompute trigger
- #1170 - Migrate GET /metering/meters to QueryEngine pagination + filters
- #1171 - [SPIKE] SQL ad-hoc metric definitions — security & sandboxing design
## Expected deliverables
- [ ] Lifecycle migration: `MeterDefinition.Activated` (bool) → `Status` (`WorkflowLifecycleStatus`)
- [ ] `AggregationType.CountDistinct` + `MeterDefinition.DistinctProperty` (JSON path)
- [ ] `POST /metering/meters/{id}/recompute` endpoint with PostgreSQL advisory lock
- [ ] `POST /metering/events/backfill` endpoint with auto-recompute trigger
- [ ] `MeterEvent.DeprecatedAt` + soft-deprecation endpoint
- [ ] `GET /metering/meters` migrated to QueryEngine pagination + status/type filters
- [ ] Spike: SQL ad-hoc metric definitions — security & sandboxing design
## Compliance
- **GDPR**: Backfill events still subject to retention policy (no extended history smuggled in); `DeprecationReason` on event soft-delete may not contain PII
- **ISO 27001**: Recompute and deprecate operations audited via `AuditedEntity` interceptor + dedicated metric counters
- **Backward compatibility**:
- `Activated` bool kept as computed property `bool Activated => Status == Published` for at least 1 release
- `POST /metering/meters/{id}/deactivate` kept as deprecated alias for 1 release
- `GET /metering/meters` response shape change (paged envelope) → release notes flagged
## Effort
2-3 weeks. **Minor breaking changes**: `AggregationType.CountDistinct` (additive OK), `deactivate` endpoint deprecated, `GET /meters` response paginated.
## Branch
`feature/metering-orb-alignment`
## Dependencies
Depends on Phase 1 (Granit.Catalog) being merged — `MeterDefinition.ItemId` already added there. Stories here can reference Catalog items in their own DTOs and validators.
## Parent
Part of #1155 — Align Granit framework on ORB billing concepts (Phase 2). Cross-references existing module EPIC #833.