mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-10 02:42:45 +02:00
docs(next-gen): iteration-9 — punch-list remediation, TS pseudocode, D23 testing policy
- Apply the full design-review punch list (PL-1..PL-36): schema/RLS/envelope corrections, F-number propagation, dangling section refs repointed, REWRITE.md stub. - Rewrite all illustrative Python pseudocode as TypeScript/Drizzle/Fastify across the component docs (PL-29); SQL/PL-pgSQL left language-agnostic. - Observer friendly name: per-tenant label on tenant_observers with a concrete GET/POST/PUT/DELETE contract, nodes?is_observer picker, and a prefix-keyed routing cache so label-only edits skip the ingester reload. - Add D23 (test pyramid & CI coverage policy): vitest unit/integration/component + Playwright e2e, qualitative coverage, real-login E2E (closes TQ1). Wire a Test-strategy section + per-phase Tests blocks through testing.md, the implementation checklist, and phasing; bump ADR count 22 -> 23.
This commit is contained in:
+2
-2
@@ -5,6 +5,6 @@ This document has been split into individual files under
|
||||
[navigation index](docs/plans/next-gen/README.md) for the full structure:
|
||||
|
||||
- [Overview & pain points](docs/plans/next-gen/overview.md)
|
||||
- [Architecture Decision Records (D01–D18)](docs/plans/next-gen/decisions/)
|
||||
- [Component designs](docs/plans/next-gen/components/) (infrastructure, data-model, ingest, auth, api, frontend, derived-state, migration)
|
||||
- [Architecture Decision Records (D01–D22)](docs/plans/next-gen/decisions/)
|
||||
- [Component designs](docs/plans/next-gen/components/) (infrastructure, data-model, ingest, auth, api, frontend, derived-state, migration, multi-tenancy)
|
||||
- [Phasing plan](docs/plans/next-gen/phasing.md) · [Testing & exit criteria](docs/plans/next-gen/testing.md) · [Open questions](docs/plans/next-gen/open-questions.md)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Next-Generation Architecture — MeshCore Hub Rewrite
|
||||
|
||||
> **Status:** Design complete (iterations 1–8). All 22 architectural decisions locked. All design
|
||||
> **Status:** Design complete (iterations 1–9). All 23 architectural decisions locked. All design
|
||||
> questions resolved. Iteration 8 applied 13 review corrections — see
|
||||
> [review-findings.md](review-findings.md) — four of them schema-level (multi-tenant uniqueness, CAGG
|
||||
> vs hypertable, RLS enforcement, phase sequencing) resolved before the Phase 0 DDL freeze.
|
||||
> Iteration 9 added [D23](decisions/D23-test-pyramid-coverage.md) (test pyramid & CI coverage policy).
|
||||
> **Supersedes:** The monolithic `REWRITE.md` (split into these files).
|
||||
|
||||
This directory contains the complete design for a from-scratch rewrite of MeshCore Hub.
|
||||
@@ -27,26 +28,26 @@ Then read the [decisions](decisions/) for the locked architectural choices, and
|
||||
| [code-warts.md](code-warts.md) | Catalog of 52 antipatterns and gotchas from the current codebase (lessons for the new repo) |
|
||||
| [phasing.md](phasing.md) | The 7-phase plan, risks, and design retrospective (what shifted across iterations) |
|
||||
| [implementation-checklist.md](implementation-checklist.md) | Single-page actionable checklist for every task across all 7 phases |
|
||||
| [testing.md](testing.md) | Validation strategies, D5 benchmark plan, and phase exit criteria |
|
||||
| [testing.md](testing.md) | Test strategy (vitest + Playwright pyramid, D23), D5 benchmark plan, and phase exit criteria |
|
||||
| [open-questions.md](open-questions.md) | All resolved — 2 deferred measurements remain (D5 benchmark, D8 compression check) |
|
||||
|
||||
### Component design documents
|
||||
|
||||
| Document | Component | Key decisions |
|
||||
|---|---|---|
|
||||
| [components/infrastructure.md](components/infrastructure.md) | Topology, NATS, Postgres+TimescaleDB, Redis, provisioning | D1, D4, D10, D14 |
|
||||
| [components/infrastructure.md](components/infrastructure.md) | Topology, NATS, Postgres+TimescaleDB, Redis, provisioning | D1, D4, D10, D14, D22 |
|
||||
| [components/data-model.md](components/data-model.md) | Schema DDL, hypertables, CAGGs, RLS, tenancy | D1, D3, D5, D8, Q-A, Q-B |
|
||||
| [components/ingest.md](components/ingest.md) | MqttIngester, IngestWorker, NATS envelopes, dedup, webhook delivery | D4, D5, D19 |
|
||||
| [components/auth.md](components/auth.md) | JWT, local passwords, OIDC, setup wizard | D6, D12, D18 |
|
||||
| [components/api.md](components/api.md) | Cache contract, SSE, settings API, custom pages API, middleware | D6, D7, D11, D20 |
|
||||
| [components/ingest.md](components/ingest.md) | MqttIngester, IngestWorker, NATS envelopes, dedup, webhook delivery | D4, D5, D19, D22 |
|
||||
| [components/auth.md](components/auth.md) | JWT, local passwords, OIDC, setup wizard | D6, D12, D18, D22 |
|
||||
| [components/api.md](components/api.md) | Cache contract, SSE, settings API, custom pages API, middleware | D6, D7, D11, D19, D20, D22 |
|
||||
| [components/frontend.md](components/frontend.md) | Generated client, code-splitting, static shell, SSE hooks, pages admin | D7, D9, D20 |
|
||||
| [components/derived-state.md](components/derived-state.md) | Worker jobs, spam, retention, observability | D15, D16 |
|
||||
| [components/derived-state.md](components/derived-state.md) | Worker jobs, spam, retention, observability | D15, D16, D19, D22 |
|
||||
| [components/migration.md](components/migration.md) | Greenfield strategy, config export/import, parallel-stack | D13, D14 |
|
||||
| [components/multi-tenancy.md](components/multi-tenancy.md) | Shared platform, observer scoping, per-tenant OIDC, hostname resolution | D21, D3, D4, D12 |
|
||||
| [components/multi-tenancy.md](components/multi-tenancy.md) | Shared platform, observer scoping, per-tenant OIDC, hostname resolution | D3, D4, D12, D21, D22 |
|
||||
|
||||
### Architecture Decision Records
|
||||
|
||||
All 22 decisions are locked. See [decisions/](decisions/) for individual records.
|
||||
All 23 decisions are locked. See [decisions/](decisions/) for individual records.
|
||||
|
||||
| # | Decision | Status |
|
||||
|---|---|---|
|
||||
@@ -72,6 +73,7 @@ All 22 decisions are locked. See [decisions/](decisions/) for individual records
|
||||
| [D20](decisions/D20-custom-pages-to-db.md) | Custom pages move to DB (Tier-3 entity) | Locked |
|
||||
| [D21](decisions/D21-multi-tenancy.md) | Multi-tenancy: shared platform, self-provisioning tenants, shared worker pool | Locked |
|
||||
| [D22](decisions/D22-node-typescript-backend.md) | Node/TypeScript backend (Fastify); primary decoder + first-party NATS | Locked |
|
||||
| [D23](decisions/D23-test-pyramid-coverage.md) | Test pyramid & CI coverage policy: vitest (unit/integration/component) + Playwright e2e | Locked |
|
||||
|
||||
## Decision summary at a glance
|
||||
|
||||
|
||||
@@ -102,81 +102,87 @@ mode it is a constant prefix; in multi-tenant mode it is what makes the shared R
|
||||
**Declarative invalidation graph** — the single source of truth, replacing AGENTS.md's "hard rule" hand-mapping:
|
||||
|
||||
```typescript
|
||||
NAMESPACES = {
|
||||
# namespace: role_scoped? invalidated_when_these_entities_change
|
||||
"nodes": (False, {"node", "node_tag", "adoption"}),
|
||||
"messages": (True, {"message", "node", "node_tag"}),
|
||||
"advertisements":(False, {"advertisement", "node", "node_tag", "adoption"}),
|
||||
"routes": (True, {"route"}),
|
||||
"channels": (True, {"channel"}),
|
||||
"profiles": (False, {"profile", "adoption"}),
|
||||
"packets": (True, {"raw_reception"}),
|
||||
"packet_groups": (True, {"raw_reception"}),
|
||||
"dashboard": (False, {"node","node_tag","message","advertisement","adoption","profile","route"}),
|
||||
"settings": (False, {"setting"}),
|
||||
"me": (False, {"profile", "adoption"}),
|
||||
"pages": (False, {"custom_page"}),
|
||||
"config": (False, {"setting", "custom_page"}),
|
||||
}
|
||||
const NAMESPACES: Record<string, { roleScoped: boolean; invalidatedBy: Set<string> }> = {
|
||||
// namespace: roleScoped invalidated when these entities change
|
||||
nodes: { roleScoped: false, invalidatedBy: new Set(["node", "node_tag", "adoption"]) },
|
||||
messages: { roleScoped: true, invalidatedBy: new Set(["message", "node", "node_tag"]) },
|
||||
advertisements: { roleScoped: false, invalidatedBy: new Set(["advertisement", "node", "node_tag", "adoption"]) },
|
||||
routes: { roleScoped: true, invalidatedBy: new Set(["route"]) },
|
||||
channels: { roleScoped: true, invalidatedBy: new Set(["channel"]) },
|
||||
profiles: { roleScoped: false, invalidatedBy: new Set(["profile", "adoption"]) },
|
||||
packets: { roleScoped: true, invalidatedBy: new Set(["raw_reception"]) },
|
||||
packet_groups: { roleScoped: true, invalidatedBy: new Set(["raw_reception"]) },
|
||||
dashboard: { roleScoped: false, invalidatedBy: new Set(["node","node_tag","message","advertisement","adoption","profile","route"]) },
|
||||
settings: { roleScoped: false, invalidatedBy: new Set(["setting"]) },
|
||||
me: { roleScoped: false, invalidatedBy: new Set(["profile", "adoption"]) },
|
||||
pages: { roleScoped: false, invalidatedBy: new Set(["custom_page"]) },
|
||||
config: { roleScoped: false, invalidatedBy: new Set(["setting", "custom_page"]) },
|
||||
};
|
||||
|
||||
# Inverted at startup: entity → set(namespaces to invalidate)
|
||||
ENTITY_INVALIDATION = invert(NAMESPACES)
|
||||
// Inverted at startup: entity → set of namespaces to invalidate
|
||||
const ENTITY_INVALIDATION: Map<string, Set<string>> = invert(NAMESPACES);
|
||||
```
|
||||
|
||||
**The `@cached` decorator** (async-only now that the API is async end-to-end):
|
||||
**The cache hook** (a Fastify route wrapper — the D22 replacement for the `@cached` decorator; async-only now that the API is async end-to-end):
|
||||
```typescript
|
||||
def cached(namespace: str, *, ttl_setting: str = "cache_ttl"):
|
||||
def decorator(handler):
|
||||
@wraps(handler)
|
||||
async def wrapper(request: Request, *args, **kwargs):
|
||||
ns = NAMESPACES[namespace]
|
||||
iid = request.state.principal.instance_id
|
||||
role = request.state.principal.role_tier if ns.role_scoped else "shared"
|
||||
qhash = sha256(sorted_query_string(request).encode())[:16].hex()
|
||||
key = f"{iid}:{namespace}:{role}:{qhash}"
|
||||
async function cachedHandler(
|
||||
route: { namespace: string; ttlSetting?: string; handler: RouteHandler },
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const ns = NAMESPACES[route.namespace];
|
||||
const principal = request.principal;
|
||||
const role = ns.roleScoped ? principal.roleTier : "shared";
|
||||
const qhash = sha256(sortedQueryString(request)).subarray(0, 16).toString("hex");
|
||||
const key = `${principal.instanceId}:${route.namespace}:${role}:${qhash}`;
|
||||
|
||||
# Conditional GET → 304
|
||||
inm = request.headers.get("if-none-match")
|
||||
cached_etag = await cache.get(f"{key}:etag")
|
||||
if cached_etag and etag_matches(inm, cached_etag):
|
||||
return Response(status_code=304, headers={"ETag": cached_etag})
|
||||
// Conditional GET → 304
|
||||
const inm = request.headers["if-none-match"];
|
||||
const cachedEtag = await cache.get(`${key}:etag`);
|
||||
if (cachedEtag && etagMatches(inm, cachedEtag)) {
|
||||
return reply.code(304).header("ETag", cachedEtag).send();
|
||||
}
|
||||
|
||||
# Cache lookup
|
||||
if (body := await cache.get(key)) is not None:
|
||||
request.state.cache_status = "HIT"
|
||||
return Response(content=body, media_type="application/json",
|
||||
headers={"ETag": cached_etag or "", "X-Cache": "HIT"})
|
||||
// Cache lookup
|
||||
const body = await cache.get(key);
|
||||
if (body !== null) {
|
||||
request.cacheStatus = "HIT";
|
||||
return reply.header("ETag", cachedEtag ?? "").header("X-Cache", "HIT")
|
||||
.type("application/json").send(body);
|
||||
}
|
||||
|
||||
# Miss → execute, serialize, store
|
||||
request.state.cache_status = "MISS"
|
||||
result = await handler(request, *args, **kwargs)
|
||||
payload, etag = serialize(result)
|
||||
ttl = getattr(request.app.state, ttl_setting)
|
||||
await cache.set(key, payload, ttl=ttl)
|
||||
await cache.set(f"{key}:etag", etag, ttl=ttl)
|
||||
return Response(content=payload, media_type="application/json",
|
||||
headers={"ETag": etag, "X-Cache": "MISS"})
|
||||
return wrapper
|
||||
return decorator
|
||||
// Miss → execute, serialize, store
|
||||
request.cacheStatus = "MISS";
|
||||
const result = await route.handler(request, reply);
|
||||
const [payload, etag] = serialize(result);
|
||||
const ttl = appState[route.ttlSetting ?? "cacheTtl"];
|
||||
await cache.set(key, payload, ttl);
|
||||
await cache.set(`${key}:etag`, etag, ttl);
|
||||
return reply.header("ETag", etag).header("X-Cache", "MISS")
|
||||
.type("application/json").send(payload);
|
||||
}
|
||||
```
|
||||
|
||||
**Invalidation after a mutation** — one call, the graph does the rest:
|
||||
```typescript
|
||||
async def invalidate_for(session_changes: Iterable[str], cache: CacheBackend, instance_id):
|
||||
namespaces = set()
|
||||
for entity in session_changes:
|
||||
namespaces |= ENTITY_INVALIDATION.get(entity, set())
|
||||
for ns in namespaces:
|
||||
await cache.delete(f"{instance_id}:{ns}:*") # SCAN + DEL by prefix, scoped to THIS tenant only
|
||||
async function invalidateFor(sessionChanges: Iterable<string>, cache: CacheBackend, instanceId: string): Promise<void> {
|
||||
const namespaces = new Set<string>();
|
||||
for (const entity of sessionChanges) {
|
||||
for (const ns of ENTITY_INVALIDATION.get(entity) ?? []) namespaces.add(ns);
|
||||
}
|
||||
for (const ns of namespaces) {
|
||||
await cache.delete(`${instanceId}:${ns}:*`); // SCAN + DEL by prefix, scoped to THIS tenant only
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A mutation handler declares what it changed:
|
||||
```typescript
|
||||
@router.put("/nodes/{pk}/tags/{key}")
|
||||
async def update_tag(...) -> NodeTagRead:
|
||||
... mutate, commit ...
|
||||
await invalidate_for({"node_tag", "node"}, cache, instance_id) # drops nodes + messages + adverts + dashboard
|
||||
return tag
|
||||
fastify.put("/nodes/:pk/tags/:key", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
// ... mutate, commit ...
|
||||
await invalidateFor(["node_tag", "node"], cache, request.principal.instanceId); // drops nodes + messages + adverts + dashboard
|
||||
return tag;
|
||||
});
|
||||
```
|
||||
|
||||
This replaces today's per-endpoint `_invalidate_node_tag_caches`/`_invalidate_adoption_caches` helpers and the dual-prefix `invalidate_dashboard` hack with a single declarative map.
|
||||
@@ -196,7 +202,7 @@ A single `applyVisibility(query, principal)` Drizzle query builder construct tha
|
||||
- The two **CAGGs** (`cagg_daily_packet_counts`, `cagg_packet_breakdown_by_type`, over `raw_receptions`) are read directly, but **RLS does not propagate to a continuous aggregate** — the dashboard query must add an explicit `WHERE instance_id = <principal.instance_id>` predicate (data-model.md §3.6).
|
||||
- Daily **message/advert counts** and **node-count history** are read from the worker-maintained rollup tables (`dashboard_daily_message_counts`, `dashboard_daily_advert_counts`, `dashboard_node_count_history`), which *are* RLS-scoped like any tenant table (data-model.md §3.6a). These replace the two message/advert CAGGs that TimescaleDB cannot build over the OLTP tables.
|
||||
|
||||
> **Implementation note (D22).** The pseudocode in this doc is Python-shaped (`@cached` decorator, `Depends`-injected `Principal`/`RequireAdmin`). In the locked TS stack these map to Fastify **`preHandler` hooks** (auth + the per-request transaction that sets `app.instance_id`) and a **cache plugin/hook** rather than a handler decorator — the contract (one key format, one invalidation graph, one resolved `Principal`) is identical; the wiring is Fastify-idiomatic, not decorator-based.
|
||||
> **Implementation note (D22).** The pseudocode in this doc is Python-shaped (`@cached` decorator, `Depends`-injected `Principal`/`RequireAdmin`, `selectinload` eager-loading). In the locked TS stack these map to Fastify **`preHandler` hooks** (auth + the per-request transaction that sets `app.instance_id`), a **cache plugin/hook** rather than a handler decorator, and **Drizzle relational queries** (`db.query.nodes.findMany({ with: { tags: true } })`) rather than `selectinload` — the contract (one key format, one invalidation graph, one resolved `Principal`, eager-load at the ORM level) is identical; the wiring is Fastify/Drizzle-idiomatic, not decorator-based.
|
||||
|
||||
## OpenAPI as the contract
|
||||
|
||||
@@ -226,7 +232,7 @@ Browser ──EventSource('/api/v1/events/stream')──→ Web tier
|
||||
|
||||
The web tier already proxies all `/api/v1/*` calls — SSE is just a long-lived, streaming variant of the same proxy pattern. The implementation requirement: **the proxy must pipe chunks as they arrive, not buffer the response.** In Fastify, this means writing to `reply.raw` (the underlying `ServerResponse`) and calling `reply.raw.flushHeaders()` before the first chunk, or using `@fastify/http-proxy` which handles streaming correctly out of the box.
|
||||
|
||||
**Single-process alternative:** if the deployment runs web + API as one Fastify process (the simpler model for small/community deployments), no proxy is needed. The `AuthMiddleware` resolves the Principal directly from the cookie (same `JWT_SESSION_SECRET`, same JWS verification the web tier uses). This adds a fourth resolution path to the middleware (after JWT-header, API-key, anonymous): cookie → verify JWS → resolve Principal. The auth boundary is unchanged — the middleware is still the single resolution point; the Principal is still the single authz artifact. The JWT becomes optional in this mode (the cookie is the credential, verified inline rather than transmitted over a network).
|
||||
**Single-process alternative:** if the deployment runs web + API as one Fastify process (the simpler model for small/community deployments), no proxy is needed. The `AuthMiddleware` resolves the Principal directly from the cookie (same `JWT_SESSION_SECRET`, same JWS verification the web tier uses). This enables the middleware's **cookie source** — the second resolution step, between JWT-header and API-key (see auth.md → AuthMiddleware): cookie → verify JWS → resolve Principal. In a split web/API deployment the cookie verifier is not configured, so that step is a no-op and the browser reaches the API via the web tier's injected JWT instead. The auth boundary is unchanged — the middleware is still the single resolution point; the Principal is still the single authz artifact. The JWT becomes optional in this mode (the cookie is the credential, verified inline rather than transmitted over a network).
|
||||
|
||||
### SSE realtime endpoint (concrete)
|
||||
|
||||
@@ -405,7 +411,7 @@ Every key below is seeded at instance creation (registration or CLI). The `value
|
||||
| `registration.subdomain_reserved` | string[] | `["www","api","admin","mail"]` | Subdomains that can't be registered |
|
||||
| `registration.max_domains_per_tenant` | int | `5` | Custom domain cap |
|
||||
|
||||
**Total: 45 keys** across 6 categories. The seed migration inserts one row per key with the default value, `updated_by = 'seed'`, and the instance's `instance_id`. On first boot, Tier-1 env vars (`NETWORK_NAME`, etc.) override the matching seed values — this is the one-time bootstrap. After that, the DB is authoritative and the Admin UI is the edit surface.
|
||||
**Total: 53 keys** across 6 categories (branding 11, features 12, tuning 20, webhooks 1, radio 4, registration 5). The seed migration inserts one row per key with the default value, `updated_by = 'seed'`, and the instance's `instance_id`. On first boot, Tier-1 env vars (`NETWORK_NAME`, etc.) override the matching seed values — this is the one-time bootstrap. After that, the DB is authoritative and the Admin UI is the edit surface.
|
||||
|
||||
### Cross-service propagation
|
||||
|
||||
@@ -446,63 +452,64 @@ This is more nuanced than "env var read at boot," but the alternative (restart t
|
||||
## Settings API (D11)
|
||||
|
||||
```typescript
|
||||
# Public — the static shell bootstraps from this (no auth)
|
||||
@router.get("/config", response_model=PublicConfig)
|
||||
async def get_public_config(settings: SettingsCache) -> PublicConfig:
|
||||
"""Branding + feature flags + radio display. No secrets, no tuning params."""
|
||||
return settings.public_snapshot()
|
||||
// Public — the static shell bootstraps from this (no auth)
|
||||
fastify.get("/config", async (request, reply) => {
|
||||
// Branding + features + radio + auth_mode + custom_pages + needs_setup. No secrets, no tuning params.
|
||||
return settings.publicSnapshot();
|
||||
});
|
||||
|
||||
# Authenticated self
|
||||
@router.get("/me", response_model=PrincipalRead)
|
||||
async def get_me(principal: RequireMember) -> PrincipalRead:
|
||||
return PrincipalRead(user_id=principal.user_id, roles=list(principal.roles), ...)
|
||||
// Authenticated self
|
||||
fastify.get("/me", { preHandler: requireMember }, async (request, reply) => {
|
||||
const p = request.principal;
|
||||
return { userId: p.userId, roles: [...p.roles], /* ... */ } satisfies PrincipalRead;
|
||||
});
|
||||
|
||||
# Admin-only — full settings, all categories
|
||||
@router.get("/settings", response_model=SettingsByCategory, dependencies=[Depends(require_role("admin"))])
|
||||
async def list_settings(settings: SettingsCache) -> SettingsByCategory:
|
||||
return settings.full_snapshot()
|
||||
// Admin-only — full settings, all categories
|
||||
fastify.get("/settings", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
return settings.fullSnapshot();
|
||||
});
|
||||
|
||||
@router.put("/settings/{category}", dependencies=[Depends(require_role("admin"))])
|
||||
async def update_settings(category: str, body: CategoryUpdate, settings: SettingsCache,
|
||||
bus: NatBus, principal: RequireAdmin) -> SettingsByCategory:
|
||||
await settings.update_category(category, body, updated_by=principal.user_id) # validate + write + commit
|
||||
await bus.publish(f"settings.updated.{principal.instance_id}.{category}") # cross-service invalidate
|
||||
return settings.full_snapshot()
|
||||
fastify.put("/settings/:category", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
const { category } = request.params as { category: string };
|
||||
await settings.updateCategory(category, request.body, request.principal.userId); // validate + write + commit
|
||||
await bus.publish(`settings.updated.${request.principal.instanceId}.${category}`); // cross-service invalidate
|
||||
return settings.fullSnapshot();
|
||||
});
|
||||
```
|
||||
|
||||
**`SettingsCache`** — in-memory snapshot per instance, refreshed on NATS notification:
|
||||
```typescript
|
||||
class SettingsCache:
|
||||
async def load(self, instance_id: UUID) -> None: ... # boot: SELECT * FROM settings
|
||||
async def public_snapshot(self) -> PublicConfig: ... # branding + features + radio
|
||||
async def full_snapshot(self) -> SettingsByCategory: ... # all categories (admin)
|
||||
async updateCategory(cat, values, updatedBy) { ... } // Zod-validate, UPSERT, commit
|
||||
async def on_settings_updated(self, msg): ... # NATS subscriber → reload category
|
||||
class SettingsCache {
|
||||
async load(instanceId: string): Promise<void> { ... } // boot: SELECT * FROM settings
|
||||
async publicSnapshot(): Promise<PublicConfig> { ... } // branding + features + radio + auth_mode + custom_pages + needs_setup
|
||||
async fullSnapshot(): Promise<SettingsByCategory> { ... } // all categories (admin)
|
||||
async updateCategory(cat: string, values: unknown, updatedBy: string): Promise<void> { ... } // Zod-validate, UPSERT, commit
|
||||
async onSettingsUpdated(msg: NatsMsg): Promise<void> { ... } // NATS subscriber → reload category
|
||||
}
|
||||
```
|
||||
|
||||
Each category has a typed Zod schema validating writes — a bad value is rejected at the API, not discovered when a service reads it. The escape hatch if a value somehow bricks a service is the ops CLI `meshcore-hub settings reset --category=<cat>` (D18 — operational, not config-mirroring) or a "Reset to defaults" button per category in the Settings UI.
|
||||
|
||||
**`PublicConfig` contract** (the public `/api/v1/config` payload, `Cache-Control: public, max-age=60`): `branding.*`, `features.*`, `radio.*`, `auth_mode`, `custom_pages: [{slug,title,url,menu_order}]`, and **`needs_setup: boolean`**. `needs_setup` is true iff no local admin exists yet (the same admin-existence count the gate middleware runs — see auth.md → First-run setup wizard); the static shell reads it and renders the SPA `/setup` route instead of the dashboard (F12). It is the only non-settings-derived field in the payload and costs one indexed count query, TTL-cached alongside the rest of the snapshot.
|
||||
|
||||
## Custom pages API (D20)
|
||||
|
||||
Custom pages move from file-based `CONTENT_HOME` to a DB-backed Tier-3 entity:
|
||||
|
||||
```typescript
|
||||
@router.get("/pages", response_model=list[CustomPageRead])
|
||||
async def list_pages(db: DbSession) -> list[CustomPageRead]:
|
||||
"""Public — enabled pages only, sorted by menu_order. Drives nav + CustomPage route."""
|
||||
fastify.get("/pages", async (request, reply) => {
|
||||
// Public — enabled pages only, sorted by menuOrder. Drives nav + CustomPage route.
|
||||
...
|
||||
});
|
||||
|
||||
@router.get("/pages/{slug}", response_model=CustomPageRead)
|
||||
async def get_page(slug: str, db: DbSession) -> CustomPageRead:
|
||||
"""Public — single page by slug (includes markdown content)."""
|
||||
fastify.get("/pages/:slug", async (request, reply) => {
|
||||
// Public — single page by slug (includes markdown content).
|
||||
...
|
||||
});
|
||||
|
||||
@router.post("/pages", response_model=CustomPageRead, dependencies=[Depends(require_role("admin"))])
|
||||
async def create_page(body: CustomPageCreate, db: DbSession) -> CustomPageRead: ...
|
||||
|
||||
@router.put("/pages/{slug}", response_model=CustomPageRead, dependencies=[Depends(require_role("admin"))])
|
||||
async def update_page(slug: str, body: CustomPageUpdate, db: DbSession) -> CustomPageRead: ...
|
||||
|
||||
@router.delete("/pages/{slug}", status_code=204, dependencies=[Depends(require_role("admin"))])
|
||||
async def delete_page(slug: str, db: DbSession) -> None: ...
|
||||
fastify.post("/pages", { preHandler: requireAdmin }, async (request, reply) => { ... });
|
||||
fastify.put("/pages/:slug", { preHandler: requireAdmin }, async (request, reply) => { ... });
|
||||
fastify.delete("/pages/:slug", { preHandler: requireAdmin }, async (request, reply) => reply.code(204).send());
|
||||
```
|
||||
|
||||
Mutations invalidate the `pages` + `config` namespaces (nav metadata is served from `/api/v1/config`). The `PublicConfig` response includes `custom_pages: [{slug, title, url, menu_order}]` for the enabled pages — replacing the per-request `__APP_CONFIG__.custom_pages` injection.
|
||||
@@ -543,6 +550,10 @@ fastify.get("/api/v1/map/data", async (request, reply) => {
|
||||
|
||||
Replaces today's `/map/data` server-rendered endpoint (FE9). Now a standard `/api/v1/*` endpoint — benefits from caching, invalidation, and the generated client. The observer-area filter is server-side (removes the 500-node client fetch — FE6).
|
||||
|
||||
### Nodes list — observer filter
|
||||
|
||||
The carried-forward `GET /api/v1/nodes` supports an `?is_observer=true` filter (returns only nodes with `is_observer = true`: `public_key`, `name`, `last_seen`, …). The multi-tenant observer picker (multi-tenancy.md §3 → Available observers) uses it to let a tenant admin select from observers their feed has already seen and pre-fill a friendly name from the node's known `name`. Cached in the `nodes` namespace like the rest of the list.
|
||||
|
||||
### Route preview (live evaluation)
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -118,31 +118,37 @@ Two artifacts: a **session cookie** (long-lived, the "refresh") and an **access
|
||||
|
||||
## The Principal (resolved once per request)
|
||||
|
||||
Every handler receives a frozen `Principal` via `Depends`. It carries everything the request needs for authz, pre-resolved at the middleware so handlers never recompute it:
|
||||
Every handler receives a frozen `Principal` (attached to the request by the `authMiddleware` preHandler). It carries everything the request needs for authz, pre-resolved at the middleware so handlers never recompute it:
|
||||
|
||||
```typescript
|
||||
@dataclass(frozen=True)
|
||||
class Principal:
|
||||
user_id: str | None # None = anonymous; "local:alice" / OIDC sub / "apikey:read"
|
||||
roles: frozenset[str]
|
||||
role_tier: str # highest resolved tier
|
||||
instance_id: UUID
|
||||
channel_indices: frozenset[int] # visible channels, pre-resolved (redaction)
|
||||
// Resolved once per request at the middleware; handlers never recompute it. Immutable.
|
||||
class Principal {
|
||||
private constructor(
|
||||
readonly userId: string | null, // null = anonymous; "local:alice" / OIDC sub / "apikey:read"
|
||||
readonly roles: ReadonlySet<string>,
|
||||
readonly roleTier: string, // highest resolved tier
|
||||
readonly instanceId: string, // uuid
|
||||
readonly channelIndices: ReadonlySet<number>, // visible channels, pre-resolved (redaction)
|
||||
) {}
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool: return self.user_id is not None
|
||||
@property
|
||||
def is_admin(self) -> bool: return "admin" in self.roles
|
||||
@property
|
||||
def is_operator_or_admin(self) -> bool: return bool(self.roles & {"admin", "operator"})
|
||||
get isAuthenticated(): boolean { return this.userId !== null; }
|
||||
get isAdmin(): boolean { return this.roles.has("admin"); }
|
||||
get isOperatorOrAdmin(): boolean { return this.roles.has("admin") || this.roles.has("operator"); }
|
||||
|
||||
static fromJwt(claims: JwtClaims, channels: ChannelResolver): Principal { ... }
|
||||
static fromSession(session: Session, channels: ChannelResolver): Principal { ... }
|
||||
static fromApiKey(key: string, readKey: string, adminKey: string, instanceId: string, channels: ChannelResolver): Principal { ... }
|
||||
static anonymous(instanceId: string, channels: ChannelResolver): Principal { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Dependency aliases replace today's scattered `RequireRead`/`RequireAdmin`/`RequireUserOwner`:
|
||||
Authz guards (Fastify `preHandler` hooks) replace today's scattered `RequireRead`/`RequireAdmin`/`RequireUserOwner`:
|
||||
```typescript
|
||||
RequireRead = Annotated[Principal, Depends(lambda p: p)] # any caller
|
||||
RequireMember = Annotated[Principal, Depends(require_authenticated)] # any logged-in
|
||||
RequireAdmin = Annotated[Principal, Depends(require_role("admin"))]
|
||||
RequireOperatorOrAdmin = Annotated[Principal, Depends(require_any_role("admin","operator"))]
|
||||
const requireRead = guard(() => true); // any caller
|
||||
const requireMember = guard((p) => p.isAuthenticated); // any logged-in
|
||||
const requireAdmin = guard((p) => p.isAdmin);
|
||||
const requireOperatorOrAdmin = guard((p) => p.isOperatorOrAdmin);
|
||||
// usage: fastify.get("/...", { preHandler: requireAdmin }, handler)
|
||||
```
|
||||
|
||||
## AuthMiddleware (single resolution point)
|
||||
@@ -186,17 +192,16 @@ async function resolve(request: FastifyRequest): Promise<Principal> {
|
||||
## Local auth endpoints (D12)
|
||||
|
||||
```typescript
|
||||
@router.post("/auth/login")
|
||||
async def local_login(body: Credentials, request: Request) -> RedirectResponse:
|
||||
user = await verify_local_credentials(body.username, body.password, request.app.state)
|
||||
if user is None:
|
||||
raise HTTPException(401, "invalid credentials")
|
||||
return _start_session(user, request) # mints session cookie, redirects to ?next=
|
||||
fastify.post("/auth/login", async (request, reply) => {
|
||||
const { username, password } = request.body as Credentials;
|
||||
const user = await verifyLocalCredentials(username, password, request.appState);
|
||||
if (!user) return reply.status(401).send({ detail: "invalid credentials" });
|
||||
return startSession(reply, user); // mints session cookie, redirects to ?next=
|
||||
});
|
||||
|
||||
@router.post("/auth/logout")
|
||||
async def logout(request: Request) -> RedirectResponse: ...
|
||||
fastify.post("/auth/logout", async (request, reply) => { ... });
|
||||
|
||||
# OIDC endpoints unchanged: /auth/login (when AUTH_MODE has oidc), /auth/callback, /auth/logout
|
||||
// OIDC endpoints unchanged: /auth/login (when AUTH_MODE has oidc), /auth/callback, /auth/logout
|
||||
```
|
||||
|
||||
The login page renders the local form, the OIDC button, or both based on `PublicConfig.auth_mode` (see Frontend component doc, Login page).
|
||||
@@ -220,18 +225,19 @@ fastify.addHook("preHandler", async (request: FastifyRequest, reply: FastifyRepl
|
||||
> payload: the static shell loads, sees `needs_setup = true`, and renders the wizard client-side — no
|
||||
> server-rendered HTML, no second templating path in the web tier. The `POST /setup` endpoints stay as a
|
||||
> plain JSON API. Server-rendering is a fallback only if the shell must not ship at all pre-setup.
|
||||
> (Casing: the public config field is `needs_setup` — snake_case like the rest of the wire payload; the server-internal gate flag is `fastify.state.needsSetup`, camelCase.)
|
||||
|
||||
**`GET/POST /setup`** — a multi-step wizard (rendered by the SPA when `config.needs_setup` is true; see the note above):
|
||||
|
||||
1. **Welcome / network identity** — network name, city, country, contact (writes the Tier-2 branding settings that are otherwise empty on a fresh DB).
|
||||
2. **Admin account** — username + password + confirm. Creates the first admin (see bootstrap insert sequence below).
|
||||
3. **Auth mode** — local (default) / oidc (enter IdP config) / hybrid. Written as a per-instance setting (`tenant_oidc_configs.auth_mode` in multi-tenant mode, seeded from the `AUTH_MODE` platform default).
|
||||
3. **Auth mode** — hybrid (default) / local / oidc (enter IdP config). Written as a per-instance setting (`tenant_oidc_configs.auth_mode` in multi-tenant mode, seeded from the `AUTH_MODE` platform default, which is `hybrid`).
|
||||
4. **Feature flags** — which pages to enable (sensible defaults pre-checked).
|
||||
5. **Done** — sets `fastify.state.needsSetup = false`, redirects to the dashboard, logged in as the new admin.
|
||||
|
||||
### Bootstrap insert sequence (shared by all three paths)
|
||||
|
||||
Every bootstrap path (env-var, CLI, setup wizard) performs the same atomic 3-table insert inside one transaction:
|
||||
Every bootstrap path (env-var, CLI, setup wizard, and multi-tenant self-service registration — multi-tenancy.md §8) performs the same atomic 3-table insert inside one transaction:
|
||||
|
||||
```sql
|
||||
-- 1. Create the profile (user_id namespaced as local:<username>)
|
||||
|
||||
@@ -35,11 +35,11 @@ Standardize on **one** content identity column per event table:
|
||||
- **TimescaleDB hypertable** partitioned by `received_at` (1-day chunks), columnar compression after 24h, configurable retention (default 30d instead of 2d).
|
||||
- Fewer, targeted indexes; compression makes scan-heavy queries cheap.
|
||||
|
||||
**`packet_path_hops`** (D5 spike — Phase 2):
|
||||
**`packet_path_hops`** (D5 spike — Phase 0):
|
||||
|
||||
- **Preferred target:** fold into a `path_hashes text[]` array column on `raw_receptions` + a GIN index. One insert per reception instead of 1+N; the matcher loads one array instead of joining N rows.
|
||||
- **Fallback:** if the GIN-containment candidate query regresses perf, keep a separate hypertable but **stop denormalizing** `packet_hash`, `received_at`, `observer_node_id` (reachable via the FK).
|
||||
- Decide with a benchmark in Phase 2; the array path is the default assumption in the DDL below.
|
||||
- Decide with a benchmark in **Phase 0**, before this DDL is authored (rescheduled from Phase 2 — F5, since the benchmark shapes the schema); the array path is the default assumption in the DDL below.
|
||||
|
||||
**`telemetry`**: hypertable; `parsed_data` JSONB + GIN; raw LPP bytes follow the same D8 `object_key` pattern as `raw_receptions`.
|
||||
|
||||
@@ -115,7 +115,7 @@ OBJECT STORAGE (deferred — D8; only if measured necessary)
|
||||
|
||||
## 3. Phase 0 — Schema DDL (target, authoritative)
|
||||
|
||||
Postgres-only (D10). Native `uuid`, native enums, `JSONB`, TimescaleDB hypertables. Every tenant-scoped table carries `instance_id` with an RLS policy (D3). PKs are `gen_random_uuid()` except where noted — specifically the high-volume hypertables use a **`bigserial`/`IDENTITY` PK** (Q-A), and `trace_paths.hops` is a **single `jsonb` array** (Q-B). See §4 for the rationale.
|
||||
Postgres-only (D10). Native `uuid`, native enums, `JSONB`, TimescaleDB hypertables. Every tenant-scoped table carries `instance_id` with an RLS policy (D3). PKs are `gen_random_uuid()` except for the hypertables, whose PK must include the time column (TimescaleDB requirement): `raw_receptions` uses a cheap **`bigint IDENTITY`** second column (Q-A), `telemetry`/`event_logs` pair the time column with a `uuid`, and `event_observers` uses a natural composite key — see §4. `trace_paths.hops` is a **single `jsonb` array** (Q-B).
|
||||
|
||||
### 3.1 Enums
|
||||
|
||||
@@ -428,6 +428,7 @@ CREATE TABLE telemetry (
|
||||
SELECT create_hypertable('telemetry', 'received_at', chunk_time_interval => INTERVAL '1 day');
|
||||
CREATE INDEX ix_telemetry_node_received ON telemetry(node_id, received_at);
|
||||
CREATE INDEX ix_telemetry_parsed_gin ON telemetry USING gin(parsed_data);
|
||||
ALTER TABLE telemetry SET (timescaledb.compress, timescaledb.compress_segmentby = 'node_id');
|
||||
SELECT add_compression_policy('telemetry', INTERVAL '24 hours');
|
||||
-- Optional retention (telemetry is otherwise unbounded). Enabled when tuning.telemetry_retention_days
|
||||
-- is set; the retention job (derived-state.md) keeps the policy in sync with the setting.
|
||||
@@ -444,6 +445,7 @@ CREATE TABLE event_logs (
|
||||
PRIMARY KEY (received_at, id)
|
||||
);
|
||||
SELECT create_hypertable('event_logs', 'received_at', chunk_time_interval => INTERVAL '1 day');
|
||||
ALTER TABLE event_logs SET (timescaledb.compress, timescaledb.compress_segmentby = 'event_type');
|
||||
SELECT add_compression_policy('event_logs', INTERVAL '24 hours');
|
||||
SELECT add_retention_policy('event_logs', INTERVAL '30 days');
|
||||
```
|
||||
@@ -453,7 +455,7 @@ Continuous aggregates (the dashboard win — replaces fan-out COUNTs).
|
||||
**A continuous aggregate can only be built over a hypertable.** `messages` and `advertisements` are
|
||||
deliberately plain OLTP tables (content-hash dedup needs a global `event_hash` unique that a hypertable
|
||||
can't provide). So **only the two `raw_receptions`-sourced counts are true CAGGs**; the dedup'd-event and
|
||||
node-count rollups the dashboard needs are **worker-maintained tables** (see §3.7a) — the same
|
||||
node-count rollups the dashboard needs are **worker-maintained tables** (see §3.6a) — the same
|
||||
"can't be a CAGG, so the worker owns it" rule the route-health tables follow (§1.4).
|
||||
|
||||
```sql
|
||||
@@ -493,7 +495,7 @@ can be a continuous aggregate. They are refreshed by the `dashboard-rollups` Der
|
||||
CREATE TABLE dashboard_daily_message_counts (
|
||||
day date NOT NULL,
|
||||
kind message_kind NOT NULL,
|
||||
channel_idx int,
|
||||
channel_idx int NOT NULL DEFAULT -1, -- -1 = the "no channel" bucket (contact messages); a PK column can't be nullable and ON CONFLICT can't match NULL
|
||||
cnt int NOT NULL,
|
||||
instance_id uuid NOT NULL REFERENCES instances(id),
|
||||
PRIMARY KEY (instance_id, day, kind, channel_idx)
|
||||
@@ -505,7 +507,7 @@ CREATE POLICY tenant_isolation ON dashboard_daily_message_counts
|
||||
|
||||
CREATE TABLE dashboard_daily_advert_counts (
|
||||
day date NOT NULL,
|
||||
route_type text,
|
||||
route_type text NOT NULL DEFAULT '', -- '' = the "no route_type" bucket; a PK column can't be nullable and ON CONFLICT can't match NULL
|
||||
cnt int NOT NULL,
|
||||
instance_id uuid NOT NULL REFERENCES instances(id),
|
||||
PRIMARY KEY (instance_id, day, route_type)
|
||||
@@ -529,7 +531,10 @@ CREATE POLICY tenant_isolation ON dashboard_node_count_history
|
||||
```
|
||||
|
||||
The job upserts completed-day buckets (`INSERT … ON CONFLICT (…) DO UPDATE`) each run — idempotent,
|
||||
cheap (a handful of `GROUP BY` queries), and RLS-scoped like every other tenant table.
|
||||
cheap (a handful of `GROUP BY` queries), and RLS-scoped like every other tenant table. The worker
|
||||
`COALESCE`s the nullable source columns into the sentinel (`channel_idx → -1`, `route_type → ''`)
|
||||
before the upsert so every bucket — including the "no channel"/"no route_type" bucket — matches on a
|
||||
total primary key (NULL ≠ NULL would otherwise break idempotency for exactly those rows).
|
||||
|
||||
### 3.7 Route health (worker-maintained, not CAGGs)
|
||||
|
||||
@@ -548,6 +553,7 @@ CREATE TABLE route_results (
|
||||
instance_id uuid NOT NULL REFERENCES instances(id) -- denormalized for RLS (1:1 with routes)
|
||||
);
|
||||
ALTER TABLE route_results ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE route_results FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON route_results
|
||||
USING (instance_id = current_setting('app.instance_id', true)::uuid);
|
||||
|
||||
@@ -562,6 +568,7 @@ CREATE TABLE route_result_history (
|
||||
PRIMARY KEY (route_id, day) -- also serves as the index; no separate redundant one
|
||||
);
|
||||
ALTER TABLE route_result_history ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE route_result_history FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON route_result_history
|
||||
USING (instance_id = current_setting('app.instance_id', true)::uuid);
|
||||
|
||||
@@ -576,6 +583,7 @@ CREATE TABLE route_recent_matches (
|
||||
PRIMARY KEY (route_id, raw_reception_rowid)
|
||||
);
|
||||
ALTER TABLE route_recent_matches ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE route_recent_matches FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON route_recent_matches
|
||||
USING (instance_id = current_setting('app.instance_id', true)::uuid);
|
||||
-- Capped at 3/route by the worker (ROUTE_RECENT_MATCHES_LIMIT), as today.
|
||||
@@ -628,8 +636,13 @@ CREATE TABLE custom_pages (
|
||||
UNIQUE (instance_id, slug)
|
||||
);
|
||||
|
||||
-- Precomputed Prometheus gauges (derived-state.md metrics-gauges job)
|
||||
-- Per-tenant: the DerivedStateWorker computes metrics per instance in the shared worker pool.
|
||||
-- Precomputed Prometheus gauges (derived-state.md metrics-gauges job).
|
||||
-- instance_id here is a gauge LABEL (one row per (key, instance)), NOT a tenancy guard: the
|
||||
-- metrics-gauges job computes per-instance values so the platform can emit instance-labeled series.
|
||||
-- Deliberately NO RLS policy on this table — a Prometheus scrape has no per-request tenant context,
|
||||
-- so the /metrics endpoint reads it as the RLS-bypassing owner role and aggregates across ALL
|
||||
-- instances (one series per instance_id). Access is controlled at the scrape layer
|
||||
-- (network policy / basic-auth on /metrics), not by RLS.
|
||||
CREATE TABLE _metrics_cache (
|
||||
key text NOT NULL,
|
||||
instance_id uuid NOT NULL REFERENCES instances(id),
|
||||
@@ -653,15 +666,15 @@ CREATE TABLE _metrics_cache (
|
||||
|
||||
Two structural sub-decisions were agreed during iteration 3 and are baked into the DDL above. They are narrower than the D-numbered decisions but worth surfacing explicitly because they shape the high-volume tables.
|
||||
|
||||
### Q-A — `bigserial`/`IDENTITY` PK on hypertables
|
||||
### Q-A — hypertable primary keys (time-column composite; `bigint IDENTITY` on `raw_receptions`)
|
||||
|
||||
The high-volume hypertables (`raw_receptions`, `event_observers`, `event_logs`, `telemetry`) use a **`bigint GENERATED ALWAYS AS IDENTITY`** primary key rather than a `uuid` PK, paired with the time column in a composite `PRIMARY KEY (received_at, id)`.
|
||||
TimescaleDB hypertables **must include the time column in the primary key**, so none of them can use a bare `uuid` PK. The four hypertables satisfy this differently, by volume and access pattern:
|
||||
|
||||
- TimescaleDB hypertables require the time column in the PK; a sequential 8-byte `bigint` is the cheapest possible second PK column.
|
||||
- 8 bytes vs 16 bytes per `uuid`, on tables that grow by millions of rows/day — the storage + index savings compound.
|
||||
- `GENERATED ALWAYS AS IDENTITY` is the SQL-standard spelling of "let Postgres manage this sequence"; no `SERIAL` pseudo-type, no manual `nextval`.
|
||||
- OLTP entity tables (`nodes`, `messages`, `advertisements`, `trace_paths`, …) keep `uuid` PKs — they're low-volume, and FK uniformity + natural-key usability matter more than 8 bytes.
|
||||
- This is reflected verbatim in §3.6: `raw_receptions.id bigint GENERATED ALWAYS AS IDENTITY`.
|
||||
- **`raw_receptions`** (the highest-volume table — one row per observer reception) uses a **`bigint GENERATED ALWAYS AS IDENTITY`** second PK column: `PRIMARY KEY (received_at, id)`. A sequential 8-byte `bigint` is the cheapest possible second PK column — 8 bytes vs 16 bytes per `uuid`, on a table that grows by millions of rows/day, so the storage + index savings compound. `GENERATED ALWAYS AS IDENTITY` is the SQL-standard spelling of "let Postgres manage this sequence"; no `SERIAL` pseudo-type, no manual `nextval`. Reflected in §3.6: `raw_receptions.id bigint GENERATED ALWAYS AS IDENTITY`.
|
||||
- **`event_observers`** (junction) uses a **natural composite key** `PRIMARY KEY (observed_at, event_hash, observer_node_id)` — no surrogate id at all, because `(event_hash, observer_node_id)` already identifies a reception uniquely and the junction is never FK-referenced.
|
||||
- **`telemetry` and `event_logs`** (lower volume) pair the time column with a **`uuid`**: `PRIMARY KEY (received_at, id)` where `id uuid DEFAULT gen_random_uuid()`. At their volume the 8-byte saving isn't worth the FK-uniformity loss, and `event_logs` rows are occasionally referenced by id.
|
||||
|
||||
OLTP entity tables (`nodes`, `messages`, `advertisements`, `trace_paths`, …) keep bare `uuid` PKs — they're low-volume, and FK uniformity + natural-key usability matter more than 8 bytes.
|
||||
|
||||
### Q-B — single `hops jsonb` array on `trace_paths`
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
One `DerivedStateWorker` process (or a sidecar mode of the collector) owns all periodic work. The wins:
|
||||
One `DerivedStateWorker` process (the `derived` service — there is no `collector` in the new stack; in small deployments it can run as a sidecar of another service) owns all periodic work. The wins:
|
||||
|
||||
- One set of metrics, one shutdown path, one retry policy.
|
||||
- **Dashboard aggregations are precomputed** instead of fan-out COUNTs (A7). Two of them — daily **packet** counts and the **packet breakdown by type** — source from the `raw_receptions` hypertable and are true TimescaleDB **continuous aggregates**. The other three — daily **message** counts, **advert** counts, and **node-count history** — source from OLTP/entity tables (`messages`, `advertisements`, `nodes`) that are *not* hypertables, so a CAGG cannot be built over them; the `dashboard-rollups` job maintains them as plain rollup tables (data-model.md §3.6a). Either way the API reads precomputed buckets, not live COUNTs.
|
||||
@@ -17,7 +17,7 @@ One `DerivedStateWorker` process (or a sidecar mode of the collector) owns all p
|
||||
|
||||
## Job manifest
|
||||
|
||||
One process — `DerivedStateWorker` — owns every periodic job. Replaces the six daemon threads. The channel-refresh thread is **gone entirely** (replaced by the ChannelKeyCache NATS event mechanism). The webhook processor thread is replaced by a separate **`WebhookWorker`** NATS subscriber (D19 — see [ingest.md §12](ingest.md#12-webhook-delivery-d19)). What remains is four periodic jobs + two health checks.
|
||||
One process — `DerivedStateWorker` — owns every periodic job. Replaces the six daemon threads. The channel-refresh thread is **gone entirely** (replaced by the ChannelKeyCache NATS event mechanism). The webhook processor thread is replaced by a separate **`WebhookWorker`** NATS subscriber (D19 — see [ingest.md §12](ingest.md#12-webhook-delivery-d19)). What remains is five periodic jobs (`route-evaluator`, `route-history`, `spam-rescore`, `retention`, `dashboard-rollups`) plus two metrics/health jobs (`metrics-gauges`, `cagg-health`) — seven in total, per the manifest below.
|
||||
|
||||
| Job | Cadence | Replaces (today) | What it does | Idempotency |
|
||||
|---|---|---|---|---|
|
||||
@@ -26,7 +26,7 @@ One process — `DerivedStateWorker` — owns every periodic job. Replaces the s
|
||||
| `spam-rescore` | 120s | `spam-rescore` thread (120s) | Symmetric-window rescore of recent `messages.spam_score` (see Spam rescoring below). | Per message: only writes when the score changes. |
|
||||
| `retention` | hourly | `cleanup` thread (hourly) | Drop expired hypertable chunks (`raw_receptions`, `event_logs`, `event_observers` at 30d); `cleanup_inactive_nodes`; `recompute_observer_flags`. Chunked (see Chunked retention). | Chunk drops are idempotent; node cleanup is keyed on `last_seen`. |
|
||||
| `dashboard-rollups` | 300s | (new — the counts that can't be CAGGs, F2) | Upsert completed-day buckets into `dashboard_daily_message_counts`, `dashboard_daily_advert_counts`, `dashboard_node_count_history` (data-model.md §3.6a). These source from OLTP/entity tables, so they cannot be TimescaleDB continuous aggregates. | Per (instance, day, …): `INSERT … ON CONFLICT DO UPDATE`. |
|
||||
| `metrics-gauges` | 60s | (new — replaces the metrics COUNT fan-out A7) | Precompute the Prometheus gauge values into a `_metrics_cache` table; `/metrics` reads the cache (TTL-cached today, but the computation moves here). | Overwrite. |
|
||||
| `metrics-gauges` | 60s | (new — replaces the metrics COUNT fan-out A7) | Precompute the Prometheus gauge values into `_metrics_cache`, one row per `(key, instance_id)`. `/metrics` reads the cache as the RLS-bypassing **owner** role and emits instance-labeled series — a scrape has no tenant context (see the `_metrics_cache` note in data-model.md §3.8). | Overwrite. |
|
||||
| `cagg-health` | 300s | (new) | Assert each CAGG's `refresh_status` is recent; log + alert if stale. Read-only check. | — |
|
||||
|
||||
> **CAGGs vs rollups:** only `cagg_daily_packet_counts` and `cagg_packet_breakdown_by_type` (over the
|
||||
@@ -36,49 +36,57 @@ One process — `DerivedStateWorker` — owns every periodic job. Replaces the s
|
||||
|
||||
## Scheduler implementation
|
||||
|
||||
A small home-grown loop (no APScheduler dependency). Each job is a registered `PeriodicJob` with: name, interval, `async def run(session)`, and a `pg_advisory_lock` key.
|
||||
A small home-grown loop (no scheduler dependency). Each job is a registered `PeriodicJob` with: a name, an interval, an async `run(tx)`, and a `pg_advisory_lock` key.
|
||||
|
||||
```typescript
|
||||
@dataclass
|
||||
class PeriodicJob:
|
||||
name: str
|
||||
interval: timedelta
|
||||
lock_key: int # pg_advisory_xact_lock key — prevents double-execution across replicas
|
||||
run: Callable[[AsyncSession], Awaitable[None]]
|
||||
interface PeriodicJob {
|
||||
name: string;
|
||||
intervalMs: number; // cadence in milliseconds
|
||||
lockKey: number; // pg_advisory_xact_lock key — prevents double-execution across replicas
|
||||
run: (tx: Tx) => Promise<void>; // Tx = a Drizzle transaction over node-postgres
|
||||
}
|
||||
|
||||
class DerivedStateWorker:
|
||||
constructor(private db: DbPool, private jobs: PeriodicJob[]) {}
|
||||
class DerivedStateWorker {
|
||||
private running = true;
|
||||
private nextRun = new Map<string, number>(); // job name → next due timestamp (epoch ms)
|
||||
|
||||
async def run(self) -> None:
|
||||
# One loop, tracks per-job next_run time. On each tick, due jobs run sequentially
|
||||
# (these are DB-heavy; parallelism just adds contention). A crash restarts the
|
||||
# process and all jobs self-heal on their next due time.
|
||||
while self._running:
|
||||
now = utcnow()
|
||||
for job in self.jobs:
|
||||
if now >= self._next_run[job.name]:
|
||||
await self._run_one(job)
|
||||
self._next_run[job.name] = now + job.interval
|
||||
await asyncio.sleep(1)
|
||||
constructor(private db: DrizzleDb, private jobs: PeriodicJob[], private instanceId: string) {}
|
||||
|
||||
async def _run_one(self, job: PeriodicJob) -> None:
|
||||
async with self.sessions() as s:
|
||||
# Two-arg advisory lock: (job key, stable per-instance key). Use hashtext(instance_id) — NOT a
|
||||
# positional instance_index, which shifts as tenants come/go and can differ between replicas,
|
||||
# letting the same (job, instance) run twice (F7). The two-arg form also avoids cross-job
|
||||
# collisions that `base_key + index` risks.
|
||||
await s.execute(text("SELECT pg_advisory_xact_lock(:j, hashtext(:iid))"),
|
||||
{"j": job.lock_key, "iid": str(self.instance_id)})
|
||||
await s.execute(text("SET LOCAL app.instance_id = :id"), {"id": self.instance_id})
|
||||
try:
|
||||
await job.run(s)
|
||||
await s.commit()
|
||||
self._record(job.name, status="ok")
|
||||
except Exception:
|
||||
await s.rollback()
|
||||
self._record(job.name, status="error")
|
||||
logger.exception("job %s failed", job.name)
|
||||
# do NOT re-raise; a failed job shouldn't kill the worker
|
||||
async run(): Promise<void> {
|
||||
// One loop, tracks per-job nextRun. On each tick, due jobs run sequentially
|
||||
// (these are DB-heavy; parallelism just adds contention). A crash restarts the
|
||||
// process and all jobs self-heal on their next due time.
|
||||
while (this.running) {
|
||||
const now = Date.now();
|
||||
for (const job of this.jobs) {
|
||||
if (now >= (this.nextRun.get(job.name) ?? 0)) {
|
||||
await this.runOne(job);
|
||||
this.nextRun.set(job.name, now + job.intervalMs);
|
||||
}
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private async runOne(job: PeriodicJob): Promise<void> {
|
||||
try {
|
||||
await this.db.transaction(async (tx) => {
|
||||
// Two-arg advisory lock: (job key, stable per-instance key). Use hashtext(instance_id) — NOT a
|
||||
// positional instance_index, which shifts as tenants come/go and can differ between replicas,
|
||||
// letting the same (job, instance) run twice (F7). The two-arg form also avoids cross-job
|
||||
// collisions that `baseKey + index` risks.
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(${job.lockKey}, hashtext(${this.instanceId}))`);
|
||||
await tx.execute(sql`SET LOCAL app.instance_id = ${this.instanceId}`);
|
||||
await job.run(tx); // normal return commits; a throw rolls the transaction back
|
||||
});
|
||||
this.record(job.name, "ok");
|
||||
} catch (err) {
|
||||
this.record(job.name, "error");
|
||||
logger.error({ err, job: job.name }, "job failed");
|
||||
// do NOT re-raise; a failed job shouldn't kill the worker
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `pg_advisory_xact_lock` makes the worker **HA-safe**: run two replicas, only one executes a given job per interval (the other blocks briefly then no-ops). This is the cheap version of distributed scheduling — no separate coordinator service.
|
||||
@@ -211,7 +219,7 @@ The clear/marginal thresholds (`1.5` / `0.75`) must be kept in sync with the fro
|
||||
|
||||
## Chunked retention (no giant DELETE)
|
||||
|
||||
Today's `cleanup_old_data` issues one `DELETE FROM <table> WHERE received_at < cutoff` per table (§4.1-W10) — a multi-second exclusive lock on large tables. TimescaleDB makes this free:
|
||||
Today's `cleanup_old_data` issues one `DELETE FROM <table> WHERE received_at < cutoff` per table (overview.md §4.1, pain W10) — a multi-second exclusive lock on large tables. TimescaleDB makes this free:
|
||||
|
||||
```sql
|
||||
-- Retention policies (set once, enforced automatically by chunk drops):
|
||||
@@ -228,19 +236,23 @@ SELECT add_retention_policy('event_observers', INTERVAL '30 days');
|
||||
For the OLTP tables that aren't hypertables (`messages`, `advertisements`, `trace_paths`), retention stays a worker job but **chunked**:
|
||||
|
||||
```typescript
|
||||
async def retention_job(session: AsyncSession) -> None:
|
||||
cutoff = utcnow() - timedelta(days=settings.data_retention_days)
|
||||
for table in ("messages", "advertisements", "trace_paths"):
|
||||
# Chunked delete: 5000 rows per statement, loop until 0 affected.
|
||||
# Each statement is short-lived → no long lock.
|
||||
while True:
|
||||
result = await session.execute(text(f"""
|
||||
DELETE FROM {table} WHERE id IN (
|
||||
SELECT id FROM {table} WHERE received_at < :cutoff LIMIT 5000
|
||||
) FOR UPDATE SKIP LOCKED
|
||||
"""), {"cutoff": cutoff})
|
||||
if result.rowcount < 5000:
|
||||
break
|
||||
async function retentionJob(tx: Tx, dataRetentionDays: number): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - dataRetentionDays * 24 * 60 * 60 * 1000);
|
||||
for (const table of ["messages", "advertisements", "trace_paths"] as const) {
|
||||
// Chunked delete: 5000 rows per statement, loop until fewer than 5000 affected.
|
||||
// Each statement is short-lived → no long lock. The table name comes from a fixed
|
||||
// allowlist (never user input), so identifier interpolation is safe.
|
||||
const ident = sql.identifier(table);
|
||||
for (;;) {
|
||||
const result = await tx.execute(sql`
|
||||
DELETE FROM ${ident} WHERE id IN (
|
||||
SELECT id FROM ${ident} WHERE received_at < ${cutoff} LIMIT 5000
|
||||
) FOR UPDATE SKIP LOCKED
|
||||
`);
|
||||
if (result.rowCount < 5000) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`cleanup_inactive_nodes` and `recompute_observer_flags` follow the same chunked pattern.
|
||||
@@ -261,4 +273,4 @@ Each job emits:
|
||||
- A histogram `derived_job_duration_seconds{job="..."}`.
|
||||
- The `cagg-health` job additionally exports `cagg_refresh_lag_seconds{name="..."}`.
|
||||
|
||||
These replace the collector's `HealthReporter` (which today just tracks MQTT/DB connectivity). The worker's health endpoint (`/health/derived`) reports whether any job is overdue beyond `2 × interval`.
|
||||
These replace the collector's `HealthReporter` (which today just tracks MQTT/DB connectivity). The worker's health endpoint (`/health/derived`) reports a job as overdue once it is past `2 × interval` (a degraded-health signal); the Prometheus alert (infrastructure.md → alerting) escalates to a pageable warning at `3 × interval`, so the health check warns before the alert fires.
|
||||
|
||||
@@ -8,8 +8,7 @@ Keep React 19 + Vite + TanStack Query + Tailwind/DaisyUI + react-router. The arc
|
||||
|
||||
### Codegen'd client + typed queries
|
||||
|
||||
- `openapi-typescript` produces `api/schema.d.ts`; `openapi-fetch` gives a typed `GET /messages` client.
|
||||
- TanStack Query hooks generated from operation IDs (`useMessagesQuery`, `useNodeTagsMutation`) via `orval` or hand-written thin wrappers over the typed client.
|
||||
- **`orval`** generates the typed API client + TanStack Query hooks from the OpenAPI spec (D9 — committed upfront): `useMessagesQuery`, `useNodeTagsMutation`, etc., keyed by operation ID. `openapi-fetch` is the documented fallback only, not the primary path.
|
||||
- Delete every hand-written `interface NodeItem` / `Channel` / `Profile` copy.
|
||||
|
||||
### Route-level code-splitting
|
||||
@@ -46,7 +45,7 @@ Keep React 19 + Vite + TanStack Query + Tailwind/DaisyUI + react-router. The arc
|
||||
|
||||
## Client generation (D9 — orval, committed)
|
||||
|
||||
orval is adopted fleet-wide from Phase 4 (first real generation against the new API spec). Phase 0 sets up the tooling only (`orval.config.ts`, `make gen-client`, CI drift check). The `x-invalidates` OpenAPI extension maps each mutation to the `ENTITY_INVALIDATION` graph, and orval emits the matching `queryClient.invalidateQueries` calls automatically.
|
||||
orval's first real generation runs in Phase 4 (against the new API spec); **fleet-wide adoption — using the generated hooks everywhere and deleting the hand-copied types — is Phase 5.** Phase 0 sets up the tooling only (`orval.config.ts`, `make gen-client`, CI drift check). The `x-invalidates` OpenAPI extension maps each mutation to the `ENTITY_INVALIDATION` graph, and orval emits the matching `queryClient.invalidateQueries` calls automatically.
|
||||
|
||||
**Proposed orval config:**
|
||||
```ts
|
||||
@@ -89,6 +88,7 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage/>} /> // eager (landing)
|
||||
<Route path="/setup" element={<SetupWizard/>} /> // rendered when config.needs_setup (F12); gate redirects all other paths here
|
||||
<Route path="/nodes" element={<NodesPage/>} /> // eager (common)
|
||||
<Route path="/dashboard" element={<Suspense fallback={<Skeleton/>}><DashboardPage/></Suspense>} />
|
||||
<Route path="/map" element={<Suspense fallback={<Skeleton/>}><MapPage/></Suspense>} />
|
||||
@@ -137,7 +137,7 @@ The HTML shell becomes a **build-time static artifact** (CDN-cacheable), carryin
|
||||
// main.tsx — renders the public shell immediately, personalises after /config + /me
|
||||
async function bootstrap() {
|
||||
const [config, me] = await Promise.all([
|
||||
apiGet<PublicConfig>('/api/v1/config'), // public: branding, features, auth_mode
|
||||
apiGet<PublicConfig>('/api/v1/config'), // public: branding, features, auth_mode, custom_pages, needs_setup
|
||||
apiGet<PrincipalRead | null>('/api/v1/me').catch(() => null), // null if not logged in
|
||||
]);
|
||||
return { config, me };
|
||||
@@ -159,6 +159,7 @@ bootstrap().then(({ config, me }) => {
|
||||
- `/api/v1/config` is public + cacheable (`Cache-Control: public, max-age=60`) — the network name, features, theme don't change per user. The browser caches it; every tab after the first is instant.
|
||||
- `/api/v1/me` returns the resolved Principal or `null` (anonymous). One call, no per-request inlining.
|
||||
- The shell renders **before** these resolve (a branded loading state), so first paint is fast and the personalisation is a progressive enhancement. This replaces today's per-request `__APP_CONFIG__` inlining with a cacheable shell.
|
||||
- **First-run gate (F12):** if `config.needs_setup` is true, `<App/>` mounts the `<SetupWizard/>` at `/setup` and the web-tier gate middleware redirects every other path there until setup completes (auth.md → First-run setup wizard). No server-rendered wizard — the same static shell renders it client-side.
|
||||
|
||||
## SSE-driven live pages
|
||||
|
||||
|
||||
@@ -501,7 +501,7 @@ Every service handles `SIGTERM` (Docker `stop`) with a drain-then-exit sequence.
|
||||
|
||||
The end-to-end "stand up the new stack" order. This is a **greenfield** deployment — fresh Postgres+TimescaleDB, NATS, new schema; no historical data migration. The few days of parallel-stack validation ([migration.md](migration.md#parallel-stack-validation-ship-gate); D14 locked at 5 days) give the new stack a continuous data view at cutover.
|
||||
|
||||
1. **D5 benchmark** runs, decision recorded, §16 schema frozen.
|
||||
1. **D5 benchmark** runs, decision recorded, data-model.md §3 schema frozen.
|
||||
2. **New infrastructure provisioned:** Postgres 17 + TimescaleDB extension, NATS with JetStream persistence volume, (optional Redis for API cache).
|
||||
3. **`drizzle-kit migrate`** on the fresh DB — creates the full schema (entities, hypertables, CAGGs, RLS policies, retention policies).
|
||||
4. **`db import-config config-bundle.json`** — loads the preserved config (user_profiles + roles, routes + nodes + observers, node_tags, adoptions, channels) + node identity stubs.
|
||||
@@ -511,4 +511,4 @@ The end-to-end "stand up the new stack" order. This is a **greenfield** deployme
|
||||
8. **Cut over** DNS / MQTT exclusivity to the new stack.
|
||||
9. **Decommission** the old stack after the grace period.
|
||||
|
||||
Steps 6–9 are where D14 (5-day parallel-stack window) is exercised. The diff harness compares per-hour event counts by `event_hash` between the old API and the new API; any divergence blocks cutover.
|
||||
Steps 6–9 are where D14 (5-day parallel-stack window) is exercised. The diff harness compares per-hour event counts and `wire_hash` coverage between the old API and the new API — it matches on the LetsMesh on-air `wire_hash` (identical in both stacks), **not** `event_hash`, which differs because the old stack hashes with MD5 and the new with SHA-256 (see migration.md → diff harness). Any divergence blocks cutover.
|
||||
|
||||
@@ -42,16 +42,16 @@ Replace the 1,200-line `LetsMeshNormalizer` field-extraction sprawl (P1, P6) wit
|
||||
### Declarative classification (single source of truth)
|
||||
|
||||
```typescript
|
||||
# One declarative classification table — the single source of truth (replaces P7).
|
||||
CLASSIFIERS: list[Classifier] = [
|
||||
ChannelMessageClassifier(), # payload_type 5 → channel_msg_recv
|
||||
ContactMessageClassifier(), # payload_type 1|2|7 → contact_msg_recv
|
||||
AdvertisementClassifier(), # payload_type 4 (+ identity metadata)
|
||||
TraceClassifier(), # payload_type 9
|
||||
ContactDiscoverClassifier(), # payload_type 11, subType 0x90
|
||||
TelemetryClassifier(), BatteryClassifier(), PathClassifier(), StatusClassifier(), # type 1 branches
|
||||
FallbackClassifier(), # the 0x00–0x0F table → informational event_type
|
||||
]
|
||||
// One declarative classification table — the single source of truth (replaces P7).
|
||||
const CLASSIFIERS: Classifier[] = [
|
||||
new ChannelMessageClassifier(), // payload_type 5 → channel_msg_recv
|
||||
new ContactMessageClassifier(), // payload_type 1|2|7 → contact_msg_recv
|
||||
new AdvertisementClassifier(), // payload_type 4 (+ identity metadata)
|
||||
new TraceClassifier(), // payload_type 9
|
||||
new ContactDiscoverClassifier(), // payload_type 11, subType 0x90
|
||||
new TelemetryClassifier(), new BatteryClassifier(), new PathClassifier(), new StatusClassifier(), // type 1 branches
|
||||
new FallbackClassifier(), // the 0x00–0x0F table → informational event_type
|
||||
];
|
||||
```
|
||||
|
||||
Each `Classifier` produces a typed `PacketFields` summary (the `decode.meta` block of the ingest envelope below) from a `DecodedPacket`. No `Subscriber` god-class, no `self._normalize_*` cascade.
|
||||
@@ -63,9 +63,10 @@ Each `Classifier` produces a typed `PacketFields` summary (the `decode.meta` blo
|
||||
Centralize the 4× duplicated dedup boilerplate (P4) into one helper:
|
||||
|
||||
```typescript
|
||||
def persist_deduped_event(
|
||||
session, *, event_type, event_hash, build_fn, observer, observer_meta
|
||||
) -> EventPersistResult: ...
|
||||
function persistDedupedEvent(
|
||||
tx: Tx,
|
||||
args: { eventType: string; eventHash: Uint8Array; buildRow: () => Row; observer: ObserverRef; observerMeta: ObserverMeta },
|
||||
): Promise<EventPersistResult>;
|
||||
```
|
||||
|
||||
- Computes the SHA-256 content hash.
|
||||
@@ -76,27 +77,31 @@ def persist_deduped_event(
|
||||
### The worker-side helper
|
||||
|
||||
```typescript
|
||||
# One dedup helper — used by every structured handler (P4).
|
||||
async def persist_deduped_event(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
model: type[DeclarativeBase],
|
||||
event_hash: bytes, # sha256(content)[:16]
|
||||
build_row: Callable[[], dict],
|
||||
observer_id: UUID,
|
||||
observer_meta: ObserverMeta,
|
||||
) -> DedupResult:
|
||||
"""INSERT ... ON CONFLICT (instance_id, event_hash) DO NOTHING; attach observer either way.
|
||||
Composite conflict target = per-tenant dedup (F1). Returns DedupResult(is_new, event_id, event_hash).
|
||||
Native Postgres — no dialect branch."""
|
||||
...
|
||||
// One dedup helper — used by every structured handler (P4).
|
||||
async function persistDedupedEvent(
|
||||
tx: Tx,
|
||||
args: {
|
||||
table: PgTable; // Drizzle table (messages, advertisements, ...)
|
||||
eventHash: Uint8Array; // sha256(content) truncated to 16 bytes
|
||||
buildRow: () => Record<string, unknown>;
|
||||
observerId: string; // uuid
|
||||
observerMeta: ObserverMeta;
|
||||
},
|
||||
): Promise<DedupResult> {
|
||||
// INSERT ... ON CONFLICT (instance_id, event_hash) DO NOTHING; attach observer either way.
|
||||
// Composite conflict target = per-tenant dedup (F1). Returns { isNew, eventId, eventHash }.
|
||||
// Native Postgres — no dialect branch.
|
||||
...
|
||||
}
|
||||
|
||||
# Each handler is ~15 lines, not ~50.
|
||||
class ChannelMessageHandler(EventHandler):
|
||||
model = Message
|
||||
def event_hash(self, env: IngestEnvelope) -> bytes:
|
||||
return sha256(f"{env.text}|{env.pubkey_prefix}|{env.channel_idx}|{env.sender_ts}|{env.txt_type}".encode())[:16]
|
||||
def build_row(self, env, observer_id, instance_id) -> dict: ...
|
||||
// Each handler is ~15 lines, not ~50.
|
||||
class ChannelMessageHandler implements EventHandler {
|
||||
table = messages;
|
||||
eventHash(env: IngestEnvelope): Uint8Array {
|
||||
return sha256(`${env.text}|${env.pubkeyPrefix}|${env.channelIdx}|${env.senderTs}|${env.txtType}`).subarray(0, 16);
|
||||
}
|
||||
buildRow(env: IngestEnvelope, observerId: string, instanceId: string): Record<string, unknown> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -105,16 +110,12 @@ class ChannelMessageHandler(EventHandler):
|
||||
|
||||
A single `DerivedStateWorker` process runs a registered set of periodic jobs:
|
||||
|
||||
```
|
||||
every 300s : route-evaluator (refresh route_results + route_recent_matches from raw_receptions.path_hashes)
|
||||
every 3600s: route-history (refresh completed-day buckets in route_result_history)
|
||||
every 120s : spam-rescore (DB function sweep over recent messages)
|
||||
every 5m : channel key refresh (push to ingesters via NATS, not a thread)
|
||||
hourly : retention enforcement (chunk drops for hypertables, chunked DELETE for OLTP — W10)
|
||||
daily : recompute observer flags
|
||||
```
|
||||
The full job manifest (cadences, idempotency, HA locking) lives in [derived-state.md → Job manifest](derived-state.md#job-manifest) — that is the source of truth. In summary the worker runs five periodic jobs (`route-evaluator` 300s, `route-history` 3600s, `spam-rescore` 120s, `retention` hourly, `dashboard-rollups` 300s) plus two metrics/health jobs (`metrics-gauges` 60s, `cagg-health` 300s). Two clarifications vs the old thread model:
|
||||
|
||||
Implemented with a small library (`node-cron`, or a home-grown `PeriodicTask` that collapses the 5 identical loops into ~50 LOC). One process, one set of metrics, one shutdown path.
|
||||
- **Channel-key refresh is NOT a scheduled job.** It is event-driven: the `ChannelKeyCache` reloads on the `channel.keys.<inst>.updated` NATS notification (§11). There is no periodic channel sweep.
|
||||
- **Observer-flag recompute is part of the hourly `retention` job**, not a separate daily job (derived-state.md folds `recompute_observer_flags` into `retention`).
|
||||
|
||||
Implemented with a small home-grown loop (no `node-cron`/APScheduler dependency — a `PeriodicJob` abstraction that collapses the old identical loops into ~50 LOC; see derived-state.md → Scheduler implementation). One process, one set of metrics, one shutdown path.
|
||||
|
||||
> **Note:** The detailed job manifest, scheduler implementation (`pg_advisory_xact_lock` for HA), and the spam-retention/retention logic live in `components/derived-state.md`. This doc is the *ingest* surface; the worker that maintains derived state is its own component.
|
||||
|
||||
@@ -210,7 +211,8 @@ JSON for v1 (debuggable; the decode is the expensive part, not serialization). S
|
||||
"observer": {
|
||||
"public_key": "<64 hex, lowercased>",
|
||||
"iata": "IPT",
|
||||
"feed": "packets" // "packets" | "status" | "internal"
|
||||
"feed": "packets", // "packets" | "status" | "internal"
|
||||
"is_observer": true // IATA code matches a known observer; drives nodes.is_observer via touchNode
|
||||
},
|
||||
"wire_hash": "<32 hex>", // LetsMesh on-air hash; becomes Nats-Msg-Id
|
||||
"mqtt": {
|
||||
@@ -231,6 +233,7 @@ JSON for v1 (debuggable; the decode is the expensive part, not serialization). S
|
||||
"path_hash_width": 1,
|
||||
"channel_idx": 17,
|
||||
"source_pubkey_prefix": "01ab2186c4d5",
|
||||
"source_pubkey_full": "<64 hex or null>", // adverts carry the full sender key; status/internal may not. touchNode prefers this over the prefix
|
||||
"route_type": "flood",
|
||||
"advert_timestamp": null
|
||||
},
|
||||
@@ -246,25 +249,26 @@ The envelope is **immutable** once produced. The MqttIngester is a pure `topic +
|
||||
## 8. MqttIngester (pure decoder + producer)
|
||||
|
||||
```typescript
|
||||
class MqttIngester:
|
||||
def __init__(
|
||||
self,
|
||||
mqtt: MqttClient,
|
||||
js: JetStreamClient, # publish client (@nats-io/jetstream)
|
||||
decoder: MeshCoreDecoder,
|
||||
key_cache: ChannelKeyCache, # §9
|
||||
observer_filter: ObserverFilter,
|
||||
instance_id: UUID,
|
||||
) -> None: ...
|
||||
class MqttIngester {
|
||||
constructor(
|
||||
private mqtt: MqttClient,
|
||||
private js: JetStreamClient, // publish client (@nats-io/jetstream)
|
||||
private decoder: MeshCoreDecoder,
|
||||
private keyCache: ChannelKeyCache, // §9
|
||||
private observerFilter: ObserverFilter,
|
||||
private instanceId: string, // uuid
|
||||
) {}
|
||||
|
||||
async def on_message(self, topic: str, payload: bytes) -> None:
|
||||
# 1. parse topic → observer pubkey / iata / feed (TopicBuilder, unchanged grammar)
|
||||
# 2. observer allow/deny (prefix match) — cheap, pre-decode
|
||||
# 3. envelope = self._build_envelope(topic, payload) # decode + normalize + classify
|
||||
# 4. ack = await js.publish(
|
||||
# subject=f"meshcore.ingest.{self.instance_id}.{envelope.observer.feed}",
|
||||
# headers: { "Nats-Msg-Id": envelope.wire_hash }) # server-side dedup
|
||||
# No DB writes. No blocking on the DB. Bursts absorbed by JetStream.
|
||||
async onMessage(topic: string, payload: Buffer): Promise<void> {
|
||||
// 1. parse topic → observer pubkey / iata / feed (TopicBuilder, unchanged grammar)
|
||||
// 2. observer allow/deny (prefix match) — cheap, pre-decode
|
||||
// 3. const envelope = this.buildEnvelope(topic, payload); // decode + normalize + classify
|
||||
// 4. await this.js.publish(
|
||||
// `meshcore.ingest.${this.instanceId}.${envelope.observer.feed}`,
|
||||
// envelopeBytes, { headers: { "Nats-Msg-Id": envelope.wire_hash } }); // server-side dedup
|
||||
// No DB writes. No blocking on the DB. Bursts absorbed by JetStream.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Decoupling win: a DB stall no longer stalls MQTT. The ingester's only external dependencies are the broker, NATS, and the read-only `ChannelKeyCache`. Horizontal scale = run N ingesters sharing the same MQTT subscription (shared subscription) — though one is usually enough.
|
||||
@@ -274,17 +278,17 @@ Decoupling win: a DB stall no longer stalls MQTT. The ingester's only external d
|
||||
## 9. IngestWorker (batched writer)
|
||||
|
||||
```typescript
|
||||
class IngestWorker:
|
||||
def __init__(
|
||||
self,
|
||||
js: JetStream,
|
||||
db: DbPool, // node-postgres pool (Drizzle)
|
||||
blob: BlobStore, // no-op when D8 off
|
||||
bus: NatBus, // core pub/sub for events.new + channel.keys
|
||||
handlers: HandlerRegistry,
|
||||
instance_id: UUID,
|
||||
batch_size: int = 100,
|
||||
) -> None: ...
|
||||
class IngestWorker {
|
||||
private _running = true;
|
||||
constructor(
|
||||
private js: JetStream,
|
||||
private db: DrizzleDb, // node-postgres pool (Drizzle)
|
||||
private blob: BlobStore, // no-op when D8 off
|
||||
private bus: NatBus, // core pub/sub for events.new + channel.keys
|
||||
private handlers: HandlerRegistry,
|
||||
private instanceId: string, // uuid
|
||||
private batchSize = 100,
|
||||
) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
// Subscribe to the whole ingest subject tree (all instances). `meshcore.ingest.*` would only
|
||||
|
||||
@@ -59,7 +59,7 @@ export carries:
|
||||
user_profiles, user_profile_roles, user_profile_nodes
|
||||
routes, route_nodes, route_observers
|
||||
node_tags
|
||||
channels (if Q-C = migrate)
|
||||
channels (D13 — locked: channels are exported)
|
||||
custom_pages (D20 — read from old CONTENT_HOME/pages/*.md via PageLoader)
|
||||
node_stubs (distinct public_keys referenced by any of the above)
|
||||
```
|
||||
@@ -105,7 +105,7 @@ Idempotent and re-runnable: `import-config` is safe to invoke multiple times (up
|
||||
Because the target is **greenfield infrastructure**, validation is parallel-stack, not parallel-schema. Two complete stacks ingest the same live MQTT feed:
|
||||
|
||||
1. **Stand up the new stack** (fresh Postgres+TimescaleDB, NATS, ingester, workers, API) alongside the old, subscribed to the **same MQTT broker/topics**. Both ingest live RF traffic simultaneously.
|
||||
2. **Diff harness** (below): a CLI job compares per-hour event counts by `event_hash` between the old API and the new API. Any divergence blocks cutover.
|
||||
2. **Diff harness** (below): a CLI job compares per-hour event counts and `wire_hash` coverage between the old API and the new API (match on `wire_hash`, not `event_hash` — see the note below). Any divergence blocks cutover.
|
||||
3. **Validate for N days** (3–7) until confidence is high — the new stack has now repopulated a few days of fresh data, so there's no missing-history gap at cutover.
|
||||
4. **Cut over** DNS / reverse-proxy / MQTT subscription exclusivity to the new stack. The old stack stops ingesting.
|
||||
5. **Decommission** the old stack once the new one has served live traffic cleanly for a grace period.
|
||||
@@ -135,7 +135,7 @@ meshcore-hub admin diff-stacks \
|
||||
| Check | Query | Pass condition |
|
||||
|---|---|---|
|
||||
| **Event count parity** | `GET /api/v1/messages?since=<hour>&until=<hour>` (and adverts, packets) — compare `total` | Counts match within ±2 (tolerance for race at hour boundaries) |
|
||||
| **Hash coverage** | Sample 100 `wire_hash` values from the old stack's hour; verify each exists in the new stack (`GET /api/v1/packet-groups?wire_hash=<hash>`) | 100% coverage |
|
||||
| **Hash coverage** | Sample 100 `wire_hash` values from the old stack's hour; verify each exists in the new stack (`GET /api/v1/packet-groups/<wire_hash>`) | 100% coverage |
|
||||
| **Observer parity** | For the sampled events, compare observer counts (`event_observers` junction) | Counts match exactly |
|
||||
| **Node count** | `GET /api/v1/nodes` — compare `total` | Within ±5 (nodes appear/disappear on advert timing) |
|
||||
|
||||
|
||||
@@ -121,28 +121,60 @@ CREATE TABLE tenant_observers (
|
||||
- **Non-empty allowlist = only those observers.** Prefix-match, identical to today's `OBSERVER_ALLOW_LIST` semantics. A prefix of `01ab21` matches observer `01ab2186c4d5...`.
|
||||
- **Shared observers.** An observer can appear in multiple tenants' allowlists (or in one tenant's allowlist while another tenant has an empty list). The MqttIngester fans out the envelope to all matching tenants.
|
||||
- **No deny list.** If a tenant wants "all except X," they enumerate the observers they want. This keeps the model simple; a deny list is a future refinement if demanded.
|
||||
- **Friendly name (`label`) is per-tenant and private.** Each allowlist row carries an optional `label` — a human note ("IPT downtown repeater") shown only in *this* tenant's Admin UI. It is distinct from the observer's network name (`nodes.name`, sourced from RF adverts/tags and shared across tenants): assigning a label does **not** rename the node, and two tenants may label the same shared observer differently.
|
||||
|
||||
### Management API
|
||||
|
||||
```typescript
|
||||
@router.get("/observers", response_model=list[ObserverAllowlistEntry])
|
||||
async def list_observers(principal: RequireAdmin) -> list[ObserverAllowlistEntry]:
|
||||
"""Tenant admin: list the observer allowlist. Empty = all observers."""
|
||||
// List the tenant's observer allowlist. Empty = all observers.
|
||||
fastify.get("/observers", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
// Returns: [{ prefix, label, knownName, createdAt }]
|
||||
// label — the tenant's private friendly name (tenant_observers.label; nullable)
|
||||
// knownName — the observer's network name, LEFT JOINed from nodes.name for display (nullable)
|
||||
...
|
||||
});
|
||||
|
||||
@router.post("/observers", response_model=ObserverAllowlistEntry, dependencies=[Depends(require_role("admin"))])
|
||||
async def add_observer(body: ObserverAdd, principal: RequireAdmin) -> ObserverAllowlistEntry:
|
||||
"""Add an observer prefix to the allowlist. Publishes observer.allowlist.updated on NATS."""
|
||||
// Add an observer to the allowlist, with an optional friendly name.
|
||||
fastify.post("/observers", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
const { prefix, label } = request.body as { prefix: string; label?: string };
|
||||
// INSERT INTO tenant_observers (instance_id, observer_pubkey_prefix, label) ...
|
||||
// Publishes observer.allowlist.updated.<instance_id> (routing changed → ingester cache reload).
|
||||
...
|
||||
});
|
||||
|
||||
@router.delete("/observers/{prefix}", status_code=204, dependencies=[Depends(require_role("admin"))])
|
||||
async def remove_observer(prefix: str, principal: RequireAdmin) -> None:
|
||||
"""Remove an observer prefix. Publishes observer.allowlist.updated on NATS."""
|
||||
// Rename an observer's friendly name (label only — prefix/routing unchanged).
|
||||
fastify.put("/observers/:prefix", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
const { label } = request.body as { label: string | null };
|
||||
// UPDATE tenant_observers SET label = ... WHERE (instance_id, observer_pubkey_prefix) = ...
|
||||
// Label-only: does NOT publish observer.allowlist.updated (the routing cache keys on prefixes,
|
||||
// which are unchanged) — just invalidates the API `observers` cache namespace.
|
||||
...
|
||||
});
|
||||
|
||||
// Remove an observer from the allowlist.
|
||||
fastify.delete("/observers/:prefix", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
// Publishes observer.allowlist.updated.<instance_id> (routing changed → ingester cache reload).
|
||||
return reply.code(204).send();
|
||||
});
|
||||
```
|
||||
|
||||
Mutations publish `observer.allowlist.updated.<instance_id>` on NATS core, triggering the MqttIngester's `ObserverAllowlistCache` to reload (same pattern as `ChannelKeyCache`).
|
||||
Routing-affecting mutations (`POST`/`DELETE`) publish `observer.allowlist.updated.<instance_id>` on NATS core, triggering the MqttIngester's `ObserverAllowlistCache` to reload (same pattern as `ChannelKeyCache`). A label-only `PUT` does **not** publish — the cache keys on prefixes, which are unchanged — it only invalidates the API `observers` cache namespace.
|
||||
|
||||
### Available observers (picker source)
|
||||
|
||||
The "add observer" flow offers a picker of observers the tenant has already seen, so the admin can select instead of typing a hex prefix:
|
||||
|
||||
- **Source:** the carried-forward nodes list filtered to observers — `GET /api/v1/nodes?is_observer=true` (returns `public_key`, `name`, `is_observer`, `last_seen`; RLS-scoped, so a tenant only sees observers its own feed has ingested).
|
||||
- **On select:** the node's full `public_key` becomes the `observer_pubkey_prefix` (a full-key prefix matches exactly that observer), and the friendly-name field is **pre-filled from the node's known `name`** — editable before save. The stored value is the per-tenant `label`, not the node name.
|
||||
- **Manual entry stays:** the allowlist is prefix-based, so an admin can also type a prefix by hand to allow an observer that hasn't been seen yet (no `nodes` row required).
|
||||
|
||||
### Admin UI
|
||||
|
||||
A new section in the Settings page (or a dedicated `/admin/observers` page): a table of observer prefixes with labels, an "add observer" input, and a note explaining "empty list = all observers." The tenant admin can also see a live list of known observers (from `nodes WHERE is_observer = true`) to pick from, but the allowlist is prefix-based (so they can add an observer before it's ever seen).
|
||||
A new section in the Settings page (or a dedicated `/admin/observers` page):
|
||||
|
||||
- An **add-observer** control with two paths: pick from the available-observers list (above), which pre-fills the friendly name from the node's known name, or type a prefix by hand.
|
||||
- A table of the tenant's allowlist rows showing **prefix + friendly name (`label`)** — with the observer's known network name alongside for reference — plus inline **rename** (the `PUT` endpoint) and remove.
|
||||
- A note explaining "empty list = all observers."
|
||||
|
||||
---
|
||||
|
||||
@@ -153,39 +185,47 @@ The single MqttIngester process decodes **all** MQTT traffic and routes each env
|
||||
### ObserverAllowlistCache
|
||||
|
||||
```typescript
|
||||
class ObserverAllowlistCache:
|
||||
"""Read-only snapshot: observer_prefix → set[instance_id].
|
||||
Loaded from tenant_observers at startup; reloaded on NATS notification.
|
||||
Immutable-snapshot swap (same pattern as ChannelKeyCache)."""
|
||||
class ObserverAllowlistCache {
|
||||
// Read-only snapshot: observer prefix → set of instance_ids.
|
||||
// Loaded from tenant_observers at startup; reloaded on NATS notification.
|
||||
// Immutable-snapshot swap (same pattern as ChannelKeyCache).
|
||||
private prefixMap!: Map<string, Set<string>>; // prefix → tenant ids
|
||||
private allowAllTenants!: Set<string>; // tenants with empty allowlists
|
||||
|
||||
def route(self, observer_pubkey: str) -> list[UUID]:
|
||||
"""Return the tenant IDs that want this observer's traffic.
|
||||
Empty allowlist tenants match ALL observers."""
|
||||
matching = set()
|
||||
for prefix, tenant_ids in self._prefix_map.items():
|
||||
if observer_pubkey.startswith(prefix):
|
||||
matching |= tenant_ids
|
||||
matching |= self._allow_all_tenants # tenants with empty allowlists
|
||||
return list(matching)
|
||||
/** Return the tenant IDs that want this observer's traffic.
|
||||
* Empty-allowlist tenants match ALL observers. */
|
||||
route(observerPubkey: string): string[] {
|
||||
const matching = new Set<string>();
|
||||
for (const [prefix, tenantIds] of this.prefixMap) {
|
||||
if (observerPubkey.startsWith(prefix)) {
|
||||
for (const id of tenantIds) matching.add(id);
|
||||
}
|
||||
}
|
||||
for (const id of this.allowAllTenants) matching.add(id); // tenants with empty allowlists
|
||||
return [...matching];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Load:** `SELECT instance_id, observer_pubkey_prefix FROM tenant_observers` at startup. Build two structures: `_prefix_map: dict[str, set[UUID]]` and `_allow_all_tenants: set[UUID]` (tenants with zero rows).
|
||||
- **Load:** `SELECT instance_id, observer_pubkey_prefix FROM tenant_observers` at startup — `label` is deliberately **not** loaded (it is display-only metadata for the Admin UI; routing keys on prefixes alone, so a label-only `PUT` needs no cache reload). Build two structures: `prefixMap: Map<string, Set<string>>` and `allowAllTenants: Set<string>` (tenants with zero rows).
|
||||
- **Reload:** subscribe to `observer.allowlist.updated.*` (core NATS); on notification, reload the snapshot atomically. The notification carries the `instance_id` so the cache can do a targeted reload (`WHERE instance_id = ?`) instead of a full table scan.
|
||||
- **Thread safety:** single-writer (the reload task), readers go through an immutable snapshot reference. Same as `ChannelKeyCache` (§ingest.md 11).
|
||||
|
||||
### Routing in on_message
|
||||
|
||||
```typescript
|
||||
async def on_message(self, topic: str, payload: bytes) -> None:
|
||||
# 1. parse topic → observer pubkey (unchanged)
|
||||
# 2. envelope = self._build_envelope(topic, payload) # decode + normalize + classify
|
||||
# 3. tenant_ids = self.observer_cache.route(envelope.observer.public_key)
|
||||
# 4. for tenant_id in tenant_ids:
|
||||
# await js.publish(
|
||||
# subject=f"meshcore.ingest.{tenant_id}.{envelope.observer.feed}",
|
||||
# payload=JSON.stringify(envelope),
|
||||
# headers: { "Nats-Msg-Id": `${tenant_id}:${envelope.wire_hash}` })
|
||||
# Envelope is tenant-agnostic; tenant routing is purely at the NATS subject level.
|
||||
async onMessage(topic: string, payload: Buffer): Promise<void> {
|
||||
// 1. parse topic → observer pubkey (unchanged)
|
||||
// 2. const envelope = this.buildEnvelope(topic, payload); // decode + normalize + classify
|
||||
// 3. const tenantIds = this.observerCache.route(envelope.observer.public_key);
|
||||
// 4. for (const tenantId of tenantIds) {
|
||||
// await this.js.publish(
|
||||
// `meshcore.ingest.${tenantId}.${envelope.observer.feed}`,
|
||||
// JSON.stringify(envelope),
|
||||
// { headers: { "Nats-Msg-Id": `${tenantId}:${envelope.wire_hash}` } });
|
||||
// }
|
||||
// Envelope is tenant-agnostic; tenant routing is purely at the NATS subject level.
|
||||
}
|
||||
```
|
||||
|
||||
**Key detail:** the `Nats-Msg-Id` is prefixed with `tenant_id` so that the same physical packet (same `wire_hash`) delivered to two tenants gets two distinct dedup keys. Without this, JetStream's server-side dedup would suppress the second tenant's copy.
|
||||
@@ -199,15 +239,19 @@ async def on_message(self, topic: str, payload: bytes) -> None:
|
||||
The MqttIngester needs channel keys from **all tenants** to decrypt channel messages (the `channel_idx` is in the decrypted payload, needed for classification and the SSE visibility filter).
|
||||
|
||||
```typescript
|
||||
class ChannelKeyCache:
|
||||
"""Multi-tenant: dict[instance_id, frozenset[ChannelKey]].
|
||||
Loads enabled channels for ALL instances at startup.
|
||||
Reloads one tenant's keys on channel.keys.<inst>.updated."""
|
||||
class ChannelKeyCache {
|
||||
// Multi-tenant: Map<instance_id, ReadonlySet<ChannelKey>>.
|
||||
// Loads enabled channels for ALL instances at startup.
|
||||
// Reloads one tenant's keys on channel.keys.<inst>.updated.
|
||||
private snapshots!: Map<string, ReadonlySet<ChannelKey>>;
|
||||
|
||||
def all_keys(self) -> Iterator[ChannelKey]:
|
||||
"""Yield keys from all tenants (for decryption attempts)."""
|
||||
for keys in self._snapshots.values():
|
||||
yield from keys
|
||||
/** Yield keys from all tenants (for decryption attempts). */
|
||||
*allKeys(): IterableIterator<ChannelKey> {
|
||||
for (const keys of this.snapshots.values()) {
|
||||
yield* keys;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Load:** `SELECT instance_id, key_hex, key_hash FROM channels WHERE enabled` at startup (all instances).
|
||||
@@ -240,15 +284,15 @@ CREATE TABLE tenant_oidc_configs (
|
||||
The web tier resolves OIDC config per request:
|
||||
|
||||
```typescript
|
||||
async def resolve_oidc_config(instance_id: UUID, settings: SettingsCache) -> OidcConfig | None:
|
||||
# 1. Per-tenant DB config (authoritative if present)
|
||||
if (cfg := await get_tenant_oidc(instance_id)) and cfg.enabled:
|
||||
return cfg
|
||||
# 2. Platform-level env-var defaults (fallback)
|
||||
if settings.oidc_client_id: # from Tier-1 env
|
||||
return OidcConfig.from_env()
|
||||
# 3. No OIDC — local-only
|
||||
return None
|
||||
async function resolveOidcConfig(instanceId: string, settings: SettingsCache): Promise<OidcConfig | null> {
|
||||
// 1. Per-tenant DB config (authoritative if present)
|
||||
const cfg = await getTenantOidc(instanceId);
|
||||
if (cfg && cfg.enabled) return cfg;
|
||||
// 2. Platform-level env-var defaults (fallback)
|
||||
if (settings.oidcClientId) return OidcConfig.fromEnv(); // from Tier-1 env
|
||||
// 3. No OIDC — local-only
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- Each tenant can point at their own IdP, share one with different client IDs, or use local-only auth.
|
||||
@@ -260,9 +304,10 @@ async def resolve_oidc_config(instance_id: UUID, settings: SettingsCache) -> Oid
|
||||
Tenant admins configure OIDC via the Settings UI (a new "Authentication" section) or the API:
|
||||
|
||||
```typescript
|
||||
@router.put("/settings/oidc", dependencies=[Depends(require_role("admin"))])
|
||||
async def update_oidc_config(body: OidcConfigUpdate, principal: RequireAdmin) -> OidcConfigRead:
|
||||
"""Tenant admin: configure their own IdP. client_secret is write-only."""
|
||||
fastify.put("/settings/oidc", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
// Tenant admin: configure their own IdP. client_secret is write-only.
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
@@ -285,6 +330,8 @@ CREATE TABLE instance_hostnames (
|
||||
instance_id uuid NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
is_primary boolean NOT NULL DEFAULT false,
|
||||
is_custom boolean NOT NULL DEFAULT false, -- true for tenant-added custom domains
|
||||
status text NOT NULL DEFAULT 'active', -- 'pending_dns' | 'active'; subdomains insert 'active', custom domains insert 'pending_dns'
|
||||
last_seen_at timestamptz, -- set on the first request that arrives on this hostname; flips status pending_dns → active
|
||||
added_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
@@ -334,7 +381,7 @@ fastify.post("/api/v1/domains", { preHandler: requireAdmin }, async (request, re
|
||||
return reply.status(409).send({ detail: "hostname already in use" });
|
||||
|
||||
await db.insert(instanceHostnames).values({
|
||||
hostname, instance_id: request.instanceId, is_custom: true,
|
||||
hostname, instance_id: request.instanceId, is_custom: true, status: "pending_dns",
|
||||
});
|
||||
await nats.publish("hostname.updated", JSON.stringify({ hostname, instance_id: request.instanceId }));
|
||||
|
||||
@@ -349,7 +396,7 @@ fastify.post("/api/v1/domains", { preHandler: requireAdmin }, async (request, re
|
||||
fastify.get("/api/v1/domains", { preHandler: requireAdmin }, async (request, reply) => {
|
||||
const rows = await db.select().from(instanceHostnames)
|
||||
.where(eq(instanceHostnames.instance_id, request.instanceId));
|
||||
return rows; // [{ hostname, is_primary, is_custom, added_at }]
|
||||
return rows; // [{ hostname, is_primary, is_custom, status, last_seen_at, added_at }]
|
||||
});
|
||||
|
||||
// Tenant admin: remove a custom domain (cannot remove the primary subdomain)
|
||||
@@ -376,7 +423,9 @@ fastify.put("/api/v1/domains/:hostname/primary", { preHandler: requireAdmin }, a
|
||||
});
|
||||
```
|
||||
|
||||
**DNS verification — not required.** The hostname is a routing key, not a credential. Adding a hostname you don't control is harmless: without a DNS record pointing to the platform, the domain simply doesn't resolve. The tenant is motivated to configure DNS correctly because they want their domain to work. The Admin UI shows the CNAME target and a "pending DNS" hint until the first request arrives on that hostname (tracked via a `last_seen_at` column or the reverse proxy's access log — lightweight, no active probing).
|
||||
**DNS verification — not required.** The hostname is a routing key, not a credential. Adding a hostname you don't control is harmless: without a DNS record pointing to the platform, the domain simply doesn't resolve. The tenant is motivated to configure DNS correctly because they want their domain to work. The Admin UI shows the CNAME target and the row's `status` (`pending_dns` → `active`). The flip happens on the **first request** that arrives on that hostname: the reverse proxy (or a lightweight first-request hook) sets `last_seen_at = now()` and `status = 'active'` — no active probing.
|
||||
|
||||
**Abuse guard (on-demand ACME).** Because custom-domain TLS is on-demand ACME (below), the reverse proxy must rate-limit certificate issuance per hostname — e.g. Caddy's `on_demand_tls` `ask` endpoint checking `instance_hostnames`, or a small allowlist of registered hostnames — so a flood of bogus custom-hostname inserts cannot exhaust the Let's Encrypt per-domain rate limit.
|
||||
|
||||
**TLS provisioning:** the reverse proxy handles this automatically:
|
||||
|
||||
@@ -401,6 +450,12 @@ async function instanceResolution(request: FastifyRequest, reply: FastifyReply)
|
||||
// 1. JWT-authenticated requests: instance_id from the token claim (already there)
|
||||
// 2. Unauthenticated requests: resolve from hostname
|
||||
const hostname = request.headers.host?.split(":")[0];
|
||||
// The bare platform root (no matching hostname row) serves the platform landing + /register pages with
|
||||
// NO instance context — it is not a tenant host. Only unknown *subdomains* 404.
|
||||
if (hostname === PLATFORM_DOMAIN) {
|
||||
request.instanceId = null; // platform context (landing / registration)
|
||||
return;
|
||||
}
|
||||
const instanceId = await hostnameCache.resolve(hostname);
|
||||
if (!instanceId) {
|
||||
return reply.status(404).send({ detail: "unknown host" });
|
||||
@@ -411,6 +466,7 @@ async function instanceResolution(request: FastifyRequest, reply: FastifyReply)
|
||||
|
||||
- The `HostnameCache` is a read-only snapshot (loaded at startup, refreshed on NATS notification when hostnames change). Same immutable-snapshot pattern as `ChannelKeyCache`.
|
||||
- **Fallback:** a Tier-1 env var `DEFAULT_INSTANCE_ID` for single-tenant deployments (Phases 0–6). When set, hostname resolution is skipped and all requests map to the default instance. This is the backwards-compatible path.
|
||||
- **Platform root:** the bare `PLATFORM_DOMAIN` apex (no subdomain) is not in `instance_hostnames` and resolves to `instanceId = null` — the instance-less platform context that serves the landing page and `/register` (§8). This is distinct from an *unknown subdomain*, which 404s. In multi-tenant mode there is no `DEFAULT_INSTANCE_ID`; the apex is the only instance-less host.
|
||||
|
||||
### What changes in the web tier
|
||||
|
||||
@@ -433,7 +489,16 @@ Tenants self-provision via a public registration flow. **No CLI, no superadmin,
|
||||
```typescript
|
||||
// Public — no auth required
|
||||
fastify.post("/api/v1/register", { schema: { body: RegisterBody } }, async (request, reply) => {
|
||||
const { community_name, subdomain, admin_username, admin_password } = request.body;
|
||||
const { community_name, subdomain, admin_username, admin_password, captcha_token } = request.body;
|
||||
|
||||
// Abuse controls — platform settings (read from the well-known platform instance; see note below).
|
||||
const reg = await platformSettings("registration");
|
||||
if (!reg.enabled)
|
||||
return reply.status(403).send({ detail: "registration disabled" });
|
||||
if (reg.require_captcha && !(await verifyCaptcha(captcha_token, request.ip)))
|
||||
return reply.status(400).send({ detail: "captcha failed" });
|
||||
if (reg.subdomain_reserved.includes(subdomain))
|
||||
return reply.status(400).send({ detail: "reserved subdomain" });
|
||||
|
||||
// Validate subdomain: alphanumeric + hyphens, 3-63 chars, unique
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(subdomain))
|
||||
@@ -443,7 +508,11 @@ fastify.post("/api/v1/register", { schema: { body: RegisterBody } }, async (requ
|
||||
if (await hostnameExists(hostname))
|
||||
return reply.status(409).send({ detail: "subdomain taken" });
|
||||
|
||||
// One transaction: instance + hostname + settings seed + admin bootstrap
|
||||
// One transaction: instance + hostname + settings seed + admin bootstrap.
|
||||
// RLS note (F3): this is the ONE explicit RLS-bypass path. The instance doesn't exist yet, so there is
|
||||
// no app.instance_id GUC to set; the registration handler runs this bootstrap transaction as the
|
||||
// RLS-bypassing OWNER role (the same role that runs migrations). Every post-registration tenant
|
||||
// operation goes through the normal meshcore_app role + SET LOCAL app.instance_id path.
|
||||
await db.transaction(async (tx) => {
|
||||
const [instance] = await tx.insert(instances).values({ name: community_name }).returning();
|
||||
await tx.insert(instanceHostnames).values({
|
||||
@@ -456,7 +525,10 @@ fastify.post("/api/v1/register", { schema: { body: RegisterBody } }, async (requ
|
||||
// After commit: notify all services
|
||||
await nats.publish("instance.created", JSON.stringify({ instance_id: instance.id, hostname }));
|
||||
|
||||
// Auto-login: mint session cookie, redirect to the tenant's dashboard
|
||||
// Auto-login: mint session cookie, redirect to the tenant's dashboard.
|
||||
// D6 carve-out: registration is the single flow where the API tier holds JWT_SESSION_SECRET and sets
|
||||
// the session cookie directly — there is no pre-existing web-tier session to lean on for a brand-new
|
||||
// tenant. Everywhere else the web tier issues credentials and the API only verifies (auth.md).
|
||||
return startSession(reply, { sub: `local:${admin_username}`, instance_id: instance.id, roles: ["admin"] })
|
||||
.redirect(302, `https://${hostname}/`);
|
||||
});
|
||||
@@ -473,7 +545,7 @@ fastify.post("/api/v1/register", { schema: { body: RegisterBody } }, async (requ
|
||||
| `registration.require_captcha` | `false` | Optional hCaptcha/Turnstile challenge on the form |
|
||||
| `registration.subdomain_reserved` | `["www","api","admin","mail"]` | Reserved subdomains that can't be registered |
|
||||
|
||||
These are **platform-level** settings (stored in the `settings` table with `instance_id = NULL` or a dedicated `platform_settings` scope — see note below). They are editable by the platform's first tenant's admin (the "platform admin" is just the admin of the first-created instance, not a separate role).
|
||||
These are **platform-level** settings, stored against the well-known platform instance id (the first instance — see the note below; `settings.instance_id` is `NOT NULL`, so there is no NULL or separate `platform_settings` scope). They are editable by the platform's first tenant's admin (the "platform admin" is just the admin of the first-created instance, not a separate role).
|
||||
|
||||
> **Platform settings note:** the `settings` table is instance-scoped. Platform-level settings (registration control, `PLATFORM_DOMAIN`) are stored with a well-known `instance_id` (the first instance, created at initial deployment via the existing `NETWORK_NAME` seed). The registration endpoint reads from this instance's settings. This avoids a new table while keeping the "no superadmin role" principle — the platform admin is a regular tenant admin with access to the platform settings category.
|
||||
|
||||
@@ -522,6 +594,10 @@ DELETE FROM route_observers WHERE route_id IN (SELECT id FROM routes WHERE
|
||||
DELETE FROM route_nodes WHERE route_id IN (SELECT id FROM routes WHERE instance_id = :id);
|
||||
DELETE FROM custom_pages WHERE instance_id = :id;
|
||||
DELETE FROM settings WHERE instance_id = :id;
|
||||
DELETE FROM dashboard_daily_message_counts WHERE instance_id = :id;
|
||||
DELETE FROM dashboard_daily_advert_counts WHERE instance_id = :id;
|
||||
DELETE FROM dashboard_node_count_history WHERE instance_id = :id;
|
||||
DELETE FROM _metrics_cache WHERE instance_id = :id;
|
||||
DELETE FROM messages WHERE instance_id = :id;
|
||||
DELETE FROM advertisements WHERE instance_id = :id;
|
||||
DELETE FROM trace_paths WHERE instance_id = :id;
|
||||
@@ -534,6 +610,11 @@ DELETE FROM event_observers WHERE instance_id = :id;
|
||||
DELETE FROM raw_receptions WHERE instance_id = :id;
|
||||
DELETE FROM telemetry WHERE instance_id = :id;
|
||||
DELETE FROM event_logs WHERE instance_id = :id;
|
||||
-- Dashboard CAGGs (cagg_daily_packet_counts, cagg_packet_breakdown_by_type) materialize from
|
||||
-- raw_receptions and group by instance_id (the dashboard reads filter on it — api.md). The raw_receptions
|
||||
-- DELETE above removes the source; run refresh_continuous_aggregate over the retention window so the
|
||||
-- deleted instance's materialized buckets recompute empty (the cagg-health job does this on its next tick
|
||||
-- if not run inline).
|
||||
-- Phase 7 tables (these DO have ON DELETE CASCADE from instances)
|
||||
DELETE FROM tenant_observers WHERE instance_id = :id;
|
||||
DELETE FROM tenant_oidc_configs WHERE instance_id = :id;
|
||||
@@ -542,7 +623,7 @@ DELETE FROM instance_hostnames WHERE instance_id = :id;
|
||||
DELETE FROM instances WHERE id = :id;
|
||||
```
|
||||
|
||||
2. The CLI confirms interactively and runs each DELETE in a transaction, reporting row counts. The hypertable deletes are the slowest (full chunk scan filtered by `instance_id`); for 30 days of community-mesh data this is a multi-minute but not catastrophic operation.
|
||||
2. The CLI confirms interactively and runs each DELETE in a transaction, reporting row counts. The hypertable deletes are the slowest (full chunk scan filtered by `instance_id`); for 30 days of community-mesh data this is a multi-minute but not catastrophic operation. After the hypertable deletes, the CLI calls `refresh_continuous_aggregate` over the retention window so the deleted instance's materialized dashboard-CAGG buckets recompute empty (they group by `instance_id`, so they purge cleanly).
|
||||
|
||||
**Why not `ON DELETE CASCADE` on core tables:** (a) TimescaleDB would scan every hypertable chunk on cascade — O(all data) with no chunk exclusion; (b) an accidental `DELETE FROM instances` (wrong WHERE, typo) would irrevocably destroy an entire tenant; (c) the explicit purge gives the operator row-count feedback and a confirmation gate. The Phase 7-specific tables (`tenant_observers`, `tenant_oidc_configs`, `instance_hostnames`) do use CASCADE because they're small and directly owned by the instance row.
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
|
||||
A single Postgres/SQLite database holds everything today, and the high-volume append-only tables are exactly the ones that hurt: W2 (`raw_packets` write amplification — 16 cols, 9 indexes, one row per observer reception), W3 (`packet_path_hops` write-amplified per reception × hop), W7 (`events_log` as an unbounded audit sink roughly doubling per-event storage), and W6 (route-health subsystem as a hand-rolled materialized-view layer of 7 tables). The 2-day raw retention ceiling and the HEAD migration that rebuilt a Postgres covering index are symptoms of forcing OLTP-shaped storage onto timeseries-shaped workloads. Dashboard aggregations (A7) fan out one COUNT per visible channel per request because nothing is pre-bucketed.
|
||||
|
||||
The §13-D1 decision had to land before the §16 schema could be drafted — every later phase depends on which store holds the high-volume streams.
|
||||
The D1 decision had to land before the Phase 0 schema (data-model.md §3) could be drafted — every later phase depends on which store holds the high-volume streams.
|
||||
|
||||
## Decision
|
||||
|
||||
**PostgreSQL 17 + TimescaleDB** (community edition, Apache-2.0). The high-volume append-only streams (`raw_receptions`, `event_observers`, `telemetry`, `event_logs`) become TimescaleDB hypertables partitioned by `received_at` (1-day chunks), with columnar compression policies after 24h and retention policies (default 30d, up from 2d). The **hypertable-sourced** dashboard time-bucketing workloads become **continuous aggregates** (`cagg_daily_packet_counts`, `cagg_packet_breakdown_by_type`, both over `raw_receptions`) refreshed on a 5-minute policy — replacing the fan-out COUNTs. The other three dashboard rollups (daily message counts, advert counts, node-count history) source from OLTP/entity tables (`messages`, `advertisements`, `nodes`), which are **not** hypertables — a continuous aggregate cannot be built over them, so they are worker-maintained rollup tables instead (F2; data-model.md §3.6a).
|
||||
|
||||
Route-health derived tables stay worker-maintained (subsequence logic is not a pure time-bucket aggregate — see §6.3.4 and D5).
|
||||
Route-health derived tables stay worker-maintained (subsequence logic is not a pure time-bucket aggregate — see data-model.md §1.4 and D5).
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today `events_log` is the catch-all audit sink — every event that doesn't match a structured handler lands here as an unbounded JSON payload (W7), roughly doubling per-event storage. It is high-volume (one row per non-structured event across all topics) but carries real diagnostic value: it is where unknown payload types, internal MQTT topics, and operational oddities are inspectable after the fact. The §13-D2 question was whether to keep it at all in the rewrite, or drop it on the assumption that structured handlers + `raw_receptions` coverage made it redundant.
|
||||
Today `events_log` is the catch-all audit sink — every event that doesn't match a structured handler lands here as an unbounded JSON payload (W7), roughly doubling per-event storage. It is high-volume (one row per non-structured event across all topics) but carries real diagnostic value: it is where unknown payload types, internal MQTT topics, and operational oddities are inspectable after the fact. The question (this record) was whether to keep it at all in the rewrite, or drop it on the assumption that structured handlers + `raw_receptions` coverage made it redundant.
|
||||
|
||||
## Decision
|
||||
|
||||
**Keep** `events_log`, **renamed to `event_logs`** for plural-table naming consistency (§6.3.6). It becomes a TimescaleDB hypertable (1-day chunks, partitioned by `received_at`) with aggressive compression after 24h and a 30-day default retention policy. Schema: `(received_at, id, observer_node_id, event_type, payload jsonb, instance_id)`. Compression + chunk-drop retention mean it costs a fraction of its current footprint while staying queryable for diagnostic detail views.
|
||||
**Keep** `events_log`, **renamed to `event_logs`** for plural-table naming consistency (data-model.md §1.6; cf. code-warts DM9). It becomes a TimescaleDB hypertable (1-day chunks, partitioned by `received_at`) with aggressive compression after 24h and a 30-day default retention policy. Schema: `(received_at, id, observer_node_id, event_type, payload jsonb, instance_id)`. Compression + chunk-drop retention mean it costs a fraction of its current footprint while staying queryable for diagnostic detail views.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today multi-tenant isolation is **connection-level only**: `SET search_path = instance_<id>` is the entire guard (S3). A connection that leaks out of its pool — a misconfigured worker, a query logged with the wrong session, a future async-session bug — silently crosses instances. The schema-per-instance model is operationally nice (one dump per instance, simple restore) but it is not defense in depth. The §13-D3 question: keep schema-per-instance as the *only* boundary, harden it with row-level `instance_id` + RLS, or move to a database-per-instance model?
|
||||
Today multi-tenant isolation is **connection-level only**: `SET search_path = instance_<id>` is the entire guard (S3). A connection that leaks out of its pool — a misconfigured worker, a query logged with the wrong session, a future async-session bug — silently crosses instances. The schema-per-instance model is operationally nice (one dump per instance, simple restore) but it is not defense in depth. The question: keep schema-per-instance as the *only* boundary, harden it with row-level `instance_id` + RLS, or move to a database-per-instance model?
|
||||
|
||||
## Decision
|
||||
|
||||
**Row-level `instance_id` column on every tenant-scoped table + Postgres Row Level Security policies**, in addition to the existing schema-per-instance *option*. Every tenant-scoped table in the §16 schema (`nodes`, `node_tags`, `user_profiles`, `channels`, `routes`, `messages`, `advertisements`, `raw_receptions`, etc.) carries `instance_id uuid NOT NULL REFERENCES instances(id)`. Each table gets:
|
||||
**Row-level `instance_id` column on every tenant-scoped table + Postgres Row Level Security policies**, in addition to the existing schema-per-instance *option*. Every tenant-scoped table in the Phase 0 schema (data-model.md §3) (`nodes`, `node_tags`, `user_profiles`, `channels`, `routes`, `messages`, `advertisements`, `raw_receptions`, etc.) carries `instance_id uuid NOT NULL REFERENCES instances(id)`. Each table gets:
|
||||
|
||||
```sql
|
||||
ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today's ingest is a single-threaded MQTT callback with no backpressure (W1): one slow DB write on the paho thread stalls all topics, and there is no internal queue to absorb bursts. The `Subscriber(LetsMeshNormalizer)` god-class (P1) mixes decode, dispatch, persistence, and webhook fan-out on one thread. The §7 redesign splits receipt from write (`MqttIngester` → durable queue → `IngestWorker` pool), which requires picking the queue. The §13-D4 question also covered realtime fan-out for the SSE endpoint (D7): one tool for both roles, or two?
|
||||
Today's ingest is a single-threaded MQTT callback with no backpressure (W1): one slow DB write on the paho thread stalls all topics, and there is no internal queue to absorb bursts. The `Subscriber(LetsMeshNormalizer)` god-class (P1) mixes decode, dispatch, persistence, and webhook fan-out on one thread. The ingest redesign (ingest.md) splits receipt from write (`MqttIngester` → durable queue → `IngestWorker` pool), which requires picking the queue. The question also covered realtime fan-out for the SSE endpoint (D7): one tool for both roles, or two?
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
## Context
|
||||
|
||||
`packet_path_hops` is the worst write-amplification offender (W3): one row per (reception × hop), so a 6-hop packet seen by 4 observers = 24 rows, each denormalizing 4 columns from `raw_packets`. It already required a Postgres covering-index rebuild (HEAD migration `a59611449e2a`) and a `window_hours` clamp as a perf band-aid. The route matcher reads it as a joined scan. The §6.3.3 proposed target is to fold the path hashes into a `path_hashes text[]` array column on `raw_receptions` with a GIN index — one insert per reception instead of 1+N — but the GIN-containment candidate query's selectivity under high hash commonality is the unknown.
|
||||
`packet_path_hops` is the worst write-amplification offender (W3): one row per (reception × hop), so a 6-hop packet seen by 4 observers = 24 rows, each denormalizing 4 columns from `raw_packets`. It already required a Postgres covering-index rebuild (HEAD migration `a59611449e2a`) and a `window_hours` clamp as a perf band-aid. The route matcher reads it as a joined scan. The proposed target (data-model.md §1.3) is to fold the path hashes into a `path_hashes text[]` array column on `raw_receptions` with a GIN index — one insert per reception instead of 1+N — but the GIN-containment candidate query's selectivity under high hash commonality is the unknown.
|
||||
|
||||
## Decision
|
||||
|
||||
**Phase 2 research spike.** Prototype the folded schema (`raw_receptions.path_hashes text[]` + GIN index, no hops table) and benchmark the route matcher against the separate-hypertable alternative using the harness in §18.3. **Default assumption: fold.** Keep folded if the D17 gate is met:
|
||||
**Phase 0 research spike.** Prototype the folded schema (`raw_receptions.path_hashes text[]` + GIN index, no hops table) and benchmark the route matcher against the separate-hypertable alternative using the harness in testing.md (D5 benchmark plan). **Default assumption: fold.** Keep folded if the D17 gate is met:
|
||||
|
||||
- At the **High** dataset shape (1M receptions, 200 routes, 40% hash commonality),
|
||||
- `sweep_ms(F) ≤ 1.5 × sweep_ms(S)`, AND
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
**Positive (if folded):** Eliminates W3's write amplification at the source — 24 rows → 1. The matcher loads one array column instead of joining N rows. One fewer table in the schema, one fewer retention policy, one fewer compression policy.
|
||||
|
||||
**Negative:** GIN indexes are larger and slower to update than btree; high hash commonality (40% of packets containing a given route hash) inflates candidate sets and shifts cost from candidate-fetch to the Python subsequence pass. If the gate fails we carry the hops table forward, slightly complicating the matcher.
|
||||
**Negative:** GIN indexes are larger and slower to update than btree; high hash commonality (40% of packets containing a given route hash) inflates candidate sets and shifts cost from candidate-fetch to the TypeScript subsequence pass. If the gate fails we carry the hops table forward, slightly complicating the matcher.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today's auth is two overlapping planes with an implicit trust boundary (S1): direct Bearer tokens for m2m, and OIDC-proxy-injected `X-User-*` headers for browser flows. The trust rests on "only the proxy holds the API key" — it is not cryptographically enforced. Some handlers read `X-User-*` directly off `request.headers`, bypassing the central auth deps (S2). Role-name resolution is duplicated between API and web tier (A8). The §13-D6 question: keep the dual-plane model, harden the header-injection path, or move to a single cryptographically-enforced credential?
|
||||
Today's auth is two overlapping planes with an implicit trust boundary (S1): direct Bearer tokens for m2m, and OIDC-proxy-injected `X-User-*` headers for browser flows. The trust rests on "only the proxy holds the API key" — it is not cryptographically enforced. Some handlers read `X-User-*` directly off `request.headers`, bypassing the central auth deps (S2). Role-name resolution is duplicated between API and web tier (A8). The question: keep the dual-plane model, harden the header-injection path, or move to a single cryptographically-enforced credential?
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today's "realtime" is polling-only (F3): every live page runs a redundant client (TanStack) + server (Redis/ETag) cache, and every poll round-trips even on 304. There is no `/ws` or SSE anywhere. The §8.6 question: add WebSocket, SSE, or stay polling? Live pages (Messages, Packets, Dashboard activity) need instant updates; the 30s poll is the source of the "feels stale" complaint.
|
||||
Today's "realtime" is polling-only (overview pain F3 — "polling-only realtime" from overview.md §4.4; an overview pain-table number, not a review-findings F-number): every live page runs a redundant client (TanStack) + server (Redis/ETag) cache, and every poll round-trips even on 304. There is no `/ws` or SSE anywhere. The question (api.md → Realtime): add WebSocket, SSE, or stay polling? Live pages (Messages, Packets, Dashboard activity) need instant updates; the 30s poll is the source of the "feels stale" complaint.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today `raw_packets` stores `raw_hex` (Text) and `decoded` (JSON) on every row — duplicate payload storage that drives W2's "retention capped at 2 days because of cost" pain. The §6.3.3 / §7.6 sketch proposed moving bytes to a `BlobStore` (MinIO / local-volume / S3) from day one. On review, that adds a runtime dependency (an object store) for every deployment — including the smallest community operator — before measurement shows it is actually needed. The §13-D8 question: object storage from day one, or compress-in-DB first and measure?
|
||||
Today `raw_packets` stores `raw_hex` (Text) and `decoded` (JSON) on every row — duplicate payload storage that drives W2's "retention capped at 2 days because of cost" pain. The data-model/ingest sketch (data-model.md §1.3, ingest.md §5) proposed moving bytes to a `BlobStore` (MinIO / local-volume / S3) from day one. On review, that adds a runtime dependency (an object store) for every deployment — including the smallest community operator — before measurement shows it is actually needed. The question: object storage from day one, or compress-in-DB first and measure?
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
|
||||
## Context
|
||||
|
||||
The frontend hand-copies backend types into every page (F1): `NodeTag` defined 3×, `Channel` 5×, `Profile` variants across 5 files. Every schema change forces a manual sync that is easy to miss. The §8.7 contract is "OpenAPI generated from the server drives a typed frontend client," but the generator choice was left open.
|
||||
The frontend hand-copies backend types into every page (overview pain F1 — "no generated type layer" from overview.md §4.4; an overview pain-table number, not a review-findings F-number): `NodeTag` defined 3×, `Channel` 5×, `Profile` variants across 5 files. Every schema change forces a manual sync that is easy to miss. The contract (api.md → OpenAPI as the contract) is "OpenAPI generated from the server drives a typed frontend client," but the generator choice was left open.
|
||||
|
||||
## Decision
|
||||
|
||||
**orval** — committed upfront, no validation spike. Generates typed TanStack Query hooks (`useMessagesQuery`, `useNodeTagsMutation`) plus tag-based invalidation via the `x-invalidates` OpenAPI extension. The mutator config (custom `apiClient` injecting `credentials: 'include'`) controls the fetch client; orval generates the hook signatures and invalidation calls.
|
||||
|
||||
**Why no spike (iteration 6):** orval's core value (generated hooks + invalidation tags) maps cleanly to the declarative `ENTITY_INVALIDATION` graph (§20.4). The main risk (ugly generated code) is mitigated by the mutator override — we control the client, orval just generates signatures. If the output is truly bad, switching to `openapi-fetch` + hand-written hooks is a day's work (the types are identical either way). The spike bought insurance we don't need.
|
||||
**Why no spike (iteration 6):** orval's core value (generated hooks + invalidation tags) maps cleanly to the declarative `ENTITY_INVALIDATION` graph (api.md → Unified cache contract). The main risk (ugly generated code) is mitigated by the mutator override — we control the client, orval just generates signatures. If the output is truly bad, switching to `openapi-fetch` + hand-written hooks is a day's work (the types are identical either way). The spike bought insurance we don't need.
|
||||
|
||||
CI gate: the generated client must be up to date with the schema (`make gen-client` + a drift check).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:** Full type fidelity from server `response_model` → TypeScript; generated hooks eliminate F1's hand-copy drift; invalidation tags mirror the server-side graph automatically. Per-page boilerplate collapses to importing the generated hook.
|
||||
**Positive:** Full type fidelity from server `response_model` → TypeScript; generated hooks eliminate pain-F1's hand-copy drift; invalidation tags mirror the server-side graph automatically. Per-page boilerplate collapses to importing the generated hook.
|
||||
|
||||
**Negative:** A codegen step in the build; generated code is a build artifact (not hand-edited). orval's generated hooks can be verbose; the mutator config is a real integration cost.
|
||||
|
||||
@@ -28,4 +28,4 @@ CI gate: the generated client must be up to date with the schema (`make gen-clie
|
||||
| **orval** (chosen) | Types + typed client + generated hooks + tag invalidation; one tool covers the full frontend data layer. |
|
||||
| openapi-typescript + openapi-fetch | Rejected — types + typed client only; hooks hand-written. More boilerplate. Available as fallback if orval output proves unmaintainable. |
|
||||
| hey-api | Rejected — strong types but weaker TanStack Query hook generation at decision time. |
|
||||
| Hand-written types (today's model) | Rejected — F1 drift is the exact pain point being solved. |
|
||||
| Hand-written types (today's model) | Rejected — overview pain F1 drift is the exact pain point being solved. |
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today the project supports two database backends: SQLite (default, deprecated ~3 months) and Postgres (opt-in via `DATABASE_BACKEND=postgres`). The cost of the dual-backend surface is substantial: every migration carries an `if conn.dialect.name ==` branch, `batch_alter_table` wrappers for SQLite's ALTER limitations, `postgresql_include` conditional indexes, and a dual-driver `[postgres]` extra. More structurally, supporting SQLite blocks unconditional adoption of native `uuid` PKs, Postgres enums, and `JSONB` — the §6.3.1 typing upgrades the rewrite relies on. SQLite was already deprecated; the question was when to actually drop it.
|
||||
Today the project supports two database backends: SQLite (default, deprecated ~3 months) and Postgres (opt-in via `DATABASE_BACKEND=postgres`). The cost of the dual-backend surface is substantial: every migration carries an `if conn.dialect.name ==` branch, `batch_alter_table` wrappers for SQLite's ALTER limitations, `postgresql_include` conditional indexes, and a dual-driver `[postgres]` extra. More structurally, supporting SQLite blocks unconditional adoption of native `uuid` PKs, Postgres enums, and `JSONB` — the typing upgrades (data-model.md §3.1) the rewrite relies on. SQLite was already deprecated; the question was when to actually drop it.
|
||||
|
||||
## Decision
|
||||
|
||||
**Drop SQLite immediately.** Phase 0 starts **Postgres-only**. Native `uuid` PKs (`gen_random_uuid()`), native Postgres enums (`channel_visibility`, `route_visibility`, `route_state`, `route_quality`, `message_kind`), `JSONB` everywhere JSON is used today, and asyncpg as the unconditional async driver. All dialect branches, `batch_alter_table`, and the dual-driver packaging disappear.
|
||||
**Drop SQLite immediately.** Phase 0 starts **Postgres-only**. Native `uuid` PKs (`gen_random_uuid()`), native Postgres enums (`channel_visibility`, `route_visibility`, `route_state`, `route_quality`, `message_kind`), `JSONB` everywhere JSON is used today, and `node-postgres` (`pg`) as the unconditional driver (per the D22 TypeScript stack — this iteration-2 decision originally named the Python `asyncpg`). All dialect branches, `batch_alter_table`, and the dual-driver packaging disappear.
|
||||
|
||||
Existing SQLite operators migrate via a documented `db migrate-to-postgres` runbook **before** upgrading to the rewrite. There is no in-place SQLite path in the new schema.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:** Every `if dialect.name ==` branch goes away — materially cleaning Phase 0. Native types everywhere: `uuid` joins are faster than `String(36)` across ~25 FK columns (W8); enums are constrainable; `JSONB` enables GIN indexes (W9). asyncpg becomes the unconditional async driver, simplifying the async API path (A5). Migrations are Postgres-native (no batch wrapper).
|
||||
**Positive:** Every `if dialect.name ==` branch goes away — materially cleaning Phase 0. Native types everywhere: `uuid` joins are faster than `String(36)` across ~25 FK columns (W8); enums are constrainable; `JSONB` enables GIN indexes (W9). `node-postgres` becomes the unconditional driver, simplifying the async API path (A5). Migrations are Postgres-native (no batch wrapper).
|
||||
|
||||
**Negative:** Operators on SQLite must migrate before upgrading — a real one-time cost. Development now requires a local Postgres (mitigated: docker compose). The zero-config "just write to a file" deployment story is gone.
|
||||
|
||||
@@ -23,7 +23,7 @@ Existing SQLite operators migrate via a documented `db migrate-to-postgres` runb
|
||||
|
||||
| Option | Verdict |
|
||||
|---|---|
|
||||
| **Drop SQLite now** (chosen) | Unblocks native types + asyncpg; eliminates dialect-branch maintenance. |
|
||||
| **Drop SQLite now** (chosen) | Unblocks native types + `node-postgres`; eliminates dialect-branch maintenance. |
|
||||
| Keep SQLite as a tier-2 supported backend | Rejected — blocks native `uuid`/enums/`JSONB`; preserves every dialect branch; SQLite was already deprecated. |
|
||||
| Defer the drop until after Phase 0 | Rejected — the native-types decision is upstream of every §16 schema choice; deferring pushes the cost into every later phase. |
|
||||
| Defer the drop until after Phase 0 | Rejected — the native-types decision is upstream of every Phase 0 schema choice (data-model.md §3); deferring pushes the cost into every later phase. |
|
||||
| SQLite for dev, Postgres for prod | Rejected — the dialect branches are the cost being eliminated; a split-env model reintroduces them. |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today ~200 env vars configure everything from branding to tuning to feature flags, and changing any of them — even an announcement banner — requires a container restart. The pain is acute for community-operated deployments where the operator isn't always the deployer: an incident-comms banner shouldn't be a git commit + redeploy. The §8.8 question: move config into a DB-backed UI-editable store, and if so, where do env vars still win? The user suggestion in iteration 4 was to move config into the API/UI.
|
||||
Today ~200 env vars configure everything from branding to tuning to feature flags, and changing any of them — even an announcement banner — requires a container restart. The pain is acute for community-operated deployments where the operator isn't always the deployer: an incident-comms banner shouldn't be a git commit + redeploy. The question (api.md → Settings API): move config into a DB-backed UI-editable store, and if so, where do env vars still win? The user suggestion in iteration 4 was to move config into the API/UI.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -17,7 +17,7 @@ Today ~200 env vars configure everything from branding to tuning to feature flag
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:** Operators tune spam thresholds, toggle feature flags, change branding, all at runtime without a restart — fits the community-operated model. The `__APP_CONFIG__` per-request rebuild (F5) collapses into `GET /api/v1/config` reading the cached snapshot. Config changes are auditable (`updated_by`, `updated_at`) and revertible, unlike env-var git commits. Aligns naturally with the static-shell design (§9.4).
|
||||
**Positive:** Operators tune spam thresholds, toggle feature flags, change branding, all at runtime without a restart — fits the community-operated model. The `__APP_CONFIG__` per-request rebuild (overview pain F5 — "per-request `__APP_CONFIG__` re-serialization" from overview.md §4.4; an overview pain-table number, not a review-findings F-number) collapses into `GET /api/v1/config` reading the cached snapshot. Config changes are auditable (`updated_by`, `updated_at`) and revertible, unlike env-var git commits. Aligns naturally with the static-shell design (frontend.md → Static shell).
|
||||
|
||||
**Negative:** Split-brain risk between env and DB (mitigated by the explicit three-tier rule + small Tier-1 allowlist). Cross-service propagation lag must be documented per category (branding = next request; feature flags = next packet for the collector; tuning = next worker tick). Bad values need an escape hatch (the `settings reset --category=...` CLI — see D18).
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today interactive login is OIDC-only (with an API-key plane for m2m). That requires every deployment — including the smallest community operator with no identity provider — to stand up an OIDC IdP before the UI is usable. The §8.3 question (raised in iteration 4 by the user): offer a built-in local password source so deployments without an IdP still work, while keeping OIDC optional for orgs that want SSO. The D6 JWT boundary is what makes this clean: every source converges on one JWT issuance, so the API stays credential-source-agnostic.
|
||||
Today interactive login is OIDC-only (with an API-key plane for m2m). That requires every deployment — including the smallest community operator with no identity provider — to stand up an OIDC IdP before the UI is usable. The question (auth.md; raised in iteration 4 by the user): offer a built-in local password source so deployments without an IdP still work, while keeping OIDC optional for orgs that want SSO. The D6 JWT boundary is what makes this clean: every source converges on one JWT issuance, so the API stays credential-source-agnostic.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -25,7 +25,7 @@ Today interactive login is OIDC-only (with an API-key plane for m2m). That requi
|
||||
|
||||
**Multi-tenant evolution (D21).** A single global `AUTH_MODE`/IdP would force every self-provisioned tenant into the platform operator's auth choice — contradicting self-service. So in Phase 7 the IdP config *and* `auth_mode` move to the per-tenant `tenant_oidc_configs` table (multi-tenancy.md §6): a community tenant runs local passwords, an enterprise tenant points at their own Okta, both on one platform. The Tier-1 `OIDC_CLIENT_*`/`AUTH_MODE` vars survive only as **platform-level defaults** (the fallback when a tenant hasn't configured their own). The one thing that stays irreducibly platform-wide is the JWT/session signing key (`JWT_SESSION_SECRET`, renamed from `OIDC_SESSION_SECRET` — it was never OIDC config): one key signs for all tenants, and the API trusts the `instance_id` claim because the platform signed it.
|
||||
|
||||
A `local_users` row links to a `user_profiles` row (adoptions, tags, route ownership work unchanged — keyed on `user_profile.id`). The profile's `user_id` is namespaced `local:<username>` to avoid colliding with OIDC `sub` claims. Password hashing: argon2id (`argon2` npm package, native bindings), parameterised at install time. Rate limiting: `failed_attempts` + `locked_until` give exponential lockout (5 → 15s, 10 → 5m, 20 → 1h) without Redis. Bootstrap admin via `ADMIN_USERNAME`/`ADMIN_PASSWORD` env, `admin create-user` CLI, or the first-run setup wizard (§20.8). Local + OIDC users share one Users management page.
|
||||
A `local_users` row links to a `user_profiles` row (adoptions, tags, route ownership work unchanged — keyed on `user_profile.id`). The profile's `user_id` is namespaced `local:<username>` to avoid colliding with OIDC `sub` claims. Password hashing: argon2id (`argon2` npm package, native bindings), parameterised at install time. Rate limiting: `failed_attempts` + `locked_until` give exponential lockout (5 → 15s, 10 → 5m, 20 → 1h) without Redis. Bootstrap admin via `ADMIN_USERNAME`/`ADMIN_PASSWORD` env, `admin create-user` CLI, or the first-run setup wizard (auth.md → First-run setup wizard). Local + OIDC users share one Users management page.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Context
|
||||
|
||||
The §18.2 preserved-config export/import carries data that cannot be repopulated from RF traffic: `user_profiles` + roles, `routes` + nodes + observers, `node_tags`, adoptions, and node identity stubs. The open question (Q-C in iteration 4): are channels in this set? Channels are *borderline* — the `name` and `visibility` tier could plausibly be re-entered by hand, but the `key_hex` is an operator secret **never transmitted over RF**. Without it, the ingester's `ChannelKeyCache` cannot decrypt incoming channel messages, and the parallel-stack validation window (D14) would lose every channel message.
|
||||
The preserved-config export/import (migration.md) carries data that cannot be repopulated from RF traffic: `user_profiles` + roles, `routes` + nodes + observers, `node_tags`, adoptions, and node identity stubs. The open question (Q-C in iteration 4): are channels in this set? Channels are *borderline* — the `name` and `visibility` tier could plausibly be re-entered by hand, but the `key_hex` is an operator secret **never transmitted over RF**. Without it, the ingester's `ChannelKeyCache` cannot decrypt incoming channel messages, and the parallel-stack validation window (D14) would lose every channel message.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today's spam scoring has two paths that share logic but not implementation: an **online** score computed at insert time (asymmetric — only looks at prior messages), and a **symmetric** rescore sweep that re-evaluates recent messages with hindsight (looks at prior + subsequent). Both are implemented in Python, with the sweep job issuing per-row queries against `messages` (`spam.py` ~315 LOC). The §13-D15 question: keep the Python implementation, move both paths into the database as a shared PL/pgSQL function, or split them across worker jobs?
|
||||
Today's spam scoring has two paths that share logic but not implementation: an **online** score computed at insert time (asymmetric — only looks at prior messages), and a **symmetric** rescore sweep that re-evaluates recent messages with hindsight (looks at prior + subsequent). Both are implemented in Python today, with the sweep job issuing per-row queries against `messages` (`spam.py` ~315 LOC). The question: keep an in-language implementation, move both paths into the database as a shared PL/pgSQL function, or split them across worker jobs?
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -18,7 +18,7 @@ Today's spam scoring has two paths that share logic but not implementation: an *
|
||||
|
||||
**Positive:** One implementation shared by online + sweep — kills the asymmetric-online / symmetric-sweep Python split (`spam.py` ~315 LOC collapses to the function + two call sites). Pure function of its inputs → idempotent. The sweep's `IS DISTINCT FROM` check avoids unnecessary writes. Parameters are configurable via Tier-2 tuning settings (D11) — operators tune weights/thresholds at runtime.
|
||||
|
||||
**Negative:** PL/pgSQL is a less familiar language for contributors than Python; debugging requires DB-side tools. A bad parameter (e.g. enormous window) can make the function slow — mitigated by per-category Pydantic validation on the settings write. Schema changes to `messages` require updating the function.
|
||||
**Negative:** PL/pgSQL is a less familiar language for contributors than TypeScript (the D22 stack); debugging requires DB-side tools. A bad parameter (e.g. enormous window) can make the function slow — mitigated by per-category Zod validation on the settings write. Schema changes to `messages` require updating the function.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
## Context
|
||||
|
||||
The §19.1 manifest consolidates six daemon threads into one `DerivedStateWorker` process owning every periodic job (route evaluator, route history, spam rescore, retention, metrics gauges, CAGG health). A single process is a SPOF — a crash stops all derived-state maintenance until restart. The §19.2 / §13-D16 question: how to provide HA without introducing a separate coordinator service (etcd/Consul) or a clustering framework?
|
||||
The job manifest (derived-state.md) consolidates six daemon threads into one `DerivedStateWorker` process owning every periodic job (route evaluator, route history, spam rescore, retention, metrics gauges, CAGG health). A single process is a SPOF — a crash stops all derived-state maintenance until restart. The question (derived-state.md → HA): how to provide HA without introducing a separate coordinator service (etcd/Consul) or a clustering framework?
|
||||
|
||||
## Decision
|
||||
|
||||
**Two-replica deployment with `pg_advisory_xact_lock` per job.** Each `PeriodicJob` carries a `lock_key: int`. The worker's `_run_one` acquires `SELECT pg_advisory_xact_lock(:k)` at the start of the job's transaction:
|
||||
**Two-replica deployment with `pg_advisory_xact_lock` per job.** Each `PeriodicJob` carries a `lockKey: int`. The worker's `runOne` acquires the **two-argument** lock `pg_advisory_xact_lock(lockKey, hashtext(instanceId))` at the start of the job's transaction — a stable per-(job, instance) key (F7), not a positional index:
|
||||
|
||||
```typescript
|
||||
await db.transaction(async (tx) => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# D17: D5 Fold Threshold (1.5× / 500ms / 40% Commonality)
|
||||
|
||||
- **Status:** Locked
|
||||
- **Iteration:** 4
|
||||
- **Iteration:** 4 (benchmark rescheduled to Phase 0 in iteration 8 — F5)
|
||||
|
||||
## Context
|
||||
|
||||
D5 defers the fold-vs-separate decision to a Phase 2 benchmark because the GIN-containment candidate query's behaviour under high hash commonality is the unknown. To make the spike produce a decision rather than a debate, §18.3.4 / §13-D17 needed a quantitative gate: what read-cost regression is acceptable in exchange for the write-cost win?
|
||||
D5 defers the fold-vs-separate decision to a Phase 0 benchmark (rescheduled from Phase 2 in iteration 8 — F5, so the outcome lands before the schema is frozen) because the GIN-containment candidate query's behaviour under high hash commonality is the unknown. To make the spike produce a decision rather than a debate, testing.md's D5 benchmark plan needed a quantitative gate: what read-cost regression is acceptable in exchange for the write-cost win?
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -16,7 +16,7 @@ D5 defers the fold-vs-separate decision to a Phase 2 benchmark because the GIN-c
|
||||
|
||||
If either fails, fall back to the separate hypertable (still better than today because TimescaleDB-partitioned and no longer denormalized). The `raw_receptions.path_hashes` column stays either way (backs the packet-group detail view); the hops table is additive only in the fallback.
|
||||
|
||||
**Rationale:** the folded schema eliminates the hops table's write amplification (a 6-hop packet × 4 observers = 24 rows today → 1 row folded). That write-cost win is worth up to a **1.5× read-cost tradeoff** because reads happen on a 300s evaluator cadence while writes happen on every packet. The **500ms p95 guard** prevents any single route becoming a latency outlier. The **40% commonality** test level is the documented "breaker" — high commonality inflates GIN candidate sets, shifting cost from candidate-fetch to the Python subsequence pass.
|
||||
**Rationale:** the folded schema eliminates the hops table's write amplification (a 6-hop packet × 4 observers = 24 rows today → 1 row folded). That write-cost win is worth up to a **1.5× read-cost tradeoff** because reads happen on a 300s evaluator cadence while writes happen on every packet. The **500ms p95 guard** prevents any single route becoming a latency outlier. The **40% commonality** test level is the documented "breaker" — high commonality inflates GIN candidate sets, shifting cost from candidate-fetch to the TypeScript subsequence pass.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## Context
|
||||
|
||||
Today the Click CLI mirrors config: `--retention-days` on the cleanup command, `--mqtt-host` as a service flag, and so on. This creates two problems. First, parameter explosion threads through `create_app` → `run_collector` — every CLI flag is another constructor argument that may or may not override the env var. Second, the "which wins, env or flag?" ambiguity is undocumented and inconsistent. The §5.1 principle 8 / §13-D18 question (iteration 5: user said "ditch CLI," then clarified "keep for migrations/management, just don't duplicate config"): shrink the CLI to genuine operational commands, or keep it as a third config surface?
|
||||
Today the Click CLI mirrors config: `--retention-days` on the cleanup command, `--mqtt-host` as a service flag, and so on. This creates two problems. First, parameter explosion threads through `create_app` → `run_collector` — every CLI flag is another constructor argument that may or may not override the env var. Second, the "which wins, env or flag?" ambiguity is undocumented and inconsistent. The principle 8 (overview.md §5) / the question (iteration 5: user said "ditch CLI," then clarified "keep for migrations/management, just don't duplicate config"): shrink the CLI to genuine operational commands, or keep it as a third config surface?
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -16,12 +16,12 @@ Today the Click CLI mirrors config: `--retention-days` on the cleanup command, `
|
||||
|
||||
**never also a CLI flag.** No `--retention-days` on the cleanup command when retention is a runtime setting; no `--mqtt-host` service flag when `MQTT_HOST` env var exists.
|
||||
|
||||
The Click group **stays** but shrinks to genuine operational commands that aren't config:
|
||||
The CLI group (Click today; **commander** in the D22 TypeScript stack) **stays** but shrinks to genuine operational commands that aren't config:
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `db upgrade` / `db revision` | Migrations (alembic) |
|
||||
| `db export-config` / `db import-config` | Preserved-config migration (§18.2) |
|
||||
| `db upgrade` / `db revision` | Migrations (alembic today; **drizzle-kit** in the D22 TS stack) |
|
||||
| `db export-config` / `db import-config` | Preserved-config migration (migration.md) |
|
||||
| `admin create-user` | Headless bootstrap (D12) |
|
||||
| `health` | Docker healthchecks |
|
||||
| `cleanup --now` / `routes --rebuild` | Force-run a job outside its cadence |
|
||||
|
||||
@@ -21,7 +21,7 @@ The ingest redesign (D4, ingest.md) moves the fan-out to NATS: the IngestWorker
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:** The IngestWorker stays lean (no httpx calls on the hot path). Webhook slowness or endpoint downtime doesn't block the ingest ack. Config is runtime-editable (Tier-2 settings) instead of env-var-only. The filter DSL becomes functional. One more small consumer process, but it shares the NATS subscription pattern the SSE endpoint already uses.
|
||||
**Positive:** The IngestWorker stays lean (no outbound HTTP — undici/fetch — calls on the hot path). Webhook slowness or endpoint downtime doesn't block the ingest ack. Config is runtime-editable (Tier-2 settings) instead of env-var-only. The filter DSL becomes functional. One more small consumer process, but it shares the NATS subscription pattern the SSE endpoint already uses.
|
||||
|
||||
**Negative:** Webhook events are lost if the `WebhookWorker` is down (non-durable NATS core). This matches today's behaviour (in-memory queue lost on crash) but is worth documenting. The `WebhookWorker` is another process to deploy and monitor.
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# D23: Test Pyramid & Coverage Policy (vitest + Playwright, CI-gated)
|
||||
|
||||
- **Status:** Locked
|
||||
- **Iteration:** 9
|
||||
|
||||
## Context
|
||||
|
||||
The plan to this point specified **acceptance gates only** — the phase exit criteria in
|
||||
[testing.md](../testing.md) ("diff = 0 over a 24h shadow run", "p95 < 200ms", "a cross-instance
|
||||
query returns 0 rows"). Those are correctness and performance gates observed against a running
|
||||
system; several are slow (the 5-day parallel-stack window, D14) or one-off (the D5 benchmark).
|
||||
None of them is a fast, repeatable regression suite that runs on every change, and nothing in the
|
||||
plan required that a rewritten component ship with automated tests.
|
||||
|
||||
D22 already locks **vitest** as the single test runner for backend and frontend, but only as a
|
||||
dev-loop command ([infrastructure.md → Development workflow](../components/infrastructure.md#development-workflow))
|
||||
— not as a layered strategy with a coverage obligation.
|
||||
|
||||
Two more inputs shape this:
|
||||
|
||||
1. **`code-warts.md` TQ1** flags that the current repo's E2E auth is *forged, not logged in* —
|
||||
no mock IdP exists, so specs mint a signed session cookie and the real login flow goes
|
||||
untested. The test surface diverges from production auth. The rewrite is the chance to fix this.
|
||||
2. **The rewrite is greenfield + a language switch (D22).** With no historical backfill,
|
||||
correctness across the whole feature surface must be proven by tests and the parallel-stack
|
||||
window rather than by years of production hardening ([phasing.md → Strategy note](../phasing.md#strategy-note--scope-realism-f13)).
|
||||
A regression suite is what keeps that surface from rotting during the build-out.
|
||||
|
||||
## Decision
|
||||
|
||||
**Every rewritten component and every piece of business logic ships with automated tests at the
|
||||
appropriate layer, and the suite is required to pass in CI.** No logic merges untested. Coverage
|
||||
is **qualitative** — there is deliberately **no numeric coverage floor**, to avoid low-value tests
|
||||
written to satisfy a percentage (see Alternatives).
|
||||
|
||||
### The pyramid
|
||||
|
||||
| Layer | Runner | Infra | Owns |
|
||||
|---|---|---|---|
|
||||
| **Unit** | vitest | none | Pure logic: classifier table, dedup hash, `computeQualityAvg`, spam-score wrapper, cache-key builder, `apply_visibility`, the `meshcore.ingest.v1` envelope Zod schema, webhook filter DSL, `PacketFields`. |
|
||||
| **Integration** | vitest | dev-compose / throwaway Postgres+NATS+Redis | DB- and bus-touching paths: Drizzle repositories, RLS enforcement (`SET LOCAL app.instance_id`), the `@cached` decorator + declarative invalidation graph, `IngestWorker` batch-commit + ack ordering, `ChannelKeyCache`/`ObserverAllowlistCache` reload, `SettingsCache`, API handlers via Fastify `inject`. |
|
||||
| **Frontend component** | vitest + Testing Library | jsdom | `useEventStream`, generated-client wrappers, admin pages (Settings/Users/Pages/Observers), chart helpers (`averageRouteTier` threshold sync), login rendering per `auth_mode`. |
|
||||
| **E2E** | Playwright (headless Chromium) | throwaway stack (own Postgres, isolated volumes) | User-facing flows: registration → login → admin → observer allowlist; SSE-driven live updates; custom pages; settings. |
|
||||
|
||||
One runner (vitest) covers the first three layers per D22; Playwright is the only second runner
|
||||
and only for browser E2E.
|
||||
|
||||
### E2E auth — real login, forged only where unavoidable
|
||||
|
||||
Closing TQ1: E2E specs **exercise the real local-login flow** (the `local_users` argon2id path,
|
||||
D12) rather than minting a session out of band. Session minting/forging is permitted **only** for
|
||||
paths that cannot be automated headlessly — the OIDC callback (D12), where no mock IdP exists.
|
||||
Where a session is forged, the spec directory documents it, so the divergence from production
|
||||
auth stays visible instead of silent.
|
||||
|
||||
### Relationship to the exit criteria
|
||||
|
||||
The pyramid and the [phase exit criteria](../testing.md) are **complementary, not the same axis**:
|
||||
|
||||
- **Pyramid** = fast, deterministic, CI-runnable regression tests authored alongside the code.
|
||||
They prove a component still behaves correctly on every change.
|
||||
- **Exit criteria** = acceptance gates, some of which are slow runtime observations (the 5-day
|
||||
parallel-stack diff, D14; live-load throughput; first-CAGG-bucket timing) that no unit test can
|
||||
reproduce. They prove a *phase* is correct and performant enough to ship.
|
||||
|
||||
**A phase is done when its checkboxes are ticked, its automated tests pass in CI, and its exit
|
||||
criteria pass.** The pyramid does not replace the exit criteria, and the exit criteria do not
|
||||
replace the pyramid.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- **Regression safety during a long build-out.** The greenfield + language-switch surface
|
||||
(19 tables, ~13 routers, the full SPA) is held in check by tests that run on every change, not
|
||||
only at phase boundaries.
|
||||
- **Auth is tested for real.** E2E drives the actual login flow; the forged-session divergence is
|
||||
confined to OIDC and documented, instead of being the default.
|
||||
- **One CI gate, fast feedback.** vitest unit + component tests run in seconds with no infra;
|
||||
integration tests spin up the dev-compose dependencies; Playwright runs against a throwaway
|
||||
stack. Each layer fails fast at the level where the bug lives.
|
||||
- **No vanity metric.** Qualitative coverage avoids the well-known failure mode of tests written
|
||||
to hit a percentage that assert nothing meaningful.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- **CI infrastructure cost.** Integration + E2E need real Postgres/TimescaleDB, NATS, Redis, and a
|
||||
browser binary in CI. Mitigated by reusing the existing throwaway-compose + deterministic-seed
|
||||
pattern from the current `e2e/` suite, and by keeping the bulk of tests at the infra-free unit
|
||||
layer.
|
||||
- **Test maintenance is now a standing obligation.** Every feature PR carries test changes. That is
|
||||
the point, but it is real ongoing cost.
|
||||
- **No single number to report.** Without a coverage floor, "are we covered?" is answered by
|
||||
review (does this logic have a test?) rather than a dashboard. Acceptable given the qualitative
|
||||
policy; revisit only if untested logic keeps slipping through review.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Option | Verdict |
|
||||
|---|---|
|
||||
| **Qualitative, CI-required, no % floor** (chosen) | Forces tests where logic lives without incentivising junk tests to hit a number. |
|
||||
| Numeric coverage floor (e.g. 80% lines) | Rejected — a hard percentage encourages low-value assertion-free tests and becomes the target rather than the measure. Reconsider only if review repeatedly misses untested logic. |
|
||||
| Exit criteria only (the prior plan) | Rejected — acceptance gates are slow and phase-boundary; they give no per-change regression safety and required no per-component tests. |
|
||||
| Keep forged E2E auth everywhere (current repo pattern) | Rejected — leaves the real login flow untested (TQ1). Forging is kept only for the un-automatable OIDC path. |
|
||||
@@ -5,12 +5,17 @@
|
||||
> [component docs](components/), and [exit criteria](testing.md).
|
||||
>
|
||||
> **Backend stack (D22):** Node/TypeScript — Fastify 5, Drizzle ORM, @nats-io/nats-core + @nats-io/jetstream,
|
||||
> mqtt.js, ioredis, Zod, jose (JWT), argon2, commander (CLI), vitest (tests),
|
||||
> mqtt.js, ioredis, Zod, jose (JWT), argon2, commander (CLI),
|
||||
> `@michaelhart/meshcore-decoder` (primary decoder). See [D22](decisions/D22-node-typescript-backend.md)
|
||||
> for the full library mapping.
|
||||
>
|
||||
> **Testing (D23):** vitest (unit + integration + frontend component) + Playwright (e2e). Every
|
||||
> component and piece of logic ships with tests at the appropriate layer; the suite is CI-required
|
||||
> (qualitative coverage, no % floor). See [testing.md → Test strategy](testing.md#test-strategy-the-test-pyramid).
|
||||
>
|
||||
> **How to use:** work top-to-bottom within each phase. A phase is done when all its checkboxes
|
||||
> are ticked AND its [exit criteria](testing.md) pass.
|
||||
> are ticked, **its automated tests pass in CI** (each phase has a `### Tests` block below), AND
|
||||
> its [exit criteria](testing.md) pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -41,6 +46,12 @@
|
||||
### Tooling
|
||||
- [ ] Set up `orval` codegen: `orval.config.ts` + `make gen-client` target + CI drift check — [D09](decisions/D09-orval-client-generation.md). **Tooling only** — first real generation happens in Phase 4 against the new API spec.
|
||||
|
||||
### Tests
|
||||
- [ ] Stand up the vitest workspace (unit + integration projects) + Playwright config; wire both into CI ([D23](decisions/D23-test-pyramid-coverage.md))
|
||||
- [ ] Integration: `drizzle-kit migrate` creates the full schema on a throwaway Postgres+TimescaleDB
|
||||
- [ ] Integration: RLS — a cross-instance query **as the `meshcore_app` role** returns 0 rows (`FORCE ROW LEVEL SECURITY` verified, not as owner)
|
||||
- [ ] Unit: Zod `DecodedPacket` schemas accept/reject representative decoder output; the declarative `CLASSIFIERS` table maps every payload-type → event-type → handler
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Ingest pipeline
|
||||
@@ -53,13 +64,13 @@
|
||||
|
||||
### MqttIngester (pure decode + produce)
|
||||
- [ ] Implement `MqttIngester.on_message`: parse topic → observer filter → decode → normalize → classify → produce envelope
|
||||
- [ ] Implement the [`meshcore.ingest.v1` envelope](components/ingest.md#172-the-ingest-envelope-meshcoreingestv1) Zod schema
|
||||
- [ ] Implement the [`meshcore.ingest.v1` envelope](components/ingest.md#7-the-ingest-envelope-meshcoreingestv1) Zod schema
|
||||
- [ ] Implement `ChannelKeyCache`: load on startup, reload on `channel.keys` NATS notification, thread-safe immutable-snapshot swap
|
||||
- [ ] Set `Nats-Msg-Id` = `wire_hash` for server-side dedup
|
||||
|
||||
### IngestWorker (batched write)
|
||||
- [ ] Implement `IngestWorker.run`: pull-subscribe `meshcore.ingest.>` (wildcard, not `*` — F8), fetch batches of 100, `SET LOCAL app.instance_id`, process, commit, publish `events.new`, ack
|
||||
- [ ] Implement [`persist_deduped_event`](components/ingest.md#74-dedup-as-a-first-class-service) helper (SHA-256 hash, `ON CONFLICT (instance_id, event_hash) DO NOTHING` — composite target, F1; observer attach)
|
||||
- [ ] Implement [`persist_deduped_event`](components/ingest.md#3-dedup-as-a-first-class-service) helper (SHA-256 hash, `ON CONFLICT (instance_id, event_hash) DO NOTHING` — composite target, F1; observer attach)
|
||||
- [ ] Implement `touchNode` + the observer-node upsert (both keyed on `(instance_id, public_key)`, F1/F11)
|
||||
- [ ] Implement the 4 structured handlers (~15 LOC each, using the dedup helper)
|
||||
- [ ] Implement the fallback `handle_event_log` handler
|
||||
@@ -73,6 +84,13 @@
|
||||
- [ ] Run the `MqttIngester` against the live feed; diff its envelopes (decoded + classified output) against the old normalizer (24h shadow, no DB/workers)
|
||||
- [ ] Full parallel-stack validation (both DBs, API diff) is **Phase 2** — it needs the provisioned schema + D5 outcome
|
||||
|
||||
### Tests
|
||||
- [ ] Unit: `MqttIngester.on_message` topic-parse → observer-filter → classify (table-driven across payload types); `meshcore.ingest.v1` envelope schema; `Nats-Msg-Id` = `wire_hash`
|
||||
- [ ] Unit: `persist_deduped_event` SHA-256 hashing + `ON CONFLICT (instance_id, event_hash)` behaviour; the 4 structured handlers + fallback `handle_event_log`
|
||||
- [ ] Integration: `ChannelKeyCache` load-on-startup + immutable-snapshot reload on `channel.keys` notification
|
||||
- [ ] Integration: `IngestWorker` batch pull → `SET LOCAL app.instance_id` → commit → `events.new` publish → ack ordering (no ack before commit); server-side dedup suppresses a redelivered `Nats-Msg-Id`
|
||||
- [ ] Integration: `WebhookWorker` dispatch with retry/backoff + filter-DSL evaluation; config reload on `settings.updated.<inst>.webhooks`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Greenfield provisioning
|
||||
@@ -106,6 +124,11 @@
|
||||
- [ ] Rewrite dashboard handlers to read CAGGs (with explicit `instance_id` predicate — RLS doesn't propagate to CAGGs) + the rollup tables (no live-query fallback in greenfield)
|
||||
- [ ] Verify first CAGG buckets + rollup rows populate within 10 min of live ingest
|
||||
|
||||
### Tests
|
||||
- [ ] Integration: `db export-config` → `db import-config` roundtrip on a fresh DB reproduces all preserved config with zero FK violations; re-import is idempotent
|
||||
- [ ] Integration: the 2 CAGGs + 3 rollup tables exist with active refresh; a dashboard handler reads them with an explicit `instance_id` predicate (no live-query fallback)
|
||||
- [ ] Integration: `BlobStore` interface with `NoopBlobStore` default; compression/retention policy presence asserted
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Derived state consolidation
|
||||
@@ -135,6 +158,13 @@
|
||||
- [ ] Implement chunked DELETE for OLTP tables (`messages`, `advertisements`, `trace_paths`) — 5000-row batches
|
||||
- [ ] Verify row counts stabilise at the retention boundary
|
||||
|
||||
### Tests
|
||||
- [ ] Unit: `computeQualityAvg` ordinal mapping (clear=2/marginal=1/else=0) + thresholds (≥1.5/≥0.75) + brand-new-route null edge; the `PeriodicJob` scheduler cadence math
|
||||
- [ ] Unit: route-matcher subsequence algorithm against `path_hashes` (true positives + false-positive rejection)
|
||||
- [ ] Integration: two-replica advisory-lock — two `DerivedStateWorker`s never double-execute the same `(job, instance)` (`pg_advisory_xact_lock(job_key, hashtext(instance_id))`)
|
||||
- [ ] Integration: `compute_spam_score` PL/pgSQL online + sweep parity (score computed once per row — not in both `WHERE` and `SET`, F10); the 3 route-health tables rebuild from fresh data
|
||||
- [ ] Integration: chunked retention DELETE batches OLTP rows and stabilises at the boundary
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — API & auth
|
||||
@@ -153,7 +183,7 @@
|
||||
- [ ] Implement local password store: `local_users` table, argon2id verify, exponential lockout
|
||||
- [ ] Implement the shared 3-table bootstrap insert (user_profiles + local_users + user_profile_roles) in one transaction
|
||||
- [ ] Implement bootstrap paths: env-var (`ADMIN_USERNAME`/`ADMIN_PASSWORD`), CLI (`admin create-user`), setup wizard — all use the shared insert
|
||||
- [ ] Implement the first-run setup wizard (5-step, server-rendered, `needsSetup` flag gated on the admin-existence query)
|
||||
- [ ] Implement the first-run setup wizard **backend** (F12 — SPA route, not SSR): a `needs_setup` boolean in `/api/v1/config`, a gate middleware redirecting all routes to `/setup` while it is true, and the JSON `GET/POST /setup` API. Server-rendering is a documented fallback only. The React wizard page itself lands in Phase 5
|
||||
- [ ] Implement `/auth/login`, `/auth/logout` (local) + `/auth/callback` (OIDC)
|
||||
- [ ] Remove all `X-User-*` header injection
|
||||
|
||||
@@ -169,7 +199,7 @@
|
||||
- [ ] Per-event channel-visibility filter
|
||||
- [ ] 15s heartbeat; bounded backpressure (NATS pending-msg cap 256)
|
||||
- [ ] Web tier proxy: verify it pipes SSE chunks without buffering (streaming proxy, not buffered)
|
||||
- [ ] If single-process mode: add cookie resolution path to AuthMiddleware (4th source after JWT-header / API-key / anonymous)
|
||||
- [ ] If single-process mode: enable the AuthMiddleware cookie source (the 2nd resolution step, between JWT-header and API-key — a no-op in split web/API deployments; see auth.md)
|
||||
|
||||
### Settings API
|
||||
- [ ] Create the `settings` table + seed migration (defaults per known key)
|
||||
@@ -187,6 +217,14 @@
|
||||
- [ ] Include enabled pages list in `PublicConfig` response (drives nav)
|
||||
- [ ] Mutations invalidate `pages` + `config` namespaces
|
||||
|
||||
### Tests
|
||||
- [ ] Unit: `AuthMiddleware` resolution order (JWT → cookie → API key → anonymous); `Principal` claim mapping; cache-key builder `{instance_id}:{namespace}:{scope}:{query_hash}`; single `apply_visibility` construct
|
||||
- [ ] Unit: argon2id verify + exponential-lockout backoff math; the shared 3-table bootstrap insert shape
|
||||
- [ ] Integration (Fastify `inject`): per-request transaction issues `SET LOCAL app.instance_id` on **read** endpoints too (RLS returns rows, not 0); cross-instance read returns 0 rows
|
||||
- [ ] Integration: `@cached` ETag / If-None-Match / 304 / X-Cache round-trip; `invalidate_for` walks the `ENTITY_INVALIDATION` graph and evicts the right namespaces
|
||||
- [ ] Integration: local login + OIDC callback converge on the same JWT/cookie issuance; SSE pushes with per-message channel-visibility filter + 15s heartbeat
|
||||
- [ ] Integration: settings + custom-pages mutations invalidate `settings`/`pages`/`config` and surface in `PublicConfig`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Frontend
|
||||
@@ -224,6 +262,12 @@
|
||||
- [ ] Replace all `alert()` with toast notifications
|
||||
- [ ] Unify title management (one path)
|
||||
|
||||
### Tests
|
||||
- [ ] Component (vitest + Testing Library): `useEventStream` hybrid patch/invalidate + 30s poll fallback; generated-client wrappers; login page renders local/OIDC/both per `auth_mode`
|
||||
- [ ] Component: Settings / Users / Pages admin pages; `averageRouteTier` threshold sync with backend `computeQualityAvg` (1.5/0.75)
|
||||
- [ ] E2E (Playwright, real local login — [D23](decisions/D23-test-pyramid-coverage.md)): login → dashboard; Messages/Packets SSE live update; create/edit/delete a custom page and see the nav update
|
||||
- [ ] E2E: first-run setup wizard flow; settings/feature-flag change propagates to an open tab via SSE
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Polish & decommission
|
||||
@@ -250,6 +294,11 @@
|
||||
- [ ] Dashboard p95 < 200ms under load
|
||||
- [ ] Route evaluator p95 < 500ms per route at D5 High shape
|
||||
|
||||
### Tests
|
||||
- [ ] Integration: RLS audit suite — cross-instance query returns 0 rows on **every** tenant-scoped table (including route-health tables)
|
||||
- [ ] Integration: JWT rotation — rotate `JWT_SESSION_SECRET`, assert in-flight sessions invalidate gracefully
|
||||
- [ ] CI gate: `npm audit` clean (backend + frontend) enforced as a required check
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Multi-tenancy (self-provisioning)
|
||||
@@ -263,7 +312,7 @@
|
||||
|
||||
### Observer scoping (D21)
|
||||
- [ ] Create `tenant_observers` table (instance_id, observer_pubkey_prefix, label)
|
||||
- [ ] Implement observer allowlist CRUD API (`GET/POST/DELETE /api/v1/observers`, admin-gated)
|
||||
- [ ] Implement observer allowlist CRUD API (`GET/POST/PUT/DELETE /api/v1/observers`, admin-gated) — each row carries an optional per-tenant friendly name (`label`); `PUT` renames the label only (no ingester reload, routing is prefix-keyed)
|
||||
- [ ] Implement `ObserverAllowlistCache` in MqttIngester (read-only snapshot, NATS reload on `observer.allowlist.updated.*`)
|
||||
- [ ] Implement multi-tenant produce: `route(observer_pubkey) → set[tenant_id]`, publish to each tenant's NATS subject
|
||||
- [ ] Tenant-prefix `Nats-Msg-Id` (`{tenant_id}:{wire_hash}`) for per-tenant JetStream dedup
|
||||
@@ -304,7 +353,14 @@
|
||||
- [ ] Hostname cache excludes soft-deleted instances (`deleted_at IS NULL`)
|
||||
|
||||
### Admin UI
|
||||
- [ ] `/admin/observers` page (allowlist CRUD + known-observer picker from `nodes WHERE is_observer`)
|
||||
- [ ] `/admin/observers` page (allowlist CRUD + known-observer picker from `nodes WHERE is_observer`, pre-filling the friendly name from the node's known name; stored as a per-tenant `label`; inline rename)
|
||||
- [ ] Settings → Authentication section (per-tenant OIDC config form)
|
||||
- [ ] Settings → Community section (custom domain management, soft-delete community)
|
||||
- [ ] Landing page at platform root with "Create your community" flow
|
||||
|
||||
### Tests
|
||||
- [ ] Unit: `ObserverAllowlistCache` routing (`route(observer_pubkey) → set[tenant_id]`, `_allow_all_tenants` on empty allowlist); subdomain validation + reserved-list; tenant-prefix `Nats-Msg-Id`
|
||||
- [ ] Integration: tenant isolation — cross-instance query returns 0 rows on every tenant-scoped table after registering two tenants; a shared observer yields one dedup'd event **per tenant**
|
||||
- [ ] Integration: `HostnameCache` excludes soft-deleted instances; `InstanceResolutionMiddleware` (JWT claim → hostname → `DEFAULT_INSTANCE_ID`)
|
||||
- [ ] E2E (Playwright, real login): register a tenant → land on subdomain logged in → manage observer allowlist (picker pre-fills the friendly name, inline rename) → add a custom domain; second tenant on the same deployment is isolated
|
||||
- [ ] E2E: per-tenant auth — tenant A local-only vs tenant B renders its own IdP button per hostname (OIDC callback itself stays forged/documented per D23)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Status (iteration 7):** All design questions resolved. The items below are **deferred
|
||||
> measurements** (decided in principle, final form pending a benchmark) — not open design
|
||||
> questions. See [decisions/](decisions/) for the 22 locked ADRs.
|
||||
> questions. See [decisions/](decisions/) for the 23 locked ADRs.
|
||||
|
||||
## Deferred measurements (locked, pending execution)
|
||||
|
||||
@@ -50,7 +50,7 @@ All six iteration-5 review questions resolved:
|
||||
| Q5 | OIDC users role-editable | **Yes — DB-additive override** (`effective = IdP ∪ DB`); Users page shows both, DB is additive only |
|
||||
| Q6a | NATS vs Redis for fan-out | **Both** — NATS for pub/sub, Redis for KV cache (optional) |
|
||||
| Q6b | Token signing algorithm | **HS256 default**, RS256 as config option |
|
||||
| Q6c | Feature-flag propagation | **Next-boundary** per flag (next packet / next tick / next request), as documented in §8.8.3 |
|
||||
| Q6c | Feature-flag propagation | **Next-boundary** per flag (next packet / next tick / next request), as documented in api.md → Settings API (cross-service propagation) |
|
||||
|
||||
## Resolved in iteration 8 (full-plan design review)
|
||||
|
||||
@@ -70,7 +70,7 @@ resolved **before** the Phase 0 DDL freeze:
|
||||
|
||||
## No remaining open design questions
|
||||
|
||||
The design covers Phases 0–7 concretely. All 22 decisions are locked; iteration 8 corrected implementation
|
||||
details without reopening any decision. The remaining work is implementation, guided by the
|
||||
The design covers Phases 0–7 concretely. All 23 decisions are locked; iteration 8 corrected implementation
|
||||
details without reopening any decision (iteration 9 added the testing-policy decision, D23). The remaining work is implementation, guided by the
|
||||
[phasing plan](phasing.md), [implementation checklist](implementation-checklist.md),
|
||||
[testing/exit criteria](testing.md), and [review-findings.md](review-findings.md).
|
||||
|
||||
@@ -82,7 +82,7 @@ The cleanup phase after all functional phases land. Three tracks:
|
||||
|
||||
### 6.3 New-repo `AGENTS.md` / `CONTRIBUTING.md`
|
||||
- [ ] Derive "we do / we don't" rules from [code-warts.md](code-warts.md) — each wart becomes an explicit convention.
|
||||
- [ ] Document the one-config-surface rule (D18), the cache-invalidation graph (§20.4), the auth boundary (D6/D12), and the codegen gate (D09).
|
||||
- [ ] Document the one-config-surface rule (D18), the cache-invalidation graph (api.md → Unified cache contract), the auth boundary (D6/D12), and the codegen gate (D09).
|
||||
- [ ] Port the still-relevant operational gotchas from the old AGENTS.md, updating for the TS stack (parenthesized exception tuples become irrelevant; random migration ID guidance becomes "let drizzle-kit generate")
|
||||
|
||||
### 6.4 Documentation overhaul
|
||||
@@ -114,6 +114,24 @@ After the single-tenant stack (Phases 0–6) is stable, extend to shared-platfor
|
||||
|
||||
---
|
||||
|
||||
## Testing (cross-cutting — every phase)
|
||||
|
||||
Locked in [D23](decisions/D23-test-pyramid-coverage.md). The rewrite ships with a regression suite,
|
||||
not just acceptance gates: **every component and piece of logic carries tests at the appropriate
|
||||
layer, and the suite must pass in CI** (qualitative coverage, no % floor).
|
||||
|
||||
- **vitest** (one runner, D22): unit (pure logic), integration (Drizzle/RLS/cache/workers against
|
||||
throwaway infra), and frontend component tests (Testing Library).
|
||||
- **Playwright** (headless Chromium, throwaway stack): user-facing E2E, driving **real local login**
|
||||
where automatable (closing the `code-warts.md` TQ1 forged-session wart; OIDC stays forged/documented).
|
||||
|
||||
This is distinct from the [phase exit criteria](testing.md#test-strategy-the-test-pyramid): the
|
||||
pyramid is fast and CI-runnable; the exit criteria include slow acceptance gates (5-day
|
||||
parallel-stack diff, live-load throughput) no unit test can reproduce. A phase is done when its
|
||||
checklist is ticked, its automated tests pass in CI, and its exit criteria pass.
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
@@ -123,7 +141,7 @@ After the single-tenant stack (Phases 0–6) is stable, extend to shared-platfor
|
||||
| NATS is a new infra dependency | Single binary, trivial to operate; JetStream persistence is file-backed; well-documented |
|
||||
| JWT refresh UX complexity | Short-lived access token (5m) + refresh via the signed cookie; transparent to the user |
|
||||
| Frontend codegen friction | CI gate + `make gen-client` target; generated code is a build artifact |
|
||||
| Local-password auth becomes a brute-force target | argon2id + exponential lockout (§8.3.2) + reverse-proxy rate limiting |
|
||||
| Local-password auth becomes a brute-force target | argon2id + exponential lockout (auth.md → local auth) + reverse-proxy rate limiting |
|
||||
| D5 fold benchmark fails (separate table needed) | Reversible — `raw_receptions.path_hashes` stays either way; hops table is additive |
|
||||
| Scope creep | Each phase is independently valuable and shippable; we can stop after any phase |
|
||||
| **Highest-risk migration shape:** greenfield rewrite + language switch (Python→TS) + two new infra deps (NATS, TimescaleDB) + multi-tenancy, all in one program | Sequenced so the language/infra risk is front-loaded (Phases 0–1) and de-risked by the decode-shadow (Phase 1) + parallel-stack (Phase 2) gates before any cutover; multi-tenancy is deferred to Phase 7 (fully additive). See the strategy note below. |
|
||||
|
||||
@@ -136,7 +136,7 @@ sections aren't under-specified for implementation.
|
||||
|
||||
### F12 — First-run setup wizard reintroduces server-rendered HTML
|
||||
The SSR wizard contradicts the static-shell principle (Principle 5). Documented the preferred approach —
|
||||
a normal SPA route gated by a `needsSetup` flag in `/api/v1/config` — with SSR kept only as a fallback.
|
||||
a normal SPA route gated by a `needs_setup` flag in `/api/v1/config` (snake_case on the wire; the server-internal gate flag is `fastify.state.needsSetup`) — with SSR kept only as a fallback.
|
||||
Files: `components/auth.md`.
|
||||
|
||||
### F13 — Greenfield rewrite + language switch + new infra + multi-tenancy is the highest-risk path
|
||||
|
||||
@@ -1,8 +1,50 @@
|
||||
# Testing & Validation — Phase Exit Criteria
|
||||
# Testing & Validation — Strategy, Policy & Phase Exit Criteria
|
||||
|
||||
> Consolidated validation plan for the MeshCore Hub rewrite. Each phase ships behind a
|
||||
> measurable exit gate; cross-cutting benchmarks are listed at the end. Phase context lives
|
||||
> in [phasing.md](phasing.md).
|
||||
> Consolidated validation plan for the MeshCore Hub rewrite. Two complementary axes:
|
||||
> (1) the **test pyramid** — fast, CI-runnable regression tests authored with the code
|
||||
> ([below](#test-strategy-the-test-pyramid), locked in [D23](decisions/D23-test-pyramid-coverage.md));
|
||||
> and (2) **phase exit criteria** — the acceptance gates each phase ships behind. Cross-cutting
|
||||
> benchmarks are listed at the end. Phase context lives in [phasing.md](phasing.md).
|
||||
|
||||
## Test strategy (the test pyramid)
|
||||
|
||||
Locked in [D23](decisions/D23-test-pyramid-coverage.md). **Every rewritten component and every
|
||||
piece of business logic ships with automated tests at the appropriate layer, and the suite must
|
||||
pass in CI.** Coverage is qualitative — deliberately **no numeric floor**.
|
||||
|
||||
| Layer | Runner | Infra | Owns |
|
||||
|---|---|---|---|
|
||||
| **Unit** | vitest | none | Pure logic: classifier table, dedup hash, `computeQualityAvg`, spam-score wrapper, cache-key builder, `apply_visibility`, the `meshcore.ingest.v1` envelope Zod schema, webhook filter DSL, `PacketFields`. |
|
||||
| **Integration** | vitest | dev-compose / throwaway Postgres+NATS+Redis | DB- and bus-touching paths: Drizzle repositories, RLS enforcement (`SET LOCAL app.instance_id`), the `@cached` decorator + declarative invalidation graph, `IngestWorker` batch-commit + ack ordering, `ChannelKeyCache`/`ObserverAllowlistCache` reload, `SettingsCache`, API handlers via Fastify `inject`. |
|
||||
| **Frontend component** | vitest + Testing Library | jsdom | `useEventStream`, generated-client wrappers, admin pages (Settings/Users/Pages/Observers), chart helpers (`averageRouteTier` threshold sync), login rendering per `auth_mode`. |
|
||||
| **E2E** | Playwright (headless Chromium) | throwaway stack (own Postgres, isolated volumes) | User flows: registration → login → admin → observer allowlist; SSE live updates; custom pages; settings. |
|
||||
|
||||
vitest is the single runner for the first three layers (D22); Playwright is the only second
|
||||
runner, used solely for browser E2E.
|
||||
|
||||
**E2E auth — real login, forged only where unavoidable (closes `code-warts.md` TQ1):** specs drive
|
||||
the real local-login flow (argon2id `local_users` path, D12). Session forging is permitted **only**
|
||||
for the un-automatable OIDC callback, and is documented in the spec directory so the divergence
|
||||
from production auth stays visible.
|
||||
|
||||
### Component → required layers
|
||||
|
||||
| Component | Unit | Integration | Frontend | E2E |
|
||||
|---|:---:|:---:|:---:|:---:|
|
||||
| [ingest.md](components/ingest.md) | ✓ | ✓ | | |
|
||||
| [data-model.md](components/data-model.md) (repos, RLS) | | ✓ | | |
|
||||
| [derived-state.md](components/derived-state.md) | ✓ | ✓ | | |
|
||||
| [auth.md](components/auth.md) | ✓ | ✓ | ✓ | ✓ |
|
||||
| [api.md](components/api.md) (cache, SSE, settings, pages) | ✓ | ✓ | | ✓ |
|
||||
| [frontend.md](components/frontend.md) | | | ✓ | ✓ |
|
||||
| [multi-tenancy.md](components/multi-tenancy.md) | ✓ | ✓ | ✓ | ✓ |
|
||||
| [migration.md](components/migration.md) (export/import) | ✓ | ✓ | | |
|
||||
|
||||
> The exit criteria below are the **acceptance gates** for each phase; some are slow runtime
|
||||
> observations (5-day parallel-stack diff, live-load throughput) that no unit test can reproduce.
|
||||
> They complement the pyramid — a phase is done when its checkboxes are ticked, **its automated
|
||||
> tests pass in CI**, and its exit criteria pass. Per-phase test deliverables are itemised in the
|
||||
> [implementation checklist](implementation-checklist.md).
|
||||
|
||||
## Phase 1 — Ingest pipeline
|
||||
|
||||
|
||||
Reference in New Issue
Block a user