diff --git a/.env.example b/.env.example index 1f0bbc45..be243324 100644 --- a/.env.example +++ b/.env.example @@ -37,5 +37,17 @@ EMAIL_RECUP_ADMIN_VAULT= # Optional. Fly.io operator token used only for owner-managed Vault deployment or recovery commands. If omitted: the related integration is disabled, reducing local secret sprawl. FLY_API_TOKEN= +# Optional. LiveKit API key shared by the livekit SFU container and the osionos bridge video-token endpoint (Docker default: devkey). If omitted: the related integration is disabled, reducing local secret sprawl. +LIVEKIT_API_KEY= + +# Optional. LiveKit API secret used to sign HS256 video access tokens, 32+ chars (Docker default is a dev-only value). If omitted: the related integration is disabled, reducing local secret sprawl. +LIVEKIT_API_SECRET= + +# Optional. Browser-facing LiveKit WebSocket URL returned by the bridge token endpoint. If omitted: the related integration is disabled, reducing local secret sprawl. +LIVEKIT_CLIENT_URL=ws://127.0.0.1:7880 + +# Optional. In-network LiveKit server URL used by the bridge for admin (twirp) API calls. If omitted: the related integration is disabled, reducing local secret sprawl. +LIVEKIT_URL=http://livekit:7880 + # Optional. Runtime configuration key for this service. If omitted: the related integration is disabled, reducing local secret sprawl. SONAR_TOK= diff --git a/.gitignore b/.gitignore index 91849274..c771ab20 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ apps/osionos-desktop/src-tauri/target/ # Vault root-token retrieval runbook — operational, keep out of the repo wiki/security/retrieve_fly_seed_admin_right.md + +# generated seeder state (uuids/emails from seed_agency_people.sh) +tools/seeds/.agency-people.env diff --git a/.gitmodules b/.gitmodules index c1824e34..fb6e9ef9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -28,3 +28,6 @@ [submodule "apps/vite-gourmand"] path = apps/vite-gourmand url = git@github.com:LESdylan/vite-gourmand.git +[submodule "apps/opposite-osiris"] + path = apps/opposite-osiris + url = git@github.com:Univers42/prismatica.git diff --git a/Makefile b/Makefile index 8e032248..3c521dc8 100644 --- a/Makefile +++ b/Makefile @@ -43,5 +43,6 @@ include $(MAKE_DIR)/playground.mk include $(MAKE_DIR)/mail.mk include $(MAKE_DIR)/calendar.mk include $(MAKE_DIR)/baas.mk +include $(MAKE_DIR)/agency.mk include $(MAKE_DIR)/baas-release.mk include $(MAKE_DIR)/baas-verify.mk diff --git a/apps/baas/.dockerignore b/apps/baas/.dockerignore new file mode 100644 index 00000000..31dec16a --- /dev/null +++ b/apps/baas/.dockerignore @@ -0,0 +1,8 @@ +# The kong image (apps/baas/Dockerfile) needs exactly two config files, but +# its build context is this whole directory — which contains mini-baas-infra +# (~3GB of sources, SDK, wiki). Allowlist style: exclude everything, then +# re-include only what the Dockerfile COPYs. +* +!Dockerfile +!config/mini-baas-infra.conf +!config/kong.track-binocle.yml diff --git a/apps/baas/.env.example b/apps/baas/.env.example index 320f2137..afe6fc2a 100644 --- a/apps/baas/.env.example +++ b/apps/baas/.env.example @@ -81,6 +81,9 @@ GOTRUE_URI_ALLOW_LIST=https://localhost:4322/**,https://localhost:3001/** # Recommended. Runtime configuration key for this service. If omitted: Docker defaults are used, which is fine for local dev but less explicit for the team. PROJECT_INIT_MARKER=track_binocle_20260504 +# Recommended. Cloudflare Turnstile anti-abuse setting. If omitted: Docker defaults are used, which is fine for local dev but less explicit for the team. +TURNSTILE_SECRET_KEY=replace-with-turnstile-secret-key + # OPTIONAL: Optional. Leave empty to disable the integration and gain a smaller local attack surface. # Optional. SMTP setting used when local email delivery is enabled. If omitted: the related integration is disabled, reducing local secret sprawl. GOTRUE_SMTP_ADMIN_EMAIL=noreply@mini-baas.local diff --git a/apps/baas/mini-baas-infra/.gitignore b/apps/baas/mini-baas-infra/.gitignore index cfc84e4b..d3a38d66 100644 --- a/apps/baas/mini-baas-infra/.gitignore +++ b/apps/baas/mini-baas-infra/.gitignore @@ -380,3 +380,6 @@ certs/* # Parity gate (G10) run artifacts — verdicts are per-run; route-sets + goldens stay tracked. .parity/ + +# generated tenant/key state from scripts/seed/agency-tenant.sh (live API key) +.agency-tenant.env diff --git a/apps/baas/mini-baas-infra/Makefile b/apps/baas/mini-baas-infra/Makefile index 56ab4801..fa64a05a 100644 --- a/apps/baas/mini-baas-infra/Makefile +++ b/apps/baas/mini-baas-infra/Makefile @@ -299,6 +299,9 @@ migrate-status: ## Show applied migration versions seed-mongo: _require-compose ## Seed MongoDB demo data @bash scripts/seed-mongo.sh +seed-live-demo: _require-compose ## Seed the live-database demo across pg+mysql+mongo (owned by the osionos app key; RESEED=1 wipes first) + @bash scripts/seed-live-demo.sh + # ========================================================================== # ##@ Secrets & Vault # ========================================================================== # @@ -322,17 +325,46 @@ vault-rotate: _require-compose ## Rotate Vault secrets (GROUP=jwt|postgres|mongo # ========================================================================== # ##@ Language tiers (TypeScript / Rust / Go) # ========================================================================== # -nestjs-ci: ## TS: install + typecheck + lint + test - @cd src && corepack enable && pnpm install && npx tsc --noEmit && npx eslint 'apps/**/*.ts' 'libs/**/*.ts' && npx jest --passWithNoTests -nestjs-build-%: ## TS: build one app (e.g. make nestjs-build-query-router) - @cd src && npx nest build $* -rust-data-plane-check: ## Rust: cargo check the data-plane workspace - @cd docker/services/data-plane-router && cargo check --workspace -rust-data-plane-build: ## Rust: build the data-plane release binary - @cd docker/services/data-plane-router && cargo build --release --bin data-plane-router -go-control-plane-check: ## Go: vet + test the control-plane module - @if command -v go >/dev/null 2>&1; then cd go/control-plane && go vet ./... && go test ./...; \ - else docker run --rm -v "$(PWD)/go/control-plane":/src -w /src golang:1.23-bookworm sh -c 'go mod tidy && go vet ./... && go test ./...'; fi +# Node also runs INSIDE Docker (no node/npm on the host); node_modules lives +# in a named volume so the host tree stays clean and installs stay warm. +NODE_IMAGE := public.ecr.aws/docker/library/node:20-alpine +NODE_RUN = docker run --rm -v "$(PWD)/src":/app -w /app \ + -v mini-baas-src-node-modules:/app/node_modules \ + -v mini-baas-npm-cache:/root/.npm \ + $(NODE_IMAGE) + +nestjs-ci: ## TS: install + typecheck + lint + test (in Docker) + @$(NODE_RUN) sh -c 'npm ci --ignore-scripts --prefer-offline --no-audit --no-fund \ + && npx tsc --noEmit && npx eslint "apps/**/*.ts" "libs/**/*.ts" && npx jest --passWithNoTests' +nestjs-build-%: ## TS: build one app in Docker (e.g. make nestjs-build-query-router) + @$(NODE_RUN) sh -c '[ -x node_modules/.bin/nest ] || npm ci --ignore-scripts --prefer-offline --no-audit --no-fund; npx nest build $*' +# Cargo runs INSIDE Docker (no rustc/cargo on the host). The registry and the +# per-workspace target dirs live in named volumes, so dependency downloads and +# incremental build state persist across runs while the host stays clean. +RUST_IMAGE := public.ecr.aws/docker/library/rust:1.89-slim-bookworm +RUST_TOOLCHAIN_IMG := mini-baas-rust-toolchain +CARGO_VOLS = -v mini-baas-cargo-registry:/usr/local/cargo/registry -v mini-baas-cargo-git:/usr/local/cargo/git +CARGO_DPR = docker run --rm -v "$(PWD)/docker/services/data-plane-router":/work -w /work $(CARGO_VOLS) -v mini-baas-dpr-target:/work/target $(RUST_TOOLCHAIN_IMG) +CARGO_REALTIME = docker run --rm -v "$(PWD)/docker/services/realtime/realtime-agnostic":/work -w /work $(CARGO_VOLS) -v mini-baas-realtime-target:/work/target $(RUST_TOOLCHAIN_IMG) + +_rust-toolchain: ## (internal) cargo-in-docker image: rust + pkg-config/libssl (layer-cached) + @printf 'FROM $(RUST_IMAGE)\nRUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*\n' \ + | docker build -q -t $(RUST_TOOLCHAIN_IMG) - >/dev/null + +rust-data-plane-check: _rust-toolchain ## Rust: cargo check the data-plane workspace (in Docker) + @$(CARGO_DPR) cargo check --workspace +rust-data-plane-build: _rust-toolchain ## Rust: build the data-plane release binary (in Docker) + @$(CARGO_DPR) cargo build --release --bin data-plane-router +rust-realtime-check: _rust-toolchain ## Rust: cargo check the realtime workspace (in Docker) + @$(CARGO_REALTIME) cargo check --workspace +rust-realtime-test: _rust-toolchain ## Rust: run the realtime unit tests (in Docker) + @$(CARGO_REALTIME) cargo test --workspace +rust-realtime-build: _rust-toolchain ## Rust: build the realtime-server release binary (in Docker) + @$(CARGO_REALTIME) cargo build --release --bin realtime-server +go-control-plane-check: ## Go: vet + test the control-plane module (in Docker, cached modules) + @docker run --rm -v "$(PWD)/go/control-plane":/src -w /src \ + -v mini-baas-gomod:/go/pkg/mod -v mini-baas-gobuild:/root/.cache/go-build \ + golang:1.23-bookworm sh -c 'GOFLAGS=-mod=mod go vet ./... && GOFLAGS=-mod=mod go test ./...' go-control-plane-build: ## Go: build the control-plane images @$(DC) --profile go-control-plane build @@ -367,8 +399,9 @@ help: ## Show this help .PHONY: all all-full clean fclean re up down restart ps logs pull build health \ bench-startup observe planes editions doctor tests test-postgres \ verify-all parity waf-test migrate migrate-mongo migrate-mysql migrate-all \ - migrate-status seed-mongo secrets secrets-validate secrets-rotate \ + migrate-status seed-mongo seed-live-demo secrets secrets-validate secrets-rotate \ check-secrets env vault-init vault-status vault-rotate nestjs-ci \ rust-data-plane-check rust-data-plane-build go-control-plane-check \ + rust-realtime-check rust-realtime-test rust-realtime-build _rust-toolchain \ go-control-plane-build preflight hooks update help \ _require-docker _require-compose _rm-stale diff --git a/apps/baas/mini-baas-infra/docker-compose.yml b/apps/baas/mini-baas-infra/docker-compose.yml index b8546f62..0d16bb90 100644 --- a/apps/baas/mini-baas-infra/docker-compose.yml +++ b/apps/baas/mini-baas-infra/docker-compose.yml @@ -148,6 +148,12 @@ services: image: trinodb/trino:467 container_name: mini-baas-trino profiles: [analytics, data-plane, extras] + # Trino's jvm.config sizes the heap as 80% of visible RAM + # (Initial/MaxRAMPercentage=80): without a limit it reserves against the + # whole HOST (~25GB on a 32GB machine, ~900MB RSS idle). The cap makes the + # JVM size itself for the local analytics workload instead. + mem_limit: 2g + cpus: 2.0 env_file: [.env] environment: MYSQL_USER: ${MYSQL_USER:-mini_baas} @@ -742,6 +748,12 @@ services: QUERY_ROUTER_ASYNC_EVENT_FLUSH_MS: ${QUERY_ROUTER_ASYNC_EVENT_FLUSH_MS:-1000} QUERY_ROUTER_ASYNC_EVENT_BATCH_SIZE: ${QUERY_ROUTER_ASYNC_EVENT_BATCH_SIZE:-25} QUERY_ROUTER_ASYNC_EVENT_MAX_ENTRIES: ${QUERY_ROUTER_ASYNC_EVENT_MAX_ENTRIES:-1000} + # Realtime fan-out (Phase 6): successful writes publish best-effort + # `row_changed` (and DDL publishes `schema_changed`) events on + # `table::` via the realtime service's internal ingest. + # Same contract + switch semantics as outbox-relay (empty URL disables). + REALTIME_PUBLISH_URL: ${REALTIME_PUBLISH_URL:-http://realtime:4000/v1/publish} + REALTIME_PUBLISH_TIMEOUT_MS: ${QUERY_ROUTER_REALTIME_PUBLISH_TIMEOUT_MS:-1500} JWT_SECRET: ${JWT_SECRET} ADAPTER_REGISTRY_SERVICE_TOKEN: ${ADAPTER_REGISTRY_SERVICE_TOKEN:-${JWT_SECRET:-dev-service-token-change-me}} LOG_LEVEL: ${LOG_LEVEL:-info} @@ -791,6 +803,7 @@ services: DATA_PLANE_ROUTER_PRODUCT_MODE: ${DATA_PLANE_ROUTER_PRODUCT_MODE:-enabled} DATA_PLANE_ADAPTER_REGISTRY_URL: ${DATA_PLANE_ADAPTER_REGISTRY_URL:-http://adapter-registry-go:3021} DATA_PLANE_PERMISSION_BUNDLE_URL: http://permission-engine:3050/permissions/bundles/latest + DATA_PLANE_PERMISSION_MODE: ${DATA_PLANE_PERMISSION_MODE:-abac} DATA_PLANE_MOUNTS: ${DATA_PLANE_MOUNTS:-} RUST_LOG: ${RUST_LOG:-info} ports: diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/Dockerfile b/apps/baas/mini-baas-infra/docker/services/data-plane-router/Dockerfile index 4441adc8..3bd83217 100644 --- a/apps/baas/mini-baas-infra/docker/services/data-plane-router/Dockerfile +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/Dockerfile @@ -1,3 +1,10 @@ +# syntax=docker/dockerfile:1.7 +# Two complementary caches keep rebuilds fast: +# - layer cache: the stub-manifest stage pre-compiles all dependencies, so the +# expensive dep build is reused as long as no Cargo.toml/lock changes; +# - BuildKit cache mounts: the crates registry and the target dir persist +# ACROSS builds, so a manifest change no longer re-downloads the registry, +# and a source change only recompiles the crates that actually changed. FROM public.ecr.aws/docker/library/rust:1.89-slim-bookworm AS deps WORKDIR /build @@ -6,7 +13,10 @@ COPY crates/data-plane-core/Cargo.toml crates/data-plane-core/Cargo.toml COPY crates/data-plane-pool/Cargo.toml crates/data-plane-pool/Cargo.toml COPY crates/data-plane-server/Cargo.toml crates/data-plane-server/Cargo.toml -RUN for dir in crates/data-plane-core crates/data-plane-pool crates/data-plane-server; do \ +RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,id=dpr-target,target=/build/target,sharing=locked \ + for dir in crates/data-plane-core crates/data-plane-pool crates/data-plane-server; do \ mkdir -p "$dir/src"; \ echo "pub fn placeholder() {}" > "$dir/src/lib.rs"; \ done && \ @@ -15,13 +25,17 @@ RUN for dir in crates/data-plane-core crates/data-plane-pool crates/data-plane-s FROM deps AS builder COPY crates/ crates/ -RUN touch crates/data-plane-core/src/*.rs crates/data-plane-pool/src/*.rs crates/data-plane-server/src/*.rs && \ - cargo build --release --bin data-plane-router +RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,id=dpr-target,target=/build/target,sharing=locked \ + touch crates/data-plane-core/src/*.rs crates/data-plane-pool/src/*.rs crates/data-plane-server/src/*.rs && \ + cargo build --release --bin data-plane-router && \ + cp /build/target/release/data-plane-router /data-plane-router FROM gcr.io/distroless/cc-debian12:nonroot AS runtime WORKDIR /app -COPY --from=builder --chown=nonroot:nonroot /build/target/release/data-plane-router /app/data-plane-router +COPY --from=builder --chown=nonroot:nonroot /data-plane-router /app/data-plane-router USER nonroot:nonroot EXPOSE 4011 diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/capability.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/capability.rs index 4ce74434..21e31a05 100644 --- a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/capability.rs +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/capability.rs @@ -61,6 +61,26 @@ pub struct EngineCapabilities { /// + group_by). `#[serde(default)]` for wire back-compat. #[serde(default)] pub aggregate: bool, + /// Whether the adapter implements `describe_schema` (engine-agnostic schema + /// introspection, M22). A *route* capability like `ddl` — gated at + /// `POST /v1/schema`, never consulted by `supports_op`. `#[serde(default)]` + /// lets a descriptor payload without the field deserialise to `false` (the + /// honest value for redis/http), so adding it is wire backward-compatible. + #[serde(default)] + pub introspect: bool, + /// Whether the adapter implements `apply_schema_ddl` (engine-agnostic, + /// single-operation schema DDL: add/drop/retype column, create/drop + /// table — M22 step 2). A *route* capability gated at + /// `POST /v1/schema/ddl`, never consulted by `supports_op`. + /// + /// Deliberately DISTINCT from `ddl` (which gates the admin migration + /// batch at `/v1/admin/migrate`): mongodb implements the + /// `$jsonSchema`-validator DDL surface but NOT `apply_migration`, so + /// flipping its `ddl` flag instead would break capability honesty. + /// `#[serde(default)]` for wire back-compat (same precedent as + /// `batch`/`aggregate`/`introspect`). + #[serde(default)] + pub schema_ddl: bool, pub stream: bool, pub ddl: bool, pub transactions: bool, @@ -100,6 +120,8 @@ impl EngineCapabilities { upsert: true, batch: false, aggregate: true, + introspect: true, + schema_ddl: true, stream: true, ddl: true, transactions: true, @@ -128,6 +150,11 @@ impl EngineCapabilities { upsert: true, batch: false, aggregate: false, + introspect: true, + // The validator-based DDL surface (collMod / createCollection) IS + // implemented — distinct from `ddl: false` below, which honestly + // reports that `apply_migration` is NotImplemented on mongo. + schema_ddl: true, stream: true, ddl: false, // mongo's `begin()` returns NotImplemented (session-threading @@ -154,6 +181,8 @@ impl EngineCapabilities { upsert: true, batch: false, aggregate: false, + introspect: true, + schema_ddl: true, stream: false, ddl: true, transactions: true, @@ -182,6 +211,8 @@ impl EngineCapabilities { upsert: true, batch: false, aggregate: false, + introspect: false, + schema_ddl: false, stream: false, ddl: false, transactions: false, @@ -206,6 +237,8 @@ impl EngineCapabilities { upsert: true, batch: false, aggregate: false, + introspect: false, + schema_ddl: false, stream: false, ddl: false, transactions: false, @@ -222,3 +255,109 @@ impl EngineCapabilities { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn introspect_flag_matches_each_engine_introspection_surface() { + // M22: engines with a describe_schema implementation advertise it; + // engines without one (redis KV, http passthrough) honestly do not. + assert!(EngineCapabilities::postgresql().introspect); + assert!(EngineCapabilities::mysql().introspect); + assert!(EngineCapabilities::mongodb().introspect); + assert!(!EngineCapabilities::redis().introspect); + assert!(!EngineCapabilities::http().introspect); + } + + #[test] + fn capabilities_payload_without_introspect_still_deserializes() { + // Wire back-compat (same precedent as `batch`/`aggregate`): a + // descriptor serialized BEFORE the field existed must keep + // deserializing, with `introspect` defaulting to false. + let mut payload = serde_json::to_value(EngineCapabilities::postgresql()) + .expect("descriptor serializes"); + payload + .as_object_mut() + .expect("descriptor is a JSON object") + .remove("introspect") + .expect("introspect was present before removal"); + let parsed: EngineCapabilities = + serde_json::from_value(payload).expect("old payload still deserializes"); + assert!(!parsed.introspect, "absent introspect defaults to false"); + // Everything else survives untouched. + assert!(parsed.read && parsed.ddl && parsed.transactions); + } + + #[test] + fn introspect_never_leaks_into_supports_op() { + // `introspect` is a route capability (like `ddl`), not an operation + // kind: flipping it must not change any supports_op answer. + let mut caps = EngineCapabilities::redis(); + let before: Vec = DataOperationKind::ALL + .iter() + .map(|k| caps.supports_op(k)) + .collect(); + caps.introspect = true; + let after: Vec = DataOperationKind::ALL + .iter() + .map(|k| caps.supports_op(k)) + .collect(); + assert_eq!(before, after); + } + + #[test] + fn schema_ddl_flag_matches_each_engine_ddl_surface() { + // M22 step 2: engines with an apply_schema_ddl implementation + // advertise it; engines without one (redis KV, http passthrough) + // honestly do not. Mongo advertises schema_ddl (the jsonSchema + // validator surface) while still NOT advertising `ddl` + // (apply_migration is NotImplemented there) — the two flags are + // deliberately independent. + assert!(EngineCapabilities::postgresql().schema_ddl); + assert!(EngineCapabilities::mysql().schema_ddl); + assert!(EngineCapabilities::mongodb().schema_ddl); + assert!(!EngineCapabilities::redis().schema_ddl); + assert!(!EngineCapabilities::http().schema_ddl); + // The capability-honesty invariant the plan pins: mongodb's migrate + // gate stays false even though its schema_ddl gate is true. + assert!(!EngineCapabilities::mongodb().ddl); + } + + #[test] + fn capabilities_payload_without_schema_ddl_still_deserializes() { + // Wire back-compat (same precedent as `batch`/`aggregate`/ + // `introspect`): a descriptor serialized BEFORE the field existed must + // keep deserializing, with `schema_ddl` defaulting to false. + let mut payload = serde_json::to_value(EngineCapabilities::postgresql()) + .expect("descriptor serializes"); + payload + .as_object_mut() + .expect("descriptor is a JSON object") + .remove("schema_ddl") + .expect("schema_ddl was present before removal"); + let parsed: EngineCapabilities = + serde_json::from_value(payload).expect("old payload still deserializes"); + assert!(!parsed.schema_ddl, "absent schema_ddl defaults to false"); + // Everything else survives untouched. + assert!(parsed.read && parsed.ddl && parsed.introspect && parsed.transactions); + } + + #[test] + fn schema_ddl_never_leaks_into_supports_op() { + // `schema_ddl` is a route capability: flipping it must not change any + // supports_op answer. + let mut caps = EngineCapabilities::redis(); + let before: Vec = DataOperationKind::ALL + .iter() + .map(|k| caps.supports_op(k)) + .collect(); + caps.schema_ddl = true; + let after: Vec = DataOperationKind::ALL + .iter() + .map(|k| caps.supports_op(k)) + .collect(); + assert_eq!(before, after); + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/lib.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/lib.rs index e3ca3c27..ef506eba 100644 --- a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/lib.rs +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/lib.rs @@ -8,6 +8,8 @@ pub mod operation; pub mod plan; pub mod planner; pub mod ports; +pub mod schema; +pub mod schema_ddl; pub mod transaction; pub use capability::{CostCapabilities, EngineCapabilities, IsolationLevel}; @@ -25,4 +27,9 @@ pub use ports::{ EngineAdapter, EngineHealth, EnginePool, MigrationRequest, MigrationResult, MigrationStatus, PoolRegistry, PoolStats, RawStatement, TxHandle, }; +pub use schema::{ColumnSchema, ForeignKeyRef, NormalizedType, SchemaDescriptor, TableSchema}; +pub use schema_ddl::{ + validate_default_expr, DdlColumnDef, SchemaDdlOp, SchemaDdlRequest, SchemaDdlResult, + SchemaDdlStatus, +}; pub use transaction::{TxBeginRequest, TxSession, TxState}; diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/ports.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/ports.rs index 27111d78..e95c7d0e 100644 --- a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/ports.rs +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/ports.rs @@ -1,6 +1,6 @@ use crate::{ DataOperation, DataOperationKind, DataPlaneError, DataPlaneResult, DataResult, DatabaseMount, - EngineCapabilities, RequestIdentity, + EngineCapabilities, RequestIdentity, SchemaDdlRequest, SchemaDdlResult, SchemaDescriptor, }; use async_trait::async_trait; use serde::{Deserialize, Serialize}; @@ -63,6 +63,41 @@ pub trait EnginePool: Send + Sync { }) } + /// Engine-agnostic schema introspection (M22). Returns every table / + /// collection visible to this mount (scoped exactly like the request + /// path — schema_per_tenant mounts only see their tenant schema) with + /// normalized column types, PK/FK metadata and enum values. Default + /// returns NotImplemented so engines without an introspection surface + /// (redis, http) opt out explicitly; the route gates on the + /// `introspect` capability flag before ever reaching this. + async fn describe_schema( + &self, + identity: RequestIdentity, + ) -> DataPlaneResult { + let _ = identity; + Err(DataPlaneError::NotImplemented { + feature: format!("schema introspection on {}", self.mount_id()), + }) + } + + /// Apply ONE engine-agnostic schema-DDL operation (M22 step 2): add / + /// drop / retype a column, create / drop a table. Engines lower the + /// request through pure, identifier-validated builders (never raw client + /// SQL). Single-op by contract — MySQL DDL self-commits, so a batch here + /// would fake atomicity. Default returns NotImplemented so engines + /// without a DDL surface (redis, http) opt out explicitly; the route + /// gates on the `schema_ddl` capability flag before ever reaching this. + async fn apply_schema_ddl( + &self, + ddl: SchemaDdlRequest, + identity: RequestIdentity, + ) -> DataPlaneResult { + let _ = (ddl, identity); + Err(DataPlaneError::NotImplemented { + feature: format!("schema DDL on {}", self.mount_id()), + }) + } + /// Apply a named migration as an atomic batch. Runs every statement /// inside a single transaction, then writes a marker row into a /// `_baas_migrations(name, applied_at)` table on the tenant DB so the diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/schema.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/schema.rs new file mode 100644 index 00000000..476b3a18 --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/schema.rs @@ -0,0 +1,130 @@ +//! Engine-agnostic schema introspection contract (M22, live-database mode). +//! +//! [`SchemaDescriptor`] is the wire shape returned by `POST /v1/schema` and +//! produced by every [`crate::EnginePool::describe_schema`] implementation: +//! relational engines read their information_schema / catalogs, document +//! engines derive it from a `$jsonSchema` validator or sample-based inference +//! (`inferred: true`). The TS query-router forwards it verbatim, so this file +//! is the single source of truth for the contract. + +use serde::{Deserialize, Serialize}; + +/// Engine-neutral column type. Snake_case on the wire (`"datetime"`, +/// `"objectid"`, …). Engines map their native types onto this set via pure, +/// unit-tested normalizer functions; anything unmappable is `Unknown` — +/// honesty over guessing. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NormalizedType { + Text, + Integer, + Float, + Decimal, + Boolean, + Date, + Datetime, + Json, + Uuid, + Enum, + Array, + Objectid, + Unknown, +} + +/// Foreign-key target of a column. Present only for FK columns. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ForeignKeyRef { + pub table: String, + pub column: String, +} + +/// One column (or document field) of a table/collection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ColumnSchema { + pub name: String, + /// The engine's own type name (`order_status`, `varchar(255)`, `string`). + pub native_type: String, + pub normalized_type: NormalizedType, + pub nullable: bool, + /// Engine-rendered default expression, when one exists. + pub default: Option, + /// Allowed values when `normalized_type` is `enum`. + pub enum_values: Option>, + /// FK target, only for foreign-key columns. + pub references: Option, + /// `true` only for sample-based inference (Mongo without a `$jsonSchema` + /// validator) — the shape is a statistical guess, not a declared contract. + pub inferred: bool, +} + +/// One table / collection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TableSchema { + pub name: String, + pub primary_key: Vec, + pub columns: Vec, +} + +/// The full per-mount schema returned by `POST /v1/schema`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SchemaDescriptor { + pub engine: String, + pub tables: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn schema_descriptor_serde_round_trip_matches_wire_contract() { + // The FIXED wire contract from the M22 plan: snake_case normalized + // types, nullable `default` / `enum_values` / `references`, `inferred`. + let wire = json!({ + "engine": "postgresql", + "tables": [ + { + "name": "orders", + "primary_key": ["id"], + "columns": [ + { + "name": "status", + "native_type": "order_status", + "normalized_type": "enum", + "nullable": false, + "default": null, + "enum_values": ["pending", "paid", "shipped", "cancelled"], + "references": { "table": "customers", "column": "id" }, + "inferred": false + } + ] + } + ] + }); + let parsed: SchemaDescriptor = serde_json::from_value(wire.clone()).expect("deserializes"); + assert_eq!(parsed.engine, "postgresql"); + assert_eq!(parsed.tables[0].primary_key, vec!["id".to_string()]); + let col = &parsed.tables[0].columns[0]; + assert_eq!(col.normalized_type, NormalizedType::Enum); + assert_eq!( + col.references, + Some(ForeignKeyRef { table: "customers".into(), column: "id".into() }) + ); + assert!(!col.inferred); + // Round trip: serializing back yields the exact same JSON. + assert_eq!(serde_json::to_value(&parsed).expect("serializes"), wire); + } + + #[test] + fn normalized_type_is_snake_case_on_the_wire() { + for (ty, wire) in [ + (NormalizedType::Text, "\"text\""), + (NormalizedType::Datetime, "\"datetime\""), + (NormalizedType::Objectid, "\"objectid\""), + (NormalizedType::Unknown, "\"unknown\""), + ] { + assert_eq!(serde_json::to_string(&ty).unwrap(), wire); + } + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/schema_ddl.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/schema_ddl.rs new file mode 100644 index 00000000..334f4073 --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-core/src/schema_ddl.rs @@ -0,0 +1,267 @@ +//! Engine-agnostic schema DDL contract (M22 live-database mode, step 2). +//! +//! [`SchemaDdlRequest`] is the wire shape consumed by `POST /v1/schema/ddl` +//! and every [`crate::EnginePool::apply_schema_ddl`] implementation: +//! relational engines lower it to `ALTER TABLE` / `CREATE TABLE` SQL through +//! pure, unit-tested builders; the document engine rewrites the collection's +//! `$jsonSchema` validator. ONE operation per request — deliberate, because +//! MySQL DDL is auto-commit/non-transactional, so a multi-op batch would fake +//! an atomicity the engine cannot deliver. + +use crate::{DataPlaneError, DataPlaneResult, NormalizedType}; +use serde::{Deserialize, Serialize}; + +/// One column definition for DDL: a [`crate::ColumnSchema`] minus the +/// describe-only fields (`native_type`, `references`, `inferred`). For +/// `alter_column_type` the caller composes the FULL target definition +/// (name + new type + nullability + default + enum values) — engines like +/// MySQL (`MODIFY COLUMN`) reset every attribute, so a partial def would +/// silently drop constraints. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DdlColumnDef { + pub name: String, + pub normalized_type: NormalizedType, + pub nullable: bool, + /// Raw engine default expression (`0`, `'pending'`, `now()`). + /// Interpolated into DDL (DEFAULT cannot bind parameters), so it is + /// guarded by [`validate_default_expr`] before any rendering. + #[serde(default)] + pub default: Option, + /// Allowed values when `normalized_type` is `enum`. + #[serde(default)] + pub enum_values: Option>, +} + +/// The single supported DDL operation kinds. Snake_case on the wire. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SchemaDdlOp { + AddColumn, + DropColumn, + AlterColumnType, + CreateTable, + DropTable, +} + +/// The `ddl` object of `POST /v1/schema/ddl`. Which optional field is +/// required depends on `op` — enforced by the `require_*` helpers so every +/// engine shares one validation surface (and one error message). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SchemaDdlRequest { + pub op: SchemaDdlOp, + pub table: String, + /// `add_column`: the new column; `alter_column_type`: the FULL target def. + #[serde(default)] + pub column: Option, + /// `drop_column`: the column to drop. + #[serde(default)] + pub column_name: Option, + /// `create_table`: the table's columns. + #[serde(default)] + pub columns: Option>, + /// `create_table`: the primary key column(s) — required. + #[serde(default)] + pub primary_key: Option>, +} + +impl SchemaDdlRequest { + /// The full column def for `add_column` / `alter_column_type`. + pub fn require_column(&self) -> DataPlaneResult<&DdlColumnDef> { + self.column.as_ref().ok_or_else(|| DataPlaneError::InvalidRequest { + message: format!("ddl op '{}' requires `column`", self.op.as_str()), + }) + } + + /// The column name for `drop_column`. + pub fn require_column_name(&self) -> DataPlaneResult<&str> { + self.column_name + .as_deref() + .filter(|n| !n.trim().is_empty()) + .ok_or_else(|| DataPlaneError::InvalidRequest { + message: format!("ddl op '{}' requires `column_name`", self.op.as_str()), + }) + } + + /// Columns + primary key for `create_table`. Both must be non-empty, and + /// every PK column must be a declared column (or `owner_id`, which the + /// engines auto-append) — catching typos here instead of as an engine 5xx. + pub fn require_create_spec(&self) -> DataPlaneResult<(&[DdlColumnDef], &[String])> { + let columns = self + .columns + .as_deref() + .filter(|c| !c.is_empty()) + .ok_or_else(|| DataPlaneError::InvalidRequest { + message: "ddl op 'create_table' requires non-empty `columns`".to_string(), + })?; + let primary_key = self + .primary_key + .as_deref() + .filter(|pk| !pk.is_empty()) + .ok_or_else(|| DataPlaneError::InvalidRequest { + message: "ddl op 'create_table' requires non-empty `primary_key`".to_string(), + })?; + for pk in primary_key { + if pk != "owner_id" && !columns.iter().any(|c| &c.name == pk) { + return Err(DataPlaneError::InvalidRequest { + message: format!("primary_key column '{pk}' is not in `columns`"), + }); + } + } + Ok((columns, primary_key)) + } +} + +impl SchemaDdlOp { + /// The wire (snake_case) name, for error messages. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::AddColumn => "add_column", + Self::DropColumn => "drop_column", + Self::AlterColumnType => "alter_column_type", + Self::CreateTable => "create_table", + Self::DropTable => "drop_table", + } + } +} + +/// Wire status of an applied DDL operation (closed set, like +/// [`crate::MigrationStatus`]). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SchemaDdlStatus { + Applied, +} + +/// Response of `POST /v1/schema/ddl`: +/// `{ "op": "...", "table": "...", "status": "applied" }`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SchemaDdlResult { + pub op: SchemaDdlOp, + pub table: String, + pub status: SchemaDdlStatus, +} + +/// Guard for caller-supplied DEFAULT expressions, which must be interpolated +/// into DDL text (a DEFAULT clause cannot bind parameters). The drivers +/// already enforce single-statement execution (PG extended query protocol; +/// mysql_async ships with multi-statements disabled), so this is defense in +/// depth: no statement separators, no SQL comments, no control characters. +/// Legitimate defaults (`0`, `'pending'`, `now()`, `CURRENT_TIMESTAMP`) pass. +pub fn validate_default_expr(expr: &str) -> DataPlaneResult<()> { + let forbidden = + expr.contains(';') || expr.contains("--") || expr.contains("/*") || expr.chars().any(char::is_control); + if forbidden { + return Err(DataPlaneError::InvalidRequest { + message: format!( + "default expression '{expr}' contains forbidden characters (';', '--', '/*', control)" + ), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn ddl_request_serde_round_trip_matches_wire_contract() { + // The FIXED wire contract: snake_case op, ColumnDef with nullable + // default / enum_values, op-specific optional fields. + let wire = json!({ + "op": "add_column", + "table": "orders", + "column": { + "name": "status", + "normalized_type": "enum", + "nullable": false, + "default": "'pending'", + "enum_values": ["pending", "paid"] + }, + "column_name": null, + "columns": null, + "primary_key": null + }); + let parsed: SchemaDdlRequest = serde_json::from_value(wire.clone()).expect("deserializes"); + assert_eq!(parsed.op, SchemaDdlOp::AddColumn); + assert_eq!(parsed.table, "orders"); + let col = parsed.column.as_ref().expect("column present"); + assert_eq!(col.normalized_type, NormalizedType::Enum); + assert_eq!(col.enum_values.as_deref(), Some(&["pending".to_string(), "paid".to_string()][..])); + assert_eq!(serde_json::to_value(&parsed).expect("serializes"), wire); + } + + #[test] + fn ddl_request_optional_fields_may_be_absent() { + // A minimal drop_table payload (no column/columns keys at all) parses. + let parsed: SchemaDdlRequest = + serde_json::from_value(json!({ "op": "drop_table", "table": "orders" })).unwrap(); + assert_eq!(parsed.op, SchemaDdlOp::DropTable); + assert!(parsed.column.is_none() && parsed.columns.is_none()); + } + + #[test] + fn ddl_result_serializes_to_the_fixed_response_shape() { + let result = SchemaDdlResult { + op: SchemaDdlOp::CreateTable, + table: "orders".to_string(), + status: SchemaDdlStatus::Applied, + }; + assert_eq!( + serde_json::to_value(&result).unwrap(), + json!({ "op": "create_table", "table": "orders", "status": "applied" }) + ); + } + + fn req(op: SchemaDdlOp) -> SchemaDdlRequest { + SchemaDdlRequest { + op, + table: "t".into(), + column: None, + column_name: None, + columns: None, + primary_key: None, + } + } + + #[test] + fn require_helpers_reject_missing_op_specific_fields() { + for (op, err_of) in [ + (SchemaDdlOp::AddColumn, req(SchemaDdlOp::AddColumn).require_column().err()), + (SchemaDdlOp::DropColumn, req(SchemaDdlOp::DropColumn).require_column_name().err()), + ] { + let err = err_of.unwrap_or_else(|| panic!("{op:?} should fail")); + assert!(matches!(err, DataPlaneError::InvalidRequest { .. }), "{op:?}: {err:?}"); + } + // create_table: missing columns, missing pk, and a pk typo all fail. + let mut r = req(SchemaDdlOp::CreateTable); + assert!(r.require_create_spec().is_err(), "no columns"); + r.columns = Some(vec![DdlColumnDef { + name: "id".into(), + normalized_type: NormalizedType::Integer, + nullable: false, + default: None, + enum_values: None, + }]); + assert!(r.require_create_spec().is_err(), "no primary_key"); + r.primary_key = Some(vec!["nope".into()]); + assert!(r.require_create_spec().is_err(), "pk references unknown column"); + r.primary_key = Some(vec!["id".into()]); + assert!(r.require_create_spec().is_ok()); + // owner_id is always a legal PK column (engines auto-append it). + r.primary_key = Some(vec!["owner_id".into()]); + assert!(r.require_create_spec().is_ok()); + } + + #[test] + fn default_expr_guard_blocks_separators_comments_and_control() { + for bad in ["0; DROP TABLE x", "1 -- evil", "1 /* evil */", "a\nb"] { + assert!(validate_default_expr(bad).is_err(), "{bad:?}"); + } + for ok in ["0", "'pending'", "now()", "CURRENT_TIMESTAMP", "gen_random_uuid()"] { + assert!(validate_default_expr(ok).is_ok(), "{ok:?}"); + } + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/mongo.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/mongo.rs index 9080df0c..b767f0f7 100644 --- a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/mongo.rs +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/mongo.rs @@ -23,14 +23,16 @@ use async_trait::async_trait; use bson::{Bson, Document}; use data_plane_core::{ - DataOperation, DataOperationKind, DataPlaneError, DataPlaneResult, DataResult, DatabaseMount, - EngineAdapter, EngineCapabilities, EngineHealth, EnginePool, RequestIdentity, ScopeDirective, - TxBeginRequest, TxHandle, + ColumnSchema, DataOperation, DataOperationKind, DataPlaneError, DataPlaneResult, DataResult, + DatabaseMount, DdlColumnDef, EngineAdapter, EngineCapabilities, EngineHealth, EnginePool, + NormalizedType, RequestIdentity, SchemaDdlOp, SchemaDdlRequest, SchemaDdlResult, + SchemaDdlStatus, SchemaDescriptor, ScopeDirective, TableSchema, TxBeginRequest, TxHandle, }; use futures::TryStreamExt; use mongodb::{ - options::{ClientOptions, FindOptions, UpdateOptions}, - Client, Collection, + options::{ClientOptions, CreateCollectionOptions, FindOptions, UpdateOptions}, + results::{CollectionSpecification, CollectionType}, + Client, Collection, Database, }; use serde_json::Value; use std::{sync::Arc, time::Duration}; @@ -43,6 +45,12 @@ use crate::resolver::MountResolver; /// shape (the equivalent of SQL injection for document stores). const RESERVED_FIELDS: [&str; 3] = ["_id", "owner_id", "tenant_id"]; +/// Trust fields enforced on FILTERS. `_id` is deliberately NOT here: it is the +/// row selector, not a trust field — stripping it turned every by-pk +/// get/update/delete into an all-owned-documents `update_many`/`delete_many` +/// (a single cell edit in the live UI would overwrite the whole collection). +const FILTER_TRUST_FIELDS: [&str; 2] = ["owner_id", "tenant_id"]; + /// MongoDB query operators that are safe to accept from an untrusted client /// filter — comparison, logical, element and array operators only. This is a /// **default-deny allowlist**: any `$`-prefixed key not in this set is rejected, @@ -277,9 +285,325 @@ impl EnginePool for MongoPool { // shutdown handshake required. Ok(()) } + + /// Engine-agnostic schema introspection (M22). Lists collections of the + /// pool's database (the per-tenant database for `schema_per_tenant`, the + /// DSN-default otherwise — same namespace the request path uses). A + /// collection with a `$jsonSchema` validator yields its declared columns + /// exactly (`inferred: false`); otherwise the shape is inferred from a + /// `$sample` of up to [`SCHEMA_SAMPLE_SIZE`] documents per-field majority + /// type (`inferred: true`). `primary_key` is always `["_id"]`. + async fn describe_schema( + &self, + identity: RequestIdentity, + ) -> DataPlaneResult { + if identity.tenant_id != self.tenant_id { + return Err(DataPlaneError::Backend { + message: "identity tenant does not match pool tenant".into(), + }); + } + let db = self.client.database(&self.db_name); + let mut specs: Vec = db + .list_collections(None, None) + .await + .map_err(mongo_err)? + .try_collect() + .await + .map_err(mongo_err)?; + specs.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut tables = Vec::with_capacity(specs.len()); + for spec in specs { + // Only real collections: views have no stable shape of their own + // and system collections are internal. + if !matches!(spec.collection_type, CollectionType::Collection) { + continue; + } + if spec.name.starts_with("system.") { + continue; + } + let json_schema = spec + .options + .validator + .as_ref() + .and_then(|v| v.get_document("$jsonSchema").ok()); + let columns = match json_schema { + // Declared contract → exact mapping, not inference. + Some(schema) => jsonschema_to_columns(schema), + None => { + // The name comes from the server's own listCollections, so + // it is trusted — no quote_ident gate (which would reject + // legitimate dotted names). + let col: Collection = db.collection(&spec.name); + let cursor = col + .aggregate( + vec![bson::doc! { "$sample": { "size": SCHEMA_SAMPLE_SIZE } }], + None, + ) + .await + .map_err(mongo_err)?; + let docs: Vec = cursor.try_collect().await.map_err(mongo_err)?; + infer_columns_from_samples(&docs) + } + }; + tables.push(TableSchema { + name: spec.name, + primary_key: vec!["_id".to_string()], + columns, + }); + } + Ok(SchemaDescriptor { engine: "mongodb".to_string(), tables }) + } + + /// Engine-agnostic schema DDL (M22 step 2) over the collection's + /// `$jsonSchema` validator — the same declared contract + /// [`Self::describe_schema`] reads back, so DDL and introspection stay + /// one source of truth: + /// * `create_table` → `createCollection` with a built validator + /// (owner_id string auto-appended, like the relational adapters); + /// * `drop_table` → `drop()`; + /// * column ops → read the current validator, transform it with + /// the pure `jsonschema_*` helpers, and `collMod` it back. + /// Mongo's PK is always `_id`; a declared `primary_key` is accepted but + /// only validated (a validator cannot express key constraints). + async fn apply_schema_ddl( + &self, + ddl: SchemaDdlRequest, + identity: RequestIdentity, + ) -> DataPlaneResult { + if identity.tenant_id != self.tenant_id { + return Err(DataPlaneError::Backend { + message: "identity tenant does not match pool tenant".into(), + }); + } + // Same name gate the request path uses (rejects `$`, dots, etc.). + let _ = self.collection(&ddl.table)?; + let db = self.client.database(&self.db_name); + match ddl.op { + SchemaDdlOp::CreateTable => { + let (columns, _primary_key) = ddl.require_create_spec()?; + let schema = columns_to_jsonschema(columns)?; + let options = CreateCollectionOptions::builder() + .validator(bson::doc! { "$jsonSchema": schema }) + .build(); + db.create_collection(&ddl.table, options) + .await + .map_err(mongo_ddl_err)?; + } + SchemaDdlOp::DropTable => { + db.collection::(&ddl.table) + .drop(None) + .await + .map_err(mongo_ddl_err)?; + } + SchemaDdlOp::AddColumn | SchemaDdlOp::AlterColumnType | SchemaDdlOp::DropColumn => { + let current = self.collection_jsonschema(&db, &ddl.table).await?; + let next = match ddl.op { + SchemaDdlOp::AddColumn => { + jsonschema_with_column_set(¤t, ddl.require_column()?, ColumnMode::Add)? + } + SchemaDdlOp::AlterColumnType => jsonschema_with_column_set( + ¤t, + ddl.require_column()?, + ColumnMode::Alter, + )?, + SchemaDdlOp::DropColumn => { + jsonschema_with_column_dropped(¤t, ddl.require_column_name()?)? + } + _ => unreachable!("outer match restricts to column ops"), + }; + db.run_command( + bson::doc! { "collMod": &ddl.table, "validator": { "$jsonSchema": next } }, + None, + ) + .await + .map_err(mongo_ddl_err)?; + } + } + Ok(SchemaDdlResult { + op: ddl.op, + table: ddl.table, + status: SchemaDdlStatus::Applied, + }) + } +} + +/// How many documents `describe_schema` samples per collection when no +/// `$jsonSchema` validator declares the shape. +const SCHEMA_SAMPLE_SIZE: i32 = 200; + +/// Maps a BSON type *name* (a `bsonType` string, or [`bson_value_type_name`] +/// output) to the engine-neutral [`NormalizedType`]. Pure. +fn bson_type_to_normalized(bson_type: &str) -> NormalizedType { + match bson_type { + "objectId" => NormalizedType::Objectid, + "string" => NormalizedType::Text, + "int" | "long" => NormalizedType::Integer, + "double" => NormalizedType::Float, + "decimal" => NormalizedType::Decimal, + "bool" => NormalizedType::Boolean, + "date" => NormalizedType::Datetime, + "array" => NormalizedType::Array, + "object" => NormalizedType::Json, + _ => NormalizedType::Unknown, + } +} + +/// The `bsonType` name of a live BSON value, matching the names a +/// `$jsonSchema` validator uses. Pure. +fn bson_value_type_name(value: &Bson) -> &'static str { + match value { + Bson::Double(_) => "double", + Bson::String(_) => "string", + Bson::Array(_) => "array", + Bson::Document(_) => "object", + Bson::Boolean(_) => "bool", + Bson::Null => "null", + Bson::Int32(_) => "int", + Bson::Int64(_) => "long", + Bson::ObjectId(_) => "objectId", + Bson::DateTime(_) => "date", + Bson::Decimal128(_) => "decimal", + _ => "unknown", + } +} + +/// Derives columns from a `$jsonSchema` validator document — the collection's +/// *declared* contract, so `inferred: false`. Handles `bsonType` as a string +/// or an array of strings (a `"null"` entry means nullable), `required` for +/// nullability, and `enum` for allowed values. Pure (unit-tested without a DB). +fn jsonschema_to_columns(schema: &Document) -> Vec { + let required: std::collections::BTreeSet<&str> = schema + .get_array("required") + .map(|arr| arr.iter().filter_map(Bson::as_str).collect()) + .unwrap_or_default(); + let Ok(props) = schema.get_document("properties") else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(props.len()); + for (name, spec) in props { + let spec_doc = spec.as_document(); + // bsonType: "string" | ["string", "null"] | absent. + let mut nullable_by_type = false; + let bson_type = match spec_doc.and_then(|d| d.get("bsonType")) { + Some(Bson::String(s)) => s.clone(), + Some(Bson::Array(items)) => { + nullable_by_type = items.iter().any(|b| b.as_str() == Some("null")); + items + .iter() + .filter_map(Bson::as_str) + .find(|s| *s != "null") + .unwrap_or("unknown") + .to_string() + } + _ => "unknown".to_string(), + }; + let enum_values: Option> = spec_doc + .and_then(|d| d.get_array("enum").ok()) + .map(|arr| { + arr.iter() + .map(|b| match b { + Bson::String(s) => s.clone(), + other => other.to_string(), + }) + .collect() + }); + let normalized_type = if enum_values.is_some() { + NormalizedType::Enum + } else { + bson_type_to_normalized(&bson_type) + }; + out.push(ColumnSchema { + name: name.clone(), + native_type: bson_type, + normalized_type, + nullable: !required.contains(name.as_str()) || nullable_by_type, + default: None, + enum_values, + references: None, + inferred: false, + }); + } + out +} + +/// Infers columns from sampled documents: per-field majority (non-null) BSON +/// type; a field absent from some documents or carrying nulls is nullable. +/// Always `inferred: true` — a statistical guess, not a declared contract. +/// Pure (unit-tested without a DB). +fn infer_columns_from_samples(docs: &[Document]) -> Vec { + use std::collections::BTreeMap; + let total = docs.len(); + // field → (present count, null count, type → count) + let mut fields: BTreeMap)> = + BTreeMap::new(); + for doc in docs { + for (key, value) in doc { + let entry = fields.entry(key.clone()).or_default(); + entry.0 += 1; + let type_name = bson_value_type_name(value); + if type_name == "null" { + entry.1 += 1; + } else { + *entry.2.entry(type_name).or_default() += 1; + } + } + } + fields + .into_iter() + .map(|(name, (present, nulls, counts))| { + // Majority vote over non-null types; BTreeMap iteration makes the + // tie-break deterministic (first alphabetically wins). + let majority = counts + .iter() + .max_by_key(|(_, count)| *count) + .map(|(ty, _)| *ty) + .unwrap_or("unknown"); + ColumnSchema { + nullable: present < total || nulls > 0, + native_type: majority.to_string(), + normalized_type: bson_type_to_normalized(majority), + name, + default: None, + enum_values: None, + references: None, + inferred: true, + } + }) + .collect() } impl MongoPool { + /// The collection's current `$jsonSchema` validator, or the empty + /// baseline (`{bsonType:"object", properties:{}}`) when it has none. + /// A missing collection is a clean client error — column DDL cannot + /// target a collection that does not exist. + async fn collection_jsonschema( + &self, + db: &Database, + name: &str, + ) -> DataPlaneResult { + let mut specs: Vec = db + .list_collections(bson::doc! { "name": name }, None) + .await + .map_err(mongo_err)? + .try_collect() + .await + .map_err(mongo_err)?; + let Some(spec) = specs.pop() else { + return Err(DataPlaneError::InvalidRequest { + message: format!("collection '{name}' does not exist"), + }); + }; + Ok(spec + .options + .validator + .as_ref() + .and_then(|v| v.get_document("$jsonSchema").ok()) + .cloned() + .unwrap_or_else(|| bson::doc! { "bsonType": "object", "properties": {} })) + } + async fn run_list( &self, col: &Collection, @@ -354,6 +678,7 @@ impl MongoPool { op: &DataOperation, identity: &RequestIdentity, ) -> DataPlaneResult { + require_row_filter(op.filter.as_ref(), "update")?; let filter = build_tenant_filter(op.filter.as_ref(), identity, &self.tenant_id)?; let data = op.data.as_ref().ok_or_else(|| DataPlaneError::InvalidRequest { message: "update requires operation.data".to_string(), @@ -375,6 +700,7 @@ impl MongoPool { op: &DataOperation, identity: &RequestIdentity, ) -> DataPlaneResult { + require_row_filter(op.filter.as_ref(), "delete")?; let filter = build_tenant_filter(op.filter.as_ref(), identity, &self.tenant_id)?; let result = col.delete_many(filter, None).await.map_err(mongo_err)?; Ok(DataResult { @@ -431,9 +757,203 @@ impl MongoPool { } fn mongo_err(e: mongodb::error::Error) -> DataPlaneError { - DataPlaneError::Backend { - message: format!("mongo backend: {e}"), + classify_mongo_message(format!("mongo backend: {e}")) +} + +/// Pure classifier behind [`mongo_err`] (testable without a driver error). +/// `$jsonSchema` validator rejections (server code 121 DocumentValidation- +/// Failure, "Document failed validation") and duplicate `_id` inserts (E11000 +/// duplicate key) are the CALLER's fault — their values don't fit the +/// declared contract — so they map to 409 Conflict, not an engine 5xx (which +/// would make outbox clients retry a write that can never succeed). +fn classify_mongo_message(message: String) -> DataPlaneError { + let lower = message.to_lowercase(); + if lower.contains("document failed validation") + || lower.contains("documentvalidationfailure") + || lower.contains("duplicate key error") + { + return DataPlaneError::Conflict { message }; + } + DataPlaneError::Backend { message } +} + +/// DDL-path error classifier (additive — the query path keeps [`mongo_err`]): +/// `createCollection` on an existing namespace is the caller's conflict +/// (409), and dropping / modifying a namespace that doesn't exist is a client +/// error (400), not an engine failure. +fn mongo_ddl_err(e: mongodb::error::Error) -> DataPlaneError { + classify_mongo_ddl_message(format!("mongo backend: {e}")) +} + +/// Pure message classifier behind [`mongo_ddl_err`] (testable without a +/// driver error). The server's NamespaceExists / NamespaceNotFound errors +/// carry these tokens in their message. +fn classify_mongo_ddl_message(message: String) -> DataPlaneError { + let lower = message.to_lowercase(); + if lower.contains("already exists") || lower.contains("namespaceexists") { + return DataPlaneError::Conflict { message }; + } + if lower.contains("ns not found") || lower.contains("namespacenotfound") { + return DataPlaneError::InvalidRequest { message }; + } + DataPlaneError::Backend { message } +} + +// ── M22 step 2: engine-agnostic schema DDL ($jsonSchema transforms, pure) ──── + +/// Whether [`jsonschema_with_column_set`] adds a new column (must NOT exist) +/// or alters an existing one (must exist). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ColumnMode { + Add, + Alter, +} + +/// The `bsonType` name for a creatable DDL column. The exact inverse of +/// [`bson_type_to_normalized`] over the creatable set; `objectid`/`unknown` +/// are describe-only and rejected (enums use `enum:` instead of `bsonType`). +fn ddl_bson_type(def: &DdlColumnDef) -> DataPlaneResult<&'static str> { + Ok(match def.normalized_type { + NormalizedType::Text | NormalizedType::Uuid => "string", + NormalizedType::Integer => "long", + NormalizedType::Float => "double", + NormalizedType::Decimal => "decimal", + NormalizedType::Boolean => "bool", + NormalizedType::Date | NormalizedType::Datetime => "date", + NormalizedType::Json => "object", + NormalizedType::Array => "array", + NormalizedType::Enum => "string", + NormalizedType::Objectid | NormalizedType::Unknown => { + return Err(DataPlaneError::InvalidRequest { + message: format!( + "column '{}': normalized_type '{:?}' cannot be created on mongodb", + def.name, def.normalized_type + ), + }) + } + }) +} + +/// One `$jsonSchema` property document for a DDL column. Nullable columns +/// declare `bsonType: [ty, "null"]` (absent OR explicit null both validate); +/// enum columns declare `enum: [...]` — exactly the shapes +/// [`jsonschema_to_columns`] reads back. +fn ddl_column_to_property(def: &DdlColumnDef) -> DataPlaneResult { + if def.normalized_type == NormalizedType::Enum { + let values = def + .enum_values + .as_deref() + .filter(|v| !v.is_empty()) + .ok_or_else(|| DataPlaneError::InvalidRequest { + message: format!("enum column '{}' requires non-empty enum_values", def.name), + })?; + return Ok(bson::doc! { "enum": values }); + } + let ty = ddl_bson_type(def)?; + Ok(if def.nullable { + bson::doc! { "bsonType": [ty, "null"] } + } else { + bson::doc! { "bsonType": ty } + }) +} + +/// Builds the full `$jsonSchema` for `create_table` from its columns, +/// auto-appending a nullable `owner_id` string when the caller didn't declare +/// one — matching the platform's owner-scoped write path (every Mongo write +/// injects `owner_id`/`tenant_id`). +fn columns_to_jsonschema(columns: &[DdlColumnDef]) -> DataPlaneResult { + let mut properties = Document::new(); + let mut required: Vec = Vec::new(); + let mut has_owner = false; + for def in columns { + if def.name == "owner_id" { + has_owner = true; + } + properties.insert(def.name.clone(), ddl_column_to_property(def)?); + if !def.nullable { + required.push(def.name.clone()); + } + } + if !has_owner { + properties.insert("owner_id", bson::doc! { "bsonType": ["string", "null"] }); + } + let mut schema = bson::doc! { "bsonType": "object", "properties": properties }; + if !required.is_empty() { + schema.insert("required", required); + } + Ok(schema) +} + +/// The `required` list of a `$jsonSchema`, as owned strings. +fn jsonschema_required(schema: &Document) -> Vec { + schema + .get_array("required") + .map(|arr| arr.iter().filter_map(Bson::as_str).map(str::to_string).collect()) + .unwrap_or_default() +} + +/// Returns a new `$jsonSchema` with `def` set (added or altered). `Add` +/// refuses an existing column (409 — same conflict PG raises for a duplicate +/// column); `Alter` refuses a missing one (400). +fn jsonschema_with_column_set( + schema: &Document, + def: &DdlColumnDef, + mode: ColumnMode, +) -> DataPlaneResult { + let mut out = schema.clone(); + let mut properties = out.get_document("properties").cloned().unwrap_or_default(); + let exists = properties.contains_key(&def.name); + match mode { + ColumnMode::Add if exists => { + return Err(DataPlaneError::Conflict { + message: format!("column '{}' already exists", def.name), + }) + } + ColumnMode::Alter if !exists => { + return Err(DataPlaneError::InvalidRequest { + message: format!("column '{}' does not exist", def.name), + }) + } + _ => {} + } + properties.insert(def.name.clone(), ddl_column_to_property(def)?); + out.insert("bsonType", "object"); + out.insert("properties", properties); + let mut required = jsonschema_required(&out); + required.retain(|r| r != &def.name); + if !def.nullable { + required.push(def.name.clone()); + } + if required.is_empty() { + out.remove("required"); + } else { + out.insert("required", required); + } + Ok(out) +} + +/// Returns a new `$jsonSchema` with `name` removed (property + required). +/// A missing column is a client error. +fn jsonschema_with_column_dropped(schema: &Document, name: &str) -> DataPlaneResult { + let mut out = schema.clone(); + let mut properties = out.get_document("properties").cloned().unwrap_or_default(); + if properties.remove(name).is_none() { + return Err(DataPlaneError::InvalidRequest { + message: format!("column '{name}' does not exist"), + }); + } + out.insert("bsonType", "object"); + out.insert("properties", properties); + let required: Vec = jsonschema_required(&out) + .into_iter() + .filter(|r| r != name) + .collect(); + if required.is_empty() { + out.remove("required"); + } else { + out.insert("required", required); } + Ok(out) } /// The per-tenant database name for a `schema_per_tenant` mount, or `None` @@ -505,6 +1025,26 @@ fn build_owned_doc( Ok(doc) } +/// No-full-collection guard for update/delete — parity with the relational +/// pools' "refusing full-table update" rule. The injected owner/tenant scope +/// is NOT row selectivity: without it, `filter: {}` rewrites every owned +/// document in one call. The filter must constrain on at least one field the +/// CLIENT chose (trust fields are stripped before querying, so they don't +/// count). +fn require_row_filter(filter: Option<&Value>, op_name: &str) -> DataPlaneResult<()> { + let selective = filter + .and_then(Value::as_object) + .is_some_and(|map| map.keys().any(|key| !FILTER_TRUST_FIELDS.contains(&key.as_str()))); + if selective { + return Ok(()); + } + Err(DataPlaneError::InvalidRequest { + message: format!( + "{op_name} requires a non-empty `filter` (refusing full-collection {op_name})" + ), + }) +} + /// Take the client filter (if any) and intersect it with the server-side /// tenant scope so an attacker cannot drop the predicate. fn build_tenant_filter( @@ -526,15 +1066,44 @@ fn build_tenant_filter( } None => Document::new(), }; - // Strip any client-provided override of the trust fields. - for field in RESERVED_FIELDS { + // Mongo only understands $and/$or/$nor at the TOP level; any other + // $-operator there (e.g. `$not`) is a driver error that would surface as + // an opaque 502 — fail it closed as the 400 it really is. + for key in doc.keys() { + if key.starts_with('$') && !matches!(key.as_str(), "$and" | "$or" | "$nor") { + return Err(DataPlaneError::InvalidRequest { + message: format!("filter operator '{key}' is not valid at the top level (use $and/$or/$nor)"), + }); + } + } + // Strip any client-provided override of the trust fields. `_id` passes + // through — it is how get/update/delete target one row (still ANDed with + // the server-trusted owner/tenant scope below). + for field in FILTER_TRUST_FIELDS { doc.remove(field); } + if let Some(id) = doc.remove("_id") { + doc.insert("_id", coerce_id_filter(id)); + } doc.insert("owner_id", MongoPool::owner(identity)); doc.insert("tenant_id", tenant_id.to_string()); Ok(doc) } +/// `_id` values round-trip as strings on the wire (`normalize_doc` hex-encodes +/// `ObjectId`s), so a client filtering on a 24-hex string may mean EITHER the +/// literal string `_id` (seeded data) or the ObjectId it encodes (driver- +/// assigned ids). Match both; everything else passes through unchanged. +fn coerce_id_filter(id: Bson) -> Bson { + match id { + Bson::String(s) => match bson::oid::ObjectId::parse_str(&s) { + Ok(oid) => bson::bson!({ "$in": [oid, s] }), + Err(_) => Bson::String(s), + }, + other => other, + } +} + fn build_sort(sort: Option<&std::collections::BTreeMap>) -> Option { let map = sort?; if map.is_empty() { @@ -574,6 +1143,88 @@ mod tests { use super::*; use serde_json::json; + fn probe_identity() -> RequestIdentity { + RequestIdentity { + tenant_id: "t1".to_string(), + project_id: None, + app_id: None, + user_id: Some("api-key:k1".to_string()), + roles: vec![], + scopes: vec![], + source: data_plane_core::IdentitySource::ServiceToken, + } + } + + #[test] + fn tenant_filter_preserves_id_and_enforces_trust_fields() { + // `_id` is the row selector and MUST survive: stripping it widened a + // by-pk update/delete to every owned document. Client attempts to spoof + // owner_id/tenant_id are still overridden by the verified identity. + let client = json!({ "_id": "evt-000001", "owner_id": "spoof", "tenant_id": "spoof" }); + let doc = build_tenant_filter(Some(&client), &probe_identity(), "t1").unwrap(); + assert_eq!(doc.get_str("_id").unwrap(), "evt-000001"); + assert_eq!(doc.get_str("owner_id").unwrap(), "api-key:k1"); + assert_eq!(doc.get_str("tenant_id").unwrap(), "t1"); + } + + #[test] + fn validator_rejections_classify_as_conflict() { + // `$jsonSchema` says no → the caller's values don't fit the declared + // contract: 409, never an opaque 502 (verified live before the fix). + let validation = classify_mongo_message( + "mongo backend: WriteError { code: 121, message: \"Document failed validation\" }".into()); + assert!(matches!(validation, DataPlaneError::Conflict { .. }), "{validation:?}"); + let dup = classify_mongo_message( + "mongo backend: E11000 duplicate key error collection: activity.notes".into()); + assert!(matches!(dup, DataPlaneError::Conflict { .. }), "{dup:?}"); + let other = classify_mongo_message("mongo backend: connection reset".into()); + assert!(matches!(other, DataPlaneError::Backend { .. }), "{other:?}"); + } + + #[test] + fn update_delete_require_a_selective_filter() { + // Parity with the relational no-full-table guard: `{}` (or trust-field + //-only filters, which are stripped anyway) must not mass-write every + // owned document. Verified live before the fix: filter {} modified 39 + // docs in one call. + for bad in [None, Some(json!({})), Some(json!({ "owner_id": "spoof" }))] { + let err = require_row_filter(bad.as_ref(), "update").unwrap_err(); + assert!(matches!(err, DataPlaneError::InvalidRequest { .. }), "{bad:?} → {err:?}"); + } + assert!(require_row_filter(Some(&json!({ "_id": "n-1" })), "update").is_ok()); + assert!(require_row_filter(Some(&json!({ "kind": "login" })), "delete").is_ok()); + } + + #[test] + fn tenant_filter_rejects_unknown_top_level_operators() { + // `$not` is operator-position-only in Mongo; at the top level the + // driver errors out (opaque 502) — fail closed as a 400 instead. + let bad = json!({ "$not": { "kind": { "$in": ["login"] } } }); + let err = build_tenant_filter(Some(&bad), &probe_identity(), "t1").unwrap_err(); + assert!(matches!(err, DataPlaneError::InvalidRequest { .. }), "{err:?}"); + // The real top-level combinators still pass. + let ok = json!({ "$or": [{ "kind": "login" }, { "kind": "search" }] }); + assert!(build_tenant_filter(Some(&ok), &probe_identity(), "t1").is_ok()); + } + + #[test] + fn tenant_filter_coerces_objectid_hex_to_dual_match() { + // Wire `_id`s are strings (normalize_doc hex-encodes ObjectIds), so a + // 24-hex value must match both the ObjectId and the literal string. + let hex = "665f1e2a9b3c4d5e6f708192"; + let client = json!({ "_id": hex }); + let doc = build_tenant_filter(Some(&client), &probe_identity(), "t1").unwrap(); + let id = doc.get_document("_id").unwrap(); + let candidates = id.get_array("$in").unwrap(); + let oid = bson::oid::ObjectId::parse_str(hex).unwrap(); + assert!(candidates.contains(&Bson::ObjectId(oid))); + assert!(candidates.contains(&Bson::String(hex.to_string()))); + // Non-hex pk strings stay literal (seeded ids like `evt-000001`). + let plain = build_tenant_filter(Some(&json!({ "_id": "evt-1" })), &probe_identity(), "t1") + .unwrap(); + assert_eq!(plain.get_str("_id").unwrap(), "evt-1"); + } + #[test] fn rejects_javascript_and_expression_operators() { // The NoSQL-injection fix: code/expression operators are refused, at any @@ -631,4 +1282,257 @@ mod tests { // ordinary and dotted (nested-path) keys are allowed. assert!(reject_top_level_operators(&json!({ "name": "x", "profile.age": 3 })).is_ok()); } + + // --- M22 schema introspection: pure mappers --- + + #[test] + fn jsonschema_maps_declared_columns_exactly() { + let schema = bson::doc! { + "bsonType": "object", + "required": ["name", "qty"], + "properties": { + "name": { "bsonType": "string" }, + "qty": { "bsonType": "int" }, + "price": { "bsonType": "decimal" }, + "tags": { "bsonType": "array" }, + "meta": { "bsonType": "object" }, + "created_at": { "bsonType": "date" }, + "owner": { "bsonType": "objectId" }, + "active": { "bsonType": "bool" }, + "ratio": { "bsonType": "double" }, + "big": { "bsonType": "long" }, + } + }; + let cols = jsonschema_to_columns(&schema); + let by_name = |n: &str| cols.iter().find(|c| c.name == n).unwrap_or_else(|| panic!("{n}")); + use NormalizedType as N; + for (name, native, normalized, nullable) in [ + ("name", "string", N::Text, false), + ("qty", "int", N::Integer, false), + ("price", "decimal", N::Decimal, true), + ("tags", "array", N::Array, true), + ("meta", "object", N::Json, true), + ("created_at", "date", N::Datetime, true), + ("owner", "objectId", N::Objectid, true), + ("active", "bool", N::Boolean, true), + ("ratio", "double", N::Float, true), + ("big", "long", N::Integer, true), + ] { + let col = by_name(name); + assert_eq!(col.native_type, native, "{name}"); + assert_eq!(col.normalized_type, normalized, "{name}"); + assert_eq!(col.nullable, nullable, "{name}"); + assert!(!col.inferred, "{name}: jsonSchema columns are declared, not inferred"); + assert!(col.references.is_none() && col.default.is_none(), "{name}"); + } + } + + #[test] + fn jsonschema_enum_and_nullable_type_arrays() { + let schema = bson::doc! { + "bsonType": "object", + "required": ["status", "note"], + "properties": { + "status": { "enum": ["pending", "paid"] }, + // ["string","null"] → string but nullable, even though required. + "note": { "bsonType": ["string", "null"] }, + } + }; + let cols = jsonschema_to_columns(&schema); + let status = cols.iter().find(|c| c.name == "status").unwrap(); + assert_eq!(status.normalized_type, NormalizedType::Enum); + assert_eq!( + status.enum_values, + Some(vec!["pending".to_string(), "paid".to_string()]) + ); + let note = cols.iter().find(|c| c.name == "note").unwrap(); + assert_eq!(note.normalized_type, NormalizedType::Text); + assert!(note.nullable, "a 'null' bsonType entry means nullable"); + // No properties → no columns (never a panic). + assert!(jsonschema_to_columns(&bson::doc! { "bsonType": "object" }).is_empty()); + } + + #[test] + fn sample_inference_majority_type_and_nullability() { + let docs = vec![ + bson::doc! { "n": 1_i32, "s": "a", "maybe": Bson::Null }, + bson::doc! { "n": 2_i32, "s": "b" }, + bson::doc! { "n": "three", "s": "c", "maybe": 5_i32 }, + ]; + let cols = infer_columns_from_samples(&docs); + let by_name = |n: &str| cols.iter().find(|c| c.name == n).unwrap(); + // Majority of `n` values are int. + let n = by_name("n"); + assert_eq!(n.normalized_type, NormalizedType::Integer); + assert_eq!(n.native_type, "int"); + assert!(!n.nullable, "present in every doc, never null"); + assert!(n.inferred, "sample-based columns are inferred"); + // `maybe` is missing from one doc AND null in another → nullable. + assert!(by_name("maybe").nullable); + // Empty sample set → no columns. + assert!(infer_columns_from_samples(&[]).is_empty()); + } + + // --- M22 step 2: schema DDL — pure $jsonSchema transforms --- + + use data_plane_core::DdlColumnDef; + + fn col(name: &str, ty: NormalizedType, nullable: bool) -> DdlColumnDef { + DdlColumnDef { + name: name.to_string(), + normalized_type: ty, + nullable, + default: None, + enum_values: None, + } + } + + #[test] + fn ddl_property_mapping_golden_table() { + use NormalizedType as N; + for (ty, bson_ty) in [ + (N::Text, "string"), + (N::Uuid, "string"), + (N::Integer, "long"), + (N::Float, "double"), + (N::Decimal, "decimal"), + (N::Boolean, "bool"), + (N::Date, "date"), + (N::Datetime, "date"), + (N::Json, "object"), + (N::Array, "array"), + ] { + assert_eq!( + ddl_column_to_property(&col("c", ty, false)).unwrap(), + bson::doc! { "bsonType": bson_ty }, + "{ty:?}" + ); + // nullable columns accept null explicitly (bsonType array). + assert_eq!( + ddl_column_to_property(&col("c", ty, true)).unwrap(), + bson::doc! { "bsonType": [bson_ty, "null"] }, + "nullable {ty:?}" + ); + } + // enum → `enum:` (no bsonType), and requires values. + let mut status = col("status", NormalizedType::Enum, false); + status.enum_values = Some(vec!["a".into(), "b".into()]); + assert_eq!( + ddl_column_to_property(&status).unwrap(), + bson::doc! { "enum": ["a", "b"] } + ); + assert!(ddl_column_to_property(&col("status", NormalizedType::Enum, false)).is_err()); + // describe-only types are rejected. + for ty in [NormalizedType::Objectid, NormalizedType::Unknown] { + assert!(matches!( + ddl_column_to_property(&col("c", ty, false)).unwrap_err(), + DataPlaneError::InvalidRequest { .. } + )); + } + } + + #[test] + fn create_table_jsonschema_appends_owner_and_round_trips_describe() { + let columns = vec![ + col("name", NormalizedType::Text, false), + col("qty", NormalizedType::Integer, true), + ]; + let schema = columns_to_jsonschema(&columns).unwrap(); + assert_eq!( + schema, + bson::doc! { + "bsonType": "object", + "properties": { + "name": { "bsonType": "string" }, + "qty": { "bsonType": ["long", "null"] }, + "owner_id": { "bsonType": ["string", "null"] }, + }, + "required": ["name"], + } + ); + // Round trip through the M22 describe mapper: DDL writes exactly the + // shapes describe_schema reads back. + let described = jsonschema_to_columns(&schema); + let by_name = |n: &str| described.iter().find(|c| c.name == n).unwrap(); + assert_eq!(by_name("name").normalized_type, NormalizedType::Text); + assert!(!by_name("name").nullable); + assert_eq!(by_name("qty").normalized_type, NormalizedType::Integer); + assert!(by_name("qty").nullable); + assert!(by_name("owner_id").nullable); + // An explicit owner_id is respected, never duplicated. + let explicit = columns_to_jsonschema(&[col("owner_id", NormalizedType::Text, false)]).unwrap(); + let props = explicit.get_document("properties").unwrap(); + assert_eq!(props.get_document("owner_id").unwrap(), &bson::doc! { "bsonType": "string" }); + } + + #[test] + fn jsonschema_add_alter_drop_column_transforms() { + let base = columns_to_jsonschema(&[col("name", NormalizedType::Text, false)]).unwrap(); + + // add: new column lands in properties (+required when non-nullable). + let added = jsonschema_with_column_set( + &base, + &col("qty", NormalizedType::Integer, false), + ColumnMode::Add, + ) + .unwrap(); + assert!(added.get_document("properties").unwrap().contains_key("qty")); + assert_eq!(jsonschema_required(&added), vec!["name", "qty"]); + // add of an existing column is a 409 conflict. + assert!(matches!( + jsonschema_with_column_set(&base, &col("name", NormalizedType::Text, true), ColumnMode::Add) + .unwrap_err(), + DataPlaneError::Conflict { .. } + )); + + // alter: full target def replaces the property AND nullability. + let altered = jsonschema_with_column_set( + &added, + &col("qty", NormalizedType::Text, true), + ColumnMode::Alter, + ) + .unwrap(); + assert_eq!( + altered.get_document("properties").unwrap().get_document("qty").unwrap(), + &bson::doc! { "bsonType": ["string", "null"] } + ); + assert_eq!(jsonschema_required(&altered), vec!["name"], "now nullable → not required"); + // alter of a missing column is a 400. + assert!(matches!( + jsonschema_with_column_set(&base, &col("ghost", NormalizedType::Text, true), ColumnMode::Alter) + .unwrap_err(), + DataPlaneError::InvalidRequest { .. } + )); + + // drop: property + required entry removed; missing column is a 400. + let dropped = jsonschema_with_column_dropped(&added, "qty").unwrap(); + assert!(!dropped.get_document("properties").unwrap().contains_key("qty")); + assert_eq!(jsonschema_required(&dropped), vec!["name"]); + assert!(matches!( + jsonschema_with_column_dropped(&added, "ghost").unwrap_err(), + DataPlaneError::InvalidRequest { .. } + )); + // dropping the LAST required column removes the (must-be-non-empty) + // `required` key entirely instead of leaving an invalid empty array. + let only = columns_to_jsonschema(&[col("name", NormalizedType::Text, false)]).unwrap(); + let none_required = jsonschema_with_column_dropped(&only, "name").unwrap(); + assert!(!none_required.contains_key("required")); + } + + #[test] + fn mongo_ddl_error_classifier_maps_namespace_errors() { + // Pure classification by message (the live errors carry these tokens). + assert!(matches!( + classify_mongo_ddl_message("Collection already exists. NS: db.t".into()), + DataPlaneError::Conflict { .. } + )); + assert!(matches!( + classify_mongo_ddl_message("Command failed: ns not found".into()), + DataPlaneError::InvalidRequest { .. } + )); + assert!(matches!( + classify_mongo_ddl_message("socket closed".into()), + DataPlaneError::Backend { .. } + )); + } } diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/mysql.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/mysql.rs index e433ef33..9d85dbac 100644 --- a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/mysql.rs +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/mysql.rs @@ -28,9 +28,11 @@ use crate::ident::quote_mysql_ident; use crate::resolver::MountResolver; use async_trait::async_trait; use data_plane_core::{ - CmpOp, DataOperation, DataOperationKind, DataPlaneError, DataPlaneResult, DataResult, - DatabaseMount, EngineAdapter, EngineCapabilities, EngineHealth, EnginePool, Filter, Folded, - MigrationRequest, MigrationResult, MigrationStatus, RawStatement, RequestIdentity, ScopeDirective, + validate_default_expr, CmpOp, ColumnSchema, DataOperation, DataOperationKind, DataPlaneError, + DataPlaneResult, DataResult, DatabaseMount, DdlColumnDef, EngineAdapter, EngineCapabilities, + EngineHealth, EnginePool, Filter, Folded, ForeignKeyRef, MigrationRequest, MigrationResult, + MigrationStatus, NormalizedType, RawStatement, RequestIdentity, SchemaDdlOp, SchemaDdlRequest, + SchemaDdlResult, SchemaDdlStatus, SchemaDescriptor, ScopeDirective, TableSchema, TxBeginRequest, TxHandle, }; use mysql_async::prelude::Queryable; @@ -400,6 +402,307 @@ impl EnginePool for MysqlPool { statements_run: run, }) } + + /// Engine-agnostic schema introspection (M22). Reads + /// `information_schema.COLUMNS` (+ `KEY_COLUMN_USAGE` for PK/FK), scoped to + /// the database the connection is on (`TABLE_SCHEMA = DATABASE()`): a + /// `schema_per_tenant` mount introspects its per-tenant database (pinned by + /// `select_namespace`, same as the request path); shared_rls / + /// db_per_tenant introspect the DSN-default database. Excludes the + /// `_baas_migrations` marker table. + async fn describe_schema( + &self, + identity: RequestIdentity, + ) -> DataPlaneResult { + if identity.tenant_id != self.tenant_id { + return Err(DataPlaneError::Backend { + message: "identity tenant does not match pool tenant".into(), + }); + } + let mut conn = self.pool.get_conn().await.map_err(backend)?; + self.select_namespace(&mut conn).await?; + + // Primary keys, per table, in key ordinal order. + let pk_rows: Vec<(String, String)> = conn + .query( + "SELECT TABLE_NAME, COLUMN_NAME \ + FROM information_schema.KEY_COLUMN_USAGE \ + WHERE TABLE_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'PRIMARY' \ + ORDER BY TABLE_NAME, ORDINAL_POSITION", + ) + .await + .map_err(backend)?; + let mut pks: BTreeMap> = BTreeMap::new(); + for (table, column) in pk_rows { + pks.entry(table).or_default().push(column); + } + + // Foreign keys: (table, column) → referenced (table, column). + let fk_rows: Vec<(String, String, String, String)> = conn + .query( + "SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME \ + FROM information_schema.KEY_COLUMN_USAGE \ + WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL", + ) + .await + .map_err(backend)?; + let mut fks: BTreeMap<(String, String), ForeignKeyRef> = BTreeMap::new(); + for (table, column, ref_table, ref_column) in fk_rows { + fks.insert((table, column), ForeignKeyRef { table: ref_table, column: ref_column }); + } + + // Columns of every BASE TABLE on the connected database. + let col_rows: Vec<(String, String, String, String, Option)> = conn + .query( + "SELECT c.TABLE_NAME, c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT \ + FROM information_schema.COLUMNS c \ + JOIN information_schema.TABLES t \ + ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME \ + WHERE c.TABLE_SCHEMA = DATABASE() \ + AND t.TABLE_TYPE = 'BASE TABLE' \ + AND c.TABLE_NAME <> '_baas_migrations' \ + ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION", + ) + .await + .map_err(backend)?; + let mut tables: BTreeMap> = BTreeMap::new(); + for (table, name, column_type, is_nullable, default) in col_rows { + let (normalized_type, enum_values) = normalize_mysql_type(&column_type); + let references = fks.get(&(table.clone(), name.clone())).cloned(); + tables.entry(table).or_default().push(ColumnSchema { + name, + native_type: column_type, + normalized_type, + nullable: is_nullable.eq_ignore_ascii_case("yes"), + default, + enum_values, + references, + inferred: false, + }); + } + + Ok(SchemaDescriptor { + engine: "mysql".to_string(), + tables: tables + .into_iter() + .map(|(name, columns)| TableSchema { + primary_key: pks.remove(&name).unwrap_or_default(), + name, + columns, + }) + .collect(), + }) + } + + /// Engine-agnostic schema DDL (M22 step 2). Lowered to ONE statement by + /// the pure [`build_mysql_ddl`] builder and executed on the same + /// namespace the request path uses (`select_namespace` pins the + /// per-tenant database for schema_per_tenant; DSN-default otherwise). + /// MySQL DDL is auto-commit — exactly why the contract is single-op: + /// there is no multi-statement atomicity to fake. Unlike the admin-gated + /// `apply_migration`, this path never issues `CREATE DATABASE`: a + /// schema_per_tenant namespace must already be provisioned. + async fn apply_schema_ddl( + &self, + ddl: SchemaDdlRequest, + identity: RequestIdentity, + ) -> DataPlaneResult { + if identity.tenant_id != self.tenant_id { + return Err(DataPlaneError::Backend { + message: "identity tenant does not match pool tenant".into(), + }); + } + let stmt = build_mysql_ddl(&ddl)?; + let mut conn = self.pool.get_conn().await.map_err(backend)?; + self.select_namespace(&mut conn).await?; + conn.query_drop(stmt).await.map_err(ddl_backend)?; + Ok(SchemaDdlResult { + op: ddl.op, + table: ddl.table, + status: SchemaDdlStatus::Applied, + }) + } +} + +/// Maps a MySQL `COLUMN_TYPE` (the full rendered type, e.g. `varchar(255)`, +/// `enum('a','b')`, `tinyint(1) unsigned`) to the engine-neutral +/// [`NormalizedType`], returning the parsed enum labels for `enum(...)` types. +/// Pure — testable without a DB. +pub(crate) fn normalize_mysql_type(column_type: &str) -> (NormalizedType, Option>) { + let lower = column_type.trim().to_ascii_lowercase(); + if lower.starts_with("enum(") { + return (NormalizedType::Enum, Some(parse_mysql_enum_values(column_type))); + } + // `tinyint(1)` (the MySQL boolean convention) before the generic int arm. + if lower == "tinyint(1)" || lower.starts_with("tinyint(1) ") { + return (NormalizedType::Boolean, None); + } + let base = lower.split(['(', ' ']).next().unwrap_or(""); + let ty = match base { + "int" | "integer" | "bigint" | "smallint" | "mediumint" | "tinyint" => { + NormalizedType::Integer + } + "float" | "double" => NormalizedType::Float, + "decimal" | "numeric" => NormalizedType::Decimal, + "date" => NormalizedType::Date, + "datetime" | "timestamp" => NormalizedType::Datetime, + "json" => NormalizedType::Json, + "char" | "varchar" | "text" | "tinytext" | "mediumtext" | "longtext" => { + NormalizedType::Text + } + _ => NormalizedType::Unknown, + }; + (ty, None) +} + +// ── M22 step 2: engine-agnostic schema DDL (pure SQL builders) ─────────────── +// +// Pure (testable without a DB). Identifiers via `quote_mysql_ident`; enum +// VALUES are escaped string literals (`mysql_literal`, which doubles quotes +// AND escapes backslash — MySQL's default sql_mode treats `\` as an escape); +// caller DEFAULT expressions pass the shared `validate_default_expr` guard. + +/// `'…'`-quoted MySQL string literal. +fn mysql_literal(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''")) +} + +/// Reverse type mapping (the inverse of [`normalize_mysql_type`]): lowers a +/// [`DdlColumnDef`] to its MySQL column type SQL. `in_primary_key` switches +/// text to `VARCHAR(255)` — `TEXT` cannot be a PK without a prefix length. +/// `objectid`/`unknown` are describe-only and rejected. +pub(crate) fn mysql_sql_type(def: &DdlColumnDef, in_primary_key: bool) -> DataPlaneResult { + Ok(match def.normalized_type { + NormalizedType::Text => { + if in_primary_key { "VARCHAR(255)".to_string() } else { "TEXT".to_string() } + } + NormalizedType::Integer => "BIGINT".to_string(), + NormalizedType::Float => "DOUBLE".to_string(), + NormalizedType::Decimal => "DECIMAL(18,6)".to_string(), + NormalizedType::Boolean => "TINYINT(1)".to_string(), + NormalizedType::Date => "DATE".to_string(), + NormalizedType::Datetime => "DATETIME".to_string(), + NormalizedType::Json => "JSON".to_string(), + NormalizedType::Uuid => "CHAR(36)".to_string(), + // v1: arrays land in JSON (MySQL has no array type). + NormalizedType::Array => "JSON".to_string(), + NormalizedType::Enum => { + let values = def + .enum_values + .as_deref() + .filter(|v| !v.is_empty()) + .ok_or_else(|| DataPlaneError::InvalidRequest { + message: format!("enum column '{}' requires non-empty enum_values", def.name), + })?; + let literals: Vec = values.iter().map(|v| mysql_literal(v)).collect(); + format!("ENUM({})", literals.join(", ")) + } + NormalizedType::Objectid | NormalizedType::Unknown => { + return Err(DataPlaneError::InvalidRequest { + message: format!( + "column '{}': normalized_type '{:?}' cannot be created on mysql", + def.name, def.normalized_type + ), + }) + } + }) +} + +/// One full column clause: `` `name` TYPE NULL|NOT NULL [DEFAULT expr] ``. +/// Nullability is ALWAYS rendered (`NULL` explicitly) because `MODIFY COLUMN` +/// resets every attribute — the caller sends the full target def precisely so +/// nothing is silently lost. +fn mysql_column_clause(def: &DdlColumnDef, in_primary_key: bool) -> DataPlaneResult { + let col = quote_mysql_ident(&def.name)?; + let ty = mysql_sql_type(def, in_primary_key)?; + let mut clause = format!("{col} {ty} {}", if def.nullable { "NULL" } else { "NOT NULL" }); + if let Some(default) = def.default.as_deref() { + validate_default_expr(default)?; + clause.push_str(&format!(" DEFAULT {default}")); + } + Ok(clause) +} + +/// Lowers a [`SchemaDdlRequest`] to its single MySQL DDL statement +/// (namespace selection happens at the connection via `USE`, mirroring the +/// request path, so statements stay unqualified). +pub(crate) fn build_mysql_ddl(ddl: &SchemaDdlRequest) -> DataPlaneResult { + let table = quote_mysql_ident(&ddl.table)?; + Ok(match ddl.op { + SchemaDdlOp::AddColumn => format!( + "ALTER TABLE {table} ADD COLUMN {}", + mysql_column_clause(ddl.require_column()?, false)? + ), + SchemaDdlOp::DropColumn => format!( + "ALTER TABLE {table} DROP COLUMN {}", + quote_mysql_ident(ddl.require_column_name()?)? + ), + // MODIFY COLUMN resets attributes — full target def by contract. + SchemaDdlOp::AlterColumnType => format!( + "ALTER TABLE {table} MODIFY COLUMN {}", + mysql_column_clause(ddl.require_column()?, false)? + ), + SchemaDdlOp::CreateTable => { + let (columns, primary_key) = ddl.require_create_spec()?; + let pk_set: std::collections::BTreeSet<&str> = + primary_key.iter().map(String::as_str).collect(); + let mut clauses = Vec::with_capacity(columns.len() + 2); + let mut has_owner = false; + for def in columns { + if def.name == "owner_id" { + has_owner = true; + } + clauses.push(mysql_column_clause(def, pk_set.contains(def.name.as_str()))?); + } + if !has_owner { + // The MySQL adapter owner-scopes every read/write on owner_id + // — a table without the column would fail its first request. + // VARCHAR(64), not CHAR(36): API-key principals are the + // synthetic `api-key:` string (44 chars), not a uuid. + clauses.push(format!("{} VARCHAR(64)", quote_mysql_ident("owner_id")?)); + } + let pk: Vec = primary_key + .iter() + .map(|c| quote_mysql_ident(c)) + .collect::>()?; + clauses.push(format!("PRIMARY KEY ({})", pk.join(", "))); + format!("CREATE TABLE {table} ({})", clauses.join(", ")) + } + SchemaDdlOp::DropTable => format!("DROP TABLE {table}"), + }) +} + +/// Parses the labels out of a MySQL `enum('a','b','it''s')` COLUMN_TYPE. +/// Handles the `''` escape for a literal quote. Pure helper for +/// [`normalize_mysql_type`]. +fn parse_mysql_enum_values(column_type: &str) -> Vec { + let inner = column_type + .find('(') + .and_then(|start| column_type.rfind(')').map(|end| &column_type[start + 1..end])) + .unwrap_or(""); + let mut values = Vec::new(); + let mut current = String::new(); + let mut in_quote = false; + let mut chars = inner.chars().peekable(); + while let Some(c) = chars.next() { + if in_quote { + if c == '\'' { + if chars.peek() == Some(&'\'') { + current.push('\''); + chars.next(); + } else { + in_quote = false; + values.push(std::mem::take(&mut current)); + } + } else { + current.push(c); + } + } else if c == '\'' { + in_quote = true; + } + // Anything outside quotes (commas, spaces) is a separator — skipped. + } + values } /// Pinned MySQL transaction. Holds the checked-out connection across @@ -697,14 +1000,55 @@ async fn run_upsert( // ── shared helpers ────────────────────────────────────────────────────────── fn backend(e: E) -> DataPlaneError { - let message = format!("mysql backend: {e}"); - // Best-effort integrity-violation detection from the server message (the - // generic helper only has the Display text): 1062 "Duplicate entry", 1452 - // foreign-key failure → a client error (409 Conflict), not an engine 5xx. + classify_mysql_error(format!("mysql backend: {e}"), false) +} + +/// DDL-path variant of [`backend`]: additionally maps the "existing data is +/// incompatible with the new type" server errors raised by `MODIFY COLUMN` +/// — 1265 "Data truncated", 1366 "Incorrect value", 1292 "Truncated +/// incorrect value" — to a 409 Conflict. Scoped to the DDL path only +/// (additive): the query path keeps [`backend`]'s existing mapping. +fn ddl_backend(e: E) -> DataPlaneError { + classify_mysql_error(format!("mysql backend: {e}"), true) +} + +/// Best-effort integrity-violation detection from the server message (the +/// generic helper only has the Display text): 1062 "Duplicate entry", 1452 +/// foreign-key failure → a client error (409 Conflict), not an engine 5xx. +/// Truncation/cast errors (1265/1292/1366 — bad enum value, unparseable +/// date) and 1264 "Out of range" classify as Conflict on BOTH paths: on DDL +/// they mean the table's data conflicts with the requested type, on writes +/// they mean the caller's VALUE doesn't fit the column — either way the +/// caller's fault, and a 5xx would make outbox clients retry a write that +/// can never succeed. +fn classify_mysql_error(message: String, ddl: bool) -> DataPlaneError { let lower = message.to_lowercase(); if lower.contains("duplicate entry") || lower.contains("foreign key constraint fails") { return DataPlaneError::Conflict { message }; } + if lower.contains("data truncated") + || lower.contains("truncated incorrect") + || lower.contains("out of range value") + || (lower.contains("incorrect") && lower.contains("value")) + { + return DataPlaneError::Conflict { message }; + } + if ddl { + // Schema-shape mistakes are deterministic client errors — a 5xx makes + // outbox-style clients retry a request that can never succeed. + // 1060 "Duplicate column name" / 1050 "Table … already exists" → 409; + // 1054 "Unknown column" / 1091 "Can't DROP …; check that column/key + // exists" / 1146 "Table … doesn't exist" → 400. + if lower.contains("duplicate column name") || lower.contains("already exists") { + return DataPlaneError::Conflict { message }; + } + if lower.contains("unknown column") + || lower.contains("check that column/key exists") + || lower.contains("doesn't exist") + { + return DataPlaneError::InvalidRequest { message }; + } + } DataPlaneError::Backend { message } } @@ -1059,6 +1403,22 @@ mod tests { } } + #[test] + fn ddl_errors_classify_schema_shape_mistakes_as_client_errors() { + // Deterministic user errors must be 4xx — a 5xx makes outbox clients + // retry a doomed request forever (poison-pill). + let dup = classify_mysql_error("Duplicate column name 'status'".into(), true); + assert!(matches!(dup, DataPlaneError::Conflict { .. }), "{dup:?}"); + let missing = classify_mysql_error( + "Can't DROP 'ghost'; check that column/key exists".into(), true); + assert!(matches!(missing, DataPlaneError::InvalidRequest { .. }), "{missing:?}"); + let no_table = classify_mysql_error("Table 'ops.ghost' doesn't exist".into(), true); + assert!(matches!(no_table, DataPlaneError::InvalidRequest { .. }), "{no_table:?}"); + // The query (non-DDL) path keeps its existing Backend mapping. + let query_path = classify_mysql_error("Unknown column 'x' in 'field list'".into(), false); + assert!(matches!(query_path, DataPlaneError::Backend { .. }), "{query_path:?}"); + } + #[test] fn owner_filter_always_injects_owner_predicate() { let id = identity_with(Some("u-1")); @@ -1268,4 +1628,242 @@ mod tests { let ns = resolve_namespace(&mk(Some("schema_per_tenant"))).unwrap(); assert!(ns.starts_with("tenant_t_1_"), "{ns}"); } + + // --- M22 schema introspection: pure type normalizer (golden table) --- + + #[test] + fn normalize_mysql_type_golden_table() { + use NormalizedType as N; + for (native, expected) in [ + ("int", N::Integer), + ("int(11)", N::Integer), + ("bigint(20) unsigned", N::Integer), + ("smallint", N::Integer), + ("tinyint(4)", N::Integer), + ("tinyint(1)", N::Boolean), // the MySQL boolean convention + ("tinyint(1) unsigned", N::Boolean), + ("float", N::Float), + ("double", N::Float), + ("decimal(10,2)", N::Decimal), + ("date", N::Date), + ("datetime", N::Datetime), + ("timestamp", N::Datetime), + ("json", N::Json), + ("char(36)", N::Text), + ("varchar(255)", N::Text), + ("text", N::Text), + ("blob", N::Unknown), + ("geometry", N::Unknown), + ] { + let (ty, values) = normalize_mysql_type(native); + assert_eq!(ty, expected, "COLUMN_TYPE {native}"); + assert_eq!(values, None, "{native} carries no enum values"); + } + } + + #[test] + fn normalize_mysql_type_parses_enum_values() { + let (ty, values) = normalize_mysql_type("enum('pending','paid','shipped','cancelled')"); + assert_eq!(ty, NormalizedType::Enum); + assert_eq!( + values, + Some(vec![ + "pending".to_string(), + "paid".to_string(), + "shipped".to_string(), + "cancelled".to_string(), + ]) + ); + // Quote escaping (`''` → literal quote) and case-insensitive keyword. + let (ty, values) = normalize_mysql_type("ENUM('it''s','b')"); + assert_eq!(ty, NormalizedType::Enum); + assert_eq!(values, Some(vec!["it's".to_string(), "b".to_string()])); + // Single value, no trailing garbage. + let (_, values) = normalize_mysql_type("enum('only')"); + assert_eq!(values, Some(vec!["only".to_string()])); + } + + // --- M22 step 2: schema DDL — pure SQL builders (golden tables) --- + + use data_plane_core::{DdlColumnDef, SchemaDdlOp, SchemaDdlRequest}; + + fn col(name: &str, ty: NormalizedType) -> DdlColumnDef { + DdlColumnDef { + name: name.to_string(), + normalized_type: ty, + nullable: true, + default: None, + enum_values: None, + } + } + + fn ddl(op: SchemaDdlOp, table: &str) -> SchemaDdlRequest { + SchemaDdlRequest { + op, + table: table.to_string(), + column: None, + column_name: None, + columns: None, + primary_key: None, + } + } + + #[test] + fn mysql_sql_type_golden_table() { + use NormalizedType as N; + for (ty, expected) in [ + (N::Text, "TEXT"), + (N::Integer, "BIGINT"), + (N::Float, "DOUBLE"), + (N::Decimal, "DECIMAL(18,6)"), + (N::Boolean, "TINYINT(1)"), + (N::Date, "DATE"), + (N::Datetime, "DATETIME"), + (N::Json, "JSON"), + (N::Uuid, "CHAR(36)"), + (N::Array, "JSON"), + ] { + assert_eq!(mysql_sql_type(&col("c", ty), false).unwrap(), expected, "{ty:?}"); + } + // text inside a PRIMARY KEY needs a bounded type. + assert_eq!(mysql_sql_type(&col("c", N::Text), true).unwrap(), "VARCHAR(255)"); + // enum values are escaped literals (quote doubled, backslash escaped). + let mut status = col("status", N::Enum); + status.enum_values = Some(vec!["pending".into(), "it's".into(), "a\\b".into()]); + assert_eq!( + mysql_sql_type(&status, false).unwrap(), + "ENUM('pending', 'it''s', 'a\\\\b')" + ); + // enum without values, and describe-only types, are client errors. + assert!(mysql_sql_type(&col("c", N::Enum), false).is_err()); + for ty in [N::Objectid, N::Unknown] { + assert!(matches!( + mysql_sql_type(&col("c", ty), false).unwrap_err(), + DataPlaneError::InvalidRequest { .. } + )); + } + } + + #[test] + fn mysql_ddl_statements_golden() { + // add_column with full attributes + let mut add = ddl(SchemaDdlOp::AddColumn, "orders"); + add.column = Some(DdlColumnDef { + name: "qty".into(), + normalized_type: NormalizedType::Integer, + nullable: false, + default: Some("0".into()), + enum_values: None, + }); + assert_eq!( + build_mysql_ddl(&add).unwrap(), + "ALTER TABLE `orders` ADD COLUMN `qty` BIGINT NOT NULL DEFAULT 0" + ); + // drop_column + let mut drop_col = ddl(SchemaDdlOp::DropColumn, "orders"); + drop_col.column_name = Some("qty".into()); + assert_eq!( + build_mysql_ddl(&drop_col).unwrap(), + "ALTER TABLE `orders` DROP COLUMN `qty`" + ); + // alter_column_type → MODIFY with the FULL def (nullability explicit, + // because MODIFY resets attributes). + let mut alter = ddl(SchemaDdlOp::AlterColumnType, "orders"); + alter.column = Some(col("note", NormalizedType::Text)); + assert_eq!( + build_mysql_ddl(&alter).unwrap(), + "ALTER TABLE `orders` MODIFY COLUMN `note` TEXT NULL" + ); + // drop_table + assert_eq!( + build_mysql_ddl(&ddl(SchemaDdlOp::DropTable, "orders")).unwrap(), + "DROP TABLE `orders`" + ); + } + + #[test] + fn mysql_ddl_create_table_appends_owner_and_uses_varchar_pk() { + let mut create = ddl(SchemaDdlOp::CreateTable, "orders"); + create.columns = Some(vec![ + DdlColumnDef { + name: "sku".into(), + normalized_type: NormalizedType::Text, + nullable: false, + default: None, + enum_values: None, + }, + col("note", NormalizedType::Text), + ]); + create.primary_key = Some(vec!["sku".into()]); + assert_eq!( + build_mysql_ddl(&create).unwrap(), + "CREATE TABLE `orders` (`sku` VARCHAR(255) NOT NULL, `note` TEXT NULL, \ + `owner_id` VARCHAR(64), PRIMARY KEY (`sku`))" + ); + // explicit owner_id is respected, not duplicated. + let mut explicit = ddl(SchemaDdlOp::CreateTable, "orders"); + explicit.columns = Some(vec![ + DdlColumnDef { + name: "id".into(), + normalized_type: NormalizedType::Integer, + nullable: false, + default: None, + enum_values: None, + }, + col("owner_id", NormalizedType::Uuid), + ]); + explicit.primary_key = Some(vec!["id".into()]); + let sql = build_mysql_ddl(&explicit).unwrap(); + assert_eq!(sql.matches("owner_id").count(), 1, "{sql}"); + } + + #[test] + fn mysql_ddl_rejects_injection_and_unsafe_defaults() { + assert!(matches!( + build_mysql_ddl(&ddl(SchemaDdlOp::DropTable, "orders`; DROP TABLE x")).unwrap_err(), + DataPlaneError::InvalidIdentifier { .. } + )); + let mut bad_default = ddl(SchemaDdlOp::AddColumn, "orders"); + bad_default.column = Some(DdlColumnDef { + name: "c".into(), + normalized_type: NormalizedType::Text, + nullable: true, + default: Some("'x'; DROP TABLE orders".into()), + enum_values: None, + }); + assert!(matches!( + build_mysql_ddl(&bad_default).unwrap_err(), + DataPlaneError::InvalidRequest { .. } + )); + } + + #[test] + fn mysql_error_classifier_maps_ddl_cast_errors_to_conflict() { + // Truncation / incorrect-value (1264/1265/1292/1366: bad enum value, + // unparseable date, overflow) → Conflict on BOTH paths now: on writes + // they mean the caller's VALUE doesn't fit the column — a 5xx made + // the live UI outbox retry a doomed write forever (M23 battery pin). + for msg in [ + "Server error: `ERROR 1265 (01000): Data truncated for column 'n' at row 1'", + "Server error: `ERROR 1292 (22007): Truncated incorrect DOUBLE value: 'abc'", + "Server error: `ERROR 1366 (HY000): Incorrect integer value: 'abc' for column 'n' at row 1", + "Server error: `ERROR 1264 (22003): Out of range value for column 'total' at row 1", + ] { + assert!( + matches!(ddl_backend(msg), DataPlaneError::Conflict { .. }), + "{msg}" + ); + assert!( + matches!(backend(msg), DataPlaneError::Conflict { .. }), + "{msg}" + ); + } + // Integrity violations stay Conflict on BOTH paths (pre-existing). + for msg in ["Duplicate entry 'x' for key 'PRIMARY'", "a foreign key constraint fails"] { + assert!(matches!(backend(msg), DataPlaneError::Conflict { .. }), "{msg}"); + assert!(matches!(ddl_backend(msg), DataPlaneError::Conflict { .. }), "{msg}"); + } + // Anything else is a Backend error on both paths. + assert!(matches!(ddl_backend("connection reset"), DataPlaneError::Backend { .. })); + } } diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/postgres.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/postgres.rs index acf74ad3..8f18e7a3 100644 --- a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/postgres.rs +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/postgres.rs @@ -2,17 +2,19 @@ use crate::ident::quote_ident; use crate::resolver::MountResolver; use async_trait::async_trait; use data_plane_core::{ - AggFunc, Aggregate, CmpOp, DataOperation, DataOperationKind, DataPlaneError, DataPlaneResult, - DataResult, DatabaseMount, EngineAdapter, EngineCapabilities, EngineHealth, EnginePool, Filter, - Isolation, MigrationRequest, MigrationResult, MigrationStatus, RawStatement, RequestIdentity, - ReturningMode, TxBeginRequest, TxHandle, + validate_default_expr, AggFunc, Aggregate, CmpOp, ColumnSchema, DataOperation, + DataOperationKind, DataPlaneError, DataPlaneResult, DataResult, DatabaseMount, DdlColumnDef, + EngineAdapter, EngineCapabilities, EngineHealth, EnginePool, Filter, ForeignKeyRef, Isolation, + MigrationRequest, MigrationResult, MigrationStatus, NormalizedType, RawStatement, + RequestIdentity, ReturningMode, SchemaDdlOp, SchemaDdlRequest, SchemaDdlResult, + SchemaDdlStatus, SchemaDescriptor, TableSchema, TxBeginRequest, TxHandle, }; use bytes::BytesMut; use deadpool_postgres::{Config as DeadpoolConfig, Object, PoolConfig, Runtime}; use serde_json::Value; use std::sync::Arc; use tokio::sync::Mutex; -use tokio_postgres::types::{to_sql_checked, IsNull, ToSql, Type}; +use tokio_postgres::types::{to_sql_checked, IsNull, Kind, ToSql, Type}; use tokio_postgres::{GenericClient, NoTls}; type BoxedParam = Box; @@ -342,6 +344,468 @@ impl EnginePool for PostgresPool { }) } } + + /// Engine-agnostic schema introspection (M22). Reads + /// `information_schema.columns` + `table_constraints`/`key_column_usage` + /// (PK + FK) + `pg_enum`/`pg_type` (enum values), scoped to the SAME schema + /// the request path executes in: a `schema_per_tenant` mount introspects + /// its tenant schema (the one `apply_search_path` pins per transaction); + /// shared_rls / db_per_tenant introspect `public` (the DSN-default search + /// path) — so the descriptor never reveals another tenant's tables. The + /// internal `_baas_migrations` marker table is excluded. + async fn describe_schema( + &self, + identity: RequestIdentity, + ) -> DataPlaneResult { + self.check_tenant(&identity)?; + let client = self.pool.get().await.map_err(|e| DataPlaneError::Backend { + message: format!("pool checkout failed: {e}"), + })?; + // Same scoping rule as the per-request `apply_search_path`: the tenant + // schema when isolation is schema_per_tenant, else `public`. + let schema = self + .search_path_schema + .clone() + .unwrap_or_else(|| "public".to_string()); + + // Enum types and their labels (sorted by declared order). Keyed by + // udt_name so a USER-DEFINED column can be resolved to its values. + let mut enums: std::collections::BTreeMap> = Default::default(); + let enum_rows = client + .query( + "SELECT t.typname, e.enumlabel + FROM pg_type t + JOIN pg_enum e ON e.enumtypid = t.oid + ORDER BY t.typname, e.enumsortorder", + &[], + ) + .await + .map_err(|e| backend(&e))?; + for row in &enum_rows { + enums + .entry(row.get::<_, String>(0)) + .or_default() + .push(row.get::<_, String>(1)); + } + + // Primary keys, per table, in key ordinal order. + let mut pks: std::collections::BTreeMap> = Default::default(); + let pk_rows = client + .query( + "SELECT tc.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + WHERE tc.table_schema = $1 AND tc.constraint_type = 'PRIMARY KEY' + ORDER BY tc.table_name, kcu.ordinal_position", + &[&schema], + ) + .await + .map_err(|e| backend(&e))?; + for row in &pk_rows { + pks.entry(row.get::<_, String>(0)) + .or_default() + .push(row.get::<_, String>(1)); + } + + // Foreign keys: (table, column) → referenced (table, column). + let mut fks: std::collections::BTreeMap<(String, String), ForeignKeyRef> = + Default::default(); + let fk_rows = client + .query( + "SELECT tc.table_name, kcu.column_name, ccu.table_name, ccu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON kcu.constraint_name = tc.constraint_name + AND kcu.table_schema = tc.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema + WHERE tc.table_schema = $1 AND tc.constraint_type = 'FOREIGN KEY'", + &[&schema], + ) + .await + .map_err(|e| backend(&e))?; + for row in &fk_rows { + fks.insert( + (row.get::<_, String>(0), row.get::<_, String>(1)), + ForeignKeyRef { table: row.get::<_, String>(2), column: row.get::<_, String>(3) }, + ); + } + + // Columns of every BASE TABLE in the scoped schema, in ordinal order. + let mut tables: std::collections::BTreeMap> = Default::default(); + let col_rows = client + .query( + "SELECT c.table_name, c.column_name, c.udt_name, c.data_type, + c.is_nullable, c.column_default + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_schema = c.table_schema AND t.table_name = c.table_name + WHERE c.table_schema = $1 + AND t.table_type = 'BASE TABLE' + AND c.table_name <> '_baas_migrations' + ORDER BY c.table_name, c.ordinal_position", + &[&schema], + ) + .await + .map_err(|e| backend(&e))?; + for row in &col_rows { + let table: String = row.get(0); + let name: String = row.get(1); + let udt: String = row.get(2); + let data_type: String = row.get(3); + let is_nullable: String = row.get(4); + let default: Option = row.get(5); + + let (normalized_type, enum_values) = match enums.get(&udt) { + // USER-DEFINED type with pg_enum rows → enum + its labels. + Some(values) if data_type == "USER-DEFINED" => { + (NormalizedType::Enum, Some(values.clone())) + } + _ => (normalize_pg_type(&udt), None), + }; + let references = fks.get(&(table.clone(), name.clone())).cloned(); + tables.entry(table).or_default().push(ColumnSchema { + name, + native_type: udt, + normalized_type, + nullable: is_nullable.eq_ignore_ascii_case("yes"), + default, + enum_values, + references, + inferred: false, + }); + } + + Ok(SchemaDescriptor { + engine: "postgresql".to_string(), + tables: tables + .into_iter() + .map(|(name, columns)| TableSchema { + primary_key: pks.remove(&name).unwrap_or_default(), + name, + columns, + }) + .collect(), + }) + } + + /// Engine-agnostic schema DDL (M22 step 2). The request is lowered to SQL + /// by the pure [`build_pg_ddl`] builder (identifier-validated, golden- + /// tested), then executed in ONE transaction — PostgreSQL DDL is + /// transactional, so a multi-statement op (alter_column_type) is atomic. + /// Enum types are ensured FIRST in auto-commit (`duplicate_object` = + /// reuse existing, per contract). Statements are schema-qualified to the + /// SAME schema `describe_schema` reads (tenant schema for + /// schema_per_tenant, else `public`), so DDL and introspection can never + /// disagree about which namespace they touch. + async fn apply_schema_ddl( + &self, + ddl: SchemaDdlRequest, + identity: RequestIdentity, + ) -> DataPlaneResult { + self.check_tenant(&identity)?; + let schema = self + .search_path_schema + .clone() + .unwrap_or_else(|| "public".to_string()); + let plan = build_pg_ddl(&schema, &ddl)?; + + let mut client = self.pool.get().await.map_err(|e| DataPlaneError::Backend { + message: format!("pool checkout failed: {e}"), + })?; + // schema_per_tenant: the tenant schema may not exist yet (first DDL on + // a fresh tenant). `schema` is pre-sanitized by `safe_schema`, so + // interpolating it (DDL cannot bind parameters) is injection-safe. + if self.search_path_schema.is_some() { + client + .batch_execute(&format!("CREATE SCHEMA IF NOT EXISTS {schema}")) + .await + .map_err(|e| backend(&e))?; + } + // Enum types auto-commit BEFORE the transactional DDL: an aborted + // CREATE TYPE inside the tx would poison it, and `duplicate_object` + // here means the named type already exists — reuse it. + for stmt in &plan.ensure_enum_types { + if let Err(e) = client.execute(stmt.as_str(), &[]).await { + if !is_duplicate_object(&e) { + return Err(ddl_backend(&e)); + } + } + } + + let tx = client.transaction().await.map_err(|e| backend(&e))?; + for stmt in &plan.statements { + tx.execute(stmt.as_str(), &[]).await.map_err(|e| ddl_backend(&e))?; + } + tx.commit().await.map_err(|e| backend(&e))?; + Ok(SchemaDdlResult { + op: ddl.op, + table: ddl.table, + status: SchemaDdlStatus::Applied, + }) + } +} + +/// Maps a Postgres `udt_name` to the engine-neutral [`NormalizedType`]. Pure +/// (testable without a DB); enum resolution happens at the call site, which +/// holds the `pg_enum` rows. Array types surface as `_` udt names (or +/// the literal `ARRAY` data_type, which callers pass through here unchanged). +pub(crate) fn normalize_pg_type(native: &str) -> NormalizedType { + match native { + "int2" | "int4" | "int8" => NormalizedType::Integer, + "float4" | "float8" => NormalizedType::Float, + "numeric" => NormalizedType::Decimal, + "bool" => NormalizedType::Boolean, + "date" => NormalizedType::Date, + "json" | "jsonb" => NormalizedType::Json, + "uuid" => NormalizedType::Uuid, + "text" | "varchar" | "char" | "bpchar" => NormalizedType::Text, + "ARRAY" => NormalizedType::Array, + n if n.starts_with("timestamp") => NormalizedType::Datetime, + n if n.starts_with('_') => NormalizedType::Array, + _ => NormalizedType::Unknown, + } +} + +// ── M22 step 2: engine-agnostic schema DDL (pure SQL builders) ─────────────── +// +// Everything below is pure (testable without a DB). Identifiers go through +// `quote_ident` (allowlist + quoting); enum VALUES are SQL string literals +// escaped by `pg_literal`; caller-supplied DEFAULT expressions pass the shared +// `validate_default_expr` guard (no `;`, comments, or control chars — defense +// in depth on top of the driver's single-statement extended protocol). + +/// The statement plan for one schema-DDL operation. +#[derive(Debug)] +pub(crate) struct PgDdlPlan { + /// `CREATE TYPE … AS ENUM (…)` statements run BEFORE the transactional + /// DDL, auto-commit; a `duplicate_object` (42710) error means "reuse the + /// existing type" (per contract). + pub(crate) ensure_enum_types: Vec, + /// The DDL statements, executed in order inside ONE transaction. + pub(crate) statements: Vec, +} + +/// `'…'`-quoted SQL string literal: single quotes double. Backslash is +/// literal under `standard_conforming_strings` (the PG default since 9.1). +fn pg_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +/// The schema-qualified, quoted name of the per-column enum type: +/// `"schema"."{table}_{column}_enum"`. Both parts go through `quote_ident`, +/// so an over-long or invalid combination fails closed (InvalidIdentifier) +/// instead of being silently truncated by the server. +fn pg_enum_type_name(schema: &str, table: &str, column: &str) -> DataPlaneResult { + quote_ident(&format!("{schema}.{table}_{column}_enum")) +} + +/// Reverse type mapping (the inverse of [`normalize_pg_type`]): lowers a +/// [`DdlColumnDef`] to the PostgreSQL column type SQL. Enum columns map to a +/// named type `"{table}_{column}_enum"` (created/reused via the plan's +/// `ensure_enum_types`). `objectid`/`unknown` are describe-only and rejected. +pub(crate) fn pg_sql_type( + schema: &str, + table: &str, + def: &DdlColumnDef, +) -> DataPlaneResult { + Ok(match def.normalized_type { + NormalizedType::Text => "text".to_string(), + NormalizedType::Integer => "bigint".to_string(), + NormalizedType::Float => "double precision".to_string(), + NormalizedType::Decimal => "numeric".to_string(), + NormalizedType::Boolean => "boolean".to_string(), + NormalizedType::Date => "date".to_string(), + NormalizedType::Datetime => "timestamptz".to_string(), + NormalizedType::Json => "jsonb".to_string(), + NormalizedType::Uuid => "uuid".to_string(), + // v1: arrays are text[] — element typing is a follow-up. + NormalizedType::Array => "text[]".to_string(), + NormalizedType::Enum => pg_enum_type_name(schema, table, &def.name)?, + NormalizedType::Objectid | NormalizedType::Unknown => { + return Err(DataPlaneError::InvalidRequest { + message: format!( + "column '{}': normalized_type '{:?}' cannot be created on postgresql", + def.name, def.normalized_type + ), + }) + } + }) +} + +/// The `CREATE TYPE … AS ENUM (…)` statement for an enum column, or `None` +/// for any other type. Values are escaped literals; an enum without values +/// is a client error. +fn pg_create_enum_stmt( + schema: &str, + table: &str, + def: &DdlColumnDef, +) -> DataPlaneResult> { + if def.normalized_type != NormalizedType::Enum { + return Ok(None); + } + let values = def + .enum_values + .as_deref() + .filter(|v| !v.is_empty()) + .ok_or_else(|| DataPlaneError::InvalidRequest { + message: format!("enum column '{}' requires non-empty enum_values", def.name), + })?; + let name = pg_enum_type_name(schema, table, &def.name)?; + let literals: Vec = values.iter().map(|v| pg_literal(v)).collect(); + Ok(Some(format!( + "CREATE TYPE {name} AS ENUM ({})", + literals.join(", ") + ))) +} + +/// One column clause (`"name" type [NOT NULL] [DEFAULT expr]`), collecting +/// any enum-type prerequisite into the plan. +fn pg_column_clause( + schema: &str, + table: &str, + def: &DdlColumnDef, + plan: &mut PgDdlPlan, +) -> DataPlaneResult { + let col = quote_ident(&def.name)?; + let ty = pg_sql_type(schema, table, def)?; + if let Some(stmt) = pg_create_enum_stmt(schema, table, def)? { + plan.ensure_enum_types.push(stmt); + } + let mut clause = format!("{col} {ty}"); + if !def.nullable { + clause.push_str(" NOT NULL"); + } + if let Some(default) = def.default.as_deref() { + validate_default_expr(default)?; + clause.push_str(&format!(" DEFAULT {default}")); + } + Ok(clause) +} + +/// Lowers a [`SchemaDdlRequest`] to its PostgreSQL statement plan, targeting +/// `schema` explicitly (`"schema"."table"` on every statement). +pub(crate) fn build_pg_ddl(schema: &str, ddl: &SchemaDdlRequest) -> DataPlaneResult { + let table = quote_ident(&format!("{schema}.{}", ddl.table))?; + let mut plan = PgDdlPlan { + ensure_enum_types: Vec::new(), + statements: Vec::new(), + }; + match ddl.op { + SchemaDdlOp::AddColumn => { + let def = ddl.require_column()?; + let clause = pg_column_clause(schema, &ddl.table, def, &mut plan)?; + plan.statements + .push(format!("ALTER TABLE {table} ADD COLUMN {clause}")); + } + SchemaDdlOp::DropColumn => { + let col = quote_ident(ddl.require_column_name()?)?; + plan.statements + .push(format!("ALTER TABLE {table} DROP COLUMN {col}")); + } + SchemaDdlOp::AlterColumnType => { + // The caller composed the FULL target definition; lower it as a + // 4-step sequence (one tx → atomic): + // 1. DROP DEFAULT — the old default may not be castable to the + // new type (PG would refuse the TYPE change otherwise); + // 2. TYPE … USING — enums cast via ::text (every type reaches + // text; text reaches any enum); + // 3. SET/DROP NOT NULL per the target def; + // 4. SET DEFAULT per the target def (when one is declared). + let def = ddl.require_column()?; + let col = quote_ident(&def.name)?; + let ty = pg_sql_type(schema, &ddl.table, def)?; + if let Some(stmt) = pg_create_enum_stmt(schema, &ddl.table, def)? { + plan.ensure_enum_types.push(stmt); + } + plan.statements + .push(format!("ALTER TABLE {table} ALTER COLUMN {col} DROP DEFAULT")); + let using = if def.normalized_type == NormalizedType::Enum { + format!("{col}::text::{ty}") + } else { + format!("{col}::{ty}") + }; + plan.statements.push(format!( + "ALTER TABLE {table} ALTER COLUMN {col} TYPE {ty} USING {using}" + )); + plan.statements.push(format!( + "ALTER TABLE {table} ALTER COLUMN {col} {} NOT NULL", + if def.nullable { "DROP" } else { "SET" } + )); + if let Some(default) = def.default.as_deref() { + validate_default_expr(default)?; + plan.statements.push(format!( + "ALTER TABLE {table} ALTER COLUMN {col} SET DEFAULT {default}" + )); + } + } + SchemaDdlOp::CreateTable => { + let (columns, primary_key) = ddl.require_create_spec()?; + let mut clauses = Vec::with_capacity(columns.len() + 2); + let mut has_owner = false; + for def in columns { + if def.name == "owner_id" { + has_owner = true; + } + clauses.push(pg_column_clause(schema, &ddl.table, def, &mut plan)?); + } + if !has_owner { + // The platform's write path owner-scopes every row (insert + // injects owner_id; update/delete filter on it) — a table + // without the column would 500 on its first write. The + // principal is NOT always a uuid: API-key callers get the + // synthetic `api-key:` string and the insert path binds + // it as text, so the column must be text. + clauses.push(format!("{} text", quote_ident("owner_id")?)); + } + let pk: Vec = primary_key + .iter() + .map(|c| quote_ident(c)) + .collect::>()?; + clauses.push(format!("PRIMARY KEY ({})", pk.join(", "))); + plan.statements + .push(format!("CREATE TABLE {table} ({})", clauses.join(", "))); + } + SchemaDdlOp::DropTable => { + plan.statements.push(format!("DROP TABLE {table}")); + } + } + Ok(plan) +} + +/// DDL-path error classifier. Class 22 data exceptions (invalid text +/// representation, numeric out of range, …) and 42804 datatype_mismatch +/// during `ALTER … USING` mean the EXISTING DATA is incompatible with the +/// requested type — the caller's conflict (409), not an engine failure (502). +/// Schema-shape mistakes are deterministic client errors too: a 5xx here +/// makes outbox-style clients retry a request that can never succeed — +/// 42701/42P07 (already exists) → 409, 42703/42P01 (no such column/table) → +/// 400. Scoped to the DDL path only (additive): `/v1/query` keeps the +/// existing [`backend`] mapping, which this falls back to (23xxx → +/// Conflict, rest → Backend). +fn ddl_backend(e: &tokio_postgres::Error) -> DataPlaneError { + if let Some(db) = e.as_db_error() { + let code = db.code().code(); + if code.starts_with("22") || code == "42804" || code == "42701" || code == "42P07" { + return DataPlaneError::Conflict { + message: db.message().to_string(), + }; + } + if code == "42703" || code == "42P01" { + return DataPlaneError::InvalidRequest { + message: db.message().to_string(), + }; + } + } + backend(e) +} + +/// SQLSTATE 42710 duplicate_object — the enum type already exists. +fn is_duplicate_object(e: &tokio_postgres::Error) -> bool { + e.as_db_error().is_some_and(|db| db.code().code() == "42710") } /// Pinned PostgreSQL transaction. Owns the checked-out connection for the @@ -598,19 +1062,34 @@ fn build_aggregate_expr(agg: &Aggregate) -> DataPlaneResult { fn backend(e: &tokio_postgres::Error) -> DataPlaneError { // SQLSTATE class 23 = integrity constraint violation (unique/PK, foreign key, - // not-null, check). That's the caller's fault (409 Conflict), not an engine - // failure (5xx). Use the DB error's own message (the top-level Display is - // just "db error") so the client learns *what* conflicted. + // not-null, check) and class 22 = data exception (invalid enum/date text, + // numeric overflow, …). Both are the caller's fault — their VALUES don't + // fit the schema — so they map to 409 Conflict, not an engine 5xx (a 5xx + // makes outbox clients retry a write that can never succeed). Use the DB + // error's own message (the top-level Display is just "db error") so the + // client learns *what* conflicted. if let Some(db) = e.as_db_error() { - if db.code().code().starts_with("23") { + let code = db.code().code(); + if code.starts_with("23") || code.starts_with("22") { return DataPlaneError::Conflict { message: db.message().to_string(), }; } } - DataPlaneError::Backend { - message: e.to_string(), + // CLIENT-side bind failures (JsonParam: "not a date" into timestamptz, + // a malformed uuid, a string into int4) never reach the server, so there + // is no SQLSTATE — but they are exactly as much the caller's fault as + // their server-side 22xxx twins. Same envelope: 409, with the cause. + let text = e.to_string(); + if text.contains("error serializing parameter") { + let detail = std::error::Error::source(e) + .map(|source| format!(": {source}")) + .unwrap_or_default(); + return DataPlaneError::Conflict { + message: format!("{text}{detail} (value does not fit the column type)"), + }; } + DataPlaneError::Backend { message: text } } /// Boxes a JSON value as a Postgres parameter whose wire encoding adapts to the @@ -665,7 +1144,16 @@ impl ToSql for JsonParam { (n.as_f64().ok_or("number is not representable as f64")? as f32).to_sql(ty, out) } Type::FLOAT8 => n.as_f64().ok_or("number is not representable as f64")?.to_sql(ty, out), - // json/jsonb → number document; numeric/anything else → checked + // numeric/decimal columns (money-like: totals, prices, + // salaries) — serde's Value only serializes into json/jsonb, + // so without this arm EVERY filter or update touching a + // numeric column was a 502. serde prints JSON numbers as + // plain decimal strings, which encode directly. + Type::NUMERIC => { + write_pg_numeric(&n.to_string(), out)?; + Ok(IsNull::No) + } + // json/jsonb → number document; anything else → checked // delegate, which rejects a true mismatch rather than corrupt it. _ => self.0.to_sql_checked(ty, out), }, @@ -675,6 +1163,17 @@ impl ToSql for JsonParam { Type::TIMESTAMP => s.parse::()?.to_sql(ty, out), Type::DATE => s.parse::()?.to_sql(ty, out), Type::JSON | Type::JSONB => self.0.to_sql(ty, out), + // Enum slots (filters/updates against enum columns — the live + // UI's board groupings depend on them): the binary wire format + // of an enum value IS its label text, but this postgres-types + // version's `&str` does not `accepts` enum kinds, which made + // every enum-column filter a 502. Write the label and let the + // SERVER validate it — an invalid label raises 22P02, which + // classifies as a clean 409 Conflict. + _ if matches!(ty.kind(), Kind::Enum(_)) => { + out.extend_from_slice(s.as_bytes()); + Ok(IsNull::No) + } // text/varchar/bpchar/name accept the string; a non-text column // (int4, bytea, …) is rejected by the inner `accepts`. _ => s.to_sql_checked(ty, out), @@ -692,6 +1191,62 @@ impl ToSql for JsonParam { to_sql_checked!(); } +/// PostgreSQL `numeric` binary wire encoding for a plain decimal string +/// (`[-]digits[.digits]` — exactly what serde_json prints for ordinary +/// numbers; exponent forms are rejected with a clear error). Layout: i16 +/// ndigits, i16 weight (position of the most significant base-10000 group +/// relative to the decimal point), u16 sign (0x0000 +, 0x4000 −), u16 dscale +/// (decimal digits after the point), then ndigits × i16 base-10000 groups. +fn write_pg_numeric( + text: &str, + out: &mut BytesMut, +) -> Result<(), Box> { + let (negative, unsigned) = match text.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, text), + }; + let (int_part, frac_part) = unsigned.split_once('.').unwrap_or((unsigned, "")); + if int_part.is_empty() && frac_part.is_empty() + || !int_part.bytes().all(|byte| byte.is_ascii_digit()) + || !frac_part.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(format!("`{text}` is not a plain decimal numeric literal").into()); + } + let dscale = u16::try_from(frac_part.len())?; + // Base-10000 groups: int digits left-padded, frac digits right-padded. + let int_trimmed = int_part.trim_start_matches('0'); + let mut padded = "0".repeat((4 - int_trimmed.len() % 4) % 4) + int_trimmed; + let int_groups = padded.len() / 4; + padded += frac_part; + padded += &"0".repeat((4 - padded.len() % 4) % 4); + let mut digits: Vec = padded + .as_bytes() + .chunks(4) + .map(|chunk| std::str::from_utf8(chunk).unwrap().parse::().unwrap()) + .collect(); + // weight counts from the first group; leading zero groups (a fraction + // like 0.00001) shift it further down, trailing zero groups just shrink. + let mut weight = int_groups as i16 - 1; + while digits.first() == Some(&0) { + digits.remove(0); + weight -= 1; + } + while digits.last() == Some(&0) { + digits.pop(); + } + if digits.is_empty() { + weight = 0; + } + out.extend_from_slice(&(i16::try_from(digits.len())?).to_be_bytes()); + out.extend_from_slice(&weight.to_be_bytes()); + out.extend_from_slice(&(if negative && !digits.is_empty() { 0x4000u16 } else { 0 }).to_be_bytes()); + out.extend_from_slice(&dscale.to_be_bytes()); + for digit in digits { + out.extend_from_slice(&digit.to_be_bytes()); + } + Ok(()) +} + /// Coerces a JSON number to `i64` for integer columns, accepting an /// integral-valued float (`3.0`) as well as a JSON integer (`3`). fn json_i64(n: &serde_json::Number) -> Result> { @@ -1646,7 +2201,10 @@ mod tests { assert!(encode(json!("42"), &Type::INT4).is_err(), "string→int4 must reject"); assert!(encode(json!(true), &Type::INT4).is_err(), "bool→int4 must reject"); assert!(encode(json!({ "a": 1 }), &Type::INT4).is_err(), "object→int4 must reject"); - assert!(encode(json!(5), &Type::NUMERIC).is_err(), "number→numeric not yet supported, must reject not corrupt"); + // numeric is now a real arm (write_pg_numeric): 5 → ndigits 1, + // weight 0, sign +, dscale 0, one base-10000 group [5]. + let (_, buf) = encode(json!(5), &Type::NUMERIC).expect("number→numeric binds"); + assert_eq!(&buf[..], &[0, 1, 0, 0, 0, 0, 0, 0, 0, 5]); } #[test] @@ -1662,4 +2220,345 @@ mod tests { let (_, buf) = encode(json!({ "a": 1 }), &Type::JSONB).unwrap(); assert_eq!(buf[0], 1, "jsonb binary format starts with a version byte"); } + + // --- M22 schema introspection: pure type normalizer (golden table) --- + + #[test] + fn normalize_pg_type_golden_table() { + use NormalizedType as N; + for (native, expected) in [ + ("int2", N::Integer), + ("int4", N::Integer), + ("int8", N::Integer), + ("float4", N::Float), + ("float8", N::Float), + ("numeric", N::Decimal), + ("bool", N::Boolean), + ("date", N::Date), + ("timestamp", N::Datetime), + ("timestamptz", N::Datetime), + ("json", N::Json), + ("jsonb", N::Json), + ("uuid", N::Uuid), + ("text", N::Text), + ("varchar", N::Text), + ("char", N::Text), + ("bpchar", N::Text), + ("ARRAY", N::Array), + ("_int4", N::Array), + ("_text", N::Array), + // USER-DEFINED enums are resolved at the call site (needs pg_enum + // rows); the bare normalizer honestly says Unknown. + ("order_status", N::Unknown), + ("bytea", N::Unknown), + ("tsvector", N::Unknown), + ] { + assert_eq!(normalize_pg_type(native), expected, "udt_name {native}"); + } + } + + // --- M22 step 2: schema DDL — pure SQL builders (golden tables) --- + + use data_plane_core::{DdlColumnDef, SchemaDdlOp, SchemaDdlRequest}; + + fn col(name: &str, ty: NormalizedType) -> DdlColumnDef { + DdlColumnDef { + name: name.to_string(), + normalized_type: ty, + nullable: true, + default: None, + enum_values: None, + } + } + + fn ddl(op: SchemaDdlOp, table: &str) -> SchemaDdlRequest { + SchemaDdlRequest { + op, + table: table.to_string(), + column: None, + column_name: None, + columns: None, + primary_key: None, + } + } + + #[test] + fn pg_sql_type_golden_table() { + use NormalizedType as N; + for (ty, expected) in [ + (N::Text, "text"), + (N::Integer, "bigint"), + (N::Float, "double precision"), + (N::Decimal, "numeric"), + (N::Boolean, "boolean"), + (N::Date, "date"), + (N::Datetime, "timestamptz"), + (N::Json, "jsonb"), + (N::Uuid, "uuid"), + (N::Array, "text[]"), + ] { + assert_eq!( + pg_sql_type("public", "orders", &col("c", ty)).unwrap(), + expected, + "{ty:?}" + ); + } + // enum → the per-column named type, schema-qualified + quoted. + assert_eq!( + pg_sql_type("public", "orders", &col("status", NormalizedType::Enum)).unwrap(), + "\"public\".\"orders_status_enum\"" + ); + // describe-only types are rejected, not guessed. + for ty in [NormalizedType::Objectid, NormalizedType::Unknown] { + assert!(matches!( + pg_sql_type("public", "orders", &col("c", ty)).unwrap_err(), + DataPlaneError::InvalidRequest { .. } + )); + } + } + + #[test] + fn pg_ddl_add_column_with_default_and_not_null() { + let mut req = ddl(SchemaDdlOp::AddColumn, "orders"); + req.column = Some(DdlColumnDef { + name: "qty".into(), + normalized_type: NormalizedType::Integer, + nullable: false, + default: Some("0".into()), + enum_values: None, + }); + let plan = build_pg_ddl("public", &req).unwrap(); + assert!(plan.ensure_enum_types.is_empty()); + assert_eq!( + plan.statements, + vec![ + "ALTER TABLE \"public\".\"orders\" ADD COLUMN \"qty\" bigint NOT NULL DEFAULT 0" + .to_string() + ] + ); + } + + #[test] + fn pg_ddl_add_enum_column_ensures_named_type_with_escaped_literals() { + let mut req = ddl(SchemaDdlOp::AddColumn, "orders"); + req.column = Some(DdlColumnDef { + name: "status".into(), + normalized_type: NormalizedType::Enum, + nullable: true, + default: None, + enum_values: Some(vec!["pending".into(), "it's".into()]), + }); + let plan = build_pg_ddl("public", &req).unwrap(); + // `''` escaping locks the literal quoting (injection cannot escape). + assert_eq!( + plan.ensure_enum_types, + vec![ + "CREATE TYPE \"public\".\"orders_status_enum\" AS ENUM ('pending', 'it''s')" + .to_string() + ] + ); + assert_eq!( + plan.statements, + vec![ + "ALTER TABLE \"public\".\"orders\" ADD COLUMN \"status\" \"public\".\"orders_status_enum\"" + .to_string() + ] + ); + // enum without values is a client error. + let mut bad = ddl(SchemaDdlOp::AddColumn, "orders"); + bad.column = Some(col("status", NormalizedType::Enum)); + assert!(matches!( + build_pg_ddl("public", &bad).unwrap_err(), + DataPlaneError::InvalidRequest { .. } + )); + } + + #[test] + fn pg_ddl_alter_column_type_emits_full_target_sequence() { + // The contract: caller sends the FULL target def; the builder lowers + // it to DROP DEFAULT → TYPE…USING → NOT NULL → SET DEFAULT, in one tx. + let mut req = ddl(SchemaDdlOp::AlterColumnType, "orders"); + req.column = Some(DdlColumnDef { + name: "qty".into(), + normalized_type: NormalizedType::Integer, + nullable: false, + default: Some("0".into()), + enum_values: None, + }); + let plan = build_pg_ddl("public", &req).unwrap(); + assert_eq!( + plan.statements, + vec![ + "ALTER TABLE \"public\".\"orders\" ALTER COLUMN \"qty\" DROP DEFAULT".to_string(), + "ALTER TABLE \"public\".\"orders\" ALTER COLUMN \"qty\" TYPE bigint USING \"qty\"::bigint" + .to_string(), + "ALTER TABLE \"public\".\"orders\" ALTER COLUMN \"qty\" SET NOT NULL".to_string(), + "ALTER TABLE \"public\".\"orders\" ALTER COLUMN \"qty\" SET DEFAULT 0".to_string(), + ] + ); + // nullable + no default → DROP NOT NULL, and no SET DEFAULT step. + let mut relaxed = ddl(SchemaDdlOp::AlterColumnType, "orders"); + relaxed.column = Some(col("qty", NormalizedType::Text)); + let plan = build_pg_ddl("public", &relaxed).unwrap(); + assert!(plan.statements[2].ends_with("DROP NOT NULL"), "{:?}", plan.statements); + assert_eq!(plan.statements.len(), 3, "no default → no SET DEFAULT"); + } + + #[test] + fn json_param_binds_strings_into_enum_slots() { + // Enum binary wire format = the label text. postgres-types' `&str` + // does not accept enum kinds, which made every enum-column filter + // (the live UI's board groupings) a 502 — the adaptive binder must + // write the label itself. Invalid labels stay the SERVER's call + // (22P02 → 409), so any label serializes here. + let enum_type = Type::new( + "order_status_t".to_string(), + 999_999, + Kind::Enum(vec!["pending".to_string(), "delivered".to_string()]), + "public".to_string(), + ); + let mut buf = bytes::BytesMut::new(); + let result = JsonParam(serde_json::json!("delivered")).to_sql(&enum_type, &mut buf); + assert!(matches!(result, Ok(IsNull::No))); + assert_eq!(&buf[..], b"delivered"); + } + + #[test] + fn pg_numeric_binary_encoding_golden_vectors() { + // (input, ndigits, weight, sign, dscale, base-10000 groups) + let cases: [(&str, i16, i16, u16, u16, &[i16]); 7] = [ + ("0", 0, 0, 0, 0, &[]), + ("1", 1, 0, 0, 0, &[1]), + ("12.34", 2, 0, 0, 2, &[12, 3400]), + ("10000", 1, 1, 0, 0, &[1]), + ("0.0001", 1, -1, 0, 4, &[1]), + ("0.00001", 1, -2, 0, 5, &[1000]), + ("-987654321.12", 4, 2, 0x4000, 2, &[9, 8765, 4321, 1200]), + ]; + for (input, ndigits, weight, sign, dscale, groups) in cases { + let mut buf = BytesMut::new(); + write_pg_numeric(input, &mut buf).unwrap_or_else(|e| panic!("{input}: {e}")); + let mut expected = Vec::new(); + expected.extend_from_slice(&ndigits.to_be_bytes()); + expected.extend_from_slice(&weight.to_be_bytes()); + expected.extend_from_slice(&sign.to_be_bytes()); + expected.extend_from_slice(&dscale.to_be_bytes()); + for group in groups { + expected.extend_from_slice(&group.to_be_bytes()); + } + assert_eq!(&buf[..], &expected[..], "{input}"); + } + // Exponent forms (serde only prints them for extreme f64s) fail closed. + let mut buf = BytesMut::new(); + assert!(write_pg_numeric("1e21", &mut buf).is_err()); + } + + #[test] + fn pg_ddl_alter_to_enum_casts_via_text() { + let mut req = ddl(SchemaDdlOp::AlterColumnType, "orders"); + req.column = Some(DdlColumnDef { + name: "status".into(), + normalized_type: NormalizedType::Enum, + nullable: true, + default: None, + enum_values: Some(vec!["a".into(), "b".into()]), + }); + let plan = build_pg_ddl("public", &req).unwrap(); + assert_eq!(plan.ensure_enum_types.len(), 1, "enum type ensured first"); + assert!( + plan.statements[1].contains( + "USING \"status\"::text::\"public\".\"orders_status_enum\"" + ), + "{:?}", + plan.statements + ); + } + + #[test] + fn pg_ddl_create_table_appends_owner_id_and_primary_key() { + let mut req = ddl(SchemaDdlOp::CreateTable, "orders"); + req.columns = Some(vec![ + DdlColumnDef { + name: "id".into(), + normalized_type: NormalizedType::Integer, + nullable: false, + default: None, + enum_values: None, + }, + col("note", NormalizedType::Text), + ]); + req.primary_key = Some(vec!["id".into()]); + let plan = build_pg_ddl("public", &req).unwrap(); + assert_eq!( + plan.statements, + vec![ + "CREATE TABLE \"public\".\"orders\" (\"id\" bigint NOT NULL, \"note\" text, \ + \"owner_id\" text, PRIMARY KEY (\"id\"))" + .to_string() + ] + ); + // An explicit owner_id column is respected, never duplicated. + let mut explicit = ddl(SchemaDdlOp::CreateTable, "orders"); + explicit.columns = Some(vec![ + DdlColumnDef { + name: "id".into(), + normalized_type: NormalizedType::Integer, + nullable: false, + default: None, + enum_values: None, + }, + col("owner_id", NormalizedType::Uuid), + ]); + explicit.primary_key = Some(vec!["id".into()]); + let plan = build_pg_ddl("public", &explicit).unwrap(); + assert_eq!(plan.statements[0].matches("owner_id").count(), 1, "{:?}", plan.statements); + } + + #[test] + fn pg_ddl_drop_ops_and_schema_scoping() { + let mut drop_col = ddl(SchemaDdlOp::DropColumn, "orders"); + drop_col.column_name = Some("note".into()); + // schema_per_tenant: every statement targets the tenant schema. + let plan = build_pg_ddl("tenant_acme_12345678", &drop_col).unwrap(); + assert_eq!( + plan.statements, + vec!["ALTER TABLE \"tenant_acme_12345678\".\"orders\" DROP COLUMN \"note\"".to_string()] + ); + let plan = build_pg_ddl("public", &ddl(SchemaDdlOp::DropTable, "orders")).unwrap(); + assert_eq!(plan.statements, vec!["DROP TABLE \"public\".\"orders\"".to_string()]); + } + + #[test] + fn pg_ddl_rejects_injection_and_unsafe_defaults() { + // table name injection + assert!(matches!( + build_pg_ddl("public", &ddl(SchemaDdlOp::DropTable, "orders; DROP TABLE x")).unwrap_err(), + DataPlaneError::InvalidIdentifier { .. } + )); + // column name injection + let mut bad_col = ddl(SchemaDdlOp::AddColumn, "orders"); + bad_col.column = Some(col("evil\"; --", NormalizedType::Text)); + assert!(matches!( + build_pg_ddl("public", &bad_col).unwrap_err(), + DataPlaneError::InvalidIdentifier { .. } + )); + // unsafe default expression + let mut bad_default = ddl(SchemaDdlOp::AddColumn, "orders"); + bad_default.column = Some(DdlColumnDef { + name: "c".into(), + normalized_type: NormalizedType::Text, + nullable: true, + default: Some("'x'; DROP TABLE orders".into()), + enum_values: None, + }); + assert!(matches!( + build_pg_ddl("public", &bad_default).unwrap_err(), + DataPlaneError::InvalidRequest { .. } + )); + // missing op-specific field surfaces the shared require_* error. + assert!(matches!( + build_pg_ddl("public", &ddl(SchemaDdlOp::AddColumn, "orders")).unwrap_err(), + DataPlaneError::InvalidRequest { .. } + )); + } } diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/registry.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/registry.rs index d35e140e..e1e019f3 100644 --- a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/registry.rs +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-pool/src/registry.rs @@ -296,6 +296,21 @@ impl EnginePool for SharedPool { ) -> DataPlaneResult { self.0.apply_migration(request, identity).await } + + async fn describe_schema( + &self, + identity: RequestIdentity, + ) -> DataPlaneResult { + self.0.describe_schema(identity).await + } + + async fn apply_schema_ddl( + &self, + ddl: data_plane_core::SchemaDdlRequest, + identity: RequestIdentity, + ) -> DataPlaneResult { + self.0.apply_schema_ddl(ddl, identity).await + } } #[cfg(test)] diff --git a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-server/src/routes.rs b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-server/src/routes.rs index 8248f717..1d47e74a 100644 --- a/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-server/src/routes.rs +++ b/apps/baas/mini-baas-infra/docker/services/data-plane-router/crates/data-plane-server/src/routes.rs @@ -15,8 +15,8 @@ use axum::response::Response; use crate::metrics::{escape_label, Metrics}; use data_plane_core::{ DataOperation, DataPlaneError, DatabaseMount, EngineAdapter, EngineCapabilities, - MigrationRequest, Plan, PoolRegistry, RawStatement, RequestIdentity, TxBeginRequest, TxHandle, - WorkloadContext, + MigrationRequest, Plan, PoolRegistry, RawStatement, RequestIdentity, SchemaDdlRequest, + TxBeginRequest, TxHandle, WorkloadContext, }; use data_plane_pool::{ DefaultPoolRegistry, EnvMountResolver, HttpEngineAdapter, MongoEngineAdapter, @@ -292,6 +292,8 @@ pub fn router(state: AppState) -> Router { .route("/metrics", get(metrics_handler)) .route("/v1/capabilities", get(capabilities)) .route("/v1/query", post(execute_query)) + .route("/v1/schema", post(describe_schema)) + .route("/v1/schema/ddl", post(apply_schema_ddl)) .route("/v1/transactions", post(begin_transaction)) .route("/v1/transactions/:tx_id/execute", post(execute_in_transaction)) .route("/v1/transactions/:tx_id/commit", post(commit_transaction)) @@ -576,6 +578,91 @@ fn map_data_plane_error(err: &DataPlaneError) -> axum::response::Response { .into_response() } +// ── /v1/schema ─────────────────────────────────────────────────────────────── +// +// Engine-agnostic schema introspection (M22, live-database mode). Returns the +// mount's tables/collections with normalized column types, PK/FK metadata and +// enum values (`SchemaDescriptor` in data-plane-core). NOT admin-gated: any +// authenticated identity that passes `validate_identity_mount` may read its +// OWN mount's schema (same gating as `begin_transaction` — identity/mount +// validation + a capability gate, nothing more). Engines without an +// introspection surface (redis, http) advertise `introspect: false` and are +// rejected here with a clean 422 instead of a deep 501. + +#[derive(Debug, Clone, Deserialize)] +struct DescribeSchemaRequest { + identity: RequestIdentity, + mount: DatabaseMount, +} + +async fn describe_schema( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + if let Err(message) = validate_identity_mount(&state, &request.identity, &request.mount) { + return bad_request(message); + } + // Honesty gate: only engines advertising `introspect` serve the schema + // surface (a route capability like `ddl`, not an operation kind). + if let Err(resp) = + require_capability(&state, &request.mount.engine, "introspect", |c| c.introspect) + { + return resp; + } + let pool = match state.registry.get_or_create(request.mount).await { + Ok(pool) => pool, + Err(err) => return map_data_plane_error(&err), + }; + match pool.describe_schema(request.identity).await { + Ok(result) => (StatusCode::OK, Json(result)).into_response(), + Err(err) => map_data_plane_error(&err), + } +} + +// ── /v1/schema/ddl ──────────────────────────────────────────────────────────── +// +// Engine-agnostic schema DDL (M22, step 2): ONE operation per request +// (add_column | drop_column | alter_column_type | create_table | drop_table) +// — single-op by contract because MySQL DDL self-commits, so a batch would +// fake atomicity. NOT admin-gated (mirrors /v1/schema): mount ownership is +// enforced upstream by the query-router's resolveConnection, the same trust +// model as /v1/query writes. Gated on the `schema_ddl` capability flag — +// deliberately distinct from `ddl` (the /v1/admin/migrate gate), because +// mongodb serves this surface but not migrations. + +#[derive(Debug, Clone, Deserialize)] +struct SchemaDdlEnvelope { + identity: RequestIdentity, + mount: DatabaseMount, + ddl: SchemaDdlRequest, +} + +async fn apply_schema_ddl( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + if let Err(message) = validate_identity_mount(&state, &request.identity, &request.mount) { + return bad_request(message); + } + if request.ddl.table.trim().is_empty() { + return bad_request("ddl.table is required".to_string()); + } + // Honesty gate: only engines advertising `schema_ddl` serve this surface. + if let Err(resp) = + require_capability(&state, &request.mount.engine, "schema_ddl", |c| c.schema_ddl) + { + return resp; + } + let pool = match state.registry.get_or_create(request.mount).await { + Ok(pool) => pool, + Err(err) => return map_data_plane_error(&err), + }; + match pool.apply_schema_ddl(request.ddl, request.identity).await { + Ok(result) => (StatusCode::OK, Json(result)).into_response(), + Err(err) => map_data_plane_error(&err), + } +} + // Default transaction TTL — after this the registry stops handing out the // handle on lookup. The connection is NOT force-closed here; a follow-up // slice can add a reaper task that calls rollback on expired entries. diff --git a/apps/baas/mini-baas-infra/docker/services/kong/conf/kong.yml b/apps/baas/mini-baas-infra/docker/services/kong/conf/kong.yml index e20e6a98..57077f15 100644 --- a/apps/baas/mini-baas-infra/docker/services/kong/conf/kong.yml +++ b/apps/baas/mini-baas-infra/docker/services/kong/conf/kong.yml @@ -38,9 +38,12 @@ plugins: - http://127.0.0.1:3001 methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS] headers: + # X-Baas-Tenant-Id: the in-browser mount catalog (osionos + # liveMountCatalog → GET /admin/v1/databases) scopes by tenant header; + # without it in the preflight allow-list that path can never work. [Authorization, Content-Type, Content-Profile, apikey, X-Baas-Api-Key, - x-client-info, X-Request-ID, Prefer, Range, Accept, Accept-Profile, - x-supabase-api-version] + X-Baas-Tenant-Id, x-client-info, X-Request-ID, Prefer, Range, Accept, + Accept-Profile, x-supabase-api-version] exposed_headers: [X-Kong-Upstream-Latency, X-Kong-Proxy-Latency, X-Request-ID] credentials: true diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/Dockerfile b/apps/baas/mini-baas-infra/docker/services/realtime/Dockerfile index 1c8d725f..283d0164 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/Dockerfile +++ b/apps/baas/mini-baas-infra/docker/services/realtime/Dockerfile @@ -1,11 +1,15 @@ # syntax=docker/dockerfile:1 # File: docker/services/realtime/Dockerfile - -FROM dlesieur/realtime-agnostic@sha256:a96024e9c3d27cd452310ea6f4c7cfa965c6ee685a26ece58325361c924d5ecd +# +# Thin wrapper over the realtime-agnostic image. NOTE: compose builds the +# `realtime` service straight from ./realtime-agnostic (this file is NOT in +# that path) — keep this wrapper aligned with the locally-built tag, never a +# stale registry digest. `docker compose build realtime` produces the tag. +FROM dlesieur/realtime-agnostic:latest EXPOSE 4000 -USER 1001:1001 +USER nonroot:nonroot -# realtime-agnostic exposes /v1/health on port 4000 (Rust HTTP server). -# Try curl first, fall back to wget if only one tool is present in the base image. +# The base image is distroless (no shell, no curl/wget): probe through the +# binary itself, which checks /v1/health on the configured port. HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ - CMD sh -c 'curl -fsS http://localhost:4000/v1/health >/dev/null 2>&1 || wget -qO- http://localhost:4000/v1/health >/dev/null 2>&1' || exit 1 + CMD ["/app/realtime-server", "--healthcheck"] diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Cargo.lock b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Cargo.lock index b86c4bdb..d0ad52bc 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Cargo.lock +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Cargo.lock @@ -2246,6 +2246,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "realtime-bus-irc" +version = "0.2.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "dashmap", + "realtime-core", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "realtime-client" version = "0.2.0" @@ -2418,6 +2433,7 @@ dependencies = [ "futures", "realtime-auth", "realtime-bus-inprocess", + "realtime-bus-irc", "realtime-core", "realtime-db-mongodb", "realtime-db-postgres", diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Cargo.toml b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Cargo.toml index 8d190dfe..208b9eca 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Cargo.toml +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/realtime-core", "crates/realtime-engine", "crates/realtime-bus-inprocess", + "crates/realtime-bus-irc", "crates/realtime-auth", "crates/realtime-gateway", "crates/realtime-db-postgres", diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Dockerfile b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Dockerfile index d82ec11a..252eb501 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Dockerfile +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Dockerfile @@ -1,18 +1,28 @@ +# syntax=docker/dockerfile:1.7 # ───────────────────────────────────────────────────────────────────────────── -# Stage 1 – Dependency layer (cached unless Cargo.toml / Cargo.lock change) +# Stage 1 – Dependency layer (cached unless Cargo.toml / Cargo.lock change). +# Two complementary caches keep rebuilds fast: +# - layer cache: this stub-manifest stage pre-compiles all dependencies; +# - BuildKit cache mounts: the crates registry and target dir persist ACROSS +# builds, so manifest changes don't re-download the registry and source +# changes only recompile the crates that actually changed. +# NOTE: every workspace member's Cargo.toml must be COPY'd below — add new +# crates here or the stub build cannot resolve the workspace graph. # ───────────────────────────────────────────────────────────────────────────── FROM public.ecr.aws/docker/library/rust:1.89-slim-bookworm AS deps WORKDIR /build -RUN apt-get update && \ - apt-get install -y pkg-config libssl-dev && \ - rm -rf /var/lib/apt/lists/* +RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ + apt-get update && \ + apt-get install -y pkg-config libssl-dev # Copy manifests only — this layer is cached as long as deps don't change. COPY Cargo.toml Cargo.lock ./ COPY crates/realtime-core/Cargo.toml crates/realtime-core/Cargo.toml COPY crates/realtime-engine/Cargo.toml crates/realtime-engine/Cargo.toml COPY crates/realtime-bus-inprocess/Cargo.toml crates/realtime-bus-inprocess/Cargo.toml +COPY crates/realtime-bus-irc/Cargo.toml crates/realtime-bus-irc/Cargo.toml COPY crates/realtime-auth/Cargo.toml crates/realtime-auth/Cargo.toml COPY crates/realtime-gateway/Cargo.toml crates/realtime-gateway/Cargo.toml COPY crates/realtime-db-postgres/Cargo.toml crates/realtime-db-postgres/Cargo.toml @@ -21,11 +31,16 @@ COPY crates/realtime-server/Cargo.toml crates/realtime-server/Cargo.toml COPY crates/realtime-client/Cargo.toml crates/realtime-client/Cargo.toml COPY tests/integration/Cargo.toml tests/integration/Cargo.toml -# Create stub lib.rs / main.rs for every crate so Cargo can resolve the graph. -RUN for dir in \ +# Stub every crate so Cargo can resolve the graph, then pre-compile all +# dependencies (the slow step — cached until the manifests change). +RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,id=realtime-target,target=/build/target,sharing=locked \ + for dir in \ crates/realtime-core \ crates/realtime-engine \ crates/realtime-bus-inprocess \ + crates/realtime-bus-irc \ crates/realtime-auth \ crates/realtime-gateway \ crates/realtime-db-postgres \ @@ -36,10 +51,8 @@ RUN for dir in \ mkdir -p "$dir/src" && echo "pub fn _placeholder() {}" > "$dir/src/lib.rs"; \ done && \ mkdir -p crates/realtime-server/src && \ - echo "fn main() {}" > crates/realtime-server/src/main.rs - -# Pre-compile all dependencies (the slow step — cached until deps change). -RUN cargo build --release --bin realtime-server 2>&1 | tail -5 + echo "fn main() {}" > crates/realtime-server/src/main.rs && \ + cargo build --release --bin realtime-server 2>&1 | tail -5 # ───────────────────────────────────────────────────────────────────────────── # Stage 2 – Application build (only re-runs when source code changes) @@ -50,9 +63,14 @@ FROM deps AS builder COPY crates/ crates/ COPY tests/ tests/ -# Touch main.rs to force Cargo to rebuild the binary (not just deps). -RUN touch crates/realtime-server/src/main.rs && \ - cargo build --release --bin realtime-server +# Touch sources so Cargo rebuilds the real crates over the stub fingerprints; +# the binary is copied OUT of the cache mount so it lands in the layer. +RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,id=realtime-target,target=/build/target,sharing=locked \ + find crates tests -name '*.rs' -exec touch {} + && \ + cargo build --release --bin realtime-server && \ + cp /build/target/release/realtime-server /realtime-server # ───────────────────────────────────────────────────────────────────────────── # Stage 3 – Minimal runtime image (~15 MB stripped binary + libc) @@ -61,7 +79,7 @@ FROM gcr.io/distroless/cc-debian12:nonroot AS runtime WORKDIR /app -COPY --from=builder --chown=nonroot:nonroot /build/target/release/realtime-server /app/realtime-server +COPY --from=builder --chown=nonroot:nonroot /realtime-server /app/realtime-server # Default config file — override by mounting your own at this path. COPY --chown=nonroot:nonroot realtime.toml /etc/realtime/realtime.toml diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Makefile b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Makefile index 0f3d2e77..9dcf2c2e 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Makefile +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/Makefile @@ -36,14 +36,29 @@ help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' -build: ## Build the Rust workspace (release) - cargo build --release --workspace - -test: ## Run all tests (78 unit + integration) - cargo test --workspace - -check: ## Check compilation with zero warnings - cargo check --workspace 2>&1 | grep -v "Compiling\|Checking\|Finished" +# Cargo runs INSIDE Docker (Docker-first: no rustc/cargo on the host). The +# registry and target dirs are named volumes shared with the mini-baas-infra +# Makefile targets, so build state stays warm across both entry points. +RUST_TOOLCHAIN_IMG ?= mini-baas-rust-toolchain +CARGO_RUN = docker run --rm -v "$(CURDIR)":/work -w /work \ + -v mini-baas-cargo-registry:/usr/local/cargo/registry \ + -v mini-baas-cargo-git:/usr/local/cargo/git \ + -v mini-baas-realtime-target:/work/target $(RUST_TOOLCHAIN_IMG) + +_toolchain: + @printf 'FROM public.ecr.aws/docker/library/rust:1.89-slim-bookworm\nRUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*\n' \ + | docker build -q -t $(RUST_TOOLCHAIN_IMG) - >/dev/null + +build: _toolchain ## Build the Rust workspace (release, in Docker) + $(CARGO_RUN) cargo build --release --workspace + +test: _toolchain ## Run all tests (78 unit + integration, in Docker) + $(CARGO_RUN) cargo test --workspace + +# --quiet prints only warnings/errors AND keeps cargo's exit code (the old +# `| grep -v` filter exited 1 whenever a clean check produced no output). +check: _toolchain ## Check compilation with zero warnings (in Docker) + $(CARGO_RUN) cargo check --workspace --quiet audit: ## Run full audit: local checks + SonarCloud report → reports/ @./scripts/audit.sh diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/Cargo.toml b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/Cargo.toml new file mode 100644 index 00000000..d0c9a83a --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "realtime-bus-irc" +version.workspace = true +edition.workspace = true + +[dependencies] +realtime-core = { path = "../realtime-core" } +tokio = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +anyhow = { workspace = true } +bytes = { workspace = true } +dashmap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/bus.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/bus.rs new file mode 100644 index 00000000..ff02c11a --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/bus.rs @@ -0,0 +1,115 @@ +//! [`IrcBus`] — an [`EventBus`] backend that bridges the gateway to an IRC +//! server. A shared service connection is the single inbound source and posts +//! platform events; per-user sessions (see [`SessionManager`]) carry each +//! platform user's own messages under their own nick. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use realtime_core::{EventBus, EventBusPublisher, EventBusSubscriber, EventEnvelope, Result}; +use tokio::sync::{broadcast, mpsc}; +use tracing::info; + +use crate::client::{run_client, SessionConfig}; +use crate::identity::DEFAULT_NICK_MAX; +use crate::publisher::IrcPublisher; +use crate::session_manager::SessionManager; +use crate::subscriber::IrcSubscriber; +use crate::IrcBusConfig; + +/// How often to sweep for idle per-user sessions, and the idle cutoff. +const REAP_INTERVAL: Duration = Duration::from_secs(60); +const USER_IDLE: Duration = Duration::from_secs(300); + +/// IRC-backed event bus with a shared service connection + per-user sessions. +pub struct IrcBus { + cmd_tx: mpsc::Sender, + inbound: broadcast::Sender, + namespace: String, + manager: Arc, +} + +impl IrcBus { + /// Connect to the configured IRC server and start the session tasks. + #[must_use] + pub fn new(config: IrcBusConfig) -> Self { + let (cmd_tx, cmd_rx) = mpsc::channel::(1024); + let (inbound, _) = broadcast::channel::(config.capacity); + + // Shared service connection: the sole inbound source; posts platform + // events and joins the configured channels. + let session = SessionConfig { + host: config.host.clone(), + port: config.port, + password: config.password.clone(), + nick: config.nick.clone(), + user: config.user.clone(), + realname: config.realname.clone(), + channels: config.channels.clone(), + namespace: config.namespace.clone(), + forward_inbound: true, + }; + let inbound_tx = inbound.clone(); + tokio::spawn(async move { + run_client(session, cmd_rx, inbound_tx).await; + }); + + // Per-user session pool (write-only sessions, no inbound duplication). + let manager = Arc::new(SessionManager::new( + config.host, + config.port, + config.password, + config.user, + config.realname, + config.namespace.clone(), + DEFAULT_NICK_MAX, + inbound.clone(), + )); + + let reaper = Arc::clone(&manager); + tokio::spawn(async move { + loop { + tokio::time::sleep(REAP_INTERVAL).await; + reaper.reap_idle(USER_IDLE).await; + } + }); + + info!(namespace = %config.namespace, "IRC event bus created (service + per-user sessions)"); + Self { + cmd_tx, + inbound, + namespace: config.namespace, + manager, + } + } +} + +#[async_trait] +impl EventBus for IrcBus { + async fn publisher(&self) -> Result> { + Ok(Box::new(IrcPublisher::new( + self.cmd_tx.clone(), + Arc::clone(&self.manager), + self.namespace.clone(), + ))) + } + + async fn subscriber(&self, _topic_pattern: &str) -> Result> { + Ok(Box::new(IrcSubscriber::new(self.inbound.subscribe()))) + } + + async fn health_check(&self) -> Result<()> { + if self.cmd_tx.is_closed() { + return Err(realtime_core::RealtimeError::EventBusError( + "IRC session task has stopped".to_string(), + )); + } + Ok(()) + } + + async fn shutdown(&self) -> Result<()> { + info!("IRC event bus shutting down"); + Ok(()) + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/client.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/client.rs new file mode 100644 index 00000000..63eda653 --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/client.rs @@ -0,0 +1,237 @@ +//! A minimal async RFC 2812 IRC client (no external IRC crate). +//! +//! One [`run_session`] drives a single TCP connection: it registers +//! (PASS/NICK/USER), joins channels, answers PING, forwards inbound PRIVMSG as +//! [`EventEnvelope`]s, and writes outbound raw lines received on an mpsc channel. +//! [`run_client`] wraps it in a reconnect loop. + +use std::time::Duration; + +use bytes::Bytes; +use realtime_core::types::{EventEnvelope, EventSource, SourceKind, TopicPath}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio::sync::{broadcast, mpsc}; +use tracing::{debug, info, warn}; + +use crate::mapping::channel_to_topic; + +/// Connection parameters for one IRC link. +#[derive(Debug, Clone)] +pub struct SessionConfig { + pub host: String, + pub port: u16, + pub password: String, + pub nick: String, + pub user: String, + pub realname: String, + pub channels: Vec, + pub namespace: String, + /// Whether inbound PRIVMSGs are forwarded to the bus as events. The shared + /// service connection sets this `true` (it is the single inbound source); + /// per-user sessions set it `false` so a channel message isn't emitted once + /// per joined session. + pub forward_inbound: bool, +} + +/// Reconnecting client loop. Runs until `cmd_rx` is closed. +pub async fn run_client( + cfg: SessionConfig, + mut cmd_rx: mpsc::Receiver, + inbound: broadcast::Sender, +) { + loop { + match run_session(&cfg, &mut cmd_rx, &inbound).await { + Ok(()) => { + info!(nick = %cfg.nick, "IRC session ended cleanly"); + return; + } + Err(e) => { + warn!(nick = %cfg.nick, error = %e, "IRC session error; reconnecting in 3s"); + tokio::time::sleep(Duration::from_secs(3)).await; + } + } + } +} + +/// Drive a single connection. Returns `Ok(())` when `cmd_rx` closes (shutdown), +/// or `Err` on a connection/IO error so the caller can reconnect. +async fn run_session( + cfg: &SessionConfig, + cmd_rx: &mut mpsc::Receiver, + inbound: &broadcast::Sender, +) -> anyhow::Result<()> { + let stream = TcpStream::connect((cfg.host.as_str(), cfg.port)).await?; + let (read_half, mut write_half) = stream.into_split(); + let mut reader = BufReader::new(read_half); + + // Registration handshake. + let mut current_nick = cfg.nick.clone(); + let mut nick_try: u32 = 0; + if !cfg.password.is_empty() { + write_line(&mut write_half, &format!("PASS {}", cfg.password)).await?; + } + write_line(&mut write_half, &format!("NICK {current_nick}")).await?; + write_line( + &mut write_half, + &format!("USER {} 0 * :{}", cfg.user, cfg.realname), + ) + .await?; + for channel in &cfg.channels { + write_line(&mut write_half, &format!("JOIN {channel}")).await?; + } + info!(nick = %cfg.nick, host = %cfg.host, port = cfg.port, "IRC connected"); + + let mut line = String::new(); + loop { + tokio::select! { + read = reader.read_line(&mut line) => { + let n = read?; + if n == 0 { + return Err(anyhow::anyhow!("connection closed by peer")); + } + let raw = line.trim_end_matches(['\r', '\n']).to_string(); + line.clear(); + if is_nick_error(&raw) { + nick_try += 1; + current_nick = retry_nick(&cfg.nick, nick_try); + write_line(&mut write_half, &format!("NICK {current_nick}")).await?; + continue; + } + handle_inbound(&raw, cfg, &mut write_half, inbound).await?; + } + cmd = cmd_rx.recv() => { + if let Some(raw) = cmd { + write_line(&mut write_half, &raw).await?; + } else { + // Channel closed: graceful shutdown. + let _ = write_line(&mut write_half, "QUIT :bye").await; + return Ok(()); + } + } + } + } +} + +/// Parse one inbound line and react: answer PING, forward PRIVMSG as an event. +async fn handle_inbound( + raw: &str, + cfg: &SessionConfig, + write_half: &mut tokio::net::tcp::OwnedWriteHalf, + inbound: &broadcast::Sender, +) -> anyhow::Result<()> { + if raw.is_empty() { + return Ok(()); + } + let msg = ParsedLine::parse(raw); + + if msg.command.eq_ignore_ascii_case("PING") { + let token = msg.trailing.unwrap_or_default(); + write_line(write_half, &format!("PONG :{token}")).await?; + return Ok(()); + } + + if msg.command.eq_ignore_ascii_case("PRIVMSG") { + if !cfg.forward_inbound { + return Ok(()); // per-user sessions don't re-emit channel traffic + } + let Some(channel) = msg.params.first() else { + return Ok(()); + }; + if !channel.starts_with('#') { + return Ok(()); // direct message, not a channel — ignored for now + } + let sender = msg.prefix_nick().unwrap_or_else(|| "unknown".to_string()); + let text = msg.trailing.unwrap_or_default(); + + let topic = channel_to_topic(channel, &cfg.namespace); + let body = serde_json::json!({ "from": sender, "text": text }); + let payload = Bytes::from(serde_json::to_vec(&body).unwrap_or_default()); + + let mut event = EventEnvelope::new(TopicPath::new(&topic), "message", payload); + event.source = Some(EventSource { + kind: SourceKind::Custom("irc".to_string()), + id: sender, + metadata: std::collections::HashMap::new(), + }); + // A send error only means there are currently no subscribers — fine. + let _ = inbound.send(event); + debug!(%channel, "forwarded IRC PRIVMSG to bus"); + } + Ok(()) +} + +/// Write one line followed by CRLF. +async fn write_line( + write_half: &mut tokio::net::tcp::OwnedWriteHalf, + line: &str, +) -> anyhow::Result<()> { + write_half.write_all(line.as_bytes()).await?; + write_half.write_all(b"\r\n").await?; + Ok(()) +} + +/// A parsed IRC protocol line: `[:prefix] COMMAND [params...] [:trailing]`. +struct ParsedLine<'a> { + prefix: Option<&'a str>, + command: &'a str, + params: Vec<&'a str>, + trailing: Option, +} + +impl<'a> ParsedLine<'a> { + fn parse(raw: &'a str) -> Self { + let mut rest = raw; + let mut prefix = None; + if let Some(stripped) = rest.strip_prefix(':') { + let (p, r) = split_once_space(stripped); + prefix = Some(p); + rest = r; + } + + let mut trailing = None; + if let Some(idx) = rest.find(" :") { + trailing = Some(rest[idx + 2..].to_string()); + rest = &rest[..idx]; + } + + let mut parts = rest.split_whitespace(); + let command = parts.next().unwrap_or(""); + let params = parts.collect::>(); + + Self { + prefix, + command, + params, + trailing, + } + } + + /// Extract the nick from a `nick!user@host` prefix. + fn prefix_nick(&self) -> Option { + self.prefix.map(|p| { + let nick = p.split('!').next().unwrap_or(p); + nick.to_string() + }) + } +} + +/// True if the line is an `ERR_NICKNAMEINUSE` (433) or `ERR_ERRONEUSNICKNAME` (432). +fn is_nick_error(raw: &str) -> bool { + let cmd = ParsedLine::parse(raw).command; + cmd == "433" || cmd == "432" +} + +/// Build a fallback nick by appending an attempt number, capped to NICKLEN (9). +fn retry_nick(base: &str, attempt: u32) -> String { + let suffix = attempt.to_string(); + let keep = 9usize.saturating_sub(suffix.len()).max(1); + let head: String = base.chars().take(keep).collect(); + format!("{head}{suffix}") +} + +/// Split a string at the first space into (head, tail). +fn split_once_space(s: &str) -> (&str, &str) { + s.find(' ') + .map_or((s, ""), |i| (&s[..i], s[i + 1..].trim_start())) +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/identity.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/identity.rs new file mode 100644 index 00000000..740037b2 --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/identity.rs @@ -0,0 +1,134 @@ +//! Derive a valid IRC nickname from a platform identity. +//! +//! RFC 2812 limits a nickname to a letter-or-special first character followed by +//! letters, digits, specials, or '-'. The IRC server advertises a max length of +//! 9, so nicks are capped short — we keep a sanitized prefix of the handle plus a +//! short, stable suffix hashed from the user id, so two users who share a handle +//! prefix still get distinct, deterministic nicks without a server round-trip. + +/// Default maximum nickname length (matches the IRC server's advertised limit). +pub const DEFAULT_NICK_MAX: usize = 9; + +const fn is_special(c: char) -> bool { + matches!(c, '[' | ']' | '\\' | '`' | '_' | '^' | '{' | '|' | '}') +} + +const fn is_nick_start(c: char) -> bool { + c.is_ascii_alphabetic() || is_special(c) +} + +const fn is_nick_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || is_special(c) +} + +/// Small, stable 64-bit string hash (no dependencies). +fn fnv1a(s: &str) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in s.bytes() { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// Render a number as a fixed-width lowercase base-36 string. +fn base36(mut n: u64, len: usize) -> String { + const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut out = vec![b'0'; len]; + for slot in out.iter_mut().rev() { + *slot = ALPHABET[(n % 36) as usize]; + n /= 36; + } + String::from_utf8(out).unwrap_or_default() +} + +/// Derive a deterministic, RFC-2812-valid nickname for a platform user. +/// +/// `handle` is the preferred display name (may be empty); `user_id` is the +/// stable account id used for the uniqueness suffix. The result is at most +/// `max_len` characters, starts with a letter or special char, and contains +/// only nick-legal characters. +#[must_use] +pub fn to_irc_nick(user_id: &str, handle: &str, max_len: usize) -> String { + let max = max_len.max(1); + let suffix_len = match max { + 0..=1 => 0, + 2..=3 => 1, + _ => 3, + }; + let suffix = base36(fnv1a(user_id), suffix_len); + + let base = if handle.trim().is_empty() { + user_id + } else { + handle + }; + let prefix_len = max - suffix_len; + let prefix: String = base + .chars() + .filter(|&c| is_nick_char(c)) + .take(prefix_len) + .collect(); + + let mut nick = format!("{prefix}{suffix}"); + if nick.is_empty() { + nick = "user".chars().take(max).collect(); + } + let first = nick.chars().next().unwrap_or('u'); + if !is_nick_start(first) { + // Prepend a legal starting char and re-cap to max_len. + nick = format!("u{nick}").chars().take(max).collect(); + } + nick +} + +#[cfg(test)] +mod tests { + use super::{is_nick_char, is_nick_start, to_irc_nick, DEFAULT_NICK_MAX}; + + fn valid(nick: &str, max: usize) -> bool { + !nick.is_empty() + && nick.chars().count() <= max + && is_nick_start(nick.chars().next().unwrap_or(' ')) + && nick.chars().all(is_nick_char) + } + + #[test] + fn plain_handle_is_valid_and_capped() { + let n = to_irc_nick("user-123", "alice", DEFAULT_NICK_MAX); + assert!(valid(&n, DEFAULT_NICK_MAX), "{n}"); + assert!(n.starts_with("alice")); + } + + #[test] + fn deterministic() { + let a = to_irc_nick("u1", "bob", DEFAULT_NICK_MAX); + let b = to_irc_nick("u1", "bob", DEFAULT_NICK_MAX); + assert_eq!(a, b); + } + + #[test] + fn same_handle_different_users_differ() { + let a = to_irc_nick("user-1", "sam", DEFAULT_NICK_MAX); + let b = to_irc_nick("user-2", "sam", DEFAULT_NICK_MAX); + assert_ne!(a, b); + } + + #[test] + fn sanitizes_illegal_chars() { + let n = to_irc_nick("uid", "a.b c@d!", DEFAULT_NICK_MAX); + assert!(valid(&n, DEFAULT_NICK_MAX), "{n}"); + } + + #[test] + fn digit_leading_handle_gets_legal_start() { + let n = to_irc_nick("uid", "9lives", DEFAULT_NICK_MAX); + assert!(is_nick_start(n.chars().next().unwrap_or(' ')), "{n}"); + } + + #[test] + fn empty_handle_falls_back_to_user_id() { + let n = to_irc_nick("acct-xyz", "", DEFAULT_NICK_MAX); + assert!(valid(&n, DEFAULT_NICK_MAX), "{n}"); + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/lib.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/lib.rs new file mode 100644 index 00000000..8f648c91 --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/lib.rs @@ -0,0 +1,66 @@ +//! # realtime-bus-irc +//! +//! An [`EventBus`](realtime_core::EventBus) backend that bridges the +//! realtime-agnostic gateway to an external RFC 2812 IRC server (e.g. `ircserv`). +//! +//! Gateway topics in the configured namespace (default `chat`) map to IRC +//! channels: a WebSocket `PUBLISH` on `chat/general` becomes a `PRIVMSG #general`, +//! and an inbound `PRIVMSG #general` becomes an `EVENT` on `chat/general`. Topics +//! outside the namespace are ignored by this bus. +//! +//! Identity is hybrid. Events that carry a user `EventSource` (the gateway +//! stamps `kind = Api` with the account id) are posted through that user's own +//! IRC session — their derived nick, real presence (they JOIN the channels they +//! speak in). Events with no user source (platform notifications, CDC, etc.) go +//! out on a single shared service connection, which is also the sole inbound +//! source so channel traffic isn't emitted once per joined session. Per-user +//! sessions open lazily on first publish and are reaped after inactivity. + +mod bus; +mod client; +mod identity; +mod mapping; +mod publisher; +mod session_manager; +mod subscriber; + +pub use bus::IrcBus; + +/// Configuration for the IRC event-bus backend. +#[derive(Debug, Clone)] +pub struct IrcBusConfig { + /// IRC server host. + pub host: String, + /// IRC server port. + pub port: u16, + /// Server password (`PASS`). Empty to skip. + pub password: String, + /// Service nickname used by the relay connection. + pub nick: String, + /// IRC username (`USER`). + pub user: String, + /// IRC realname (`USER` trailing). + pub realname: String, + /// Channels to auto-join on connect. + pub channels: Vec, + /// Gateway topic namespace bridged to IRC (e.g. `chat`). + pub namespace: String, + /// Inbound broadcast channel capacity. + pub capacity: usize, +} + +impl Default for IrcBusConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), + port: 6667, + password: String::new(), + nick: "platform-gw".to_string(), + user: "platform".to_string(), + realname: "Realtime Gateway".to_string(), + channels: Vec::new(), + namespace: "chat".to_string(), + capacity: 65_536, + } + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/mapping.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/mapping.rs new file mode 100644 index 00000000..2aa212c1 --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/mapping.rs @@ -0,0 +1,58 @@ +//! Topic <-> IRC channel mapping. +//! +//! Topics in the gateway look like `"/"` (e.g. `chat/general`) +//! and map to IRC channels `"#"` (e.g. `#general`). Only the configured +//! namespace is bridged; other topics are ignored by the IRC bus. + +/// Convert a gateway topic into an IRC channel name, if it belongs to the +/// bridged namespace. +/// +/// Returns `None` when the topic is outside the namespace (and therefore not +/// an IRC channel). +#[must_use] +pub fn topic_to_channel(topic: &str, namespace: &str) -> Option { + let prefix = format!("{namespace}/"); + topic.strip_prefix(&prefix).map(|room| { + if room.starts_with('#') { + room.to_string() + } else { + format!("#{room}") + } + }) +} + +/// Convert an IRC channel name into a gateway topic in the bridged namespace. +#[must_use] +pub fn channel_to_topic(channel: &str, namespace: &str) -> String { + let room = channel.trim_start_matches('#'); + format!("{namespace}/{room}") +} + +#[cfg(test)] +mod tests { + use super::{channel_to_topic, topic_to_channel}; + + #[test] + fn topic_in_namespace_maps_to_channel() { + assert_eq!( + topic_to_channel("chat/general", "chat"), + Some("#general".to_string()) + ); + } + + #[test] + fn topic_outside_namespace_is_ignored() { + assert_eq!(topic_to_channel("orders/created", "chat"), None); + } + + #[test] + fn channel_maps_back_to_topic() { + assert_eq!(channel_to_topic("#general", "chat"), "chat/general"); + } + + #[test] + fn round_trip_is_stable() { + let ch = topic_to_channel("chat/dev", "chat").unwrap_or_default(); + assert_eq!(channel_to_topic(&ch, "chat"), "chat/dev"); + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/publisher.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/publisher.rs new file mode 100644 index 00000000..673ab502 --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/publisher.rs @@ -0,0 +1,112 @@ +//! Publisher side: gateway PUBLISH -> IRC. +//! +//! Events that carry a user `EventSource` (`kind = Api`) are posted through that +//! user's own IRC session (their nick); everything else (platform events with no +//! user, CDC, scheduler, ...) goes out on the shared service connection. + +use std::sync::Arc; + +use async_trait::async_trait; +use realtime_core::{ + EventBusPublisher, EventEnvelope, PublishReceipt, RealtimeError, Result, SourceKind, +}; +use tokio::sync::mpsc; + +use crate::mapping::topic_to_channel; +use crate::session_manager::SessionManager; + +/// Publishes events onto IRC, choosing the per-user or service identity. +pub struct IrcPublisher { + service_tx: mpsc::Sender, + manager: Arc, + namespace: String, +} + +impl IrcPublisher { + pub(crate) const fn new( + service_tx: mpsc::Sender, + manager: Arc, + namespace: String, + ) -> Self { + Self { + service_tx, + manager, + namespace, + } + } +} + +/// Extract a human-readable message from an event payload. +/// +/// Accepts a bare JSON string, a `{ "text": "..." }` object, or falls back to +/// the lossy UTF-8 rendering of the raw bytes. +fn payload_text(event: &EventEnvelope) -> String { + if let Ok(value) = serde_json::from_slice::(&event.payload) { + if let Some(s) = value.as_str() { + return s.to_string(); + } + if let Some(s) = value.get("text").and_then(serde_json::Value::as_str) { + return s.to_string(); + } + return value.to_string(); + } + String::from_utf8_lossy(&event.payload).to_string() +} + +#[async_trait] +impl EventBusPublisher for IrcPublisher { + async fn publish(&self, topic: &str, event: &EventEnvelope) -> Result { + let Some(channel) = topic_to_channel(topic, &self.namespace) else { + // Outside the bridged namespace: nothing to do on IRC. + return Ok(PublishReceipt { + event_id: event.event_id.clone(), + sequence: event.sequence, + delivered_to_bus: false, + }); + }; + + let text = payload_text(event); + + match &event.source { + // A platform user (the gateway stamps kind = Api with the user id). + Some(src) if src.kind == SourceKind::Api && !src.id.is_empty() => { + let handle = src.metadata.get("handle").map_or("", String::as_str); + self.manager + .publish_as_user(&src.id, handle, &channel, &text) + .await; + } + // Platform event / system source: post on the shared service nick, + // tagged with the event type. + _ => { + let body = if event.event_type.is_empty() { + text + } else { + format!("[{}] {}", event.event_type, text) + }; + self.service_tx + .send(format!("PRIVMSG {channel} :{body}")) + .await + .map_err(|e| { + RealtimeError::EventBusError(format!("IRC session unavailable: {e}")) + })?; + } + } + + Ok(PublishReceipt { + event_id: event.event_id.clone(), + sequence: event.sequence, + delivered_to_bus: true, + }) + } + + async fn publish_batch( + &self, + events: &[(String, EventEnvelope)], + ) -> Result> { + let mut receipts = Vec::with_capacity(events.len()); + for (topic, event) in events { + receipts.push(self.publish(topic, event).await?); + } + Ok(receipts) + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/session_manager.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/session_manager.rs new file mode 100644 index 00000000..1edb7558 --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/session_manager.rs @@ -0,0 +1,154 @@ +//! Per-user IRC session pool — the "hybrid" identity model. +//! +//! Each platform user that publishes gets their own IRC connection (NICK +//! derived from their handle), so they appear individually in IRC with real +//! presence (they JOIN the channels they speak in) and their messages carry +//! their own nick rather than a shared service identity. Sessions open lazily +//! on first publish and are reaped after a period of inactivity. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use realtime_core::types::EventEnvelope; +use tokio::sync::{broadcast, mpsc}; +use tracing::{debug, info}; + +use crate::client::{run_client, SessionConfig}; +use crate::identity::to_irc_nick; + +/// A live per-user IRC session: a write channel plus tracking state. +struct UserHandle { + cmd_tx: mpsc::Sender, + joined: Mutex>, + last_active: Mutex, +} + +/// Owns and lazily creates per-user IRC sessions. +pub struct SessionManager { + host: String, + port: u16, + password: String, + user: String, + realname: String, + namespace: String, + nick_max: usize, + inbound: broadcast::Sender, + users: DashMap>, +} + +impl SessionManager { + #[allow(clippy::too_many_arguments)] + pub fn new( + host: String, + port: u16, + password: String, + user: String, + realname: String, + namespace: String, + nick_max: usize, + inbound: broadcast::Sender, + ) -> Self { + Self { + host, + port, + password, + user, + realname, + namespace, + nick_max, + inbound, + users: DashMap::new(), + } + } + + /// Publish `text` to `channel` as the given user, opening their session and + /// joining the channel on first use. + pub async fn publish_as_user(&self, user_id: &str, handle: &str, channel: &str, text: &str) { + let user = self.get_or_create(user_id, handle); + + // Join the channel once per session (lock released before awaiting). + let need_join = { + let mut joined = user.joined.lock().unwrap_or_else(PoisonError::into_inner); + if joined.contains(channel) { + false + } else { + joined.insert(channel.to_string()); + true + } + }; + if need_join && user.cmd_tx.send(format!("JOIN {channel}")).await.is_err() { + self.drop_user(user_id); + return; + } + + if user + .cmd_tx + .send(format!("PRIVMSG {channel} :{text}")) + .await + .is_err() + { + self.drop_user(user_id); + return; + } + *user.last_active.lock().unwrap_or_else(PoisonError::into_inner) = Instant::now(); + } + + fn get_or_create(&self, user_id: &str, handle: &str) -> Arc { + if let Some(existing) = self.users.get(user_id) { + return existing.clone(); + } + let nick = to_irc_nick(user_id, handle, self.nick_max); + let (cmd_tx, cmd_rx) = mpsc::channel::(256); + let session = SessionConfig { + host: self.host.clone(), + port: self.port, + password: self.password.clone(), + nick, + user: self.user.clone(), + realname: self.realname.clone(), + channels: Vec::new(), + namespace: self.namespace.clone(), + forward_inbound: false, // service connection is the sole inbound source + }; + let inbound = self.inbound.clone(); + let entry = self + .users + .entry(user_id.to_string()) + .or_insert_with(|| { + tokio::spawn(run_client(session, cmd_rx, inbound)); + Arc::new(UserHandle { + cmd_tx, + joined: Mutex::new(HashSet::new()), + last_active: Mutex::new(Instant::now()), + }) + }) + .clone(); + info!(%user_id, "opened per-user IRC session"); + entry + } + + fn drop_user(&self, user_id: &str) { + self.users.remove(user_id); + debug!(%user_id, "dropped per-user IRC session"); + } + + /// QUIT and remove sessions idle longer than `max_idle`. + pub async fn reap_idle(&self, max_idle: Duration) { + let now = Instant::now(); + let mut stale = Vec::new(); + for kv in &self.users { + let last = *kv.value().last_active.lock().unwrap_or_else(PoisonError::into_inner); + if now.duration_since(last) > max_idle { + stale.push(kv.key().clone()); + } + } + for key in stale { + if let Some((_, handle)) = self.users.remove(&key) { + let _ = handle.cmd_tx.send("QUIT :idle".to_string()).await; + debug!(user_id = %key, "reaped idle per-user IRC session"); + } + } + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/subscriber.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/subscriber.rs new file mode 100644 index 00000000..0edfc1ff --- /dev/null +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-bus-irc/src/subscriber.rs @@ -0,0 +1,41 @@ +//! Subscriber side: IRC PRIVMSG -> gateway EVENT. + +use async_trait::async_trait; +use realtime_core::{EventBusSubscriber, EventEnvelope, EventId, Result}; +use tokio::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; +use tracing::warn; + +/// Receives IRC-originated events from the session task's broadcast channel. +pub struct IrcSubscriber { + rx: broadcast::Receiver, +} + +impl IrcSubscriber { + pub(crate) const fn new(rx: broadcast::Receiver) -> Self { + Self { rx } + } +} + +#[async_trait] +impl EventBusSubscriber for IrcSubscriber { + async fn next_event(&mut self) -> Option { + loop { + match self.rx.recv().await { + Ok(event) => return Some(event), + Err(RecvError::Lagged(skipped)) => { + warn!(skipped, "IRC subscriber lagged; dropped events"); + } + Err(RecvError::Closed) => return None, + } + } + } + + async fn ack(&self, _event_id: &EventId) -> Result<()> { + Ok(()) // fire-and-forget bus: nothing to acknowledge + } + + async fn nack(&self, _event_id: &EventId) -> Result<()> { + Ok(()) + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-gateway/src/fanout.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-gateway/src/fanout.rs index b5b2b54d..a5877e26 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-gateway/src/fanout.rs +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-gateway/src/fanout.rs @@ -149,11 +149,12 @@ mod tests { .await .unwrap(); - let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + let (sub_id, received) = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) .await .unwrap() .unwrap(); + assert_eq!(sub_id, "sub-1"); assert_eq!(received.event_type, "test"); } } diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-gateway/src/ws_handler/handlers.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-gateway/src/ws_handler/handlers.rs index 6c48fa7f..6fa43b16 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-gateway/src/ws_handler/handlers.rs +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-gateway/src/ws_handler/handlers.rs @@ -15,8 +15,8 @@ use std::net::SocketAddr; use bytes::Bytes; use chrono::Utc; use realtime_core::{ - filter::FilterExpr, AuthContext, ConnectionId, EventEnvelope, ServerMessage, SubscribeItem, - Subscription, SubscriptionId, TopicPath, TopicPattern, + filter::FilterExpr, AuthContext, ConnectionId, EventEnvelope, EventSource, ServerMessage, + SourceKind, SubscribeItem, Subscription, SubscriptionId, TopicPath, TopicPattern, }; use smol_str::SmolStr; use tokio::sync::mpsc; @@ -178,11 +178,30 @@ pub(super) async fn handle_publish( return Action::Continue; } }; - let envelope = EventEnvelope::new( + let mut envelope = EventEnvelope::new( TopicPath::new(&topic), &event_type, Bytes::from(payload_bytes), ); + // Stamp the originating platform user so identity-aware buses (e.g. the IRC + // bridge) can attribute the event to that user rather than a service nick. + if let Some(claims) = &auth.claims { + let mut metadata = std::collections::HashMap::new(); + if let Some(handle) = claims + .metadata + .get("handle") + .or_else(|| claims.metadata.get("name")) + .or_else(|| claims.metadata.get("preferred_username")) + .and_then(serde_json::Value::as_str) + { + metadata.insert("handle".to_string(), handle.to_string()); + } + envelope.source = Some(EventSource { + kind: SourceKind::Api, + id: claims.sub.clone(), + metadata, + }); + } if let Err(e) = state .bus_publisher .publish(envelope.topic.as_str(), &envelope) diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/Cargo.toml b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/Cargo.toml index 314e7b3e..578887cc 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/Cargo.toml +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" realtime-core = { path = "../realtime-core" } realtime-engine = { path = "../realtime-engine" } realtime-bus-inprocess = { path = "../realtime-bus-inprocess" } +realtime-bus-irc = { path = "../realtime-bus-irc" } realtime-auth = { path = "../realtime-auth" } realtime-gateway = { path = "../realtime-gateway" } realtime-db-postgres = { path = "../realtime-db-postgres" } diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/config/auth.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/config/auth.rs index edb57000..9b4b0db4 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/config/auth.rs +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/config/auth.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; /// /// - `NoAuth` — accepts all tokens (development only). /// - `Jwt` — validates HMAC-SHA256 / RSA tokens. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Clone, Default, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AuthConfig { #[serde(rename = "none")] @@ -33,3 +33,19 @@ pub enum AuthConfig { audience: Option, }, } + +/// Manual Debug: the server logs its config at startup (`Auth: {:?}`), and the +/// derived impl printed the JWT secret in plain text into container logs. +impl std::fmt::Debug for AuthConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoAuth => write!(f, "NoAuth"), + Self::Jwt { issuer, audience, .. } => f + .debug_struct("Jwt") + .field("secret", &"") + .field("issuer", issuer) + .field("audience", audience) + .finish(), + } + } +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/config/bus.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/config/bus.rs index 0971f1a2..1485ad16 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/config/bus.rs +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/config/bus.rs @@ -16,8 +16,9 @@ use serde::{Deserialize, Serialize}; /// Event bus backend selection. /// -/// Currently only `InProcess` is supported. Future variants could include -/// Redis Streams, NATS `JetStream`, or Apache Kafka. +/// `InProcess` is the default single-node bus. `Irc` bridges the gateway to an +/// external RFC 2812 IRC server (e.g. `ircserv`): topics in the configured +/// namespace map to IRC channels. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum EventBusConfig { @@ -26,6 +27,35 @@ pub enum EventBusConfig { #[serde(default = "default_bus_capacity")] capacity: usize, }, + #[serde(rename = "irc")] + Irc { + /// IRC server host. + host: String, + /// IRC server port. + #[serde(default = "default_irc_port")] + port: u16, + /// Server password (`PASS`); empty to skip. + #[serde(default)] + password: String, + /// Service nickname for the relay connection. + #[serde(default = "default_irc_nick")] + nick: String, + /// IRC username. + #[serde(default = "default_irc_user")] + user: String, + /// IRC realname. + #[serde(default = "default_irc_realname")] + realname: String, + /// Channels to auto-join on connect. + #[serde(default)] + channels: Vec, + /// Gateway topic namespace bridged to IRC. + #[serde(default = "default_irc_namespace")] + namespace: String, + /// Inbound broadcast channel capacity. + #[serde(default = "default_bus_capacity")] + capacity: usize, + }, } impl Default for EventBusConfig { @@ -39,3 +69,23 @@ impl Default for EventBusConfig { const fn default_bus_capacity() -> usize { 65536 } + +const fn default_irc_port() -> u16 { + 6667 +} + +fn default_irc_nick() -> String { + "platform-gw".to_string() +} + +fn default_irc_user() -> String { + "platform".to_string() +} + +fn default_irc_realname() -> String { + "Realtime Gateway".to_string() +} + +fn default_irc_namespace() -> String { + "chat".to_string() +} diff --git a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/server.rs b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/server.rs index 3d64edd7..06391e20 100644 --- a/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/server.rs +++ b/apps/baas/mini-baas-infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/server.rs @@ -25,6 +25,7 @@ use axum::{ }; use realtime_auth::NoAuthProvider; use realtime_bus_inprocess::InProcessBus; +use realtime_bus_irc::{IrcBus, IrcBusConfig}; use realtime_core::{AuthProvider, DatabaseProducer, EventBus, EventBusPublisher}; use realtime_engine::{ registry::SubscriptionRegistry, router::EventRouter, sequence::SequenceGenerator, @@ -83,6 +84,27 @@ pub async fn run(config: ServerConfig) -> anyhow::Result<()> { fn build_event_bus(config: &ServerConfig) -> Arc { let bus: Arc = match &config.event_bus { EventBusConfig::InProcess { capacity } => Arc::new(InProcessBus::new(*capacity)), + EventBusConfig::Irc { + host, + port, + password, + nick, + user, + realname, + channels, + namespace, + capacity, + } => Arc::new(IrcBus::new(IrcBusConfig { + host: host.clone(), + port: *port, + password: password.clone(), + nick: nick.clone(), + user: user.clone(), + realname: realname.clone(), + channels: channels.clone(), + namespace: namespace.clone(), + capacity: *capacity, + })), }; bus } diff --git a/apps/baas/mini-baas-infra/go/control-plane/Dockerfile b/apps/baas/mini-baas-infra/go/control-plane/Dockerfile index ec7c6e01..4dafdf39 100644 --- a/apps/baas/mini-baas-infra/go/control-plane/Dockerfile +++ b/apps/baas/mini-baas-infra/go/control-plane/Dockerfile @@ -12,12 +12,20 @@ FROM golang:1.23-bookworm AS builder ARG APP WORKDIR /src +# Module + build caches persist across builds (and across the APP variants), +# so each binary build only compiles what changed instead of cold-compiling +# the whole dependency graph per app. COPY go.mod go.sum* ./ ENV GOFLAGS=-mod=mod -RUN go mod download all || go mod tidy +RUN --mount=type=cache,id=gomod,target=/go/pkg/mod,sharing=locked \ + go mod download all || true COPY . . -RUN go mod tidy && \ +# -mod=mod lets the build resolve anything go.sum is missing; `go mod tidy` +# at image-build time rewrote go.mod inside the image (non-reproducible) and +# re-resolved the graph on every build. +RUN --mount=type=cache,id=gomod,target=/go/pkg/mod,sharing=locked \ + --mount=type=cache,id=gobuild,target=/root/.cache/go-build,sharing=locked \ CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ -o /out/app ./cmd/${APP} diff --git a/apps/baas/mini-baas-infra/scripts/seed-live-demo.sh b/apps/baas/mini-baas-infra/scripts/seed-live-demo.sh new file mode 100644 index 00000000..13c56f29 --- /dev/null +++ b/apps/baas/mini-baas-infra/scripts/seed-live-demo.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash +# **************************************************************************** # +# # +# ::: :::::::: # +# seed-live-demo.sh :+: :+: :+: # +# +:+ +:+ +:+ # +# By: dlesieur +#+ +:+ +#+ # +# +#+#+#+#+#+ +#+ # +# Created: 2026/06/10 00:00:00 by dlesieur #+# #+# # +# Updated: 2026/06/10 00:00:00 by dlesieur ### ########.fr # +# # +# **************************************************************************** # +# +# Live-database demo seed (M22 / Phase 7): a realistic commerce+ops company +# spread across the three engines, owned by the osionos app's own API key so +# the notion-database-sys live mode can read AND edit every row. +# +# pg-commerce postgres `commerce` db — customers, products, employees, +# inventory, orders, order_items, edges (enums + FKs + +# updated_at triggers) +# mysql-ops mysql `ops` db — projects, tasks, tickets, time_entries +# (ENUM columns, FK constraints, tinyint(1) booleans) +# mongo-activity mongo `activity` db — events, product_reviews, notes +# ($jsonSchema validators → exact introspection, enum kinds) +# +# Identity model (the part that makes edits work): the app authenticates with +# X-Baas-Api-Key only, so the data plane stamps owner_id = `api-key:` +# on writes and owner-scopes updates/deletes (MySQL/Mongo also scope reads). +# The seeder therefore (1) resolves the APP'S key → tenant + key uuid through +# tenant-control /v1/keys/verify, (2) registers the mounts under that tenant, +# and (3) stamps that exact principal on every bulk row. Control-plane steps +# go through the REAL gateway path; bulk data goes straight into the engines +# (the gateway insert op is single-row — 165k HTTP calls is not a seeder). +# +# Deterministic (PRNG seed 42, anchored clock) + idempotent (CREATE IF NOT +# EXISTS / ON CONFLICT DO NOTHING / INSERT IGNORE / insertMany ordered:false): +# run it twice, get identical counts. RESEED=1 drops the three demo databases +# first (destructive, demo data only). +# +# Usage: make seed-live-demo # from apps/baas/mini-baas-infra +# RESEED=1 make seed-live-demo # drop + reload from scratch +# SEED_PAGES=0 ... # skip the osionos workspace pages +# Env: BAAS_API_KEY (defaults to VITE_BAAS_API_KEY from the app .env), +# APP_ENV_FILE, SEED_SCALE, SKIP_ENV_WRITE=1 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BAAS_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${BAAS_DIR}/../../.." && pwd)" +cd "${BAAS_DIR}" + +cyan() { printf '\033[0;36m%s\033[0m\n' "$*"; } +green() { printf '\033[0;32m%s\033[0m\n' "$*"; } +red() { printf '\033[0;31m%s\033[0m\n' "$*"; } +step() { cyan "[SEED] $*"; } +pass() { green "[SEED] PASS: $*"; } +fail() { red "[SEED] FAIL: $*"; exit 1; } + +# Container-env / host-port / json helpers (shared with the verify gates). +# shellcheck source=scripts/verify/lib-live-tenant.sh +source "${SCRIPT_DIR}/verify/lib-live-tenant.sh" + +APP_ENV_FILE="${APP_ENV_FILE:-${REPO_ROOT}/apps/osionos/app/.env}" +NODE_IMAGE="${BAAS_WS_NODE_IMAGE:-node:22-alpine}" +DC=(docker compose) + +# ── 1) stack endpoints + the app's identity ───────────────────────────────── +step "resolving the running stack" +KONG_PORT="$(_lt_host_port mini-baas-kong 8000/tcp)" +TC_PORT="$(_lt_host_port mini-baas-tenant-control 3022/tcp)" +[[ -n "${KONG_PORT}" && -n "${TC_PORT}" ]] || fail "mini-baas stack not up (kong/tenant-control unmapped) — run: make up EDITION=full" +KONG_URL="http://127.0.0.1:${KONG_PORT}" +TC_URL="http://127.0.0.1:${TC_PORT}" +SERVICE_TOKEN="$(_lt_env mini-baas-tenant-control INTERNAL_SERVICE_TOKEN)" +ANON_KEY="$(_lt_env mini-baas-kong KONG_PUBLIC_API_KEY)" +SERVICE_KEY="$(_lt_env mini-baas-kong KONG_SERVICE_API_KEY)" +RT_JWT_SECRET="$(_lt_env mini-baas-realtime REALTIME_JWT_SECRET)" +[[ -n "${SERVICE_TOKEN}" && -n "${ANON_KEY}" && -n "${SERVICE_KEY}" ]] || fail "control-plane credentials not found on the running containers" + +APP_KEY="${BAAS_API_KEY:-$(sed -n 's/^VITE_BAAS_API_KEY=//p' "${APP_ENV_FILE}" 2>/dev/null | head -1)}" +[[ "${APP_KEY}" == mbk_* ]] || fail "no tenant API key — set BAAS_API_KEY or VITE_BAAS_API_KEY in ${APP_ENV_FILE}" +verify=$(curl -fsS -X POST "${TC_URL}/v1/keys/verify" \ + -H "X-Service-Token: ${SERVICE_TOKEN}" -H 'Content-Type: application/json' \ + -d "{\"key\":\"${APP_KEY}\"}") || fail "tenant-control /v1/keys/verify unreachable" +echo "${verify}" | grep -q '"valid":true' || fail "the app key is not valid: ${verify}" +TENANT="$(echo "${verify}" | _lt_json_field tenant_id)" +KEY_ID="$(echo "${verify}" | _lt_json_field key_id)" +[[ -n "${TENANT}" && -n "${KEY_ID}" ]] || fail "verify response missing tenant_id/key_id: ${verify}" +OWNER="api-key:${KEY_ID}" +pass "app key → tenant '${TENANT}', owner principal '${OWNER}'" + +# ── 2) engine credentials + demo databases ────────────────────────────────── +PG_USER="$(_lt_env mini-baas-postgres POSTGRES_USER)"; PG_USER="${PG_USER:-postgres}" +PG_PASS="$(_lt_env mini-baas-postgres POSTGRES_PASSWORD)" +MYSQL_USER="$(_lt_env mini-baas-mysql MYSQL_USER)"; MYSQL_USER="${MYSQL_USER:-mini_baas}" +MYSQL_PASS="$(_lt_env mini-baas-mysql MYSQL_PASSWORD)" +MYSQL_ROOT_PASS="$(_lt_env mini-baas-mysql MYSQL_ROOT_PASSWORD)" +MONGO_USER="$(_lt_env mini-baas-mongo MONGO_INITDB_ROOT_USERNAME)"; MONGO_USER="${MONGO_USER:-mongo}" +MONGO_PASS="$(_lt_env mini-baas-mongo MONGO_INITDB_ROOT_PASSWORD)" +[[ -n "${PG_PASS}" && -n "${MYSQL_PASS}" && -n "${MONGO_PASS}" ]] || fail "engine credentials not found on the running containers" +urlenc() { python3 -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1"; } + +if [[ "${RESEED:-0}" == "1" ]]; then + step "RESEED=1 — dropping the demo databases (commerce / ops / activity)" + "${DC[@]}" exec -T postgres psql -U "${PG_USER}" -d postgres \ + -c "DROP DATABASE IF EXISTS commerce WITH (FORCE)" >/dev/null + "${DC[@]}" exec -T -e MYSQL_PWD="${MYSQL_ROOT_PASS}" mysql \ + mysql -uroot -e "DROP DATABASE IF EXISTS ops" >/dev/null + "${DC[@]}" exec -T mongo mongosh --quiet \ + "mongodb://${MONGO_USER}:$(urlenc "${MONGO_PASS}")@127.0.0.1:27017/activity?authSource=admin" \ + --eval 'db.dropDatabase()' >/dev/null +fi + +step "ensuring the demo databases exist" +"${DC[@]}" exec -T postgres psql -U "${PG_USER}" -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname='commerce'" | grep -q 1 \ + || "${DC[@]}" exec -T postgres psql -U "${PG_USER}" -d postgres -c "CREATE DATABASE commerce" >/dev/null +"${DC[@]}" exec -T -e MYSQL_PWD="${MYSQL_ROOT_PASS}" mysql mysql -uroot -e \ + "CREATE DATABASE IF NOT EXISTS ops CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + GRANT ALL PRIVILEGES ON ops.* TO '${MYSQL_USER}'@'%'; FLUSH PRIVILEGES;" >/dev/null +pass "commerce (pg) + ops (mysql) ready; activity (mongo) is implicit" + +# ── 3) mounts through the real gateway (idempotent by tenant+name) ────────── +register_mount() { # $1 name, $2 engine, $3 dsn → echoes mount id + local code + code=$(curl -s -o /tmp/seed-mount.json -w '%{http_code}' -X POST \ + "${KONG_URL}/admin/v1/databases" \ + -H "apikey: ${SERVICE_KEY}" -H "X-Tenant-Id: ${TENANT}" \ + -H 'Content-Type: application/json' \ + -d "{\"engine\":\"$2\",\"name\":\"$1\",\"connection_string\":\"$3\"}") + if [[ "${code}" == "201" ]]; then + _lt_json_field id < /tmp/seed-mount.json + elif [[ "${code}" == "409" ]]; then + curl -fsS "${KONG_URL}/admin/v1/databases" \ + -H "apikey: ${SERVICE_KEY}" -H "X-Tenant-Id: ${TENANT}" \ + | python3 -c "import json, sys; rows = json.load(sys.stdin); print(next(r['id'] for r in rows if r.get('name') == '$1'))" + else + fail "mount $1 registration failed (${code}): $(cat /tmp/seed-mount.json)" + fi +} +step "registering the three mounts under tenant '${TENANT}'" +PG_DB_ID="$(register_mount pg-commerce postgresql \ + "postgres://$(urlenc "${PG_USER}"):$(urlenc "${PG_PASS}")@postgres:5432/commerce")" +MY_DB_ID="$(register_mount mysql-ops mysql \ + "mysql://$(urlenc "${MYSQL_USER}"):$(urlenc "${MYSQL_PASS}")@mysql:3306/ops")" +MG_DB_ID="$(register_mount mongo-activity mongodb \ + "mongodb://$(urlenc "${MONGO_USER}"):$(urlenc "${MONGO_PASS}")@mongo:27017/activity?authSource=admin")" +[[ -n "${PG_DB_ID}" && -n "${MY_DB_ID}" && -n "${MG_DB_ID}" ]] || fail "mount registration returned empty ids" +pass "mounts: pg-commerce=${PG_DB_ID} mysql-ops=${MY_DB_ID} mongo-activity=${MG_DB_ID}" + +# ── 4) generate the dataset (deterministic, in a pinned node container) ───── +step "generating the dataset (seed 42${SEED_SCALE:+, scale ${SEED_SCALE}})" +OUT_DIR="$(mktemp -d /tmp/seed-live-demo.XXXXXX)" +trap 'rm -rf "${OUT_DIR}"' EXIT +docker run --rm --network none \ + -v "${SCRIPT_DIR}/seed/live-demo-generate.mjs:/gen.mjs:ro" -v "${OUT_DIR}:/out" \ + -e SEED_OWNER="${OWNER}" -e SEED_TENANT="${TENANT}" -e SEED_SCALE="${SEED_SCALE:-1}" \ + "${NODE_IMAGE}" node /gen.mjs || fail "generator failed" +count_of() { python3 -c "import json; print(json.load(open('${OUT_DIR}/counts.json'))['$1']['$2'])"; } + +# ── 5) load (idempotent: re-runs converge on identical counts) ────────────── +step "loading postgres commerce ($(count_of pg orders) orders / $(count_of pg order_items) items)" +"${DC[@]}" exec -T postgres psql -U "${PG_USER}" -d commerce -q \ + < "${OUT_DIR}/pg-commerce.sql" >/dev/null || fail "postgres load failed" +step "loading mysql ops ($(count_of mysql tasks) tasks / $(count_of mysql tickets) tickets)" +"${DC[@]}" exec -T -e MYSQL_PWD="${MYSQL_PASS}" mysql mysql -u"${MYSQL_USER}" ops \ + < "${OUT_DIR}/mysql-ops.sql" || fail "mysql load failed" +step "loading mongo activity ($(count_of mongo events) events / $(count_of mongo product_reviews) reviews)" +# Sibling container, NOT `exec` into mini-baas-mongo: mongosh is a Node app +# and parsing the multi-MB seed script inside mongod's 512MB cgroup OOM-kills +# the database container. Same image (version-matched mongosh), stack network. +STACK_NET="$(docker inspect mini-baas-kong \ + --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' | head -1)" +MONGO_IMAGE="$(docker inspect mini-baas-mongo --format '{{.Config.Image}}')" +docker run --rm --network "${STACK_NET}" \ + -v "${OUT_DIR}/mongo-activity.js:/seed.js:ro" "${MONGO_IMAGE}" \ + mongosh --quiet \ + "mongodb://${MONGO_USER}:$(urlenc "${MONGO_PASS}")@mongo:27017/activity?authSource=admin" \ + /seed.js || fail "mongo load failed" +pass "all three engines loaded" + +step "verifying engine row counts against the generator manifest" +pg_count() { "${DC[@]}" exec -T postgres psql -U "${PG_USER}" -d commerce -tAc "SELECT count(*) FROM $1"; } +my_count() { "${DC[@]}" exec -T -e MYSQL_PWD="${MYSQL_PASS}" mysql mysql -u"${MYSQL_USER}" ops -N -e "SELECT count(*) FROM $1"; } +mg_count() { docker run --rm --network "${STACK_NET}" "${MONGO_IMAGE}" mongosh --quiet "mongodb://${MONGO_USER}:$(urlenc "${MONGO_PASS}")@mongo:27017/activity?authSource=admin" --eval "print(db.$1.countDocuments())"; } +for table in customers products employees inventory orders order_items edges; do + actual="$(pg_count "${table}" | tr -d '[:space:]')"; expected="$(count_of pg "${table}")" + [[ "${actual}" == "${expected}" ]] || fail "pg ${table}: ${actual} rows, expected ${expected}" +done +for table in projects tasks tickets time_entries; do + actual="$(my_count "${table}" | tr -d '[:space:]')"; expected="$(count_of mysql "${table}")" + [[ "${actual}" == "${expected}" ]] || fail "mysql ${table}: ${actual} rows, expected ${expected}" +done +for coll in events product_reviews notes; do + actual="$(mg_count "${coll}" | tr -d '[:space:]')"; expected="$(count_of mongo "${coll}")" + [[ "${actual}" == "${expected}" ]] || fail "mongo ${coll}: ${actual} docs, expected ${expected}" +done +pass "every table matches the manifest (idempotent re-runs converge here)" + +# ── 6) realtime token + app env wiring ────────────────────────────────────── +step "minting the app realtime WS token (HS256, 30 days)" +RT_TOKEN="$(docker run --rm --network none -e RT_JWT_SECRET="${RT_JWT_SECRET}" "${NODE_IMAGE}" node -e ' +const { createHmac } = require("node:crypto"); +const b64u = (o) => Buffer.from(JSON.stringify(o)).toString("base64url"); +const head = b64u({ alg: "HS256", typ: "JWT" }); +const body = b64u({ sub: "osionos-live-demo", exp: Math.floor(Date.now() / 1000) + 30 * 86400 }); +const sig = createHmac("sha256", process.env.RT_JWT_SECRET).update(`${head}.${body}`).digest("base64url"); +console.log(`${head}.${body}.${sig}`);')" +[[ -n "${RT_TOKEN}" ]] || fail "realtime token mint failed" + +LIVE_MOUNTS_JSON="[{\"dbId\":\"${PG_DB_ID}\",\"name\":\"pg-commerce\",\"engine\":\"postgresql\"},{\"dbId\":\"${MY_DB_ID}\",\"name\":\"mysql-ops\",\"engine\":\"mysql\"},{\"dbId\":\"${MG_DB_ID}\",\"name\":\"mongo-activity\",\"engine\":\"mongodb\"}]" +if [[ "${SKIP_ENV_WRITE:-0}" != "1" ]]; then + step "writing live-demo keys into ${APP_ENV_FILE}" + python3 - "${APP_ENV_FILE}" </dev/null)" ]]; then + step "seeding the 'Live Databases' pages into dylan's osionos workspace" + DYLAN="ff284cf3-ab7d-4756-ade3-369257e36b2a" + WS="$("${ROOT_DC[@]}" exec -T postgres psql -U postgres -d postgres -tAc \ + "SELECT workspace_id FROM public.osionos_pages WHERE owner_id='${DYLAN}' GROUP BY 1 ORDER BY count(*) DESC LIMIT 1" \ + | tr -d '[:space:]')" + WS="${WS:-0ea96910-277a-49d6-901c-524b147cc009}" + python3 "${SCRIPT_DIR}/seed/live-demo-pages.py" \ + "${WS}" "${DYLAN}" "${PG_DB_ID}" "${MY_DB_ID}" "${MG_DB_ID}" \ + | "${ROOT_DC[@]}" exec -T postgres psql -U postgres -d postgres -q -v ON_ERROR_STOP=1 >/dev/null \ + || fail "workspace page seed failed" + # Dylan must also SEE the shared agency wiki (26 pages, visibility=shared, + # seeded by tools/seeds/seed_agency_wiki.py into the org workspace): grant + # editor membership so the workspace shows up in his switcher. + AGENCY_WS="b1a0c1e5-0000-4000-a000-000000000001" + if "${ROOT_DC[@]}" exec -T postgres psql -U postgres -d postgres -tAc \ + "SELECT 1 FROM public.osionos_workspaces WHERE id='${AGENCY_WS}'" 2>/dev/null | grep -q 1; then + "${ROOT_DC[@]}" exec -T postgres psql -U postgres -d postgres -q -c \ + "INSERT INTO public.osionos_workspace_members (workspace_id, user_id, role, permissions) + VALUES ('${AGENCY_WS}','${DYLAN}','editor', ARRAY['read','write']) + ON CONFLICT (workspace_id, user_id) DO NOTHING" \ + && pass "dylan is an editor of the agency org workspace (shared wiki visible)" + fi + if "${ROOT_DC[@]}" exec -T postgres psql -U postgres -d postgres -tAc \ + "SELECT 1 FROM auth.users WHERE email='dylan@gmail.com'" 2>/dev/null | grep -q 1; then + pass "pages seeded in workspace ${WS}; dylan@gmail.com exists in gotrue" + else + red "[SEED] WARN: dylan@gmail.com not found in auth.users — sign the account up once via the website" + fi + else + red "[SEED] WARN: root-stack postgres is not running — skipped the workspace pages (run 'make all' at the repo root, then re-run with SEED_PAGES=1)" + fi +fi + +green "[SEED] OK — live demo ready: pg-commerce=${PG_DB_ID} mysql-ops=${MY_DB_ID} mongo-activity=${MG_DB_ID} (owner ${OWNER})" diff --git a/apps/baas/mini-baas-infra/scripts/seed/agency-policies.sh b/apps/baas/mini-baas-infra/scripts/seed/agency-policies.sh new file mode 100755 index 00000000..3ee2bf3b --- /dev/null +++ b/apps/baas/mini-baas-infra/scripts/seed/agency-policies.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# **************************************************************************** # +# agency-policies.sh — ABAC roles + policies for the Binocle agency # +# # +# Seeds the permission-engine store (migration 007 tables in the mini-baas # +# postgres) with the agency role model: # +# - 11 roles, slug-namespaced `agency:*` (Go EnsureRole discipline), # +# attributes (department / clearance / region) in roles.metadata # +# - user_roles for the owner + 20 employees from tools/seeds/ # +# .agency-people.env # +# - resource_policies per table with attribute-flavoured field masks: # +# analyst → transactions.amount redacted '***' # +# clearance < 3 → subjects.ssn hidden, cases.budget redacted # +# non-finance → assignments.hourly_rate redacted '—' # +# guest → explicit deny on evidence + communications # +# # +# Idempotent: roles upsert on name; agency:* policies are replaced # +# wholesale each run (they are the only policies this script owns). # +# # +# osionos AbacEngine note: workspace-level defaults ride on its built-in # +# role fallback (owner/admin/member/guest); explicit page rules are seeded # +# by the share-dialog workstream, not here. # +# **************************************************************************** # +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../../.." && pwd)" +PEOPLE_ENV="${REPO_ROOT}/tools/seeds/.agency-people.env" +PG_CTN="mini-baas-postgres" + +cyan() { printf '\033[0;36m[agency-policies] %s\033[0m\n' "$*"; } +fail() { printf '\033[0;31m[agency-policies] FAIL: %s\033[0m\n' "$*" >&2; exit 1; } + +[[ -f "${PEOPLE_ENV}" ]] || fail "missing ${PEOPLE_ENV} (run seed_agency_people.sh first)" +docker inspect "${PG_CTN}" >/dev/null 2>&1 || fail "${PG_CTN} not running" + +PSQL() { docker exec -i "${PG_CTN}" psql -U postgres -d postgres -v ON_ERROR_STOP=1 -q "$@"; } + +# ── roles + metadata ────────────────────────────────────────────────────────── +cyan "upserting 11 agency:* roles" +PSQL <<'SQL' +INSERT INTO public.roles (name, description, is_system, metadata) VALUES + ('agency:director', 'Agency director — full authority', false, '{"department":"command","clearance":5,"org":"agency"}'), + ('agency:deputy_director', 'Deputy director — full authority', false, '{"department":"command","clearance":5,"org":"agency"}'), + ('agency:case_manager', 'Case manager — runs case operations', false, '{"department":"operations","clearance":4,"org":"agency"}'), + ('agency:senior_investigator', 'Senior investigator — case work, no finance', false, '{"department":"investigations","clearance":4,"org":"agency"}'), + ('agency:field_agent', 'Field agent — collection, low clearance', false, '{"department":"investigations","clearance":2,"org":"agency"}'), + ('agency:analyst', 'Intelligence analyst — reads all, masked finance',false, '{"department":"analysis","clearance":3,"org":"agency"}'), + ('agency:forensics', 'Forensics — evidence custody', false, '{"department":"forensics","clearance":3,"org":"agency"}'), + ('agency:surveillance', 'Surveillance — comms & locations, low clearance', false, '{"department":"surveillance","clearance":2,"org":"agency"}'), + ('agency:legal', 'Legal counsel — review access', false, '{"department":"legal","clearance":3,"org":"agency"}'), + ('agency:accountant', 'Accountant — finance only', false, '{"department":"finance","clearance":3,"org":"agency"}'), + ('agency:it_admin', 'IT admin — systems, least data access', false, '{"department":"it","clearance":4,"org":"agency"}') +ON CONFLICT (name) DO UPDATE SET + description = EXCLUDED.description, metadata = EXCLUDED.metadata, updated_at = now(); +SQL + +# ── user → role assignments from the roster ─────────────────────────────────── +cyan "assigning roles to the 21 roster members" +{ + echo "BEGIN;" + grep "^AGENCY_PERSON_[0-9]" "${PEOPLE_ENV}" | while IFS='=' read -r _key value; do + IFS='|' read -r uuid _email _name role _dept _clr _region _ws_role <<<"${value}" + cat </dev/null <<'SQL' +BEGIN; +DELETE FROM public.resource_policies + WHERE role_id IN (SELECT id FROM public.roles WHERE name LIKE 'agency:%'); + +CREATE FUNCTION pg_temp.pol( + p_role TEXT, p_resource TEXT, p_actions TEXT[], + p_effect TEXT, p_priority INT, p_conditions JSONB DEFAULT NULL +) RETURNS VOID AS $$ + INSERT INTO public.resource_policies + (role_id, resource_type, resource_name, actions, conditions, effect, priority) + SELECT r.id, 'table', p_resource, p_actions, p_conditions, p_effect, p_priority + FROM public.roles r WHERE r.name = p_role; +$$ LANGUAGE SQL; + +-- command: full authority on every table +SELECT pg_temp.pol('agency:director', '*', ARRAY['select','insert','update','delete'], 'allow', 100); +SELECT pg_temp.pol('agency:deputy_director', '*', ARRAY['select','insert','update','delete'], 'allow', 100); + +-- operations: everything except deleting evidence (custody integrity) +SELECT pg_temp.pol('agency:case_manager', '*', ARRAY['select','insert','update','delete'], 'allow', 80); +SELECT pg_temp.pol('agency:case_manager', 'evidence', ARRAY['delete'], 'deny', 90); + +-- senior investigators: full case work, read-only finance, rates masked +SELECT pg_temp.pol('agency:senior_investigator', '*', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:senior_investigator', 'transactions', ARRAY['insert','update','delete'], 'deny', 80); +SELECT pg_temp.pol('agency:senior_investigator', 'assignments', ARRAY['select'], 'allow', 75, + '{"mask":{"redact":{"hourly_rate":"—"}}}'); + +-- field agents (clearance 2): collection surfaces; no finance/comms; +-- subjects ssn hidden, case budgets redacted +SELECT pg_temp.pol('agency:field_agent', 'cases', ARRAY['select'], 'allow', 70, + '{"mask":{"redact":{"budget":"***"}}}'); +SELECT pg_temp.pol('agency:field_agent', 'subjects', ARRAY['select'], 'allow', 70, + '{"mask":{"hide":["ssn"]}}'); +SELECT pg_temp.pol('agency:field_agent', 'leads', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:field_agent', 'evidence', ARRAY['select','insert'], 'allow', 70); +SELECT pg_temp.pol('agency:field_agent', 'locations', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:field_agent', 'vehicles', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:field_agent', 'assignments', ARRAY['select'], 'allow', 70, + '{"mask":{"redact":{"hourly_rate":"—"}}}'); +SELECT pg_temp.pol('agency:field_agent', 'transactions', ARRAY['select','insert','update','delete'], 'deny', 90); +SELECT pg_temp.pol('agency:field_agent', 'communications', ARRAY['select','insert','update','delete'], 'deny', 90); + +-- analysts (clearance 3): read everything, write reports/leads; +-- transaction amounts redacted, rates masked +SELECT pg_temp.pol('agency:analyst', '*', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:analyst', 'transactions', ARRAY['select'], 'allow', 75, + '{"mask":{"redact":{"amount":"***"}}}'); +SELECT pg_temp.pol('agency:analyst', 'assignments', ARRAY['select'], 'allow', 75, + '{"mask":{"redact":{"hourly_rate":"—"}}}'); +SELECT pg_temp.pol('agency:analyst', 'reports', ARRAY['insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:analyst', 'leads', ARRAY['insert','update'], 'allow', 70); + +-- forensics: evidence custody + case context +SELECT pg_temp.pol('agency:forensics', 'evidence', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:forensics', 'cases', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:forensics', 'subjects', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:forensics', 'reports', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:forensics', 'locations',ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:forensics', 'transactions', ARRAY['select','insert','update','delete'], 'deny', 90); + +-- surveillance (clearance 2): comms + movement surfaces; no finance +SELECT pg_temp.pol('agency:surveillance', 'communications', ARRAY['select','insert'], 'allow', 70); +SELECT pg_temp.pol('agency:surveillance', 'locations', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:surveillance', 'vehicles', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:surveillance', 'subjects', ARRAY['select'], 'allow', 70, + '{"mask":{"hide":["ssn"]}}'); +SELECT pg_temp.pol('agency:surveillance', 'cases', ARRAY['select'], 'allow', 70, + '{"mask":{"redact":{"budget":"***"}}}'); +SELECT pg_temp.pol('agency:surveillance', 'transactions', ARRAY['select','insert','update','delete'], 'deny', 90); + +-- legal: review-only on the case record; privileged comms stay out of reach +SELECT pg_temp.pol('agency:legal', 'cases', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:legal', 'subjects', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:legal', 'evidence', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:legal', 'reports', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:legal', 'assignments', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:legal', 'communications', ARRAY['select','insert','update','delete'], 'deny', 90); +SELECT pg_temp.pol('agency:legal', 'transactions', ARRAY['select','insert','update','delete'], 'deny', 90); + +-- accountant: finance surfaces only (rates and amounts unmasked — their job) +SELECT pg_temp.pol('agency:accountant', 'transactions', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:accountant', 'assignments', ARRAY['select','insert','update'], 'allow', 70); +SELECT pg_temp.pol('agency:accountant', 'cases', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:accountant', 'subjects', ARRAY['select','insert','update','delete'], 'deny', 90); +SELECT pg_temp.pol('agency:accountant', 'evidence', ARRAY['select','insert','update','delete'], 'deny', 90); +SELECT pg_temp.pol('agency:accountant', 'communications', ARRAY['select','insert','update','delete'], 'deny', 90); + +-- it_admin: least data access — case index + reports only +SELECT pg_temp.pol('agency:it_admin', 'cases', ARRAY['select'], 'allow', 70); +SELECT pg_temp.pol('agency:it_admin', 'reports', ARRAY['select'], 'allow', 70); + +COMMIT; +SQL + +# ── guest deny demo (system role — additive, owned policies tagged by name) ── +cyan "ensuring guest deny policies on evidence + communications" +PSQL <<'SQL' +INSERT INTO public.resource_policies (role_id, resource_type, resource_name, actions, conditions, effect, priority) +SELECT r.id, 'table', t.tbl, ARRAY['select','insert','update','delete'], '{"org":"agency"}'::jsonb, 'deny', 100 + FROM public.roles r, (VALUES ('evidence'), ('communications')) AS t(tbl) + WHERE r.name = 'guest' + AND NOT EXISTS ( + SELECT 1 FROM public.resource_policies rp + WHERE rp.role_id = r.id AND rp.resource_type = 'table' + AND rp.resource_name = t.tbl AND rp.effect = 'deny'); +SQL + +ROLES=$(PSQL -At -c "SELECT count(*) FROM public.roles WHERE name LIKE 'agency:%'" /schema/ddl) — owner_id is auto-appended # +# - FK constraints added via psql (the DDL contract has no FK support; # +# introspection picks them up so the graph gets fk_ref edges) # +# # +# State lands in .agency-tenant.env next to the repo's mini-baas-infra root # +# so the data/policy seeders and verify gates can source it. # +# **************************************************************************** # +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INFRA_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +STATE_ENV="${INFRA_ROOT}/.agency-tenant.env" +PG_CTN="mini-baas-postgres" +AGENCY_DB="agency" +MOUNT_NAME="agency-db" +TENANT_SLUG="agency" + +cyan() { printf '\033[0;36m[agency-tenant] %s\033[0m\n' "$*"; } +fail() { printf '\033[0;31m[agency-tenant] FAIL: %s\033[0m\n' "$*" >&2; exit 1; } + +# shellcheck source=../verify/lib-live-tenant.sh +source "${SCRIPT_DIR}/../verify/lib-live-tenant.sh" + +# ── 0) endpoints + secrets from the running stack ──────────────────────────── +kong_port="$(_lt_host_port mini-baas-kong 8000/tcp)" +tc_port="$(_lt_host_port mini-baas-tenant-control 3022/tcp)" +[[ -n "${kong_port}" && -n "${tc_port}" ]] || fail "mini-baas stack not running (kong/tenant-control ports)" +KONG_URL="http://127.0.0.1:${kong_port}" +TC_URL="http://127.0.0.1:${tc_port}" +SERVICE_TOKEN="$(_lt_env mini-baas-tenant-control INTERNAL_SERVICE_TOKEN)" +ANON_KEY="$(_lt_env mini-baas-kong KONG_PUBLIC_API_KEY)" +SERVICE_KEY="$(_lt_env mini-baas-kong KONG_SERVICE_API_KEY)" +[[ -n "${SERVICE_TOKEN}" && -n "${ANON_KEY}" && -n "${SERVICE_KEY}" ]] || fail "stack secrets not found" +PG_USER="$(_lt_env "${PG_CTN}" POSTGRES_USER)"; PG_USER="${PG_USER:-postgres}" +PG_PASS="$(_lt_env "${PG_CTN}" POSTGRES_PASSWORD)"; PG_PASS="${PG_PASS:-postgres}" + +# ── 1) dedicated database ───────────────────────────────────────────────────── +cyan "ensuring database '${AGENCY_DB}' on ${PG_CTN}" +docker exec "${PG_CTN}" psql -U "${PG_USER}" -d postgres -tc \ + "SELECT 1 FROM pg_database WHERE datname='${AGENCY_DB}'" | grep -q 1 \ + || docker exec "${PG_CTN}" psql -U "${PG_USER}" -d postgres -c "CREATE DATABASE ${AGENCY_DB}" >/dev/null + +# ── 2) tenant ───────────────────────────────────────────────────────────────── +cyan "ensuring tenant '${TENANT_SLUG}'" +code=$(curl -s -o /tmp/agency-tenant.json -w '%{http_code}' -X POST "${TC_URL}/v1/tenants" \ + -H "X-Service-Token: ${SERVICE_TOKEN}" -H 'Content-Type: application/json' \ + -d "{\"id\":\"${TENANT_SLUG}\",\"name\":\"Binocle Intelligence Agency\"}") +[[ "${code}" == "201" || "${code}" == "409" ]] || fail "tenant create (${code}): $(cat /tmp/agency-tenant.json)" + +# ── 3) API key — reuse a still-valid key from a previous run ───────────────── +API_KEY=""; KEY_ID=""; DB_ID="" +if [[ -f "${STATE_ENV}" ]]; then + # shellcheck disable=SC1090 + source "${STATE_ENV}" + API_KEY="${AGENCY_API_KEY:-}"; KEY_ID="${AGENCY_KEY_ID:-}"; DB_ID="${AGENCY_DB_ID:-}" +fi +key_ok=0 +if [[ -n "${API_KEY}" && -n "${DB_ID}" ]]; then + probe=$(curl -s -o /dev/null -w '%{http_code}' "${KONG_URL}/query/v1/${DB_ID}/schema" \ + -H "apikey: ${ANON_KEY}" -H "X-Baas-Api-Key: ${API_KEY}") + [[ "${probe}" == "200" ]] && key_ok=1 +fi +if [[ "${key_ok}" == "1" ]]; then + cyan "reusing existing key + mount (${DB_ID})" +else + cyan "minting API key" + code=$(curl -s -o /tmp/agency-key.json -w '%{http_code}' -X POST \ + "${TC_URL}/v1/tenants/${TENANT_SLUG}/keys" \ + -H "X-Service-Token: ${SERVICE_TOKEN}" -H 'Content-Type: application/json' \ + -d '{"name":"agency-app","scopes":["read","write"]}') + [[ "${code}" == "201" ]] || fail "key mint (${code}): $(cat /tmp/agency-key.json)" + API_KEY="$(_lt_json_field key < /tmp/agency-key.json)" + KEY_ID="$(_lt_json_field id < /tmp/agency-key.json)" + [[ "${API_KEY}" == mbk_* ]] || fail "minted key has unexpected shape" + + cyan "registering mount '${MOUNT_NAME}' → ${AGENCY_DB} database" + code=$(curl -s -o /tmp/agency-mount.json -w '%{http_code}' -X POST \ + "${KONG_URL}/admin/v1/databases" \ + -H "apikey: ${SERVICE_KEY}" -H "X-Tenant-Id: ${TENANT_SLUG}" \ + -H 'Content-Type: application/json' \ + -d "{\"engine\":\"postgresql\",\"name\":\"${MOUNT_NAME}\",\"connection_string\":\"postgres://${PG_USER}:${PG_PASS}@postgres:5432/${AGENCY_DB}\"}") + if [[ "${code}" == "201" ]]; then + DB_ID="$(_lt_json_field id < /tmp/agency-mount.json)" + elif [[ "${code}" == "409" && -n "${DB_ID}" ]]; then + cyan "mount already registered, keeping ${DB_ID}" + else + fail "mount register (${code}): $(cat /tmp/agency-mount.json)" + fi + [[ -n "${DB_ID}" ]] || fail "no mount id" +fi + +# ── 4) tables through the REAL gateway DDL path ────────────────────────────── +# col helper: name:type[:nullable] — nullable defaults true, id cols false +ddl_create() { # $1 table, $2.. cols "name:type[:notnull]" + local table="$1"; shift + local cols="" col name type notnull + for col in "$@"; do + IFS=':' read -r name type notnull <<<"${col}" + [[ -n "${cols}" ]] && cols+="," + cols+="{\"name\":\"${name}\",\"normalized_type\":\"${type}\",\"nullable\":$([[ "${notnull:-}" == "notnull" ]] && echo false || echo true),\"default\":null,\"enum_values\":null}" + done + curl -s -o /tmp/agency-ddl.json -w '%{http_code}' -X POST \ + "${KONG_URL}/query/v1/${DB_ID}/schema/ddl" \ + -H "apikey: ${ANON_KEY}" -H "X-Baas-Api-Key: ${API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{\"op\":\"create_table\",\"table\":\"${table}\",\"columns\":[${cols}],\"primary_key\":[\"id\"]}" +} + +existing="$(curl -fsS "${KONG_URL}/query/v1/${DB_ID}/schema" \ + -H "apikey: ${ANON_KEY}" -H "X-Baas-Api-Key: ${API_KEY}")" || fail "schema introspection failed" + +ensure_table() { # $1 table, rest cols + local table="$1"; shift + if echo "${existing}" | grep -q "\"name\":\"${table}\""; then + cyan "table ${table} already exists" + return 0 + fi + cyan "DDL create_table ${table}" + local code + code=$(ddl_create "${table}" "$@") + [[ "${code}" == "200" || "${code}" == "201" ]] || fail "create_table ${table} (${code}): $(cat /tmp/agency-ddl.json)" + grep -q '"status":"applied"' /tmp/agency-ddl.json || fail "create_table ${table} not applied" +} + +ensure_table cases \ + id:integer:notnull code:text:notnull title:text:notnull status:text:notnull \ + priority:text classification:text budget:decimal lead_investigator:text \ + client:text opened_at:datetime closed_at:datetime summary:text +ensure_table subjects \ + id:integer:notnull case_id:integer full_name:text:notnull alias:text \ + ssn:text nationality:text risk_level:text occupation:text \ + date_of_birth:date notes:text +ensure_table locations \ + id:integer:notnull label:text:notnull kind:text address:text city:text \ + country:text lat:float lng:float surveillance_active:boolean +ensure_table evidence \ + id:integer:notnull case_id:integer:notnull kind:text:notnull description:text \ + chain_of_custody:text storage_location_id:integer collected_by:text \ + collected_at:datetime integrity_verified:boolean +ensure_table leads \ + id:integer:notnull case_id:integer:notnull subject_id:integer source:text \ + credibility:text status:text detail:text received_at:datetime +ensure_table transactions \ + id:integer:notnull subject_id:integer:notnull amount:decimal:notnull currency:text \ + counterparty:text account_ref:text flagged:boolean executed_at:datetime method:text +ensure_table vehicles \ + id:integer:notnull owner_subject_id:integer plate:text:notnull make:text \ + model:text color:text year:integer last_seen_location_id:integer +ensure_table communications \ + id:integer:notnull subject_id:integer:notnull channel:text:notnull \ + counterparty:text intercepted_at:datetime summary:text classification:text \ + case_id:integer +ensure_table reports \ + id:integer:notnull case_id:integer:notnull title:text:notnull author:text \ + status:text classification:text published_at:datetime body:text +ensure_table assignments \ + id:integer:notnull case_id:integer:notnull employee_email:text:notnull \ + role_on_case:text hours:float hourly_rate:decimal started_at:date active:boolean + +# ── 5) edges table via psql ("from"/"to" are SQL keywords the DDL path would +# mangle; the graph service's PRIMARY edge source reads these columns) ── +cyan "ensuring edges table (graph primary edge source)" +docker exec -i "${PG_CTN}" psql -U "${PG_USER}" -d "${AGENCY_DB}" -v ON_ERROR_STOP=1 -q <<'SQL' +CREATE TABLE IF NOT EXISTS public.edges ( + id INTEGER PRIMARY KEY, + "from" TEXT NOT NULL, + "to" TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'linked', + label TEXT, + directed BOOLEAN NOT NULL DEFAULT TRUE, + owner_id TEXT +); +SQL + +# ── 6) FK constraints via psql (DDL contract carries no FK clauses) ────────── +cyan "adding FK constraints (introspection → graph fk_ref edges)" +docker exec -i "${PG_CTN}" psql -U "${PG_USER}" -d "${AGENCY_DB}" -v ON_ERROR_STOP=1 -q <<'SQL' +DO $$ +DECLARE + fk RECORD; +BEGIN + FOR fk IN + SELECT * FROM (VALUES + ('subjects', 'subjects_case_fk', 'case_id', 'cases'), + ('evidence', 'evidence_case_fk', 'case_id', 'cases'), + ('evidence', 'evidence_location_fk', 'storage_location_id', 'locations'), + ('leads', 'leads_case_fk', 'case_id', 'cases'), + ('leads', 'leads_subject_fk', 'subject_id', 'subjects'), + ('transactions', 'transactions_subject_fk', 'subject_id', 'subjects'), + ('vehicles', 'vehicles_subject_fk', 'owner_subject_id', 'subjects'), + ('vehicles', 'vehicles_location_fk', 'last_seen_location_id','locations'), + ('communications', 'communications_subject_fk','subject_id', 'subjects'), + ('communications', 'communications_case_fk', 'case_id', 'cases'), + ('reports', 'reports_case_fk', 'case_id', 'cases'), + ('assignments', 'assignments_case_fk', 'case_id', 'cases') + ) AS t(tbl, conname, col, reftbl) + LOOP + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = fk.conname) THEN + EXECUTE format('ALTER TABLE public.%I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES public.%I(id)', + fk.tbl, fk.conname, fk.col, fk.reftbl); + END IF; + END LOOP; +END $$; +SQL + +# ── 7) persist state ────────────────────────────────────────────────────────── +cat > "${STATE_ENV}" < +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/10 00:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/10 00:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ +// +// Deterministic commerce+ops dataset generator for the live-database demo +// (M22 / osionos notion-database-sys). Zero dependencies on purpose: the +// repo's supply-chain policy forbids ad-hoc installs, so randomness is a +// seeded mulberry32 PRNG and "faker" is curated word pools. Same inputs → +// byte-identical outputs → the emitted loaders are upsert-idempotent +// (ON CONFLICT DO NOTHING / INSERT IGNORE / insertMany ordered:false). +// +// Inputs (env): +// SEED_OWNER required — owner_id stamped on EVERY row. The platform's +// write path injects the caller principal (api-key:) +// and owner-scopes updates/deletes (and MySQL/Mongo reads), so +// bulk-loaded rows must carry the app key's principal or the +// app cannot see or edit them. +// SEED_TENANT required — tenant_id stamped on Mongo docs (the Mongo pool +// also tenant-scopes every read/write). +// OUT_DIR default /out +// SEED_SCALE default 1 — multiplies the big row counts. +// +// Outputs: pg-commerce.sql, mysql-ops.sql, mongo-activity.js, counts.json. + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const OWNER = process.env.SEED_OWNER; +const TENANT = process.env.SEED_TENANT; +const OUT = process.env.OUT_DIR ?? '/out'; +const SCALE = Number(process.env.SEED_SCALE ?? '1'); +if (!OWNER || !TENANT) { + console.error('SEED_OWNER and SEED_TENANT are required'); + process.exit(2); +} + +// ── deterministic PRNG (mulberry32, seed 42) ──────────────────────────────── +let prngState = 42 >>> 0; +function rand() { + prngState = (prngState + 0x6d2b79f5) >>> 0; + let t = prngState; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} +const int = (lo, hi) => lo + Math.floor(rand() * (hi - lo + 1)); +const pick = (arr) => arr[Math.floor(rand() * arr.length)]; +const chance = (p) => rand() < p; +const money = (lo, hi) => (lo + rand() * (hi - lo)).toFixed(2); +// Weighted pick: [[value, weight], …] +function wpick(pairs) { + const total = pairs.reduce((s, [, w]) => s + w, 0); + let roll = rand() * total; + for (const [value, weight] of pairs) { + roll -= weight; + if (roll <= 0) return value; + } + return pairs[pairs.length - 1][0]; +} +// Anchored "now" so re-runs a few days apart still converge on the same rows. +const NOW = Date.parse('2026-06-09T12:00:00Z'); +const DAY = 86_400_000; +const daysAgo = (max, min = 0) => new Date(NOW - int(min, max) * DAY - int(0, DAY - 1)); +const iso = (d) => d.toISOString(); +const isoDate = (d) => d.toISOString().slice(0, 10); +const sqlDt = (d) => iso(d).slice(0, 19).replace('T', ' '); // MySQL DATETIME + +// ── curated pools ─────────────────────────────────────────────────────────── +const FIRST = ['Ada', 'Alan', 'Amara', 'Bjorn', 'Camille', 'Chen', 'Dana', 'Diego', 'Elif', 'Emeka', + 'Farah', 'Felix', 'Grace', 'Hana', 'Hugo', 'Ines', 'Ivan', 'Jonas', 'Kavya', 'Kenji', + 'Lena', 'Liam', 'Mai', 'Marco', 'Nadia', 'Noah', 'Olga', 'Omar', 'Priya', 'Quentin', + 'Rafael', 'Rosa', 'Sami', 'Sofia', 'Tariq', 'Tessa', 'Umar', 'Vera', 'Wei', 'Yara', 'Zoe', 'Dylan']; +const LAST = ['Achebe', 'Bauer', 'Costa', 'Dubois', 'Eriksen', 'Fontaine', 'Garcia', 'Haddad', 'Ito', 'Jansen', + 'Kowalski', 'Lindqvist', 'Moreau', 'Nakamura', 'Okafor', 'Petrov', 'Quispe', 'Rossi', 'Sato', 'Tanaka', + 'Ueda', 'Vasquez', 'Weber', 'Xu', 'Yilmaz', 'Zhang', 'Lesieur', 'Mercier', 'Novak', 'Silva']; +const CITY = { + NA: ['Austin', 'Toronto', 'Denver', 'Montreal', 'Seattle', 'Chicago'], + EU: ['Paris', 'Berlin', 'Lisbon', 'Prague', 'Amsterdam', 'Madrid'], + APAC: ['Osaka', 'Singapore', 'Seoul', 'Melbourne', 'Taipei', 'Bangalore'], + LATAM: ['Bogota', 'Santiago', 'Lima', 'Montevideo', 'Mexico City', 'Recife'], +}; +const REGIONS = ['NA', 'EU', 'APAC', 'LATAM']; +const ADJ = ['Aurora', 'Cobalt', 'Drift', 'Ember', 'Flux', 'Granite', 'Halo', 'Ion', 'Juniper', 'Kite', + 'Lumen', 'Mistral', 'Nimbus', 'Onyx', 'Pulse', 'Quartz', 'Ridge', 'Sable', 'Terra', 'Volt']; +const NOUN = ['Backpack', 'Blender', 'Camera', 'Chair', 'Desk Pad', 'Earbuds', 'Hoodie', 'Kettle', 'Keyboard', + 'Lamp', 'Monitor Arm', 'Mug', 'Notebook', 'Racket', 'Scooter', 'Speaker', 'Tent', 'Tracker', 'Trimmer', 'Watch']; +const CATEGORY = ['electronics', 'home', 'sports', 'toys', 'office', 'grocery', 'apparel', 'beauty']; +const WAREHOUSES = ['paris', 'berlin', 'austin', 'osaka']; +const PROJECT_WORDS = ['Atlas', 'Beacon', 'Caravel', 'Dynamo', 'Estuary', 'Foxtrot', 'Gantry', 'Harbor', + 'Icarus', 'Jigsaw', 'Krypton', 'Lighthouse', 'Meridian', 'Nautilus', 'Obelisk', 'Pinwheel', + 'Quasar', 'Rampart', 'Sextant', 'Trellis']; +const TAGS = ['ops', 'retro', 'q3', 'supply-chain', 'pricing', 'urgent', 'idea', 'customer', 'logistics', 'growth']; +const EVENT_KINDS = ['page_view', 'search', 'add_to_cart', 'checkout', 'login', 'support_chat']; +const CHANNELS = ['web', 'mobile', 'email']; +const personName = () => `${pick(FIRST)} ${pick(LAST)}`; + +// ── volumes ───────────────────────────────────────────────────────────────── +const scaled = (n) => Math.max(1, Math.round(n * SCALE)); +const N = { + customers: scaled(5000), products: scaled(1200), employees: 60, + orders: scaled(25000), projects: 40, tasks: scaled(2000), + tickets: scaled(3000), timeEntries: scaled(6000), + events: scaled(30000), reviews: scaled(8000), notes: scaled(400), +}; + +// ── SQL helpers ───────────────────────────────────────────────────────────── +const q = (s) => `'${String(s).replace(/'/g, "''")}'`; +const orNull = (v, f = (x) => x) => (v === null || v === undefined ? 'NULL' : f(v)); +function insertBatches(out, table, columns, rows, render, { conflict = '', prefix = 'INSERT INTO' } = {}) { + for (let i = 0; i < rows.length; i += 500) { + const values = rows.slice(i, i + 500).map(render).join(',\n'); + out.push(`${prefix} ${table} (${columns}) VALUES\n${values}${conflict};`); + } +} + +// ═════════════════════════════ PostgreSQL — commerce ════════════════════════ +const customers = Array.from({ length: N.customers }, (_, i) => { + const region = pick(REGIONS); + const name = personName(); + return { + id: i + 1, name, region, city: pick(CITY[region]), + email: `${name.toLowerCase().replace(/[^a-z]/g, '.')}${i + 1}@example.com`, + signup: daysAgo(900, 1), optIn: chance(0.55), ltv: money(0, 9000), + }; +}); +const products = Array.from({ length: N.products }, (_, i) => { + const price = Number(money(4.99, 1999)); + return { + id: i + 1, name: `${pick(ADJ)} ${pick(NOUN)} ${int(100, 999)}`, + sku: `SKU-${String(i + 1).padStart(5, '0')}`, category: pick(CATEGORY), + price, cost: (price * (0.45 + rand() * 0.25)).toFixed(2), + active: chance(0.9), launched: daysAgo(1200, 30), + }; +}); +const employees = Array.from({ length: N.employees }, (_, i) => { + const name = personName(); + return { + // Suffix with the id: 60 draws from the name pools WILL collide + // (birthday paradox) and email is UNIQUE. + id: i + 1, name, email: `${name.toLowerCase().replace(/[^a-z]/g, '.')}.${i + 1}@trackbinocle.io`, + role: i < 6 ? 'management' : pick(['sales', 'support', 'ops', 'engineering']), + region: pick(REGIONS), hired: daysAgo(2000, 60), salary: money(32000, 110000), + managerId: i < 6 ? null : int(1, 6), + }; +}); +const inventory = products.flatMap((product) => { + const slots = chance(0.8) ? 2 : 3; + return [...WAREHOUSES].sort(() => rand() - 0.5).slice(0, slots).map((warehouse, j) => ({ + id: (product.id - 1) * 4 + j + 1, productId: product.id, warehouse, + qty: int(0, 480), reorder: int(10, 60), restocked: daysAgo(120), + })); +}); +const orders = []; +const orderItems = []; +let itemId = 1; +for (let i = 1; i <= N.orders; i += 1) { + const placed = daysAgo(540); + const ageDays = (NOW - placed.getTime()) / DAY; + const status = ageDays > 30 + ? wpick([['delivered', 78], ['cancelled', 12], ['refunded', 10]]) + : wpick([['pending', 25], ['paid', 35], ['shipped', 40]]); + const shipped = status === 'shipped' || status === 'delivered' + ? new Date(placed.getTime() + int(1, 5) * DAY) : null; + const discount = chance(0.25) ? pick([5, 10, 15, 20]) : 0; + let total = 0; + for (let line = int(1, 5); line > 0; line -= 1) { + const product = pick(products); + const qty = int(1, 4); + orderItems.push({ id: itemId, orderId: i, productId: product.id, qty, unitPrice: product.price.toFixed(2) }); + total += qty * product.price; + itemId += 1; + } + orders.push({ + id: i, customerId: int(1, N.customers), employeeId: chance(0.7) ? int(1, N.employees) : null, + status, ship: pick(['standard', 'express', 'overnight', 'pickup']), placed, shipped, + total: (total * (1 - discount / 100)).toFixed(2), discount, + notes: chance(0.08) ? pick(['Gift wrap requested', 'Leave at door', 'Call before delivery', 'Fragile items']) : null, + }); +} + +const pg = []; +pg.push('\\set ON_ERROR_STOP on'); +pg.push('BEGIN;'); +for (const [name, values] of [ + ['region_t', REGIONS], ['order_status_t', ['pending', 'paid', 'shipped', 'delivered', 'cancelled', 'refunded']], + ['ship_method_t', ['standard', 'express', 'overnight', 'pickup']], ['product_category_t', CATEGORY], + ['employee_role_t', ['sales', 'support', 'ops', 'engineering', 'management']], ['warehouse_t', WAREHOUSES], +]) { + pg.push(`DO $$ BEGIN CREATE TYPE ${name} AS ENUM (${values.map(q).join(', ')}); EXCEPTION WHEN duplicate_object THEN NULL; END $$;`); +} +pg.push(` +CREATE TABLE IF NOT EXISTS customers ( + id serial PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, + region region_t NOT NULL, city text NOT NULL, signup_date date NOT NULL, + marketing_opt_in boolean NOT NULL DEFAULT false, lifetime_value numeric(12,2) NOT NULL DEFAULT 0, + owner_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS products ( + id serial PRIMARY KEY, name text NOT NULL, sku text NOT NULL UNIQUE, category product_category_t NOT NULL, + price numeric(10,2) NOT NULL, cost numeric(10,2) NOT NULL, active boolean NOT NULL DEFAULT true, + launched_on date, owner_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS employees ( + id serial PRIMARY KEY, name text NOT NULL, email text NOT NULL UNIQUE, role employee_role_t NOT NULL, + region region_t NOT NULL, hired_on date NOT NULL, salary numeric(10,2) NOT NULL, + manager_id integer REFERENCES employees(id), owner_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS inventory ( + id serial PRIMARY KEY, product_id integer NOT NULL REFERENCES products(id), warehouse warehouse_t NOT NULL, + qty_on_hand integer NOT NULL DEFAULT 0, reorder_level integer NOT NULL DEFAULT 10, restocked_at timestamptz, + owner_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (product_id, warehouse)); +CREATE TABLE IF NOT EXISTS orders ( + id serial PRIMARY KEY, customer_id integer NOT NULL REFERENCES customers(id), + employee_id integer REFERENCES employees(id), status order_status_t NOT NULL DEFAULT 'pending', + ship_method ship_method_t NOT NULL DEFAULT 'standard', placed_at timestamptz NOT NULL, + shipped_at timestamptz, total numeric(12,2) NOT NULL, discount_pct numeric(4,1) NOT NULL DEFAULT 0, + notes text, owner_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS order_items ( + id serial PRIMARY KEY, order_id integer NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id integer NOT NULL REFERENCES products(id), qty integer NOT NULL, unit_price numeric(10,2) NOT NULL, + owner_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS edges ( + id serial PRIMARY KEY, src_kind text NOT NULL, src_id text NOT NULL, + dst_kind text NOT NULL, dst_id text NOT NULL, rel text NOT NULL, + owner_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (src_kind, src_id, dst_kind, dst_id, rel)); +CREATE INDEX IF NOT EXISTS idx_orders_customer ON orders (customer_id); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders (status); +CREATE INDEX IF NOT EXISTS idx_items_order ON order_items (order_id); +CREATE INDEX IF NOT EXISTS idx_items_product ON order_items (product_id); +CREATE INDEX IF NOT EXISTS idx_inventory_product ON inventory (product_id); +CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ +BEGIN NEW.updated_at = now(); RETURN NEW; END $$ LANGUAGE plpgsql;`); +for (const table of ['customers', 'products', 'employees', 'inventory', 'orders', 'order_items', 'edges']) { + pg.push(`DROP TRIGGER IF EXISTS trg_${table}_updated ON ${table};`); + pg.push(`CREATE TRIGGER trg_${table}_updated BEFORE UPDATE ON ${table} FOR EACH ROW EXECUTE FUNCTION set_updated_at();`); +} +pg.push('COMMIT;'); +pg.push('BEGIN;'); +const skip = { conflict: '\nON CONFLICT (id) DO NOTHING' }; +insertBatches(pg, 'customers', + 'id, name, email, region, city, signup_date, marketing_opt_in, lifetime_value, owner_id', customers, + (c) => `(${c.id}, ${q(c.name)}, ${q(c.email)}, ${q(c.region)}, ${q(c.city)}, ${q(isoDate(c.signup))}, ${c.optIn}, ${c.ltv}, ${q(OWNER)})`, skip); +insertBatches(pg, 'products', + 'id, name, sku, category, price, cost, active, launched_on, owner_id', products, + (p) => `(${p.id}, ${q(p.name)}, ${q(p.sku)}, ${q(p.category)}, ${p.price.toFixed(2)}, ${p.cost}, ${p.active}, ${q(isoDate(p.launched))}, ${q(OWNER)})`, skip); +insertBatches(pg, 'employees', + 'id, name, email, role, region, hired_on, salary, manager_id, owner_id', employees, + (e) => `(${e.id}, ${q(e.name)}, ${q(e.email)}, ${q(e.role)}, ${q(e.region)}, ${q(isoDate(e.hired))}, ${e.salary}, ${orNull(e.managerId)}, ${q(OWNER)})`, skip); +insertBatches(pg, 'inventory', + 'id, product_id, warehouse, qty_on_hand, reorder_level, restocked_at, owner_id', inventory, + (s) => `(${s.id}, ${s.productId}, ${q(s.warehouse)}, ${s.qty}, ${s.reorder}, ${q(iso(s.restocked))}, ${q(OWNER)})`, skip); +insertBatches(pg, 'orders', + 'id, customer_id, employee_id, status, ship_method, placed_at, shipped_at, total, discount_pct, notes, owner_id', orders, + (o) => `(${o.id}, ${o.customerId}, ${orNull(o.employeeId)}, ${q(o.status)}, ${q(o.ship)}, ${q(iso(o.placed))}, ${orNull(o.shipped, (d) => q(iso(d)))}, ${o.total}, ${o.discount}, ${orNull(o.notes, q)}, ${q(OWNER)})`, skip); +insertBatches(pg, 'order_items', + 'id, order_id, product_id, qty, unit_price, owner_id', orderItems, + (l) => `(${l.id}, ${l.orderId}, ${l.productId}, ${l.qty}, ${l.unitPrice}, ${q(OWNER)})`, skip); +pg.push('COMMIT;'); + +// ═════════════════════════════ MySQL — ops ══════════════════════════════════ +const projects = Array.from({ length: N.projects }, (_, i) => ({ + id: i + 1, name: `Project ${PROJECT_WORDS[i % PROJECT_WORDS.length]}${i >= 20 ? ' II' : ''}`, + code: `OPS-${String(i + 1).padStart(3, '0')}`, + status: wpick([['planning', 15], ['active', 45], ['on_hold', 10], ['done', 25], ['archived', 5]]), + lead: personName(), budget: money(8000, 250000), starts: daysAgo(700, 30), +})); +const tasks = Array.from({ length: N.tasks }, (_, i) => { + const status = wpick([['todo', 25], ['in_progress', 25], ['review', 12], ['blocked', 8], ['done', 30]]); + return { + id: i + 1, projectId: int(1, N.projects), + title: `${pick(['Audit', 'Refactor', 'Ship', 'Design', 'Benchmark', 'Document', 'Migrate', 'Review'])} ${pick(['warehouse sync', 'pricing rules', 'returns flow', 'vendor portal', 'SLA alerts', 'pick list', 'carrier API', 'stock report'])}`, + status, priority: wpick([['low', 20], ['medium', 45], ['high', 25], ['urgent', 10]]), + assignee: personName(), estimate: (rand() * 16 + 0.5).toFixed(1), + due: chance(0.8) ? daysAgo(-60, -1) : null, // -days → future due dates + doneAt: status === 'done' ? daysAgo(200) : null, + }; +}); +const tickets = Array.from({ length: N.tickets }, (_, i) => { + const status = wpick([['open', 18], ['triaged', 14], ['in_progress', 18], ['waiting', 10], ['resolved', 25], ['closed', 15]]); + const opened = daysAgo(400); + const resolved = status === 'resolved' || status === 'closed' + ? new Date(opened.getTime() + int(1, 21) * DAY) : null; + return { + id: i + 1, projectId: chance(0.7) ? int(1, N.projects) : null, + title: `${pick(['Late delivery', 'Refund request', 'Damaged item', 'Wrong size', 'Billing mismatch', 'Login issue', 'Stock discrepancy', 'Carrier exception'])} #${i + 1}`, + severity: wpick([['low', 30], ['minor', 35], ['major', 25], ['critical', 10]]), + status, reporter: personName(), channel: pick(['email', 'chat', 'phone', 'web']), + orderRef: chance(0.6) ? int(1, N.orders) : null, opened, resolved, + satisfaction: resolved && chance(0.7) ? int(1, 5) : null, + }; +}); +const timeEntries = Array.from({ length: N.timeEntries }, (_, i) => ({ + id: i + 1, taskId: int(1, N.tasks), person: personName(), + hours: (rand() * 7.5 + 0.25).toFixed(2), date: daysAgo(180), billable: chance(0.65), + note: chance(0.3) ? pick(['pairing', 'code review', 'incident follow-up', 'customer call', 'spec writing']) : null, +})); + +const my = []; +my.push('SET NAMES utf8mb4;'); +my.push(` +CREATE TABLE IF NOT EXISTS projects ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, code VARCHAR(16) NOT NULL UNIQUE, + status ENUM('planning','active','on_hold','done','archived') NOT NULL DEFAULT 'planning', + lead_name VARCHAR(80) NOT NULL, budget DECIMAL(12,2) NOT NULL DEFAULT 0, starts_on DATE NOT NULL, + owner_id VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS tasks ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, project_id INT NOT NULL, + title VARCHAR(160) NOT NULL, + status ENUM('todo','in_progress','review','blocked','done') NOT NULL DEFAULT 'todo', + priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium', + assignee VARCHAR(80) NOT NULL, estimate_h DECIMAL(5,1) NOT NULL DEFAULT 1.0, + due_on DATE NULL, done_at DATETIME NULL, owner_id VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_tasks_project FOREIGN KEY (project_id) REFERENCES projects(id), INDEX idx_tasks_project (project_id)) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS tickets ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, project_id INT NULL, + title VARCHAR(160) NOT NULL, + severity ENUM('low','minor','major','critical') NOT NULL DEFAULT 'minor', + status ENUM('open','triaged','in_progress','waiting','resolved','closed') NOT NULL DEFAULT 'open', + reporter VARCHAR(80) NOT NULL, channel ENUM('email','chat','phone','web') NOT NULL DEFAULT 'web', + order_ref INT NULL, opened_at DATETIME NOT NULL, resolved_at DATETIME NULL, + satisfaction TINYINT NULL, owner_id VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_tickets_project FOREIGN KEY (project_id) REFERENCES projects(id), INDEX idx_tickets_project (project_id)) ENGINE=InnoDB; +CREATE TABLE IF NOT EXISTS time_entries ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, task_id INT NOT NULL, + person VARCHAR(80) NOT NULL, hours DECIMAL(5,2) NOT NULL, entry_date DATE NOT NULL, + billable TINYINT(1) NOT NULL DEFAULT 1, note VARCHAR(255) NULL, owner_id VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_time_task FOREIGN KEY (task_id) REFERENCES tasks(id), INDEX idx_time_task (task_id)) ENGINE=InnoDB;`); +const ignore = { prefix: 'INSERT IGNORE INTO' }; +insertBatches(my, 'projects', 'id, name, code, status, lead_name, budget, starts_on, owner_id', projects, + (p) => `(${p.id}, ${q(p.name)}, ${q(p.code)}, ${q(p.status)}, ${q(p.lead)}, ${p.budget}, ${q(isoDate(p.starts))}, ${q(OWNER)})`, ignore); +insertBatches(my, 'tasks', 'id, project_id, title, status, priority, assignee, estimate_h, due_on, done_at, owner_id', tasks, + (t) => `(${t.id}, ${t.projectId}, ${q(t.title)}, ${q(t.status)}, ${q(t.priority)}, ${q(t.assignee)}, ${t.estimate}, ${orNull(t.due, (d) => q(isoDate(d)))}, ${orNull(t.doneAt, (d) => q(sqlDt(d)))}, ${q(OWNER)})`, ignore); +insertBatches(my, 'tickets', 'id, project_id, title, severity, status, reporter, channel, order_ref, opened_at, resolved_at, satisfaction, owner_id', tickets, + (t) => `(${t.id}, ${orNull(t.projectId)}, ${q(t.title)}, ${q(t.severity)}, ${q(t.status)}, ${q(t.reporter)}, ${q(t.channel)}, ${orNull(t.orderRef)}, ${q(sqlDt(t.opened))}, ${orNull(t.resolved, (d) => q(sqlDt(d)))}, ${orNull(t.satisfaction)}, ${q(OWNER)})`, ignore); +insertBatches(my, 'time_entries', 'id, task_id, person, hours, entry_date, billable, note, owner_id', timeEntries, + (e) => `(${e.id}, ${e.taskId}, ${q(e.person)}, ${e.hours}, ${q(isoDate(e.date))}, ${e.billable ? 1 : 0}, ${orNull(e.note, q)}, ${q(OWNER)})`, ignore); + +// ═════════════════════════════ MongoDB — activity ═══════════════════════════ +const events = Array.from({ length: N.events }, (_, i) => { + const kind = wpick([['page_view', 45], ['search', 18], ['add_to_cart', 14], ['checkout', 8], ['login', 10], ['support_chat', 5]]); + const customerRef = int(1, N.customers); + const path = kind === 'search' ? `/search?q=${pick(NOUN).toLowerCase()}` : `/products/${int(1, N.products)}`; + return { + _id: `evt-${String(i + 1).padStart(6, '0')}`, kind, + summary: `${kind} ${path} by cust-${customerRef}`, + customer_ref: customerRef, order_ref: kind === 'checkout' ? int(1, N.orders) : null, + channel: pick(CHANNELS), ts: daysAgo(90), payload: { path, ms: int(40, 2400) }, + }; +}); +const reviews = Array.from({ length: N.reviews }, (_, i) => { + const rating = wpick([[5, 38], [4, 30], [3, 14], [2, 9], [1, 9]]); + return { + _id: `rev-${String(i + 1).padStart(5, '0')}`, product_ref: int(1, N.products), + customer_ref: int(1, N.customers), rating, + title: pick(['Exceeded expectations', 'Solid value', 'Does the job', 'Mixed feelings', 'Not as described', 'Five stars', 'Would buy again', 'Save your money']), + body: pick(['Arrived early and works perfectly.', 'Quality feels premium for the price.', 'Setup took five minutes.', 'The finish scratches easily.', 'Customer support resolved my issue fast.', 'Battery life is shorter than advertised.', 'My second purchase from this brand.']), + verified: chance(0.7), helpful_votes: int(0, 240), reviewed_at: daysAgo(360), + }; +}); +const notes = Array.from({ length: N.notes }, (_, i) => ({ + _id: `note-${String(i + 1).padStart(4, '0')}`, + title: `${pick(['Standup', 'Retro', 'Supplier call', 'Inventory check', 'Pricing review', 'Roadmap', 'Incident', 'Hiring'])} — ${pick(PROJECT_WORDS)}`, + body: pick(['Follow up next week.', 'Decision: ship behind a flag.', 'Carrier renegotiation pending.', 'Stock levels look healthy.', 'Two candidates moved to onsite.', 'Root cause: stale cache on the edge.']), + tags: [...new Set([pick(TAGS), pick(TAGS)])], pinned: chance(0.1), + related_kind: chance(0.5) ? pick(['project', 'ticket', 'order']) : null, + related_id: null, created_at: daysAgo(250), +})); +for (const note of notes) { + if (note.related_kind === 'project') note.related_id = String(int(1, N.projects)); + if (note.related_kind === 'ticket') note.related_id = String(int(1, N.tickets)); + if (note.related_kind === 'order') note.related_id = String(int(1, N.orders)); +} + +// Validators: declared contract → exact (inferred:false) introspection. Date +// fields accept date|string so in-place edits (JSON strings over HTTP) pass +// validation; introspection still normalizes them to datetime (first type). +const mongoDate = (d) => `new Date(${JSON.stringify(iso(d))})`; +function mongoDoc(obj) { + const fields = Object.entries(obj).map(([key, value]) => { + if (value instanceof Date) return `${JSON.stringify(key)}: ${mongoDate(value)}`; + return `${JSON.stringify(key)}: ${JSON.stringify(value)}`; + }); + return `{ ${fields.join(', ')}, "owner_id": ${JSON.stringify(OWNER)}, "tenant_id": ${JSON.stringify(TENANT)} }`; +} +const mongo = []; +mongo.push(`// Idempotent mongo seed (live-demo). Run against the activity database. +function ensureCollection(name, validator) { + try { db.createCollection(name, { validator: validator }); } + catch (e) { db.runCommand({ collMod: name, validator: validator }); } +} +const S = { bsonType: ['string', 'null'] }; +const I = { bsonType: ['int', 'long', 'double', 'null'] }; +const D = { bsonType: ['date', 'string', 'null'] }; +ensureCollection('events', { $jsonSchema: { + bsonType: 'object', required: ['owner_id', 'tenant_id'], + properties: { + _id: { bsonType: ['string', 'objectId'] }, kind: { enum: ${JSON.stringify(EVENT_KINDS)} }, + summary: S, customer_ref: I, order_ref: I, + channel: { enum: ${JSON.stringify(CHANNELS)} }, ts: D, payload: { bsonType: ['object', 'null'] }, + owner_id: { bsonType: ['string', 'objectId'] }, tenant_id: { bsonType: ['string', 'objectId'] }, + } } }); +ensureCollection('product_reviews', { $jsonSchema: { + bsonType: 'object', required: ['owner_id', 'tenant_id'], + properties: { + _id: { bsonType: ['string', 'objectId'] }, product_ref: I, customer_ref: I, + rating: I, title: S, body: S, verified: { bsonType: ['bool', 'null'] }, + helpful_votes: I, reviewed_at: D, + owner_id: { bsonType: ['string', 'objectId'] }, tenant_id: { bsonType: ['string', 'objectId'] }, + } } }); +ensureCollection('notes', { $jsonSchema: { + bsonType: 'object', required: ['owner_id', 'tenant_id'], + properties: { + _id: { bsonType: ['string', 'objectId'] }, title: S, body: S, + tags: { bsonType: ['array', 'null'] }, pinned: { bsonType: ['bool', 'null'] }, + related_kind: S, related_id: S, created_at: D, + owner_id: { bsonType: ['string', 'objectId'] }, tenant_id: { bsonType: ['string', 'objectId'] }, + } } }); +function load(name, docs) { + try { db[name].insertMany(docs, { ordered: false }); } + catch (e) { /* duplicate _ids on re-run are expected */ } +}`); +for (const [name, docs] of [['events', events], ['product_reviews', reviews], ['notes', notes]]) { + for (let i = 0; i < docs.length; i += 1000) { + mongo.push(`load('${name}', [\n${docs.slice(i, i + 1000).map(mongoDoc).join(',\n')}\n]);`); + } +} +mongo.push(`print('mongo-activity seeded: events=' + db.events.countDocuments() + ' product_reviews=' + db.product_reviews.countDocuments() + ' notes=' + db.notes.countDocuments());`); + +// ═══════════════ cross-engine edges (PG table, built from the same streams) ═ +const edges = []; +for (const ticket of tickets) { + if (ticket.orderRef !== null) { + edges.push({ srcKind: 'ticket', srcId: String(ticket.id), dstKind: 'order', dstId: String(ticket.orderRef), rel: 'ticket_about_order' }); + } +} +for (const review of reviews.slice(0, 2000)) { + edges.push({ srcKind: 'review', srcId: review._id, dstKind: 'product', dstId: String(review.product_ref), rel: 'review_of_product' }); +} +for (const event of events.slice(0, 2000)) { + edges.push({ srcKind: 'event', srcId: event._id, dstKind: 'customer', dstId: String(event.customer_ref), rel: 'event_by_customer' }); +} +pg.push('BEGIN;'); +insertBatches(pg, 'edges', 'src_kind, src_id, dst_kind, dst_id, rel, owner_id', edges, + (e) => `(${q(e.srcKind)}, ${q(e.srcId)}, ${q(e.dstKind)}, ${q(e.dstId)}, ${q(e.rel)}, ${q(OWNER)})`, + { conflict: '\nON CONFLICT (src_kind, src_id, dst_kind, dst_id, rel) DO NOTHING' }); +pg.push('COMMIT;'); +// Serial sequences must clear the seeded ids or the app's first INSERT 409s. +for (const table of ['customers', 'products', 'employees', 'inventory', 'orders', 'order_items', 'edges']) { + pg.push(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), GREATEST((SELECT COALESCE(MAX(id), 1) FROM ${table}), 1));`); +} +pg.push('ANALYZE;'); + +// ── write outputs ─────────────────────────────────────────────────────────── +mkdirSync(OUT, { recursive: true }); +writeFileSync(join(OUT, 'pg-commerce.sql'), `${pg.join('\n')}\n`); +writeFileSync(join(OUT, 'mysql-ops.sql'), `${my.join('\n')}\n`); +writeFileSync(join(OUT, 'mongo-activity.js'), `${mongo.join('\n')}\n`); +const counts = { + pg: { + customers: customers.length, products: products.length, employees: employees.length, + inventory: inventory.length, orders: orders.length, order_items: orderItems.length, edges: edges.length, + }, + mysql: { + projects: projects.length, tasks: tasks.length, tickets: tickets.length, time_entries: timeEntries.length, + }, + mongo: { events: events.length, product_reviews: reviews.length, notes: notes.length }, +}; +writeFileSync(join(OUT, 'counts.json'), `${JSON.stringify(counts, null, 2)}\n`); +const total = Object.values(counts).flatMap((engine) => Object.values(engine)).reduce((a, b) => a + b, 0); +console.log(`generated ${total} rows → ${OUT} (owner=${OWNER}, tenant=${TENANT})`); diff --git a/apps/baas/mini-baas-infra/scripts/seed/live-demo-pages.py b/apps/baas/mini-baas-infra/scripts/seed/live-demo-pages.py new file mode 100644 index 00000000..8fc93ab1 --- /dev/null +++ b/apps/baas/mini-baas-infra/scripts/seed/live-demo-pages.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Emit SQL for the osionos 'Live Databases' workspace pages (stdout → psql). + +Usage: live-demo-pages.py + +One folder + one page per mounted table; each page holds a single +`database_full_page` block whose databaseId is `baas::
` — the id +namespace DatabaseBlock.tsx dispatches to the LiveMountAdapter. Page uuids are +uuid5 of the mount-name+table (stable across runs), while the block's mount id +is re-resolved every run, so ON CONFLICT DO UPDATE refreshes the content if +the mounts were ever re-registered under new ids. +""" +import base64 +import json +import sys +import uuid + +WS, OWNER, PG_ID, MY_ID, MG_ID = sys.argv[1:6] +NS = uuid.UUID("6ba7b811-9dad-11d1-80b4-00c04fd430c8") # uuid5 URL namespace + +TABLES = [ + ("pg-commerce", PG_ID, "postgresql", "🐘", [ + ("orders", "🧾", "25k orders with status enum, ship method, FK to customers/employees."), + ("customers", "👥", "5k customers across four regions — edit a cell, it lands in Postgres."), + ("products", "📦", "1.2k products with category enum and pricing."), + ("order_items", "🧮", "75k order lines (FK to orders + products)."), + ("inventory", "🏬", "Stock per warehouse enum, reorder levels."), + ("employees", "🧑‍💼", "Org with a self-referencing manager FK."), + ("edges", "🕸️", "Cross-engine relations: tickets→orders, reviews→products, events→customers."), + ]), + ("mysql-ops", MY_ID, "mysql", "🐬", [ + ("projects", "🗂️", "40 ops projects (status enum, budget)."), + ("tasks", "✅", "2k tasks — status/priority enums make great board views."), + ("tickets", "🎫", "3k support tickets, severity enum, order_ref pointing at pg-commerce."), + ("time_entries", "⏱️", "6k entries with tinyint(1) → checkbox booleans."), + ]), + ("mongo-activity", MG_ID, "mongodb", "🍃", [ + ("events", "📈", "30k activity events — $jsonSchema validator, kind enum."), + ("product_reviews", "⭐", "8k reviews with ratings; edits validate against the declared schema."), + ("notes", "📝", "Ops notes with tag arrays — fully editable documents."), + ]), +] + + +def b64(payload) -> str: + return base64.b64encode(json.dumps(payload).encode()).decode() + + +def q(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def page_sql(page_id, parent, title, icon, surface, content) -> str: + return ( + "INSERT INTO public.osionos_pages" + " (id, workspace_id, parent_page_id, owner_id, title, icon, surface," + " visibility, collaborators, properties, content, created_at, updated_at) VALUES (" + f"{q(page_id)}, {q(WS)}, {('NULL' if parent is None else q(parent))}, {q(OWNER)}," + f" {q(title)}, {q(icon)}, {q(surface)}, 'private', '[]'::jsonb," + f" convert_from(decode('{b64([])}','base64'),'utf8')::jsonb," + f" convert_from(decode('{b64(content)}','base64'),'utf8')::jsonb, now(), now())" + " ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, icon = EXCLUDED.icon," + " content = EXCLUDED.content, parent_page_id = EXCLUDED.parent_page_id," + " updated_at = now();" + ) + + +def main() -> None: + folder_id = str(uuid.uuid5(NS, "live-demo:folder")) + statements = ["BEGIN;"] + statements.append(page_sql(folder_id, None, "Live Databases", "🔌", "folder", [])) + intro_id = str(uuid.uuid5(NS, "live-demo:intro")) + statements.append(page_sql(intro_id, folder_id, "Start here — live data", "🚀", "page", [ + {"id": "blk-1", "type": "heading_1", "content": "Real engines, real rows"}, + {"id": "blk-2", "type": "paragraph", "content": + "Every database under this folder is a LIVE mount: rows come from " + "PostgreSQL, MySQL and MongoDB through the mini-baas gateway, and " + "any cell you edit is written back to the source engine."}, + {"id": "blk-3", "type": "callout", "content": + "Edits are optimistic with an outbox — a 409 conflict snaps the " + "cell back to server truth. Schema changes (add/remove column) " + "run real DDL.", "color": "💡"}, + {"id": "blk-4", "type": "paragraph", "content": + "pg-commerce holds the storefront (orders, customers, products); " + "mysql-ops the internal tooling (projects, tasks, tickets); " + "mongo-activity the event stream (events, reviews, notes)."}, + ])) + for mount_name, db_id, engine, engine_icon, tables in TABLES: + mount_page = str(uuid.uuid5(NS, f"live-demo:{mount_name}")) + statements.append(page_sql( + mount_page, folder_id, mount_name, engine_icon, "folder", [])) + for table, icon, blurb in tables: + page_id = str(uuid.uuid5(NS, f"live-demo:{mount_name}:{table}")) + title = table.replace("_", " ").title() + statements.append(page_sql(page_id, mount_page, title, icon, "page", [ + {"id": "blk-1", "type": "paragraph", + "content": f"{blurb} (engine: {engine}, mount {mount_name})"}, + {"id": "blk-2", "type": "database_full_page", "content": "", + "databaseId": f"baas:{db_id}:{table}"}, + ])) + statements.append("COMMIT;") + print("\n".join(statements)) + + +main() diff --git a/apps/baas/mini-baas-infra/scripts/verify/lib-live-tenant.sh b/apps/baas/mini-baas-infra/scripts/verify/lib-live-tenant.sh new file mode 100644 index 00000000..6f32c273 --- /dev/null +++ b/apps/baas/mini-baas-infra/scripts/verify/lib-live-tenant.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# **************************************************************************** # +# # +# ::: :::::::: # +# lib-live-tenant.sh :+: :+: :+: # +# +:+ +:+ +:+ # +# By: dlesieur +#+ +:+ +#+ # +# +#+#+#+#+#+ +#+ # +# Created: 2026/06/10 00:00:00 by dlesieur #+# #+# # +# Updated: 2026/06/10 00:00:00 by dlesieur ### ########.fr # +# # +# **************************************************************************** # +# +# Reusable live-stack helper for verify gates: provision a scratch +# (tenant, API key, postgresql mount) triple against the RUNNING mini-baas +# stack so a gate can exercise the REAL gateway path — Kong key-auth → +# query-router ApiKeyMiddleware (tenant-control /v1/keys/verify) → +# adapter-registry mount resolution → Rust data plane. +# +# Everything is discovered from the running containers (docker inspect / +# docker port), never from the caller's shell, so a wrong host env can't +# poison the probe: +# - tenant-control service token : container env INTERNAL_SERVICE_TOKEN +# - Kong consumer keys : container env KONG_PUBLIC/SERVICE_API_KEY +# - host ports : docker port (resolve-ports.sh may have +# moved them off the defaults) +# - scratch mount DSN : postgres container env, host `postgres` +# (the in-network alias the data plane dials) +# +# Usage (source, then): +# live_tenant_provision # idempotent; sets the LIVE_TENANT_* vars +# live_tenant_cleanup # best-effort revoke/deregister (EXIT trap) +# +# Exported on success: +# LIVE_TENANT_SLUG tenant id (slug) — adapter-registry rows key on it +# LIVE_TENANT_KEY_ID minted key uuid (the api-key actor is api-key:) +# LIVE_TENANT_API_KEY full mbk_… key (send as X-Baas-Api-Key) +# LIVE_TENANT_DB_ID registered mount id (the /query/v1/ path part) +# LIVE_KONG_URL http://127.0.0.1: +# LIVE_ANON_APIKEY Kong anon consumer key (send as apikey) +# LIVE_SERVICE_APIKEY Kong service_role consumer key (admin routes) +# LIVE_SERVICE_TOKEN control-plane service token (X-Service-Token) +# LIVE_TENANT_CONTROL_URL http://127.0.0.1: + +# Container env var (works for distroless images — no `sh` needed). +_lt_env() { # $1 container, $2 var + docker inspect "$1" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \ + | grep "^$2=" | head -1 | cut -d= -f2- +} + +# Host port a container publishes for $2 (e.g. 8000/tcp) — empty if unmapped. +_lt_host_port() { # $1 container, $2 container-port/proto + docker port "$1" "$2" 2>/dev/null | head -1 | sed 's/.*://' +} + +# First "field":"value" occurrence in a JSON blob (control-plane responses are +# flat enough that this stays unambiguous; values are uuid/slug/key-safe). +_lt_json_field() { # $1 field, stdin json + sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" | head -1 +} + +live_tenant_provision() { # $1 slug (must match ^[a-z0-9][a-z0-9_-]{1,62}$) + local slug="$1" + [[ -n "${slug}" ]] || { echo "live_tenant_provision: slug required" >&2; return 1; } + + local kong_port tc_port + kong_port="$(_lt_host_port mini-baas-kong 8000/tcp)" + tc_port="$(_lt_host_port mini-baas-tenant-control 3022/tcp)" + [[ -n "${kong_port}" ]] || { echo "kong host port not found (is the stack up?)" >&2; return 1; } + [[ -n "${tc_port}" ]] || { echo "tenant-control host port not found" >&2; return 1; } + LIVE_KONG_URL="http://127.0.0.1:${kong_port}" + LIVE_TENANT_CONTROL_URL="http://127.0.0.1:${tc_port}" + + LIVE_SERVICE_TOKEN="$(_lt_env mini-baas-tenant-control INTERNAL_SERVICE_TOKEN)" + LIVE_ANON_APIKEY="$(_lt_env mini-baas-kong KONG_PUBLIC_API_KEY)" + LIVE_SERVICE_APIKEY="$(_lt_env mini-baas-kong KONG_SERVICE_API_KEY)" + [[ -n "${LIVE_SERVICE_TOKEN}" ]] || { echo "INTERNAL_SERVICE_TOKEN not found on tenant-control" >&2; return 1; } + [[ -n "${LIVE_ANON_APIKEY}" && -n "${LIVE_SERVICE_APIKEY}" ]] \ + || { echo "Kong consumer keys not found on mini-baas-kong" >&2; return 1; } + + # 1) tenant — idempotent: 201 created or 409 already exists are both fine. + local code + code=$(curl -s -o /tmp/lt-tenant.json -w '%{http_code}' -X POST \ + "${LIVE_TENANT_CONTROL_URL}/v1/tenants" \ + -H "X-Service-Token: ${LIVE_SERVICE_TOKEN}" -H 'Content-Type: application/json' \ + -d "{\"id\":\"${slug}\",\"name\":\"${slug}\"}") + [[ "${code}" == "201" || "${code}" == "409" ]] \ + || { echo "tenant create failed (${code}): $(cat /tmp/lt-tenant.json)" >&2; return 1; } + LIVE_TENANT_SLUG="${slug}" + + # 2) API key — read+write scopes (what an app key carries; admin not needed). + code=$(curl -s -o /tmp/lt-key.json -w '%{http_code}' -X POST \ + "${LIVE_TENANT_CONTROL_URL}/v1/tenants/${slug}/keys" \ + -H "X-Service-Token: ${LIVE_SERVICE_TOKEN}" -H 'Content-Type: application/json' \ + -d '{"name":"verify-probe","scopes":["read","write"]}') + [[ "${code}" == "201" ]] || { echo "key mint failed (${code}): $(cat /tmp/lt-key.json)" >&2; return 1; } + LIVE_TENANT_API_KEY="$(_lt_json_field key < /tmp/lt-key.json)" + LIVE_TENANT_KEY_ID="$(_lt_json_field id < /tmp/lt-key.json)" + [[ "${LIVE_TENANT_API_KEY}" == mbk_* ]] || { echo "minted key has unexpected shape" >&2; return 1; } + + # 3) scratch postgresql mount THROUGH THE GATEWAY (the adapter-registry + # /admin/v1/databases route): identity = X-Tenant-Id (the slug), guarded + # by Kong key-auth + ip-restriction. DSN points at the stack's own + # postgres (in-network alias), credentials read from the live container. + local pg_user pg_pass pg_db unique_name + pg_user="$(_lt_env mini-baas-postgres POSTGRES_USER)"; pg_user="${pg_user:-postgres}" + pg_pass="$(_lt_env mini-baas-postgres POSTGRES_PASSWORD)"; pg_pass="${pg_pass:-postgres}" + pg_db="$(_lt_env mini-baas-postgres POSTGRES_DB)"; pg_db="${pg_db:-postgres}" + unique_name="probe-$(date +%s)" + code=$(curl -s -o /tmp/lt-mount.json -w '%{http_code}' -X POST \ + "${LIVE_KONG_URL}/admin/v1/databases" \ + -H "apikey: ${LIVE_SERVICE_APIKEY}" -H "X-Tenant-Id: ${slug}" \ + -H 'Content-Type: application/json' \ + -d "{\"engine\":\"postgresql\",\"name\":\"${unique_name}\",\"connection_string\":\"postgres://${pg_user}:${pg_pass}@postgres:5432/${pg_db}\"}") + [[ "${code}" == "201" ]] || { echo "mount register failed (${code}): $(cat /tmp/lt-mount.json)" >&2; return 1; } + LIVE_TENANT_DB_ID="$(_lt_json_field id < /tmp/lt-mount.json)" + [[ -n "${LIVE_TENANT_DB_ID}" ]] || { echo "mount register returned no id" >&2; return 1; } + + export LIVE_TENANT_SLUG LIVE_TENANT_KEY_ID LIVE_TENANT_API_KEY LIVE_TENANT_DB_ID + export LIVE_KONG_URL LIVE_ANON_APIKEY LIVE_SERVICE_APIKEY LIVE_SERVICE_TOKEN + export LIVE_TENANT_CONTROL_URL + return 0 +} + +# Best-effort teardown (safe to call from an EXIT trap, never fails the gate): +# deregister the mount, revoke the key, soft-delete the tenant. +live_tenant_cleanup() { + [[ -n "${LIVE_TENANT_DB_ID:-}" ]] && curl -s -o /dev/null -X DELETE \ + "${LIVE_KONG_URL}/admin/v1/databases/${LIVE_TENANT_DB_ID}" \ + -H "apikey: ${LIVE_SERVICE_APIKEY}" -H "X-Service-Token: ${LIVE_SERVICE_TOKEN}" \ + -H "X-Tenant-Id: ${LIVE_TENANT_SLUG}" || true + [[ -n "${LIVE_TENANT_KEY_ID:-}" ]] && curl -s -o /dev/null -X DELETE \ + "${LIVE_TENANT_CONTROL_URL}/v1/tenants/${LIVE_TENANT_SLUG}/keys/${LIVE_TENANT_KEY_ID}" \ + -H "X-Service-Token: ${LIVE_SERVICE_TOKEN}" || true + [[ -n "${LIVE_TENANT_SLUG:-}" ]] && curl -s -o /dev/null -X DELETE \ + "${LIVE_TENANT_CONTROL_URL}/v1/tenants/${LIVE_TENANT_SLUG}" \ + -H "X-Service-Token: ${LIVE_SERVICE_TOKEN}" || true + return 0 +} diff --git a/apps/baas/mini-baas-infra/scripts/verify/m22-live-database.sh b/apps/baas/mini-baas-infra/scripts/verify/m22-live-database.sh new file mode 100755 index 00000000..b1ecbe5e --- /dev/null +++ b/apps/baas/mini-baas-infra/scripts/verify/m22-live-database.sh @@ -0,0 +1,584 @@ +#!/usr/bin/env bash +# **************************************************************************** # +# # +# ::: :::::::: # +# m22-live-database.sh :+: :+: :+: # +# +:+ +:+ +:+ # +# By: dlesieur +#+ +:+ +#+ # +# +#+#+#+#+#+ +#+ # +# Created: 2026/06/09 00:00:00 by dlesieur #+# #+# # +# Updated: 2026/06/09 00:00:00 by dlesieur ### ########.fr # +# # +# **************************************************************************** # +# +# Gate for milestone M22 (live-database mode), steps 1+2: engine-agnostic +# schema introspection AND schema DDL. +# +# Static checks (step 1 — introspection): +# - data-plane-core declares SchemaDescriptor + NormalizedType (the FIXED +# wire contract) and the EnginePool::describe_schema port +# - EngineCapabilities carries the `introspect` route capability (true for +# postgresql/mysql/mongodb, false for redis/http) +# - POST /v1/schema route mounted, gated by validate_identity_mount + +# require_capability("introspect") — NOT admin-gated +# - postgres/mysql/mongo pools implement describe_schema with pure, +# unit-tested type normalizers; SharedPool delegates it +# - query-router: SchemaController (GET /:dbId/schema), SchemaService +# (TTL cache), RustDataPlaneProxy.describeSchema, module registration +# +# Static checks (step 2 — DDL): +# - data-plane-core declares SchemaDdlRequest/Result + the +# EnginePool::apply_schema_ddl port; EngineCapabilities carries the NEW +# `schema_ddl` route capability (distinct from `ddl` — mongo's migrate +# gate stays false) +# - POST /v1/schema/ddl mounted, gated on schema_ddl — NOT admin-gated +# - postgres (pg_sql_type + build_pg_ddl) / mysql (mysql_sql_type + +# build_mysql_ddl) / mongo (validator transforms) implement +# apply_schema_ddl; SharedPool delegates it +# - query-router: POST /:dbId/schema/ddl (confirm gate for destructive ops, +# alter target composition, cache bust), proxy applySchemaDdl, DTO +# +# Live checks (BAAS_VERIFY_LIVE=1 or --live, requires docker): +# - POST /v1/schema on a postgresql mount returns engine+tables[].columns[] +# - POST /v1/schema on a redis mount is a clean 422 unsupported_capability +# - DDL on a scratch PG table: create_table → add_column → describe shows it +# → alter_column_type over incompatible data is a 409 → drop_column → +# drop_table; redis DDL is a clean 422 +# +# Live gateway tier (Phase 6 — runs with the live tier, full Kong path): +# - provisions a scratch tenant + API key + postgresql mount through the +# REAL control plane (lib-live-tenant.sh: tenant-control admin API + +# Kong POST /admin/v1/databases) — or reuses BAAS_SCHEMA_DB_ID + +# BAAS_API_KEY (a tenant mbk_… key) when provided +# - GET /query/v1//schema through Kong with the minted key → 200 +# with tables[] + capabilities; unconfirmed destructive DDL → 400 +# - realtime echo: a WS subscriber (in-band AUTH with an HS256 JWT signed +# with the stack's JWT_SECRET, SUBSCRIBE table::
) receives +# the query-router's best-effort `row_changed` event within 5s of a +# gateway `op:insert` on a gateway-DDL-created scratch table + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../../.." && pwd)" +cd "${REPO_ROOT}" + +BAAS_DIR="apps/baas/mini-baas-infra" +ROUTER_DIR="${BAAS_DIR}/docker/services/data-plane-router" +QR_DIR="${BAAS_DIR}/src/apps/query-router/src" +COMPOSE_FILE="${BAAS_DIR}/docker-compose.yml" + +cyan() { printf '\033[0;36m%s\033[0m\n' "$*"; } +red() { printf '\033[0;31m%s\033[0m\n' "$*"; } +green() { printf '\033[0;32m%s\033[0m\n' "$*"; } +fail() { red "[M22] FAIL: $*"; exit 1; } +step() { cyan "[M22] ${*}"; } +pass() { green "[M22] PASS: ${*}"; } + +LIVE="${BAAS_VERIFY_LIVE:-0}" +for arg in "$@"; do + [[ "${arg}" == "--live" ]] && LIVE=1 +done + +# ── 1) Core contract: SchemaDescriptor + describe_schema port ──────────────── +step "checking data-plane-core schema contract" +SCHEMA_RS="${ROUTER_DIR}/crates/data-plane-core/src/schema.rs" +[[ -f "${SCHEMA_RS}" ]] || fail "missing ${SCHEMA_RS}" +for symbol in \ + "pub struct SchemaDescriptor" \ + "pub struct TableSchema" \ + "pub struct ColumnSchema" \ + "pub struct ForeignKeyRef" \ + "pub enum NormalizedType" \ + 'rename_all = "snake_case"'; do + grep -q "${symbol}" "${SCHEMA_RS}" || fail "${SCHEMA_RS} missing: ${symbol}" +done +grep -q "pub use schema::" "${ROUTER_DIR}/crates/data-plane-core/src/lib.rs" \ + || fail "data-plane-core lib.rs does not re-export the schema contract" +grep -q "async fn describe_schema" "${ROUTER_DIR}/crates/data-plane-core/src/ports.rs" \ + || fail "EnginePool::describe_schema port missing (NotImplemented default)" +pass "SchemaDescriptor contract + describe_schema port exist" + +# ── 2) Capability flag: introspect is a route capability ───────────────────── +step "checking the introspect capability flag" +CAP_RS="${ROUTER_DIR}/crates/data-plane-core/src/capability.rs" +grep -q "pub introspect: bool" "${CAP_RS}" \ + || fail "EngineCapabilities.introspect missing" +grep -B2 "pub introspect: bool" "${CAP_RS}" | grep -q "serde(default)" \ + || fail "introspect must be #[serde(default)] for wire back-compat" +pass "EngineCapabilities.introspect declared with wire back-compat default" + +# ── 3) Route: POST /v1/schema, identity/mount + capability gated ───────────── +step "checking the Rust /v1/schema route" +ROUTES="${ROUTER_DIR}/crates/data-plane-server/src/routes.rs" +grep -q '"/v1/schema"' "${ROUTES}" || fail "POST /v1/schema route missing" +grep -q "struct DescribeSchemaRequest" "${ROUTES}" \ + || fail "DescribeSchemaRequest envelope missing" +grep -q "async fn describe_schema" "${ROUTES}" || fail "describe_schema handler missing" +grep -A8 "async fn describe_schema" "${ROUTES}" | grep -q "validate_identity_mount" \ + || fail "describe_schema must validate identity/mount (begin_transaction-style gating)" +grep -A14 "async fn describe_schema" "${ROUTES}" | grep -q '"introspect"' \ + || fail "describe_schema must gate on the introspect capability" +grep -A14 "async fn describe_schema" "${ROUTES}" | grep -q "is_admin" \ + && fail "/v1/schema must NOT be admin-gated (any authenticated identity reads its own mount)" +pass "/v1/schema mounted: validate_identity_mount + introspect capability gate, no admin gate" + +# ── 4) Engine implementations + pure normalizers + SharedPool delegation ───── +step "checking engine describe_schema implementations" +grep -q "async fn describe_schema" "${ROUTER_DIR}/crates/data-plane-pool/src/postgres.rs" \ + || fail "postgres describe_schema missing" +grep -q "fn normalize_pg_type" "${ROUTER_DIR}/crates/data-plane-pool/src/postgres.rs" \ + || fail "postgres normalize_pg_type (pure) missing" +grep -q "async fn describe_schema" "${ROUTER_DIR}/crates/data-plane-pool/src/mysql.rs" \ + || fail "mysql describe_schema missing" +grep -q "fn normalize_mysql_type" "${ROUTER_DIR}/crates/data-plane-pool/src/mysql.rs" \ + || fail "mysql normalize_mysql_type (pure) missing" +grep -q "async fn describe_schema" "${ROUTER_DIR}/crates/data-plane-pool/src/mongo.rs" \ + || fail "mongo describe_schema missing" +grep -q "fn jsonschema_to_columns" "${ROUTER_DIR}/crates/data-plane-pool/src/mongo.rs" \ + || fail "mongo jsonschema_to_columns (pure) missing" +grep -q "fn infer_columns_from_samples" "${ROUTER_DIR}/crates/data-plane-pool/src/mongo.rs" \ + || fail "mongo infer_columns_from_samples (pure) missing" +grep -q '_baas_migrations' "${ROUTER_DIR}/crates/data-plane-pool/src/postgres.rs" \ + || fail "postgres introspection must exclude _baas_migrations" +grep -q "self.0.describe_schema" "${ROUTER_DIR}/crates/data-plane-pool/src/registry.rs" \ + || fail "SharedPool must delegate describe_schema to the underlying engine" +pass "postgres + mysql + mongo implement describe_schema; SharedPool delegates" + +# ── 5) query-router surface: GET /:dbId/schema ──────────────────────────────── +step "checking query-router schema surface" +PROXY_TS="${QR_DIR}/proxy/rust-data-plane.proxy.ts" +grep -q "describeSchema" "${PROXY_TS}" \ + || fail "${PROXY_TS} missing describeSchema method" +grep -q "'/v1/schema'" "${PROXY_TS}" \ + || fail "${PROXY_TS} does not POST to /v1/schema" +SCHEMA_SVC="${QR_DIR}/query/schema.service.ts" +[[ -f "${SCHEMA_SVC}" ]] || fail "missing ${SCHEMA_SVC}" +grep -q "resolveConnection" "${SCHEMA_SVC}" \ + || fail "SchemaService must resolve the mount via QueryService.resolveConnection" +grep -q "describeSchema" "${SCHEMA_SVC}" \ + || fail "SchemaService never calls rustProxy.describeSchema" +grep -q "QUERY_ROUTER_SCHEMA_CACHE_TTL_MS" "${SCHEMA_SVC}" \ + || fail "SchemaService missing the TTL cache" +SCHEMA_CTRL="${QR_DIR}/query/schema.controller.ts" +[[ -f "${SCHEMA_CTRL}" ]] || fail "missing ${SCHEMA_CTRL}" +grep -q "':dbId/schema'" "${SCHEMA_CTRL}" \ + || fail "SchemaController does not serve GET /:dbId/schema" +grep -q "AuthGuard" "${SCHEMA_CTRL}" \ + || fail "SchemaController is not guarded (AuthGuard missing)" +QUERY_MOD="${QR_DIR}/query/query.module.ts" +grep -q "SchemaController" "${QUERY_MOD}" \ + || fail "QueryModule does not register SchemaController" +grep -q "SchemaService" "${QUERY_MOD}" \ + || fail "QueryModule does not register SchemaService" +grep -q "resolveConnection" "${QR_DIR}/query/query.service.ts" \ + || fail "QueryService.resolveConnection (public wrapper) missing" +pass "GET /query/v1/:dbId/schema wired: proxy + service (TTL cache) + guarded controller" + +# ── 6) DDL contract: SchemaDdlRequest + apply_schema_ddl port + capability ─── +step "checking the data-plane-core schema DDL contract" +DDL_RS="${ROUTER_DIR}/crates/data-plane-core/src/schema_ddl.rs" +[[ -f "${DDL_RS}" ]] || fail "missing ${DDL_RS}" +for symbol in \ + "pub struct SchemaDdlRequest" \ + "pub struct SchemaDdlResult" \ + "pub struct DdlColumnDef" \ + "pub enum SchemaDdlOp" \ + "pub fn validate_default_expr"; do + grep -q "${symbol}" "${DDL_RS}" || fail "${DDL_RS} missing: ${symbol}" +done +grep -q "pub use schema_ddl::" "${ROUTER_DIR}/crates/data-plane-core/src/lib.rs" \ + || fail "data-plane-core lib.rs does not re-export the schema DDL contract" +grep -q "async fn apply_schema_ddl" "${ROUTER_DIR}/crates/data-plane-core/src/ports.rs" \ + || fail "EnginePool::apply_schema_ddl port missing (NotImplemented default)" +grep -q "pub schema_ddl: bool" "${CAP_RS}" \ + || fail "EngineCapabilities.schema_ddl missing" +grep -B2 "pub schema_ddl: bool" "${CAP_RS}" | grep -q "serde(default)" \ + || fail "schema_ddl must be #[serde(default)] for wire back-compat" +pass "SchemaDdlRequest contract + apply_schema_ddl port + schema_ddl capability exist" + +# ── 7) Route: POST /v1/schema/ddl, identity/mount + schema_ddl gated ───────── +step "checking the Rust /v1/schema/ddl route" +grep -q '"/v1/schema/ddl"' "${ROUTES}" || fail "POST /v1/schema/ddl route missing" +grep -q "struct SchemaDdlEnvelope" "${ROUTES}" || fail "SchemaDdlEnvelope missing" +grep -A8 "async fn apply_schema_ddl" "${ROUTES}" | grep -q "validate_identity_mount" \ + || fail "apply_schema_ddl must validate identity/mount" +grep -A16 "async fn apply_schema_ddl" "${ROUTES}" | grep -q '"schema_ddl"' \ + || fail "apply_schema_ddl must gate on the schema_ddl capability" +grep -A16 "async fn apply_schema_ddl" "${ROUTES}" | grep -q "is_admin" \ + && fail "/v1/schema/ddl must NOT be admin-gated (same trust model as /v1/query writes)" +pass "/v1/schema/ddl mounted: validate_identity_mount + schema_ddl capability gate, no admin gate" + +# ── 8) Engine apply_schema_ddl implementations + SharedPool delegation ─────── +step "checking engine apply_schema_ddl implementations" +grep -q "async fn apply_schema_ddl" "${ROUTER_DIR}/crates/data-plane-pool/src/postgres.rs" \ + || fail "postgres apply_schema_ddl missing" +grep -q "fn pg_sql_type" "${ROUTER_DIR}/crates/data-plane-pool/src/postgres.rs" \ + || fail "postgres pg_sql_type (pure reverse mapper) missing" +grep -q "fn build_pg_ddl" "${ROUTER_DIR}/crates/data-plane-pool/src/postgres.rs" \ + || fail "postgres build_pg_ddl (pure statement builder) missing" +grep -q "async fn apply_schema_ddl" "${ROUTER_DIR}/crates/data-plane-pool/src/mysql.rs" \ + || fail "mysql apply_schema_ddl missing" +grep -q "fn mysql_sql_type" "${ROUTER_DIR}/crates/data-plane-pool/src/mysql.rs" \ + || fail "mysql mysql_sql_type (pure reverse mapper) missing" +grep -q "fn build_mysql_ddl" "${ROUTER_DIR}/crates/data-plane-pool/src/mysql.rs" \ + || fail "mysql build_mysql_ddl (pure statement builder) missing" +grep -q "async fn apply_schema_ddl" "${ROUTER_DIR}/crates/data-plane-pool/src/mongo.rs" \ + || fail "mongo apply_schema_ddl missing" +grep -q "fn columns_to_jsonschema" "${ROUTER_DIR}/crates/data-plane-pool/src/mongo.rs" \ + || fail "mongo columns_to_jsonschema (pure) missing" +grep -q "fn jsonschema_with_column_set" "${ROUTER_DIR}/crates/data-plane-pool/src/mongo.rs" \ + || fail "mongo jsonschema_with_column_set (pure transform) missing" +grep -q "self.0.apply_schema_ddl" "${ROUTER_DIR}/crates/data-plane-pool/src/registry.rs" \ + || fail "SharedPool must delegate apply_schema_ddl to the underlying engine" +pass "postgres + mysql + mongo implement apply_schema_ddl; SharedPool delegates" + +# ── 9) query-router surface: POST /:dbId/schema/ddl ────────────────────────── +step "checking query-router schema DDL surface" +grep -q "applySchemaDdl" "${PROXY_TS}" \ + || fail "${PROXY_TS} missing applySchemaDdl method" +grep -q "'/v1/schema/ddl'" "${PROXY_TS}" \ + || fail "${PROXY_TS} does not POST to /v1/schema/ddl" +[[ -f "${QR_DIR}/query/dto/schema-ddl.dto.ts" ]] || fail "missing schema-ddl.dto.ts" +grep -q "applyDdl" "${SCHEMA_SVC}" || fail "SchemaService.applyDdl missing" +grep -q "confirm" "${SCHEMA_SVC}" \ + || fail "SchemaService.applyDdl missing the destructive-op confirm gate" +grep -q "cache.delete" "${SCHEMA_SVC}" \ + || fail "SchemaService.applyDdl must bust the schema cache after DDL" +grep -q "':dbId/schema/ddl'" "${SCHEMA_CTRL}" \ + || fail "SchemaController does not serve POST /:dbId/schema/ddl" +pass "POST /query/v1/:dbId/schema/ddl wired: proxy + confirm gate + cache bust + DTO" + +# ── 10) Live: pg mount yields tables+columns; redis mount yields 422 ───────── +if [[ "${LIVE}" == "1" ]]; then + command -v docker >/dev/null 2>&1 || fail "docker required for live mode" + command -v curl >/dev/null 2>&1 || fail "curl required for live mode" + + step "live: starting postgres + the Rust data-plane-router" + # resolve-ports first: without it compose falls back to the DEFAULT host + # ports, sees drift on a running stack, and RECREATES postgres — straight + # into a port clash when the root track-binocle stack holds 5432. + eval "$(bash "${BAAS_DIR}/scripts/resolve-ports.sh" 2>/dev/null || true)" + docker compose -f "${COMPOSE_FILE}" up -d --wait postgres >/dev/null + docker compose -f "${COMPOSE_FILE}" --profile rust-data-plane up -d --wait data-plane-router-rust >/dev/null + + RUST_URL="http://127.0.0.1:${DATA_PLANE_RUST_PORT:-4011}" + # Read the credentials from the RUNNING container, not the caller's shell — + # a wrong/default DSN here poisons the cached pool for the probe mount key. + pg_env() { docker compose -f "${COMPOSE_FILE}" exec -T postgres sh -lc "printf '%s' \"\$$1\""; } + PG_USER="$(pg_env POSTGRES_USER)"; PG_USER="${PG_USER:-postgres}" + PG_PASS="$(pg_env POSTGRES_PASSWORD)"; PG_PASS="${PG_PASS:-postgres}" + PG_DB="$(pg_env POSTGRES_DB)"; PG_DB="${PG_DB:-postgres}" + PG_DSN="postgres://${PG_USER}:${PG_PASS}@postgres:5432/${PG_DB}" + TENANT="00000000-0000-4000-8000-00000000m22a" + # Unique per run: the registry keys pools on mount id + credential version, + # so reusing an id would hand back a pool built from an earlier (possibly + # bad) DSN instead of dialing with this run's credentials. + PROBE_RUN="$(date +%s)" + + envelope() { # $1 engine, $2 inline_dsn + cat </dev/null 2>&1 || true + } + trap cleanup_scratch EXIT + + ddl_envelope() { # $1 engine, $2 inline_dsn, $3 ddl-json + cat </dev/null \ + || fail "seeding the incompatible row failed" + code=$(curl -s -o /tmp/m22-ddl-409.json -w '%{http_code}' -X POST "${RUST_URL}/v1/schema/ddl" \ + -H 'Content-Type: application/json' \ + -d "$(ddl_envelope postgresql "${PG_DSN}" "{ + \"op\": \"alter_column_type\", \"table\": \"${SCRATCH}\", + \"column\": { \"name\": \"note\", \"normalized_type\": \"integer\", \"nullable\": true, \"default\": null, \"enum_values\": null } + }")") + [[ "${code}" == "409" ]] || fail "text→integer over 'abc' must be 409, got ${code}: $(cat /tmp/m22-ddl-409.json)" + grep -q '"error":"conflict"' /tmp/m22-ddl-409.json \ + || fail "DDL 409 must carry error=conflict" + pass "incompatible alter_column_type rejected with 409 conflict (data preserved)" + + step "live: DDL drop_column extra on ${SCRATCH}" + body=$(curl -fsS -X POST "${RUST_URL}/v1/schema/ddl" \ + -H 'Content-Type: application/json' \ + -d "$(ddl_envelope postgresql "${PG_DSN}" "{ \"op\": \"drop_column\", \"table\": \"${SCRATCH}\", \"column_name\": \"extra\" }")") \ + || fail "DDL drop_column failed" + echo "${body}" | grep -q '"status":"applied"' || fail "drop_column did not apply: ${body}" + pass "drop_column applied" + + step "live: DDL drop_table ${SCRATCH}" + body=$(curl -fsS -X POST "${RUST_URL}/v1/schema/ddl" \ + -H 'Content-Type: application/json' \ + -d "$(ddl_envelope postgresql "${PG_DSN}" "{ \"op\": \"drop_table\", \"table\": \"${SCRATCH}\" }")") \ + || fail "DDL drop_table failed" + echo "${body}" | grep -q '"status":"applied"' || fail "drop_table did not apply: ${body}" + pass "drop_table applied" + + step "live: DDL on a redis mount is a clean 422 unsupported_capability" + code=$(curl -s -o /tmp/m22-redis-ddl.json -w '%{http_code}' -X POST "${RUST_URL}/v1/schema/ddl" \ + -H 'Content-Type: application/json' \ + -d "$(ddl_envelope redis "redis://redis:6379" "{ \"op\": \"drop_table\", \"table\": \"t\" }")") + [[ "${code}" == "422" ]] || fail "redis DDL must be 422, got ${code}" + grep -q "unsupported_capability" /tmp/m22-redis-ddl.json \ + || fail "redis DDL 422 must carry error=unsupported_capability" + pass "redis DDL rejected with 422 unsupported_capability" + + cleanup_scratch + + # ── Gateway-path probe (Phase 6 — always runs in live mode) ──────────────── + # BAAS_SCHEMA_DB_ID + BAAS_API_KEY (a tenant mbk_… key) override with a + # pre-registered mount; otherwise a scratch tenant + key + mount is + # provisioned through the REAL control plane (lib-live-tenant.sh). + # Wire note: Kong's key-auth consumes `apikey` (the anon/service consumer + # key) while the TENANT key travels as X-Baas-Api-Key, which the + # query-router's ApiKeyMiddleware exchanges via tenant-control + # /v1/keys/verify — that pair is the only combination that traverses both + # auth layers. + # shellcheck source=scripts/verify/lib-live-tenant.sh + source "${SCRIPT_DIR}/lib-live-tenant.sh" + + RT_TABLE="m22_rt_${PROBE_RUN}" + WS_NAME="m22-ws-${PROBE_RUN}" + cleanup_gateway() { + docker rm -f "${WS_NAME}" >/dev/null 2>&1 || true + docker compose -f "${COMPOSE_FILE}" exec -T postgres \ + psql -U "${PG_USER}" -d "${PG_DB}" \ + -c "DROP TABLE IF EXISTS public.${RT_TABLE}" >/dev/null 2>&1 || true + } + cleanup_live_all() { + cleanup_scratch + cleanup_gateway + # Only deprovision what THIS run minted (no-op in override mode). + live_tenant_cleanup + } + trap cleanup_live_all EXIT + + if [[ -n "${BAAS_SCHEMA_DB_ID:-}" && -n "${BAAS_API_KEY:-}" ]]; then + GW_DB_ID="${BAAS_SCHEMA_DB_ID}" + GW_TENANT_KEY="${BAAS_API_KEY}" + GW_KONG_URL="http://127.0.0.1:$(docker port mini-baas-kong 8000/tcp 2>/dev/null | head -1 | sed 's/.*://')" + GW_ANON_KEY="$(docker inspect mini-baas-kong \ + --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \ + | grep '^KONG_PUBLIC_API_KEY=' | cut -d= -f2-)" + [[ -n "${GW_ANON_KEY}" ]] || fail "KONG_PUBLIC_API_KEY not found on mini-baas-kong" + else + step "live: provisioning scratch tenant + API key + mount through the gateway" + live_tenant_provision "m22live${PROBE_RUN}" || fail "live tenant provisioning failed" + GW_DB_ID="${LIVE_TENANT_DB_ID}" + GW_TENANT_KEY="${LIVE_TENANT_API_KEY}" + GW_KONG_URL="${LIVE_KONG_URL}" + GW_ANON_KEY="${LIVE_ANON_APIKEY}" + pass "tenant '${LIVE_TENANT_SLUG}' + key + mount ${GW_DB_ID} provisioned (Kong /admin/v1/databases)" + fi + + step "live: GET /query/v1/${GW_DB_ID}/schema through the gateway with the minted key" + gw=$(curl -fsS "${GW_KONG_URL}/query/v1/${GW_DB_ID}/schema" \ + -H "apikey: ${GW_ANON_KEY}" -H "X-Baas-Api-Key: ${GW_TENANT_KEY}") \ + || fail "gateway schema fetch failed" + echo "${gw}" | grep -q '"tables":' || fail "gateway schema response missing tables[]" + echo "${gw}" | grep -q '"capabilities":' || fail "gateway schema response missing capabilities" + pass "gateway GET /query/v1/:dbId/schema serves tables + capabilities" + + step "live: gateway DDL confirm gate — destructive op without confirm is a 400" + code=$(curl -s -o /tmp/m22-gw-confirm.json -w '%{http_code}' -X POST \ + "${GW_KONG_URL}/query/v1/${GW_DB_ID}/schema/ddl" \ + -H "apikey: ${GW_ANON_KEY}" -H "X-Baas-Api-Key: ${GW_TENANT_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{ "op": "drop_table", "table": "m22_confirm_probe_nonexistent" }') + [[ "${code}" == "400" ]] || fail "unconfirmed drop_table must be 400, got ${code}" + grep -qi "confirm" /tmp/m22-gw-confirm.json \ + || fail "unconfirmed-DDL 400 must mention the confirm requirement" + pass "gateway POST /query/v1/:dbId/schema/ddl enforces the confirm gate" + + # ── Realtime echo: gateway write → row_changed on table::
───── + step "live: gateway DDL create_table ${RT_TABLE} (scratch realtime table)" + # owner_id is declared EXPLICITLY as text: the platform's write path stamps + # the caller principal (api-key: here) into owner_id, matching the + # owner_id TEXT shape of the seeded platform tables (mock_orders, projects). + code=$(curl -s -o /tmp/m22-rt-ddl.json -w '%{http_code}' -X POST \ + "${GW_KONG_URL}/query/v1/${GW_DB_ID}/schema/ddl" \ + -H "apikey: ${GW_ANON_KEY}" -H "X-Baas-Api-Key: ${GW_TENANT_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{ + \"op\": \"create_table\", \"table\": \"${RT_TABLE}\", + \"columns\": [ + { \"name\": \"id\", \"normalized_type\": \"integer\", \"nullable\": false }, + { \"name\": \"note\", \"normalized_type\": \"text\", \"nullable\": true }, + { \"name\": \"owner_id\", \"normalized_type\": \"text\", \"nullable\": true } + ], + \"primary_key\": [\"id\"] + }") + [[ "${code}" == "200" || "${code}" == "201" ]] \ + || fail "gateway create_table failed (${code}): $(cat /tmp/m22-rt-ddl.json)" + grep -q '"status":"applied"' /tmp/m22-rt-ddl.json \ + || fail "gateway create_table did not apply: $(cat /tmp/m22-rt-ddl.json)" + pass "gateway DDL created ${RT_TABLE}" + + step "live: starting WS subscriber on table:${GW_DB_ID}:${RT_TABLE}" + STACK_NET="$(docker inspect mini-baas-kong \ + --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null | head -1)" + [[ -n "${STACK_NET}" ]] || fail "could not resolve the stack docker network from mini-baas-kong" + RT_JWT_SECRET="$(docker inspect mini-baas-realtime \ + --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null \ + | grep '^REALTIME_JWT_SECRET=' | cut -d= -f2-)" + [[ -n "${RT_JWT_SECRET}" ]] || fail "REALTIME_JWT_SECRET not found on mini-baas-realtime" + WS_PROBE="$(mktemp /tmp/m22-ws-probe.XXXXXX.mjs)" + # Node's built-in WebSocket client (stable on 22, flag-gated on 20) — no npm + # install needed, the probe runs offline. JWT minted with node:crypto HS256. + cat > "${WS_PROBE}" <<'WSJS' +import { createHmac } from 'node:crypto'; +const b64u = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); +const head = b64u({ alg: 'HS256', typ: 'JWT' }); +const body = b64u({ sub: 'm22-verify', exp: Math.floor(Date.now() / 1000) + 300 }); +const sig = createHmac('sha256', process.env.RT_JWT_SECRET) + .update(`${head}.${body}`).digest('base64url'); +const ws = new WebSocket(process.env.RT_WS_URL); +const die = (msg) => { console.log(msg); process.exit(1); }; +setTimeout(() => die('TIMEOUT waiting for row_changed'), Number(process.env.RT_WAIT_MS ?? 30000)); +ws.onopen = () => ws.send(JSON.stringify({ type: 'AUTH', token: `${head}.${body}.${sig}` })); +ws.onerror = (e) => die(`WS_ERROR ${e.message ?? 'connect failed'}`); +ws.onmessage = (m) => { + const f = JSON.parse(m.data); + if (f.type === 'AUTH_OK') { + ws.send(JSON.stringify({ type: 'SUBSCRIBE', sub_id: 's1', topic: process.env.RT_TOPIC })); + } else if (f.type === 'SUBSCRIBED') { + console.log('SUBSCRIBED'); + } else if (f.type === 'EVENT' && f.event.event_type === 'row_changed') { + console.log(`ROW_CHANGED ${f.event.topic} ${JSON.stringify(f.event.payload)}`); + process.exit(0); + } else if (f.type === 'ERROR') { + die(`PROTO_ERROR ${f.code} ${f.message}`); + } +}; +WSJS + WS_NODE_IMAGE="${BAAS_WS_NODE_IMAGE:-node:22-alpine}" + docker rm -f "${WS_NAME}" >/dev/null 2>&1 || true + docker run -d --name "${WS_NAME}" --network "${STACK_NET}" \ + -v "${WS_PROBE}:/probe.mjs:ro" \ + -e RT_WS_URL="ws://kong:8000/realtime/v1/ws" \ + -e RT_JWT_SECRET="${RT_JWT_SECRET}" \ + -e RT_TOPIC="table:${GW_DB_ID}:${RT_TABLE}" \ + -e RT_WAIT_MS=30000 \ + "${WS_NODE_IMAGE}" node --experimental-websocket /probe.mjs >/dev/null \ + || fail "could not start the WS subscriber container (${WS_NODE_IMAGE})" + for _ in $(seq 1 60); do + docker logs "${WS_NAME}" 2>&1 | grep -q 'SUBSCRIBED' && break + sleep 0.5 + done + docker logs "${WS_NAME}" 2>&1 | grep -q 'SUBSCRIBED' \ + || fail "WS subscriber never reached SUBSCRIBED: $(docker logs "${WS_NAME}" 2>&1 | tail -3)" + pass "WS subscriber authenticated (in-band JWT) and subscribed via /realtime/v1/ws" + + step "live: gateway op=insert on ${RT_TABLE} → row_changed echo within 5s" + code=$(curl -s -o /tmp/m22-rt-insert.json -w '%{http_code}' -X POST \ + "${GW_KONG_URL}/query/v1/${GW_DB_ID}/tables/${RT_TABLE}" \ + -H "apikey: ${GW_ANON_KEY}" -H "X-Baas-Api-Key: ${GW_TENANT_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{ "op": "insert", "data": { "id": 1, "note": "m22 realtime echo" } }') + [[ "${code}" == "200" || "${code}" == "201" ]] \ + || fail "gateway insert failed (${code}): $(cat /tmp/m22-rt-insert.json)" + grep -q '"rowCount":1' /tmp/m22-rt-insert.json \ + || fail "gateway insert did not report rowCount 1: $(cat /tmp/m22-rt-insert.json)" + ECHO_OK=0 + for _ in $(seq 1 10); do + if docker logs "${WS_NAME}" 2>&1 | grep -q 'ROW_CHANGED'; then ECHO_OK=1; break; fi + sleep 0.5 + done + [[ "${ECHO_OK}" == "1" ]] \ + || fail "no row_changed frame within 5s: $(docker logs "${WS_NAME}" 2>&1 | tail -3)" + docker logs "${WS_NAME}" 2>&1 | grep 'ROW_CHANGED' \ + | grep -q "table:${GW_DB_ID}:${RT_TABLE}" \ + || fail "row_changed arrived on the wrong topic: $(docker logs "${WS_NAME}" 2>&1 | tail -3)" + pass "realtime echo: gateway insert delivered as row_changed on table:${GW_DB_ID}:${RT_TABLE}" + rm -f "${WS_PROBE}" +fi + +green "[M22] OK — engine-agnostic schema introspection + DDL verified" diff --git a/apps/baas/mini-baas-infra/scripts/verify/m23-agency-foundation.sh b/apps/baas/mini-baas-infra/scripts/verify/m23-agency-foundation.sh new file mode 100755 index 00000000..787c7524 --- /dev/null +++ b/apps/baas/mini-baas-infra/scripts/verify/m23-agency-foundation.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# **************************************************************************** # +# m23-agency-foundation.sh — gate for the agency simulation foundation # +# # +# Proves Wave 0 of the Binocle Intelligence Agency build: # +# 1) live tenant: 11 tables exist on the agency mount with the expected # +# row counts, served through the REAL gateway path (Kong key-auth → # +# query-router → Rust data plane) # +# 2) graph: /query/v1/graph/overview returns a connected investigation # +# graph (explicit edges + FK reference generator) # +# 3) identities: 21 @agency.local accounts in gotrue, 21 bridge # +# identities, 21 members on the org workspace # +# 4) ABAC: /permissions/decide returns the designed role differences — # +# director allow, analyst masked amount, field agent denied comms + # +# hidden ssn, guest denied evidence # +# # +# Requires: both stacks running, agency-tenant.sh + seed_agency_people.sh # +# + seed_agency.py + agency-policies.sh applied (make agency-all). # +# **************************************************************************** # +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INFRA_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +REPO_ROOT="$(cd "${INFRA_ROOT}/../../.." && pwd)" +TENANT_ENV="${INFRA_ROOT}/.agency-tenant.env" +PEOPLE_ENV="${REPO_ROOT}/tools/seeds/.agency-people.env" + +cyan() { printf '\033[0;36m[M23] %s\033[0m\n' "$*"; } +green() { printf '\033[0;32m[M23] PASS: %s\033[0m\n' "$*"; } +fail() { printf '\033[0;31m[M23] FAIL: %s\033[0m\n' "$*" >&2; exit 1; } + +[[ -f "${TENANT_ENV}" ]] || fail "missing ${TENANT_ENV} (run make agency-seed)" +[[ -f "${PEOPLE_ENV}" ]] || fail "missing ${PEOPLE_ENV} (run make agency-people)" +# shellcheck disable=SC1090 +source "${TENANT_ENV}" +OWNER_UUID="$(grep '^AGENCY_OWNER_UUID=' "${PEOPLE_ENV}" | cut -d= -f2)" +ORG_WS_ID="$(grep '^AGENCY_ORG_WORKSPACE_ID=' "${PEOPLE_ENV}" | cut -d= -f2)" +person_uuid() { grep "^AGENCY_PERSON_$1=" "${PEOPLE_ENV}" | cut -d= -f2 | cut -d'|' -f1; } +ANALYST_UUID="$(person_uuid 11)" # Erik Johansson — analyst +AGENT_UUID="$(person_uuid 7)" # Jack Sullivan — field_agent + +# ── 1) tenant tables + row counts through the gateway ──────────────────────── +cyan "checking the 11 agency tables through the gateway" +declare -A EXPECT=( + [cases]=40 [subjects]=60 [locations]=50 [evidence]=120 [leads]=80 + [transactions]=150 [vehicles]=30 [communications]=200 [reports]=40 + [assignments]=100 [edges]=80 +) +schema="$(curl -fsS "${AGENCY_KONG_URL}/query/v1/${AGENCY_DB_ID}/schema" \ + -H "apikey: ${AGENCY_ANON_APIKEY}" -H "X-Baas-Api-Key: ${AGENCY_API_KEY}")" \ + || fail "gateway schema fetch failed" +for table in "${!EXPECT[@]}"; do + echo "${schema}" | grep -q "\"name\":\"${table}\"" || fail "schema missing table ${table}" +done +for table in cases transactions edges; do + body="$(curl -fsS -X POST "${AGENCY_KONG_URL}/query/v1/${AGENCY_DB_ID}/tables/${table}" \ + -H "apikey: ${AGENCY_ANON_APIKEY}" -H "X-Baas-Api-Key: ${AGENCY_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{"op":"aggregate","aggregate":{"aggregates":[{"func":"count","alias":"n"}]}}')" \ + || fail "aggregate count on ${table} failed" + echo "${body}" | grep -q "\"n\":[ ]*\"\?${EXPECT[${table}]}" \ + || fail "${table} count != ${EXPECT[${table}]}: ${body}" +done +green "11 tables present; spot counts (cases/transactions/edges) match" + +# ── 2) graph overview ───────────────────────────────────────────────────────── +cyan "checking the investigation graph" +graph="$(curl -fsS -X POST "${AGENCY_KONG_URL}/query/v1/graph/overview" \ + -H "apikey: ${AGENCY_ANON_APIKEY}" -H "X-Baas-Api-Key: ${AGENCY_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{\"resources\":[{\"dbId\":\"${AGENCY_DB_ID}\",\"table\":\"cases\"},{\"dbId\":\"${AGENCY_DB_ID}\",\"table\":\"subjects\"}],\"edgesDbId\":\"${AGENCY_DB_ID}\",\"limit\":200}")" \ + || fail "graph overview failed" +echo "${graph}" | grep -q '"type":"associate"' || fail "graph missing explicit associate edges" +nodes=$(echo "${graph}" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["nodes"]))') +[[ "${nodes}" -ge 100 ]] || fail "graph returned only ${nodes} nodes" +green "graph overview serves ${nodes} nodes with explicit investigation edges" + +# ── 3) identities ───────────────────────────────────────────────────────────── +cyan "checking accounts, identities, org membership" +RPSQL() { docker exec -i track-binocle-postgres-1 psql -U postgres -d postgres -At "$@"; } +n=$(RPSQL -c "SELECT count(*) FROM auth.users WHERE email LIKE '%@agency.local'") +[[ "${n}" == "21" ]] || fail "expected 21 gotrue accounts, found ${n}" +n=$(RPSQL -c "SELECT count(*) FROM osionos_bridge_identities i JOIN auth.users u ON u.id=i.user_id WHERE u.email LIKE '%@agency.local'") +[[ "${n}" == "21" ]] || fail "expected 21 bridge identities, found ${n}" +n=$(RPSQL -c "SELECT count(*) FROM osionos_workspace_members WHERE workspace_id='${ORG_WS_ID}'") +[[ "${n}" == "21" ]] || fail "expected 21 org members, found ${n}" +# Invite-path evidence is generated fresh each run (Mailpit is an in-memory +# sink — historical emails do not survive container restarts): invite a probe +# user, assert the email lands, then delete the probe account. +PROBE_EMAIL="probe.invite.$(date +%s)@agency.local" +docker exec -e PROBE_EMAIL="${PROBE_EMAIL}" track-binocle-osionos-bridge-1 node -e ' +const run = async () => { + const auth = { "Content-Type": "application/json", Authorization: `Bearer ${process.env.SERVICE_ROLE_KEY}` }; + // Pin the ROOT stack gotrue by container name — the bridge is dual-homed + // and the `gotrue` alias also exists on the mini-baas network. + const GOTRUE = "http://track-binocle-gotrue-1:9999"; + const r = await fetch(`${GOTRUE}/invite`, { method: "POST", headers: auth, + body: JSON.stringify({ email: process.env.PROBE_EMAIL }) }); + if (!r.ok) throw new Error(`invite ${r.status}`); + const u = await r.json(); + const d = await fetch(`${GOTRUE}/admin/users/${u.id}`, { method: "DELETE", headers: auth }); + if (!d.ok) throw new Error(`cleanup ${d.status}`); +}; +run().catch((e) => { console.error(e.message); process.exit(1); }); +' || fail "gotrue invite path failed" +sleep 1 +curl -s "http://localhost:8025/api/v1/search?query=${PROBE_EMAIL}" | grep -q "You have been invited" \ + || fail "invitation email for ${PROBE_EMAIL} not found in Mailpit" +green "21 accounts + identities + org members; invite→Mailpit path verified live" + +# ── 4) ABAC decisions ───────────────────────────────────────────────────────── +cyan "checking role-differentiated ABAC decisions" +PE_TOKEN="$(docker inspect mini-baas-permission-engine \ + --format '{{range .Config.Env}}{{println .}}{{end}}' \ + | grep '^ADAPTER_REGISTRY_SERVICE_TOKEN=' | cut -d= -f2-)" +[[ -n "${PE_TOKEN}" ]] || fail "permission-engine service token not found" +decide() { # $1 user uuid, $2 table, $3 op + curl -s -X POST "${AGENCY_KONG_URL}/permissions/v1/permissions/decide" \ + -H "apikey: ${AGENCY_SERVICE_APIKEY}" -H "X-Service-Token: ${PE_TOKEN}" \ + -H "X-Tenant-Id: agency" -H 'Content-Type: application/json' \ + -d "{\"user\":{\"id\":\"$1\"},\"resource_type\":\"table\",\"resource_name\":\"$2\",\"op\":\"$3\"}" +} +decide "${OWNER_UUID}" transactions list | grep -q '"allow":true' \ + || fail "director must be allowed on transactions" +analyst="$(decide "${ANALYST_UUID}" transactions list)" +echo "${analyst}" | grep -q '"allow":true' || fail "analyst must be allowed on transactions" +echo "${analyst}" | grep -q '"amount":"\*\*\*"' || fail "analyst transactions must carry the amount mask: ${analyst}" +decide "${AGENT_UUID}" communications list | grep -q '"allow":false' \ + || fail "field agent must be denied on communications" +decide "${AGENT_UUID}" subjects list | grep -q '"hide":\["ssn"\]' \ + || fail "field agent subjects must hide ssn" +decide "${AGENT_UUID}" transactions update | grep -q '"allow":false' \ + || fail "field agent must be denied transaction writes" +GUEST_UUID="00000000-0000-4000-8000-00000000beef" +docker exec -i mini-baas-postgres psql -U postgres -d postgres -q -c \ + "INSERT INTO user_roles (user_id, role_id) SELECT '${GUEST_UUID}'::uuid, id FROM roles WHERE name='guest' ON CONFLICT DO NOTHING" +decide "${GUEST_UUID}" evidence list | grep -q '"allow":false' \ + || fail "guest must be denied on evidence" +green "director allow / analyst masked / agent denied+hidden / guest denied" + +green "M23 agency foundation OK — tenant, data, graph, identities, ABAC all live" diff --git a/apps/baas/mini-baas-infra/scripts/verify/m23-live-edge-battery.sh b/apps/baas/mini-baas-infra/scripts/verify/m23-live-edge-battery.sh new file mode 100644 index 00000000..3310d979 --- /dev/null +++ b/apps/baas/mini-baas-infra/scripts/verify/m23-live-edge-battery.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# **************************************************************************** # +# # +# ::: :::::::: # +# m23-live-edge-battery.sh :+: :+: :+: # +# +:+ +:+ +:+ # +# By: dlesieur +#+ +:+ +#+ # +# +#+#+#+#+#+ +#+ # +# Created: 2026/06/10 00:00:00 by dlesieur #+# #+# # +# Updated: 2026/06/10 00:00:00 by dlesieur ### ########.fr # +# # +# **************************************************************************** # +# +# M23 — live-database edge battery. Pins the WIRE CONTRACT the osionos live +# UI (notion-database-sys store/live) is built against, by probing the REAL +# gateway path (Kong key-auth → query-router → Rust data plane → engines) +# with the app's own key on the seeded demo mounts. Every case here is either +# a bug found live (and fixed) or a guarantee the frontend write/conflict +# pipeline depends on: +# +# filter grammar sql ($ilike/$null/$between/top-$not) vs mongo native +# ($regex/$exists/$nin); mongo rejects sql-only ops 400 +# mass-write guard update/delete with an empty filter → 400 on ALL engines +# (mongo executed it before the fix: rowCount 39) +# single-row writes a by-pk update touches exactly one document (the mongo +# `_id`-stripping mass-update fix) +# error envelopes caller-data faults are 4xx, never 5xx: bad enum / bad +# date / numeric overflow / validator rejection / dup _id +# → 409; FK + NOT NULL → 409; DDL on missing column → +# 400, duplicate column → 409 (5xx made the outbox retry +# doomed writes forever) +# lifecycle insert (echo pk) → unicode/emoji roundtrip → update +# (rowCount 1, neighbor untouched) → delete → gone, +# per engine +# +# Requires: the mini-baas stack up + `make seed-live-demo` run (mount ids are +# read from the osionos app .env). Probe rows are created and deleted by the +# battery itself; seeded rows are only ever touched with INVALID values +# (rejected — nothing changes). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BAAS_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +REPO_ROOT="$(cd "${BAAS_DIR}/../../.." && pwd)" + +cyan() { printf '\033[0;36m%s\033[0m\n' "$*"; } +green() { printf '\033[0;32m%s\033[0m\n' "$*"; } +red() { printf '\033[0;31m%s\033[0m\n' "$*"; } +step() { cyan "[M23] $*"; } +pass() { green "[M23] PASS: $*"; } +fail() { red "[M23] FAIL: $*"; exit 1; } + +# shellcheck source=scripts/verify/lib-live-tenant.sh +source "${SCRIPT_DIR}/lib-live-tenant.sh" + +APP_ENV_FILE="${APP_ENV_FILE:-${REPO_ROOT}/apps/osionos/app/.env}" +KONG_PORT="$(_lt_host_port mini-baas-kong 8000/tcp)" +[[ -n "${KONG_PORT}" ]] || fail "mini-baas kong is not up" +KONG="http://127.0.0.1:${KONG_PORT}" +ANON="$(_lt_env mini-baas-kong KONG_PUBLIC_API_KEY)" +APP_KEY="${BAAS_API_KEY:-$(sed -n 's/^VITE_BAAS_API_KEY=//p' "${APP_ENV_FILE}" | head -1)}" +[[ "${APP_KEY}" == mbk_* ]] || fail "no app key (run make seed-live-demo first)" +MOUNTS_JSON="$(sed -n 's/^VITE_BAAS_LIVE_MOUNTS=//p' "${APP_ENV_FILE}" | head -1)" +[[ -n "${MOUNTS_JSON}" ]] || fail "VITE_BAAS_LIVE_MOUNTS missing (run make seed-live-demo first)" +mount_id() { python3 -c "import json,sys; print(next(m['dbId'] for m in json.loads(sys.argv[1]) if m['name']==sys.argv[2]))" "${MOUNTS_JSON}" "$1"; } +PG="$(mount_id pg-commerce)"; MY="$(mount_id mysql-ops)"; MG="$(mount_id mongo-activity)" + +# gw → body in /tmp/m23.json. +# Retries on Kong 429 (rate limiting under back-to-back gate runs) and on the +# transient auth_verify_unavailable hiccup — every battery op is idempotent +# (same filters; writes carry idempotencyKeys), so replays are safe. +gw() { + local expected="$1" path="$2" body="${3:--}" code attempt + for attempt in 1 2 3 4; do + if [[ "${body}" == "-" ]]; then + code=$(curl -s -o /tmp/m23.json -w '%{http_code}' "${KONG}${path}" \ + -H "apikey: ${ANON}" -H "X-Baas-Api-Key: ${APP_KEY}") + else + code=$(curl -s -o /tmp/m23.json -w '%{http_code}' -X POST "${KONG}${path}" \ + -H "apikey: ${ANON}" -H "X-Baas-Api-Key: ${APP_KEY}" \ + -H 'Content-Type: application/json' -d "${body}") + fi + if [[ "${code}" == "429" ]] || grep -q 'auth_verify_unavailable' /tmp/m23.json 2>/dev/null; then + [[ "${attempt}" -lt 4 ]] && { sleep $((attempt * 3)); continue; } + fi + break + done + [[ "${code}" == "${expected}" || ( "${expected}" == "2xx" && "${code}" =~ ^2 ) ]] \ + || fail "${path} expected ${expected}, got ${code}: $(head -c 200 /tmp/m23.json) ← ${body}" +} +has() { grep -q "$1" /tmp/m23.json || fail "response missing $1: $(head -c 200 /tmp/m23.json)"; } +# NOT-matched is the GOOD case — `grep && fail` would exit 1 under set -e. +lacks() { ! grep -q "$1" /tmp/m23.json || fail "response unexpectedly contains $1"; } + +# ── 1) filter grammar forks by engine ──────────────────────────────────────── +step "filter grammar: sql dialect on postgresql + mysql" +gw 2xx "/query/v1/${PG}/tables/orders" '{"op":"list","limit":2,"filter":{"status":{"$eq":"delivered"}}}' +has '"rows":\[{' +gw 2xx "/query/v1/${PG}/tables/orders" '{"op":"list","limit":2,"filter":{"notes":{"$null":false}}}' +gw 2xx "/query/v1/${PG}/tables/customers" '{"op":"list","limit":2,"filter":{"name":{"$ilike":"%ada%"}}}' +gw 2xx "/query/v1/${PG}/tables/orders" '{"op":"list","limit":2,"filter":{"total":{"$between":[100,200]}}}' +gw 2xx "/query/v1/${PG}/tables/orders" '{"op":"list","limit":2,"filter":{"$not":{"status":{"$in":["cancelled"]}}}}' +gw 2xx "/query/v1/${MY}/tables/tasks" '{"op":"list","limit":2,"filter":{"status":{"$eq":"done"}}}' +has '"rows":\[{' +gw 2xx "/query/v1/${MY}/tables/tasks" '{"op":"list","limit":2,"filter":{"done_at":{"$null":true}}}' +pass "sql grammar (\$eq/\$null/\$ilike/\$between/top-\$not) on pg + mysql" + +step "filter grammar: mongo native dialect" +gw 2xx "/query/v1/${MG}/tables/events" '{"op":"list","limit":2,"filter":{"summary":{"$regex":"page","$options":"i"}}}' +has '"id":"evt-' +gw 2xx "/query/v1/${MG}/tables/events" '{"op":"list","limit":2,"filter":{"order_ref":{"$exists":true}}}' +gw 2xx "/query/v1/${MG}/tables/events" '{"op":"list","limit":2,"filter":{"kind":{"$nin":["login","search"]}}}' +gw 400 "/query/v1/${MG}/tables/events" '{"op":"list","limit":2,"filter":{"summary":{"$ilike":"%x%"}}}' +gw 400 "/query/v1/${MG}/tables/events" '{"op":"list","limit":2,"filter":{"summary":{"$null":true}}}' +gw 400 "/query/v1/${MG}/tables/events" '{"op":"list","limit":2,"filter":{"$not":{"kind":{"$in":["login"]}}}}' +gw 400 "/query/v1/${MG}/tables/events" '{"op":"list","limit":1,"filter":{"$where":"true"}}' +pass "mongo dialect (\$regex/\$exists/\$nin ok; \$ilike/\$null/top-\$not/\$where → 400)" + +# ── 2) mass-write guard parity ─────────────────────────────────────────────── +step "empty-filter update/delete is refused on every engine" +gw 400 "/query/v1/${PG}/tables/orders" '{"op":"update","filter":{},"data":{"discount_pct":99}}' +gw 400 "/query/v1/${MY}/tables/tasks" '{"op":"update","filter":{},"data":{"priority":"low"}}' +gw 400 "/query/v1/${MG}/tables/notes" '{"op":"update","filter":{},"data":{"pinned":false}}' +gw 400 "/query/v1/${MG}/tables/notes" '{"op":"delete","filter":{}}' +gw 400 "/query/v1/${MG}/tables/notes" '{"op":"update","filter":{"owner_id":"x"},"data":{"pinned":false}}' +pass "full-table/collection writes are 400 on pg + mysql + mongo" + +# ── 3) caller-data faults are 4xx, never 5xx ───────────────────────────────── +step "validation/conflict envelopes (the 502→409 fix)" +gw 409 "/query/v1/${PG}/tables/orders" '{"op":"update","filter":{"id":2},"data":{"status":"bogus"}}' +gw 409 "/query/v1/${PG}/tables/orders" '{"op":"update","filter":{"id":2},"data":{"placed_at":"not a date"}}' +gw 409 "/query/v1/${PG}/tables/orders" '{"op":"update","filter":{"id":2},"data":{"total":99999999999999}}' +gw 409 "/query/v1/${PG}/tables/order_items" '{"op":"update","filter":{"id":1},"data":{"product_id":999999}}' +gw 409 "/query/v1/${PG}/tables/customers" '{"op":"update","filter":{"id":1},"data":{"name":null}}' +gw 409 "/query/v1/${MY}/tables/tasks" '{"op":"update","filter":{"id":1},"data":{"status":"bogus"}}' +gw 409 "/query/v1/${MG}/tables/events" '{"op":"update","filter":{"_id":"evt-000002"},"data":{"kind":"bogus"}}' +pass "bad enum / bad date / overflow / FK / NOT-NULL / validator → 409" + +step "DDL envelopes for schema-shape mistakes" +gw 400 "/query/v1/${PG}/schema/ddl" '{"op":"drop_column","table":"orders","column_name":"never_existed","confirm":true}' +gw 409 "/query/v1/${PG}/schema/ddl" '{"op":"add_column","table":"orders","column":{"name":"status","normalized_type":"text","nullable":true}}' +gw 400 "/query/v1/${MY}/schema/ddl" '{"op":"drop_column","table":"tasks","column_name":"never_existed","confirm":true}' +pass "drop-missing → 400, add-duplicate → 409 (no more retry-forever 502s)" + +# ── 4) per-engine write lifecycle on battery-owned probe rows ──────────────── +UNI='ünïcødé 🚀 — "double" '"'"'single'"'"' 100%_done\\path' +step "postgresql lifecycle: insert echo → unicode roundtrip → single-row update → delete" +gw 2xx "/query/v1/${PG}/tables/orders" \ + '{"op":"insert","data":{"customer_id":1,"status":"pending","ship_method":"standard","placed_at":"2026-06-10T00:00:00Z","total":12.34,"discount_pct":0},"idempotencyKey":"m23-pg-probe"}' +has '"rows":\[{' +PG_PROBE_ID=$(python3 -c "import json; print(json.load(open('/tmp/m23.json'))['rows'][0]['id'])") +[[ -n "${PG_PROBE_ID}" ]] || fail "pg insert echoed no id" +gw 2xx "/query/v1/${PG}/tables/orders" \ + "$(python3 -c "import json,sys; print(json.dumps({'op':'update','filter':{'id':int(sys.argv[1])},'data':{'notes':sys.argv[2]}}))" "${PG_PROBE_ID}" "${UNI}")" +has '"rowCount":1' +gw 2xx "/query/v1/${PG}/tables/orders" "{\"op\":\"get\",\"filter\":{\"id\":${PG_PROBE_ID}}}" +grep -q '🚀' /tmp/m23.json || fail "pg unicode roundtrip lost the emoji" +gw 2xx "/query/v1/${PG}/tables/orders" "{\"op\":\"delete\",\"filter\":{\"id\":${PG_PROBE_ID}}}" +gw 2xx "/query/v1/${PG}/tables/orders" "{\"op\":\"get\",\"filter\":{\"id\":${PG_PROBE_ID}}}" +has '"rows":\[\]' +pass "pg probe row: inserted id=${PG_PROBE_ID}, roundtripped, deleted" + +step "mysql lifecycle: insert echo → update → delete" +gw 2xx "/query/v1/${MY}/tables/time_entries" \ + '{"op":"insert","data":{"task_id":1,"person":"M23 Battery","hours":1.25,"entry_date":"2026-06-10","billable":1},"idempotencyKey":"m23-my-probe"}' +MY_PROBE_ID=$(python3 -c "import json; r=json.load(open('/tmp/m23.json'))['rows'][0]; print(r.get('id') or r.get('insertId') or '')") +[[ -n "${MY_PROBE_ID}" ]] || fail "mysql insert echoed no id: $(head -c 200 /tmp/m23.json)" +gw 2xx "/query/v1/${MY}/tables/time_entries" "{\"op\":\"update\",\"filter\":{\"id\":${MY_PROBE_ID}},\"data\":{\"note\":\"battery ok\"}}" +has '"rowCount":1' +gw 2xx "/query/v1/${MY}/tables/time_entries" "{\"op\":\"delete\",\"filter\":{\"id\":${MY_PROBE_ID}}}" +pass "mysql probe row: inserted id=${MY_PROBE_ID}, updated, deleted" + +step "mongo lifecycle: insert echo → single-row isolation → delete" +gw 2xx "/query/v1/${MG}/tables/notes" \ + '{"op":"insert","data":{"title":"M23 probe","body":"created by the battery","tags":["battery"],"pinned":false},"idempotencyKey":"m23-mg-probe"}' +MG_PROBE_ID=$(python3 -c "import json; print(json.load(open('/tmp/m23.json'))['rows'][0]['id'])") +[[ -n "${MG_PROBE_ID}" ]] || fail "mongo insert echoed no id" +STAMP="m23-$(date +%s)" +gw 2xx "/query/v1/${MG}/tables/notes" "{\"op\":\"update\",\"filter\":{\"_id\":\"${MG_PROBE_ID}\"},\"data\":{\"body\":\"${STAMP}\"}}" +has '"rowCount":1' +gw 2xx "/query/v1/${MG}/tables/notes" '{"op":"get","filter":{"_id":"note-0002"}}' +lacks "${STAMP}" +gw 2xx "/query/v1/${MG}/tables/notes" "{\"op\":\"delete\",\"filter\":{\"_id\":\"${MG_PROBE_ID}\"}}" +has '"rowCount":1' +pass "mongo probe doc: ObjectId-echo update isolated to one row, deleted" + +# ── 5) aggregates + pagination ─────────────────────────────────────────────── +step "aggregates (pg) + pagination + capability honesty" +gw 2xx "/query/v1/${PG}/tables/orders" \ + '{"op":"aggregate","aggregate":{"groupBy":["status"],"aggregates":[{"func":"count","alias":"n"},{"func":"sum","field":"total","alias":"revenue"}]}}' +has '"status":"delivered"' +gw 2xx "/query/v1/${PG}/tables/orders" '{"op":"list","limit":500,"offset":24999}' +gw 400 "/query/v1/${PG}/tables/orders" '{"op":"list","limit":501}' +gw 2xx "/query/v1/${PG}/tables/orders" '{"op":"list","limit":1,"sort":{"total":"desc"}}' +pass "GROUP BY aggregate + limit cap (500) + offset + sort" + +# ── 6) latency budgets (server slice of the live-view budgets) ─────────────── +step "latency budgets through the full gateway path" +bench() { # $1 path, $2 body|-, → ms + local t + if [[ "$2" == "-" ]]; then + t=$(curl -s -o /dev/null -w '%{time_total}' "${KONG}$1" \ + -H "apikey: ${ANON}" -H "X-Baas-Api-Key: ${APP_KEY}") + else + t=$(curl -s -o /dev/null -w '%{time_total}' -X POST "${KONG}$1" \ + -H "apikey: ${ANON}" -H "X-Baas-Api-Key: ${APP_KEY}" \ + -H 'Content-Type: application/json' -d "$2") + fi + python3 -c "print(int(float('${t}') * 1000))" +} +curl -s -o /dev/null "${KONG}/query/v1/${PG}/schema" -H "apikey: ${ANON}" -H "X-Baas-Api-Key: ${APP_KEY}" # warm the cache +SCHEMA_MS=$(bench "/query/v1/${PG}/schema" -) +LIST_MS=$(bench "/query/v1/${PG}/tables/orders" '{"op":"list","limit":500}') +AGG_MS=$(bench "/query/v1/${PG}/tables/orders" '{"op":"aggregate","aggregate":{"groupBy":["status"],"aggregates":[{"func":"count","alias":"n"},{"func":"sum","field":"total","alias":"r"}]}}') +GET_MS=$(bench "/query/v1/${MG}/tables/notes" '{"op":"get","filter":{"_id":"note-0001"}}') +echo "[M23] bench: schema(cached)=${SCHEMA_MS}ms list500=${LIST_MS}ms aggregate25k=${AGG_MS}ms mongoGet=${GET_MS}ms" +[[ "${SCHEMA_MS}" -lt 100 ]] || fail "cached /schema took ${SCHEMA_MS}ms (budget 100ms through Kong)" +[[ "${LIST_MS}" -lt 1000 ]] || fail "list limit=500 took ${LIST_MS}ms (budget 1000ms)" +[[ "${AGG_MS}" -lt 1500 ]] || fail "GROUP BY aggregate over 25k rows took ${AGG_MS}ms (budget 1500ms)" +[[ "${GET_MS}" -lt 300 ]] || fail "mongo by-pk get took ${GET_MS}ms (budget 300ms)" +pass "budgets: cached schema <100ms, 500-row list <1s, 25k aggregate <1.5s, by-pk get <300ms" + +green "[M23] OK — live edge battery: filter dialects, guards, envelopes, lifecycles all pinned" diff --git a/apps/baas/mini-baas-infra/src/apps/permission-engine/src/app.module.ts b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/app.module.ts index 1e23e8b0..3f1cddea 100644 --- a/apps/baas/mini-baas-infra/src/apps/permission-engine/src/app.module.ts +++ b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/app.module.ts @@ -18,6 +18,7 @@ import { PostgresModule } from '@mini-baas/database'; import { PermissionsModule } from './permissions/permissions.module'; import { PoliciesModule } from './policies/policies.module'; import { DecisionsModule } from './decisions/decisions.module'; +import { BundlesModule } from './bundles/bundles.module'; import { HealthController } from './health.controller'; import { AuditModule, ObservabilityModule, createPinoHttpOptions } from '@mini-baas/common'; @@ -31,6 +32,7 @@ import { AuditModule, ObservabilityModule, createPinoHttpOptions } from '@mini-b PermissionsModule, PoliciesModule, DecisionsModule, + BundlesModule, AuditModule, ], controllers: [HealthController], diff --git a/apps/baas/mini-baas-infra/src/apps/permission-engine/src/bundles/bundles.controller.ts b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/bundles/bundles.controller.ts new file mode 100644 index 00000000..b092ac27 --- /dev/null +++ b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/bundles/bundles.controller.ts @@ -0,0 +1,78 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* bundles.controller.ts :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/10 12:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/10 12:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { ServiceTokenGuard } from '@mini-baas/common'; +import { PoliciesService } from '../policies/policies.service'; +import { CreatePolicyDto } from '../policies/dto/policy.dto'; +import { BundlesService } from './bundles.service'; + +/** + * Service-token surface for permission distribution + admin tooling. + * + * - GET /permissions/bundles/latest — PolicyBundle for the Rust in-process + * evaluator (data-plane-router `DATA_PLANE_PERMISSION_BUNDLE_URL`). + * - GET /permissions/bundles/roles — role catalogue (matrix rows). + * - GET /permissions/bundles/policies — policy rows WITH ids (matrix cells). + * - POST/DELETE …/policies — matrix mutations. + * + * Everything is guarded like /permissions/decide (ServiceTokenGuard: + * X-Service-Token = ADAPTER_REGISTRY_SERVICE_TOKEN + X-Tenant-Id), because the + * /policies controller's AuthGuard envelope is unreachable for external + * service callers in strict identity mode. + */ +@ApiTags('bundles') +@Controller('permissions/bundles') +@UseGuards(ServiceTokenGuard) +export class BundlesController { + constructor( + private readonly bundles: BundlesService, + private readonly policies: PoliciesService, + ) {} + + @Get('latest') + @ApiSecurity('service-token') + @ApiOperation({ summary: 'Policy bundle (user_roles + policies) for the Rust ABAC evaluator' }) + latest() { + return this.bundles.latest(); + } + + @Get('roles') + @ApiSecurity('service-token') + @ApiOperation({ summary: 'All roles with id/name/description/metadata (admin matrix rows)' }) + async roles() { + return { roles: await this.bundles.roles() }; + } + + @Get('policies') + @ApiSecurity('service-token') + @ApiOperation({ summary: 'All resource policies including ids (admin matrix cells)' }) + async listPolicies() { + return { policies: await this.policies.list() }; + } + + @Post('policies') + @ApiSecurity('service-token') + @ApiOperation({ summary: 'Create a resource policy (admin matrix mutation)' }) + createPolicy(@Body() dto: CreatePolicyDto) { + return this.policies.create(dto); + } + + @Delete('policies/:id') + @ApiSecurity('service-token') + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + @ApiOperation({ summary: 'Delete a resource policy by id' }) + removePolicy(@Param('id', ParseUUIDPipe) id: string) { + return this.policies.remove(id); + } +} diff --git a/apps/baas/mini-baas-infra/src/apps/permission-engine/src/bundles/bundles.module.ts b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/bundles/bundles.module.ts new file mode 100644 index 00000000..1c6b2807 --- /dev/null +++ b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/bundles/bundles.module.ts @@ -0,0 +1,23 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* bundles.module.ts :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/10 12:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/10 12:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +import { Module } from '@nestjs/common'; +import { PoliciesModule } from '../policies/policies.module'; +import { BundlesController } from './bundles.controller'; +import { BundlesService } from './bundles.service'; + +@Module({ + imports: [PoliciesModule], + controllers: [BundlesController], + providers: [BundlesService], +}) +export class BundlesModule {} diff --git a/apps/baas/mini-baas-infra/src/apps/permission-engine/src/bundles/bundles.service.ts b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/bundles/bundles.service.ts new file mode 100644 index 00000000..20d83f5a --- /dev/null +++ b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/bundles/bundles.service.ts @@ -0,0 +1,100 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* bundles.service.ts :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/10 12:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/10 12:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +import { Injectable } from '@nestjs/common'; +import { PostgresService } from '@mini-baas/database'; + +/** + * Serializes the live permission store into the PolicyBundle JSON the Rust + * data-plane evaluator deserializes (data-plane-server/src/abac.rs): + * + * PolicyBundle { user_roles: Vec, policies: Vec } + * UserRole { user_id, role_id, expires_at: Option> } + * Policy { role_id, resource_type, resource_name, actions: Vec, + * effect: "allow"|"deny", priority: i32, conditions: Option } + * + * Field names/casing MUST stay snake_case to match the serde derive. Extra + * top-level keys (generated_at) are safe: serde ignores unknown fields. + */ + +export interface BundleUserRole { + user_id: string; + role_id: string; + expires_at: string | null; +} + +export interface BundlePolicy { + role_id: string; + resource_type: string; + resource_name: string; + actions: string[]; + effect: 'allow' | 'deny'; + priority: number; + conditions: Record | null; +} + +export interface PolicyBundle { + generated_at: string; + user_roles: BundleUserRole[]; + policies: BundlePolicy[]; +} + +export interface RoleRow { + id: string; + name: string; + description: string | null; + metadata: Record; +} + +@Injectable() +export class BundlesService { + constructor(private readonly pg: PostgresService) {} + + /** Active user→role assignments + every policy, in abac.rs PolicyBundle shape. */ + async latest(): Promise { + const [userRoles, policies] = await Promise.all([ + this.pg.adminQuery( + `SELECT ur.user_id::text AS user_id, + ur.role_id::text AS role_id, + to_char(ur.expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS expires_at + FROM public.user_roles ur + WHERE ur.expires_at IS NULL OR ur.expires_at > now() + ORDER BY ur.user_id, ur.role_id`, + ), + this.pg.adminQuery( + `SELECT rp.role_id::text AS role_id, + rp.resource_type, + rp.resource_name, + rp.actions, + rp.effect, + rp.priority, + rp.conditions + FROM public.resource_policies rp + ORDER BY rp.priority DESC, rp.resource_type, rp.resource_name`, + ), + ]); + return { + generated_at: new Date().toISOString(), + user_roles: userRoles, + policies, + }; + } + + /** All roles (id/name/description/metadata) — rows of the admin matrix UI. */ + async roles(): Promise { + return this.pg.adminQuery( + `SELECT r.id::text AS id, r.name, r.description, r.metadata + FROM public.roles r + ORDER BY r.name`, + ); + } +} diff --git a/apps/baas/mini-baas-infra/src/apps/permission-engine/src/policies/policies.module.ts b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/policies/policies.module.ts index 0241410f..d689948a 100644 --- a/apps/baas/mini-baas-infra/src/apps/permission-engine/src/policies/policies.module.ts +++ b/apps/baas/mini-baas-infra/src/apps/permission-engine/src/policies/policies.module.ts @@ -17,5 +17,6 @@ import { PoliciesService } from './policies.service'; @Module({ controllers: [PoliciesController], providers: [PoliciesService], + exports: [PoliciesService], }) export class PoliciesModule {} diff --git a/apps/baas/mini-baas-infra/src/apps/query-router/src/proxy/rust-data-plane.proxy.ts b/apps/baas/mini-baas-infra/src/apps/query-router/src/proxy/rust-data-plane.proxy.ts index f67fa052..09d8e961 100644 --- a/apps/baas/mini-baas-infra/src/apps/query-router/src/proxy/rust-data-plane.proxy.ts +++ b/apps/baas/mini-baas-infra/src/apps/query-router/src/proxy/rust-data-plane.proxy.ts @@ -105,6 +105,62 @@ export interface RustCapabilitiesResponse { engines: RustEngineDescriptor[]; } +/** One column from the Rust router's `POST /v1/schema` descriptor. Mirrors + * `data_plane_core::ColumnSchema` (snake_case on the wire). */ +export interface RustColumnSchema { + name: string; + native_type: string; + /** text|integer|float|decimal|boolean|date|datetime|json|uuid|enum|array|objectid|unknown */ + normalized_type: string; + nullable: boolean; + default: string | null; + enum_values: string[] | null; + references: { table: string; column: string } | null; + /** true only for Mongo sample-based inference. */ + inferred: boolean; +} + +/** One table/collection from `POST /v1/schema`. */ +export interface RustTableSchema { + name: string; + primary_key: string[]; + columns: RustColumnSchema[]; +} + +/** The full `POST /v1/schema` payload — `data_plane_core::SchemaDescriptor`. */ +export interface RustSchemaDescriptor { + engine: string; + tables: RustTableSchema[]; +} + +/** One DDL column definition — `data_plane_core::DdlColumnDef` (snake_case on + * the wire). The FULL definition: the service composes it before calling, + * because engines like MySQL (`MODIFY COLUMN`) reset omitted attributes. */ +export interface RustDdlColumnDef { + name: string; + normalized_type: string; + nullable: boolean; + default: string | null; + enum_values: string[] | null; +} + +/** The `ddl` object of `POST /v1/schema/ddl` — `data_plane_core::SchemaDdlRequest`. */ +export interface RustSchemaDdlRequest { + op: string; + table: string; + column?: RustDdlColumnDef | null; + column_name?: string | null; + columns?: RustDdlColumnDef[] | null; + primary_key?: string[] | null; +} + +/** `POST /v1/schema/ddl` response — `data_plane_core::SchemaDdlResult`. */ +export interface RustSchemaDdlResult { + op: string; + table: string; + status: string; +} + @Injectable() export class RustDataPlaneProxy { private readonly logger = new Logger(RustDataPlaneProxy.name); @@ -229,6 +285,53 @@ export class RustDataPlaneProxy { return this.normalizeResult(data); } + /** + * Engine-agnostic schema introspection (M22): POSTs the `{ identity, mount }` + * envelope to the Rust router's `/v1/schema` and returns its + * `SchemaDescriptor` verbatim (tables + normalized columns + PK/FK + enum + * values). Engines without an introspection surface (redis/http) are + * rejected upstream with 422 `unsupported_capability`, which `postJson` + * surfaces as a clean `UnprocessableEntityException`. + */ + async describeSchema(context: RustProxyContext): Promise { + const body = { + identity: this.buildIdentity(context), + mount: this.buildMount(context, 'schema'), + }; + return this.postJson( + '/v1/schema', + body, + context, + `${context.engine}.schema`, + ); + } + + /** + * Engine-agnostic schema DDL (M22 step 2): POSTs the + * `{ identity, mount, ddl }` envelope to the Rust router's `/v1/schema/ddl` + * (one operation per request) and returns its `SchemaDdlResult` verbatim. + * Error mapping mirrors {@link describeSchema}: a capability rejection + * (redis/http) surfaces as 422 `unsupported_capability`, and "existing data + * is incompatible with the new type" surfaces as a 409 Conflict — both + * preserved by `postJson`. + */ + async applySchemaDdl( + context: RustProxyContext, + ddl: RustSchemaDdlRequest, + ): Promise { + const body = { + identity: this.buildIdentity(context), + mount: this.buildMount(context, 'schema'), + ddl, + }; + return this.postJson( + '/v1/schema/ddl', + body, + context, + `${context.engine}.schema.ddl.${ddl.op}`, + ); + } + // ── Single-mount transactions ────────────────────────────────────────────── // // The data plane binds a transaction to ONE mount/connection (`POST diff --git a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/dto/schema-ddl.dto.ts b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/dto/schema-ddl.dto.ts new file mode 100644 index 00000000..4d8aa4c6 --- /dev/null +++ b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/dto/schema-ddl.dto.ts @@ -0,0 +1,154 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* schema-ddl.dto.ts :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/09 00:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/09 00:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsBoolean, + IsEnum, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; + +/** The single supported DDL operations (snake_case, the Rust wire values). */ +export const SCHEMA_DDL_OPS = [ + 'add_column', + 'drop_column', + 'alter_column_type', + 'create_table', + 'drop_table', +] as const; +export type SchemaDdlOp = (typeof SCHEMA_DDL_OPS)[number]; + +/** Normalized column types creatable via DDL. `objectid`/`unknown` are + * describe-only (the data plane rejects them with 400). */ +export const SCHEMA_DDL_COLUMN_TYPES = [ + 'text', + 'integer', + 'float', + 'decimal', + 'boolean', + 'date', + 'datetime', + 'json', + 'uuid', + 'enum', + 'array', +] as const; +export type SchemaDdlColumnType = (typeof SCHEMA_DDL_COLUMN_TYPES)[number]; + +/** + * One column definition (snake_case keys — this DTO IS the wire `ddl.column` + * shape, minus the describe-only fields). `nullable`/`default`/`enum_values` + * are optional here: for `add_column`/`create_table` the service defaults + * them (nullable, no default); for `alter_column_type` the service composes + * the FULL target definition by merging with the CURRENT column, so an + * omitted attribute means "keep what the column has today". + */ +export class SchemaDdlColumnDto { + @ApiProperty({ description: 'Column name' }) + @IsString() + name!: string; + + @ApiProperty({ enum: SCHEMA_DDL_COLUMN_TYPES, description: 'Target normalized type' }) + @IsEnum(SCHEMA_DDL_COLUMN_TYPES) + normalized_type!: SchemaDdlColumnType; + + @ApiPropertyOptional({ description: 'Whether NULLs are allowed (default true on create/add)' }) + @IsOptional() + @IsBoolean() + nullable?: boolean; + + @ApiPropertyOptional({ + description: + "Raw engine default expression (`0`, `'pending'`, `now()`); `null` clears the default", + nullable: true, + }) + @IsOptional() + @IsString() + default?: string | null; + + @ApiPropertyOptional({ + description: 'Allowed values — required when normalized_type is `enum`', + nullable: true, + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + enum_values?: string[] | null; +} + +/** + * Request body of `POST /query/v1/:dbId/schema/ddl` — ONE schema operation + * per request (deliberate: MySQL DDL is auto-commit/non-transactional, so a + * batch would fake atomicity). Destructive ops (`drop_column`, `drop_table`) + * additionally require `confirm: true`. + */ +export class SchemaDdlRequestDto { + @ApiProperty({ enum: SCHEMA_DDL_OPS, description: 'The DDL operation' }) + @IsEnum(SCHEMA_DDL_OPS) + op!: SchemaDdlOp; + + @ApiProperty({ description: 'Target table / collection' }) + @IsString() + table!: string; + + @ApiPropertyOptional({ + type: SchemaDdlColumnDto, + description: + 'add_column: the new column; alter_column_type: the target definition ' + + '(attributes omitted here are preserved from the current column)', + }) + @IsOptional() + @ValidateNested() + @Type(() => SchemaDdlColumnDto) + column?: SchemaDdlColumnDto; + + @ApiPropertyOptional({ description: 'drop_column: the column to drop' }) + @IsOptional() + @IsString() + column_name?: string; + + @ApiPropertyOptional({ + type: [SchemaDdlColumnDto], + description: 'create_table: the table columns (owner_id is auto-appended if absent)', + }) + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @ValidateNested({ each: true }) + @Type(() => SchemaDdlColumnDto) + columns?: SchemaDdlColumnDto[]; + + @ApiPropertyOptional({ + type: [String], + description: 'create_table: primary key column(s) — required for create_table', + }) + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + primary_key?: string[]; + + @ApiPropertyOptional({ + description: 'Must be `true` for destructive ops (drop_column, drop_table)', + }) + @IsOptional() + @IsBoolean() + confirm?: boolean; +} diff --git a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/query.module.ts b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/query.module.ts index c9c4e465..2a928f7f 100644 --- a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/query.module.ts +++ b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/query.module.ts @@ -17,8 +17,11 @@ import { QueryController } from './query.controller'; import { TxnController } from './txn.controller'; import { EnginesController } from './engines.controller'; import { CapabilitiesController } from './capabilities.controller'; +import { SchemaController } from './schema.controller'; import { QueryService } from './query.service'; import { OutboxService } from './outbox.service'; +import { RealtimePublisherService } from './realtime-publisher.service'; +import { SchemaService } from './schema.service'; import { RustDataPlaneProxy } from '../proxy/rust-data-plane.proxy'; import { GraphController } from '../graph/graph.controller'; import { GraphService } from '../graph/graph.service'; @@ -32,7 +35,21 @@ import { GraphService } from '../graph/graph.service'; @Module({ imports: [ConfigModule, HttpModule], - controllers: [QueryController, TxnController, EnginesController, CapabilitiesController, GraphController], - providers: [QueryService, OutboxService, RustDataPlaneProxy, GraphService], + controllers: [ + QueryController, + TxnController, + EnginesController, + CapabilitiesController, + SchemaController, + GraphController, + ], + providers: [ + QueryService, + OutboxService, + RealtimePublisherService, + RustDataPlaneProxy, + SchemaService, + GraphService, + ], }) export class QueryModule {} diff --git a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/query.service.ts b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/query.service.ts index 0864e1fc..d15bca57 100644 --- a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/query.service.ts +++ b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/query.service.ts @@ -33,9 +33,14 @@ import type { EngineCaps, IDatabaseAdapter, QueryResult } from '@mini-baas/datab import { ExecuteQueryDto } from './dto/query.dto'; import { TxnOpDto } from './dto/txn.dto'; import { OutboxService } from './outbox.service'; +import { RealtimePublisherService, RealtimeWriteOp } from './realtime-publisher.service'; import { RustDataPlaneProxy, RustProxyContext } from '../proxy/rust-data-plane.proxy'; -interface AdapterResponse { +/** Ops that fan out as best-effort `row_changed` realtime events (mirrors the + * outbox's MUTATING_OPS — reads never publish). */ +const REALTIME_WRITE_OPS: ReadonlySet = new Set(['insert', 'update', 'delete', 'upsert']); + +export interface AdapterResponse { engine: string; connection_string: string; // Tenant isolation strategy from adapter-registry (shared_rls default | @@ -190,6 +195,7 @@ export class QueryService implements OnModuleInit { private readonly http: HttpService, private readonly outbox: OutboxService, private readonly rustProxy: RustDataPlaneProxy, + private readonly realtime: RealtimePublisherService, ) { this.registryUrl = this.config.getOrThrow('ADAPTER_REGISTRY_URL'); this.permissionUrl = this.config.get('PERMISSION_ENGINE_URL', 'http://permission-engine:3050'); @@ -235,6 +241,17 @@ export class QueryService implements OnModuleInit { })); } + /** + * Public, additive wrapper over the private {@link fetchConnection} so + * sibling services (SchemaService) can resolve a mount's engine + DSN + + * isolation without duplicating the static-mount/registry/cache logic. + * Same semantics: static `DATA_PLANE_MOUNTS` bypass first, then the + * TTL-cached adapter-registry `/connect` hop. + */ + async resolveConnection(dbId: string, tenantId: string): Promise { + return this.fetchConnection(dbId, tenantId); + } + private async fetchConnection(dbId: string, userId: string): Promise { // Bypass path: lean deploys can ship a static DATA_PLANE_MOUNTS map and // skip running adapter-registry-go entirely. Match by dbId only — the @@ -396,6 +413,16 @@ export class QueryService implements OnModuleInit { this.logger.warn(`outbox emission failed for ${engine}.${resource}.${op}: ${error.message}`); }); + // Best-effort realtime fan-out — fire-and-forget so the write response is + // never delayed; the publisher swallows every failure internally. + if (REALTIME_WRITE_OPS.has(op)) { + void this.realtime.publishRowChanged(dbId, resource, op as RealtimeWriteOp, { + filter: dto.filter, + idempotencyKey: dto.idempotencyKey, + pk: result.rows[0]?.['id'] ?? dto.data?.['id'] ?? dto.filter?.['id'], + }); + } + return this.applyFieldMask(result, decision.mask); } @@ -441,6 +468,16 @@ export class QueryService implements OnModuleInit { const results = await this.runTransaction(proxyCtx, ops); this.emitTxnOutbox(engine, ops, results, userId, context); + // Post-commit realtime fan-out: one best-effort `row_changed` per op in + // the committed batch (TXN_OPS are all writes). Fire-and-forget — the + // publisher never rejects. + for (const o of ops) { + void this.realtime.publishRowChanged(dbId, o.resource, o.op as RealtimeWriteOp, { + filter: o.filter, + idempotencyKey: o.idempotencyKey, + pk: o.data?.['id'] ?? o.filter?.['id'], + }); + } return { guarantee: 'atomic', mount: dbId, results }; } diff --git a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/realtime-publisher.service.spec.ts b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/realtime-publisher.service.spec.ts new file mode 100644 index 00000000..713bc7d0 --- /dev/null +++ b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/realtime-publisher.service.spec.ts @@ -0,0 +1,351 @@ +// `@jest/globals` (bundled with jest) provides the typings — the monorepo does +// not ship `@types/jest`, so the globals must be imported explicitly. +import { afterAll, afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { ConfigService } from '@nestjs/config'; +import type { HttpService } from '@nestjs/axios'; +import type { VerifiedRequestIdentity } from '@mini-baas/common'; +import { RealtimePublisherService } from './realtime-publisher.service'; +import { QueryService } from './query.service'; +import { SchemaService } from './schema.service'; +import { ExecuteQueryDto } from './dto/query.dto'; +import type { TxnOpDto } from './dto/txn.dto'; +import type { OutboxService } from './outbox.service'; +import type { RustDataPlaneProxy } from '../proxy/rust-data-plane.proxy'; + +// ── shared fetch mock (the publisher's only transport) ────────────────────── + +const realFetch = globalThis.fetch; +let fetchMock: jest.Mock<(url: unknown, init?: unknown) => Promise<{ ok: boolean; status: number }>>; + +beforeEach(() => { + fetchMock = jest.fn<(url: unknown, init?: unknown) => Promise<{ ok: boolean; status: number }>>( + async () => ({ ok: true, status: 200 }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +afterAll(() => { + globalThis.fetch = realFetch; +}); + +/** Drains the fire-and-forget publish chains (fetch promise + handlers). */ +const flush = async () => { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +}; + +function publishedBody(call = 0): Record { + const init = fetchMock.mock.calls[call][1] as { body: string }; + return JSON.parse(init.body) as Record; +} + +function buildPublisher(overrides: Record = {}): RealtimePublisherService { + const config = { + get: (key: string, def?: string) => overrides[key] ?? def, + } as unknown as ConfigService; + return new RealtimePublisherService(config); +} + +// ── RealtimePublisherService (unit) ────────────────────────────────────────── + +describe('RealtimePublisherService', () => { + it('POSTs the exact row_changed envelope to the publish URL', async () => { + const publisher = buildPublisher(); + await publisher.publishRowChanged('db-1', 'notes', 'insert', { + filter: { id: 7 }, + idempotencyKey: 'idem-1', + pk: 7, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('http://realtime:4000/v1/publish'); + const body = publishedBody(); + expect(body.topic).toBe('table:db-1:notes'); + expect(body.event_type).toBe('row_changed'); + expect(body.idempotency_key).toBe('idem-1'); + expect(body.payload).toMatchObject({ + dbId: 'db-1', + table: 'notes', + op: 'insert', + filter: { id: 7 }, + pk: 7, + }); + expect(typeof body.payload.ts).toBe('string'); + expect(Number.isNaN(Date.parse(body.payload.ts))).toBe(false); + }); + + it('omits idempotency_key when none is provided', async () => { + const publisher = buildPublisher(); + await publisher.publishRowChanged('db-1', 'notes', 'delete', { filter: { id: 1 } }); + expect('idempotency_key' in publishedBody()).toBe(false); + }); + + it('drops oversized and non-serializable filters (event still sent)', async () => { + const publisher = buildPublisher(); + const huge = { blob: 'x'.repeat(10_000) }; + await publisher.publishRowChanged('db-1', 'notes', 'update', { filter: huge }); + expect(publishedBody(0).payload.filter).toBeUndefined(); + + const cyclic: Record = {}; + cyclic['self'] = cyclic; + await publisher.publishRowChanged('db-1', 'notes', 'update', { filter: cyclic }); + expect(publishedBody(1).payload.filter).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('publishes schema_changed on the same table channel', async () => { + const publisher = buildPublisher(); + await publisher.publishSchemaChanged('db-1', 'notes', 'add_column'); + const body = publishedBody(); + expect(body.topic).toBe('table:db-1:notes'); + expect(body.event_type).toBe('schema_changed'); + expect(body.payload).toMatchObject({ dbId: 'db-1', table: 'notes', op: 'add_column' }); + }); + + it('never rejects: network failure and non-2xx are swallowed', async () => { + const publisher = buildPublisher(); + fetchMock.mockImplementationOnce(() => Promise.reject(new Error('ECONNREFUSED'))); + await expect( + publisher.publishRowChanged('db-1', 'notes', 'insert', {}), + ).resolves.toBeUndefined(); + + fetchMock.mockImplementationOnce(async () => ({ ok: false, status: 503 })); + await expect( + publisher.publishRowChanged('db-1', 'notes', 'insert', {}), + ).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('is disabled entirely when REALTIME_PUBLISH_URL is empty', async () => { + const publisher = buildPublisher({ REALTIME_PUBLISH_URL: '' }); + await publisher.publishRowChanged('db-1', 'notes', 'insert', {}); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +// ── QueryService wiring (publisher called from the write paths) ───────────── + +const DB_ID = 'db-1'; +const MOUNTS_JSON = JSON.stringify({ + [DB_ID]: { engine: 'postgresql', connection_string: 'postgres://example/db' }, +}); + +/** api-key identity → decideByApiKeyScope short-circuits ABAC (no HTTP). */ +const identity: VerifiedRequestIdentity = { + tenantId: 't-1', + projectId: 't-1', + appId: 'api-key', + userId: 'api-key:k-1', + role: 'authenticated', + roleNames: ['authenticated'], + scopes: ['read', 'write'], + authMethod: 'kong-hmac', +}; + +function buildQueryService() { + const config = { + getOrThrow: () => 'http://adapter-registry-go:3021', + get: (key: string, def?: unknown) => (key === 'DATA_PLANE_MOUNTS' ? MOUNTS_JSON : def), + } as unknown as ConfigService; + const outbox = { emitForQuery: jest.fn(async () => undefined) }; + const rustProxy = { + shouldForward: jest.fn(() => true), + execute: jest.fn(async () => ({ rows: [{ id: 'row-1' }], rowCount: 1 })), + beginTransaction: jest.fn(async () => 'tx-1'), + executeInTransaction: jest.fn(async () => ({ rows: [], rowCount: 1 })), + commitTransaction: jest.fn(async () => undefined), + rollbackTransaction: jest.fn(async () => undefined), + }; + const realtime = buildPublisher(); + const service = new QueryService( + config, + {} as HttpService, + outbox as unknown as OutboxService, + rustProxy as unknown as RustDataPlaneProxy, + realtime, + ); + return { service, outbox, rustProxy, realtime }; +} + +function queryDto(input: Partial): ExecuteQueryDto { + return Object.assign(new ExecuteQueryDto(), input); +} + +describe('QueryService realtime wiring', () => { + it('publishes row_changed (exact topic + payload) after a successful insert', async () => { + const { service } = buildQueryService(); + const result = await service.executeQuery( + DB_ID, + 'notes', + 'api-key:k-1', + queryDto({ op: 'insert', data: { title: 'hello' }, idempotencyKey: 'idem-9' }), + { identity }, + ); + await flush(); + + expect(result.rows).toEqual([{ id: 'row-1' }]); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = publishedBody(); + expect(body.topic).toBe(`table:${DB_ID}:notes`); + expect(body.event_type).toBe('row_changed'); + expect(body.idempotency_key).toBe('idem-9'); + expect(body.payload).toMatchObject({ + dbId: DB_ID, + table: 'notes', + op: 'insert', + pk: 'row-1', // taken from the returned row's id + }); + }); + + it('does NOT publish for read ops (list/get/aggregate)', async () => { + const { service } = buildQueryService(); + for (const op of ['list', 'get', 'aggregate'] as const) { + await service.executeQuery( + DB_ID, + 'notes', + 'api-key:k-1', + queryDto({ + op, + aggregate: + op === 'aggregate' ? { aggregates: [{ func: 'count', alias: 'n' }] } : undefined, + }), + { identity }, + ); + } + await flush(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does NOT publish when the write itself fails', async () => { + const { service, rustProxy } = buildQueryService(); + rustProxy.execute.mockImplementationOnce(() => Promise.reject(new Error('backend down'))); + await expect( + service.executeQuery(DB_ID, 'notes', 'api-key:k-1', queryDto({ op: 'insert', data: { a: 1 } }), { + identity, + }), + ).rejects.toThrow('backend down'); + await flush(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('write result is unaffected when the publish rejects', async () => { + const { service } = buildQueryService(); + fetchMock.mockImplementation(() => Promise.reject(new Error('realtime down'))); + const result = await service.executeQuery( + DB_ID, + 'notes', + 'api-key:k-1', + queryDto({ op: 'insert', data: { a: 1 } }), + { identity }, + ); + await flush(); + expect(result).toEqual({ rows: [{ id: 'row-1' }], rowCount: 1 }); + expect(fetchMock).toHaveBeenCalledTimes(1); // attempted, swallowed + }); + + it('publishes one row_changed per op after a committed transaction', async () => { + const { service } = buildQueryService(); + const ops = [ + { op: 'insert', resource: 'nodes', data: { id: 'n-1' } }, + { op: 'update', resource: 'edges', filter: { id: 'e-1' }, idempotencyKey: 'idem-e' }, + ] as TxnOpDto[]; + const result = await service.executeTransaction(DB_ID, 'api-key:k-1', ops, { identity }); + await flush(); + + expect(result.guarantee).toBe('atomic'); + expect(fetchMock).toHaveBeenCalledTimes(2); + const first = publishedBody(0); + expect(first.topic).toBe(`table:${DB_ID}:nodes`); + expect(first.payload).toMatchObject({ op: 'insert', table: 'nodes', pk: 'n-1' }); + const second = publishedBody(1); + expect(second.topic).toBe(`table:${DB_ID}:edges`); + expect(second.idempotency_key).toBe('idem-e'); + expect(second.payload).toMatchObject({ op: 'update', table: 'edges', filter: { id: 'e-1' } }); + }); + + it('does NOT publish when the transaction rolls back', async () => { + const { service, rustProxy } = buildQueryService(); + rustProxy.executeInTransaction.mockImplementationOnce(() => + Promise.reject(new Error('constraint violation')), + ); + await expect( + service.executeTransaction( + DB_ID, + 'api-key:k-1', + [{ op: 'insert', resource: 'nodes', data: {} }] as TxnOpDto[], + { identity }, + ), + ).rejects.toThrow('constraint violation'); + await flush(); + expect(rustProxy.rollbackTransaction).toHaveBeenCalledTimes(1); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +// ── SchemaService wiring (schema_changed from the DDL path) ───────────────── + +describe('SchemaService realtime wiring', () => { + function buildSchemaService() { + const config = { + get: (_key: string, def?: string) => def, + } as unknown as ConfigService; + const query = { + resolveConnection: jest.fn(async () => ({ + engine: 'postgresql', + connection_string: 'postgres://example/db', + isolation: 'shared_rls', + })), + }; + const proxy = { + applySchemaDdl: jest.fn( + async (_ctx: unknown, ddl: { op: string; table: string }) => ({ + op: ddl.op, + table: ddl.table, + status: 'applied', + }), + ), + }; + const service = new SchemaService( + config, + query as unknown as QueryService, + proxy as unknown as RustDataPlaneProxy, + buildPublisher(), + ); + return { service, proxy }; + } + + it('publishes schema_changed after a successful DDL', async () => { + const { service } = buildSchemaService(); + await service.applyDdl( + DB_ID, + 'user-1', + { op: 'add_column', table: 'notes', column: { name: 'extra', normalized_type: 'text' } } as never, + { tenantId: 't-1' } as never, + ); + await flush(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = publishedBody(); + expect(body.topic).toBe(`table:${DB_ID}:notes`); + expect(body.event_type).toBe('schema_changed'); + expect(body.payload).toMatchObject({ dbId: DB_ID, table: 'notes', op: 'add_column' }); + }); + + it('does NOT publish when the data plane rejects the DDL', async () => { + const { service, proxy } = buildSchemaService(); + proxy.applySchemaDdl.mockImplementationOnce(() => Promise.reject(new Error('409 conflict'))); + await expect( + service.applyDdl( + DB_ID, + 'user-1', + { op: 'add_column', table: 'notes', column: { name: 'x', normalized_type: 'text' } } as never, + { tenantId: 't-1' } as never, + ), + ).rejects.toThrow('409 conflict'); + await flush(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/realtime-publisher.service.ts b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/realtime-publisher.service.ts new file mode 100644 index 00000000..3644ebcb --- /dev/null +++ b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/realtime-publisher.service.ts @@ -0,0 +1,156 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* realtime-publisher.service.ts :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/10 00:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/10 00:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** Write operations that fan out as `row_changed` realtime events. */ +export type RealtimeWriteOp = 'insert' | 'update' | 'delete' | 'upsert'; + +/** Optional context attached to a `row_changed` event. */ +export interface RowChangedDetail { + /** The write's filter (update/delete) — sanitized + size-capped before send. */ + filter?: unknown; + /** Caller idempotency key; forwarded so the realtime bus can dedupe. */ + idempotencyKey?: string; + /** Best-effort primary key of the touched row (`id` when known). */ + pk?: unknown; +} + +/** Filters above this serialized size are dropped from the payload (the event + * stays useful without them; subscribers refetch). Keeps the envelope far + * below the realtime service's 64KB payload cap. */ +const MAX_FILTER_JSON_BYTES = 8_192; + +/** + * Best-effort fan-out of committed writes to the realtime WebSocket service + * (Phase 6 — live `row_changed` publishing). + * + * Mirrors outbox-relay's `publishRealtime` ingest contract: an internal, + * unauthenticated `POST ${REALTIME_PUBLISH_URL}` (default + * `http://realtime:4000/v1/publish`) with `{ topic, event_type, + * idempotency_key?, payload }`. Subscribers join `table::
` + * (an Exact TopicPattern — no wildcards needed) over `/realtime/v1/ws`. + * + * Delivery posture — deliberately weaker than the outbox: + * - fire-and-forget with a short timeout (default 1.5s); + * - EVERY failure is swallowed (debug-logged), the write response is never + * affected; + * - no retries — clients converge via their poll fallback, and the durable + * outbox → relay path remains the at-least-once channel. + * + * An empty `REALTIME_PUBLISH_URL` disables publishing entirely (same switch + * semantics as outbox-relay). + */ +@Injectable() +export class RealtimePublisherService { + private readonly logger = new Logger(RealtimePublisherService.name); + private readonly publishUrl: string; + private readonly timeoutMs: number; + + constructor(config: ConfigService) { + this.publishUrl = config.get( + 'REALTIME_PUBLISH_URL', + 'http://realtime:4000/v1/publish', + ); + this.timeoutMs = Number(config.get('REALTIME_PUBLISH_TIMEOUT_MS', '1500')); + } + + /** + * Publish a `row_changed` event for one SUCCESSFUL write on + * `table::
`. Never rejects — all failures are debug-logged. + */ + async publishRowChanged( + dbId: string, + table: string, + op: RealtimeWriteOp, + detail: RowChangedDetail = {}, + ): Promise { + await this.post(`table:${dbId}:${table}`, 'row_changed', detail.idempotencyKey, { + dbId, + table, + op, + filter: this.sanitizeFilter(detail.filter), + pk: this.sanitizeFilter(detail.pk), + ts: new Date().toISOString(), + }); + } + + /** + * Publish a `schema_changed` event after a SUCCESSFUL DDL on the SAME + * `table::
` channel — subscribed clients refetch the schema. + * `op` is the DDL operation (create_table | add_column | …). + */ + async publishSchemaChanged(dbId: string, table: string, op: string): Promise { + await this.post(`table:${dbId}:${table}`, 'schema_changed', undefined, { + dbId, + table, + op, + ts: new Date().toISOString(), + }); + } + + /** POST one event envelope. Best-effort: timeout-capped, never throws. */ + private async post( + topic: string, + eventType: string, + idempotencyKey: string | undefined, + payload: Record, + ): Promise { + if (!this.publishUrl) return; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await fetch(this.publishUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + topic, + event_type: eventType, + ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}), + payload, + }), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + } catch (error) { + // Best-effort by design: a realtime outage must never surface to the + // write path. The poll fallback (and the durable outbox relay) cover + // missed events, so this is debug — not warn — noise. + this.logger.debug( + `realtime publish skipped for ${topic} (${eventType}): ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + } finally { + clearTimeout(timer); + } + } + + /** + * Returns a JSON-safe, size-capped copy of `value`, or `undefined` when it + * is absent, non-serializable, or larger than {@link MAX_FILTER_JSON_BYTES}. + * The JSON round-trip drops functions/undefined/symbols and breaks no cycles + * silently (cycles throw → dropped). + */ + private sanitizeFilter(value: unknown): unknown { + if (value === null || value === undefined) return undefined; + try { + const json = JSON.stringify(value); + if (typeof json !== 'string' || json.length > MAX_FILTER_JSON_BYTES) return undefined; + return JSON.parse(json) as unknown; + } catch { + return undefined; + } + } +} diff --git a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/schema.controller.ts b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/schema.controller.ts new file mode 100644 index 00000000..27a141c6 --- /dev/null +++ b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/schema.controller.ts @@ -0,0 +1,74 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* schema.controller.ts :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/09 00:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/09 00:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; +import { + AuthGuard, + CurrentIdentity, + CurrentUser, + UserContext, + VerifiedRequestIdentity, +} from '@mini-baas/common'; +import { SchemaService, SchemaDdlResponse, SchemaResponse } from './schema.service'; +import { SchemaDdlRequestDto } from './dto/schema-ddl.dto'; + +// Root-mounted like QueryController: Kong strips `/query/v1` (strip_path), so +// this serves the public `GET /query/v1/:dbId/schema` as `GET /:dbId/schema`. +@ApiTags('query') +@Controller() +@UseGuards(AuthGuard) +export class SchemaController { + constructor(private readonly service: SchemaService) {} + + @Get(':dbId/schema') + @ApiParam({ name: 'dbId', type: 'string', format: 'uuid' }) + @ApiOperation({ + summary: 'Describe the schema of a registered database (engine-agnostic)', + description: + 'Returns the mount’s tables/collections with normalized column types ' + + '(text|integer|float|decimal|boolean|date|datetime|json|uuid|enum|array|objectid|unknown), ' + + 'primary/foreign keys and enum values, plus the engine’s live capability descriptor. ' + + 'Mongo collections without a $jsonSchema validator are sample-inferred (`inferred: true`). ' + + 'Engines without an introspection surface (redis/http) return 422 unsupported_capability.', + }) + async describe( + @CurrentUser() user: UserContext, + @CurrentIdentity() identity: VerifiedRequestIdentity, + @Param('dbId', ParseUUIDPipe) dbId: string, + ): Promise { + return this.service.describe(dbId, user.id, identity); + } + + @Post(':dbId/schema/ddl') + @ApiParam({ name: 'dbId', type: 'string', format: 'uuid' }) + @ApiOperation({ + summary: 'Apply ONE schema-DDL operation to a registered database (engine-agnostic)', + description: + 'Single operation per request (add_column | drop_column | alter_column_type | ' + + 'create_table | drop_table) — deliberate, MySQL DDL is non-transactional. ' + + 'Destructive ops (drop_column, drop_table) require `"confirm": true`. ' + + 'For alter_column_type the FULL target definition is composed server-side: ' + + 'attributes omitted from `column` are preserved from the current column. ' + + 'When the existing data is incompatible with the requested type the engine ' + + 'rejects with 409 Conflict; engines without a DDL surface (redis/http) return ' + + '422 unsupported_capability. On success the schema cache for this database is busted.', + }) + async applyDdl( + @CurrentUser() user: UserContext, + @CurrentIdentity() identity: VerifiedRequestIdentity, + @Param('dbId', ParseUUIDPipe) dbId: string, + @Body() dto: SchemaDdlRequestDto, + ): Promise { + return this.service.applyDdl(dbId, user.id, dto, identity); + } +} diff --git a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/schema.service.spec.ts b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/schema.service.spec.ts new file mode 100644 index 00000000..493ef4ae --- /dev/null +++ b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/schema.service.spec.ts @@ -0,0 +1,327 @@ +// `@jest/globals` (bundled with jest) provides the typings — the monorepo does +// not ship `@types/jest`, so the globals must be imported explicitly. +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { ConfigService } from '@nestjs/config'; +import { SchemaService } from './schema.service'; +import type { QueryService } from './query.service'; +import type { + RustCapabilitiesResponse, + RustDataPlaneProxy, + RustSchemaDescriptor, +} from '../proxy/rust-data-plane.proxy'; + +describe('SchemaService', () => { + const descriptor = (engine = 'postgresql'): RustSchemaDescriptor => ({ + engine, + tables: [ + { + name: 'orders', + primary_key: ['id'], + columns: [ + { + name: 'status', + native_type: 'order_status', + normalized_type: 'enum', + nullable: false, + default: null, + enum_values: ['pending', 'paid'], + references: null, + inferred: false, + }, + ], + }, + ], + }); + + const capabilities = { + router: {}, + engines: [ + { + engine: 'postgresql', + phase: 'pool_v2_active', + capabilities: { read: true, write: true, ddl: true, introspect: true }, + }, + ], + } as unknown as RustCapabilitiesResponse; + + function build(configOverrides: Record = {}) { + const config = { + get: (key: string, def?: string) => configOverrides[key] ?? def, + } as unknown as ConfigService; + const query = { + resolveConnection: jest.fn(async () => ({ + engine: 'postgresql', + connection_string: 'postgres://example/db', + isolation: 'shared_rls', + })), + }; + const proxy = { + describeSchema: jest.fn(async () => descriptor()), + getCapabilitiesCached: jest.fn(async () => capabilities), + applySchemaDdl: jest.fn( + async (_ctx: unknown, ddl: { op: string; table: string; column?: unknown }) => ({ + op: ddl.op, + table: ddl.table, + status: 'applied', + }), + ), + }; + const service = new SchemaService( + config, + query as unknown as QueryService, + proxy as unknown as RustDataPlaneProxy, + ); + return { service, query, proxy }; + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('resolves the mount, forwards to the Rust proxy and merges live capabilities', async () => { + const { service, query, proxy } = build(); + const result = await service.describe('db-1', 'user-1', { tenantId: 't-1' } as never); + + expect(query.resolveConnection).toHaveBeenCalledWith('db-1', 't-1'); + expect(proxy.describeSchema).toHaveBeenCalledWith( + expect.objectContaining({ + databaseId: 'db-1', + engine: 'postgresql', + tenantId: 't-1', + userId: 'user-1', + connectionString: 'postgres://example/db', + isolation: 'shared_rls', + }), + ); + expect(result).toEqual({ + dbId: 'db-1', + engine: 'postgresql', + capabilities: capabilities.engines[0].capabilities, + tables: descriptor().tables, + }); + }); + + it('serves a second call within the TTL from cache (single upstream fetch)', async () => { + const { service, proxy } = build(); + const first = await service.describe('db-1', 'user-1', { tenantId: 't-1' } as never); + const second = await service.describe('db-1', 'user-1', { tenantId: 't-1' } as never); + expect(proxy.describeSchema).toHaveBeenCalledTimes(1); + expect(second).toEqual(first); + }); + + it('refetches after the TTL expires', async () => { + const { service, proxy } = build({ QUERY_ROUTER_SCHEMA_CACHE_TTL_MS: '60000' }); + const start = Date.now(); + const now = jest.spyOn(Date, 'now'); + + now.mockReturnValue(start); + await service.describe('db-1', 'user-1', { tenantId: 't-1' } as never); + // Still inside the window → cached. + now.mockReturnValue(start + 59_000); + await service.describe('db-1', 'user-1', { tenantId: 't-1' } as never); + expect(proxy.describeSchema).toHaveBeenCalledTimes(1); + // Past the window → expired entry is dropped and refetched. + now.mockReturnValue(start + 61_000); + await service.describe('db-1', 'user-1', { tenantId: 't-1' } as never); + expect(proxy.describeSchema).toHaveBeenCalledTimes(2); + }); + + it('isolates cache entries by tenant (no cross-tenant cache hits)', async () => { + const { service, proxy } = build(); + await service.describe('db-1', 'user-a', { tenantId: 't-a' } as never); + await service.describe('db-1', 'user-b', { tenantId: 't-b' } as never); + // Same dbId but different tenants → two distinct upstream fetches. + expect(proxy.describeSchema).toHaveBeenCalledTimes(2); + // And a repeat for tenant A still hits A's cache. + await service.describe('db-1', 'user-a', { tenantId: 't-a' } as never); + expect(proxy.describeSchema).toHaveBeenCalledTimes(2); + }); + + it('evicts the oldest entry once the cache cap is reached', async () => { + const { service, proxy } = build({ QUERY_ROUTER_SCHEMA_CACHE_MAX_ENTRIES: '2' }); + await service.describe('db-1', 'u', { tenantId: 't-1' } as never); // entry 1 (oldest) + await service.describe('db-2', 'u', { tenantId: 't-1' } as never); // entry 2 → at cap + await service.describe('db-3', 'u', { tenantId: 't-1' } as never); // evicts db-1 + expect(proxy.describeSchema).toHaveBeenCalledTimes(3); + // db-3 and db-2... db-2 was evicted? No: oldest (db-1) was. db-2 still cached. + await service.describe('db-2', 'u', { tenantId: 't-1' } as never); + expect(proxy.describeSchema).toHaveBeenCalledTimes(3); + // db-1 was evicted → refetch. + await service.describe('db-1', 'u', { tenantId: 't-1' } as never); + expect(proxy.describeSchema).toHaveBeenCalledTimes(4); + }); + + it('still serves the schema when the capabilities lookup fails', async () => { + const { service, proxy } = build(); + proxy.getCapabilitiesCached.mockImplementation(() => + Promise.reject(new Error('capabilities down')), + ); + const result = await service.describe('db-1', 'user-1', { tenantId: 't-1' } as never); + expect(result.capabilities).toBeNull(); + expect(result.tables).toHaveLength(1); + }); + + it('falls back to userId as the tenant scope when no identity is present', async () => { + const { service, query } = build(); + await service.describe('db-1', 'user-1'); + expect(query.resolveConnection).toHaveBeenCalledWith('db-1', 'user-1'); + }); + + // ── M22 step 2: applyDdl (POST /:dbId/schema/ddl) ────────────────────────── + + describe('applyDdl', () => { + const identity = { tenantId: 't-1' } as never; + + it('refuses destructive ops without confirm: true (no proxy call)', async () => { + const { service, proxy } = build(); + for (const op of ['drop_column', 'drop_table'] as const) { + await expect( + service.applyDdl('db-1', 'user-1', { op, table: 'orders', column_name: 'x' } as never, identity), + ).rejects.toMatchObject({ status: 400 }); + } + expect(proxy.applySchemaDdl).not.toHaveBeenCalled(); + // …and proceeds once confirmed. + const result = await service.applyDdl( + 'db-1', + 'user-1', + { op: 'drop_column', table: 'orders', column_name: 'status', confirm: true } as never, + identity, + ); + expect(proxy.applySchemaDdl).toHaveBeenCalledTimes(1); + expect(result).toEqual({ op: 'drop_column', table: 'orders', status: 'applied', dbId: 'db-1' }); + }); + + it('forwards the proxy call with the resolved mount context and full wire ddl', async () => { + const { service, proxy, query } = build(); + await service.applyDdl( + 'db-1', + 'user-1', + { + op: 'add_column', + table: 'orders', + column: { name: 'qty', normalized_type: 'integer' }, + } as never, + identity, + ); + expect(query.resolveConnection).toHaveBeenCalledWith('db-1', 't-1'); + expect(proxy.applySchemaDdl).toHaveBeenCalledWith( + expect.objectContaining({ + databaseId: 'db-1', + engine: 'postgresql', + tenantId: 't-1', + userId: 'user-1', + connectionString: 'postgres://example/db', + isolation: 'shared_rls', + }), + { + op: 'add_column', + table: 'orders', + // optional attributes are defaulted into a FULL wire def + column: { name: 'qty', normalized_type: 'integer', nullable: true, default: null, enum_values: null }, + column_name: null, + columns: null, + primary_key: null, + }, + ); + }); + + it('alter_column_type composes the FULL target def, preserving current attributes', async () => { + const { service, proxy } = build(); + // current `status` column: nullable=false, default=null, enum ['pending','paid'] + await service.applyDdl( + 'db-1', + 'user-1', + { + op: 'alter_column_type', + table: 'orders', + column: { name: 'status', normalized_type: 'text' }, + } as never, + identity, + ); + const [, ddl] = proxy.applySchemaDdl.mock.calls[0]; + expect(ddl.column).toEqual({ + name: 'status', + normalized_type: 'text', // requested type wins + nullable: false, // preserved from the current column + default: null, // preserved + enum_values: ['pending', 'paid'], // preserved + }); + // …and an explicit override wins over the current value. + await service.applyDdl( + 'db-1', + 'user-1', + { + op: 'alter_column_type', + table: 'orders', + column: { name: 'status', normalized_type: 'text', nullable: true, enum_values: null }, + } as never, + identity, + ); + const [, overridden] = proxy.applySchemaDdl.mock.calls[1]; + expect(overridden.column).toEqual({ + name: 'status', + normalized_type: 'text', + nullable: true, + default: null, + enum_values: null, + }); + }); + + it('alter_column_type 404s on an unknown table or column', async () => { + const { service, proxy } = build(); + await expect( + service.applyDdl( + 'db-1', + 'user-1', + { op: 'alter_column_type', table: 'ghosts', column: { name: 'x', normalized_type: 'text' } } as never, + identity, + ), + ).rejects.toMatchObject({ status: 404 }); + await expect( + service.applyDdl( + 'db-1', + 'user-1', + { op: 'alter_column_type', table: 'orders', column: { name: 'ghost', normalized_type: 'text' } } as never, + identity, + ), + ).rejects.toMatchObject({ status: 404 }); + expect(proxy.applySchemaDdl).not.toHaveBeenCalled(); + }); + + it('busts the schema cache entry after a successful DDL', async () => { + const { service, proxy } = build(); + await service.describe('db-1', 'user-1', identity); + await service.describe('db-1', 'user-1', identity); + expect(proxy.describeSchema).toHaveBeenCalledTimes(1); // cached + await service.applyDdl( + 'db-1', + 'user-1', + { op: 'add_column', table: 'orders', column: { name: 'qty', normalized_type: 'integer' } } as never, + identity, + ); + await service.describe('db-1', 'user-1', identity); + expect(proxy.describeSchema).toHaveBeenCalledTimes(2); // cache busted → refetch + // another tenant's entry is untouched by the bust (keyed per tenant+db). + await service.describe('db-1', 'user-2', { tenantId: 't-2' } as never); + await service.describe('db-1', 'user-2', { tenantId: 't-2' } as never); + expect(proxy.describeSchema).toHaveBeenCalledTimes(3); + }); + + it('does not bust the cache when the data plane rejects the DDL', async () => { + const { service, proxy } = build(); + await service.describe('db-1', 'user-1', identity); + proxy.applySchemaDdl.mockImplementationOnce(() => Promise.reject(new Error('409 conflict'))); + await expect( + service.applyDdl( + 'db-1', + 'user-1', + { op: 'add_column', table: 'orders', column: { name: 'qty', normalized_type: 'integer' } } as never, + identity, + ), + ).rejects.toThrow('409 conflict'); + await service.describe('db-1', 'user-1', identity); + // still cached — nothing changed on the engine. + expect(proxy.describeSchema).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/baas/mini-baas-infra/src/apps/query-router/src/query/schema.service.ts b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/schema.service.ts new file mode 100644 index 00000000..a10749c9 --- /dev/null +++ b/apps/baas/mini-baas-infra/src/apps/query-router/src/query/schema.service.ts @@ -0,0 +1,296 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* schema.service.ts :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/09 00:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/09 00:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + Optional, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { VerifiedRequestIdentity } from '@mini-baas/common'; +import { QueryService } from './query.service'; +import { RealtimePublisherService } from './realtime-publisher.service'; +import { + RustDataPlaneProxy, + RustDdlColumnDef, + RustEngineCapabilities, + RustProxyContext, + RustSchemaDdlRequest, + RustSchemaDdlResult, + RustTableSchema, +} from '../proxy/rust-data-plane.proxy'; +import { SchemaDdlColumnDto, SchemaDdlRequestDto } from './dto/schema-ddl.dto'; + +/** Response of `GET /query/v1/:dbId/schema` — the Rust `SchemaDescriptor` + * enriched with the mount id and the engine's live capability descriptor so + * a client learns in ONE call both what the data looks like and what it may + * do with it (read/write/ddl/transactions/introspect…). */ +export interface SchemaResponse { + dbId: string; + engine: string; + capabilities: RustEngineCapabilities | null; + tables: RustTableSchema[]; +} + +/** Response of `POST /query/v1/:dbId/schema/ddl` — the Rust `SchemaDdlResult` + * enriched with the mount id. */ +export interface SchemaDdlResponse extends RustSchemaDdlResult { + dbId: string; +} + +/** Ops that destroy data and therefore require an explicit `confirm: true`. */ +const DESTRUCTIVE_DDL_OPS = new Set(['drop_column', 'drop_table']); + +interface SchemaCacheEntry { + value: SchemaResponse; + expiresAt: number; +} + +/** + * Engine-agnostic schema introspection (M22, live-database mode). + * + * Resolves the mount through {@link QueryService.resolveConnection} (the same + * static-mount/registry path the query hot path uses), forwards to the Rust + * data plane's `POST /v1/schema` via {@link RustDataPlaneProxy.describeSchema}, + * and merges in the engine's live capabilities from the shared + * `/v1/capabilities` TTL cache. + * + * Results are TTL-cached in-memory keyed by `${tenantId}:${dbId}` (schema + * changes are rare; 60s staleness is acceptable and keeps the catalog queries + * off the hot path), capped at a fixed number of entries with simple + * oldest-entry eviction (Map iteration is insertion order). + */ +@Injectable() +export class SchemaService { + private readonly logger = new Logger(SchemaService.name); + private readonly cache = new Map(); + private readonly cacheTtlMs: number; + private readonly cacheMaxEntries: number; + + constructor( + config: ConfigService, + private readonly query: QueryService, + private readonly rustProxy: RustDataPlaneProxy, + // Optional so existing call sites/tests that build the service without a + // publisher keep working; Nest injects it when the provider is registered. + @Optional() private readonly realtime?: RealtimePublisherService, + ) { + this.cacheTtlMs = Number(config.get('QUERY_ROUTER_SCHEMA_CACHE_TTL_MS', '60000')); + this.cacheMaxEntries = Number( + config.get('QUERY_ROUTER_SCHEMA_CACHE_MAX_ENTRIES', '256'), + ); + } + + /** Describe `dbId`'s schema for the calling identity (tenant-scoped). */ + async describe( + dbId: string, + userId: string, + identity?: VerifiedRequestIdentity, + ): Promise { + const tenantId = identity?.tenantId ?? userId; + const cacheKey = `${tenantId}:${dbId}`; + const cached = this.readCache(cacheKey); + if (cached) return cached; + + const { engine, connection_string, isolation } = await this.query.resolveConnection( + dbId, + tenantId, + ); + const ctx: RustProxyContext = { + databaseId: dbId, + engine, + tenantId, + projectId: identity?.projectId, + appId: identity?.appId, + userId, + credentialReference: dbId, + credentialVersion: 'live', + connectionString: connection_string, + isolation, + }; + const descriptor = await this.rustProxy.describeSchema(ctx); + const capabilities = await this.capabilitiesFor(engine); + const value: SchemaResponse = { + dbId, + engine: descriptor.engine, + capabilities, + tables: descriptor.tables, + }; + this.writeCache(cacheKey, value); + return value; + } + + /** + * Apply ONE schema-DDL operation to `dbId` (M22 step 2). Flow: + * 1. destructive ops (drop_column / drop_table) require an explicit + * `confirm: true` — refused with a 400 otherwise; + * 2. `alter_column_type` composes the FULL target column definition by + * merging the request with the CURRENT column (via the cached + * describe path) — engines like MySQL (`MODIFY COLUMN`) reset omitted + * attributes, so the data plane always receives a complete def. An + * unknown table/column is a 404; + * 3. forward to the Rust data plane (`POST /v1/schema/ddl`); + * 4. on success, BUST this tenant+db's schema cache entry so the next + * describe sees the new shape immediately (not after the TTL). + */ + async applyDdl( + dbId: string, + userId: string, + dto: SchemaDdlRequestDto, + identity?: VerifiedRequestIdentity, + ): Promise { + if (DESTRUCTIVE_DDL_OPS.has(dto.op) && dto.confirm !== true) { + throw new BadRequestException( + `ddl op '${dto.op}' is destructive — set "confirm": true to proceed`, + ); + } + const tenantId = identity?.tenantId ?? userId; + + let column: RustDdlColumnDef | null = dto.column ? this.fullColumn(dto.column) : null; + if (dto.op === 'alter_column_type') { + column = await this.composeAlterTarget(dbId, userId, dto, identity); + } + + const { engine, connection_string, isolation } = await this.query.resolveConnection( + dbId, + tenantId, + ); + const ctx: RustProxyContext = { + databaseId: dbId, + engine, + tenantId, + projectId: identity?.projectId, + appId: identity?.appId, + userId, + credentialReference: dbId, + credentialVersion: 'live', + connectionString: connection_string, + isolation, + }; + const ddl: RustSchemaDdlRequest = { + op: dto.op, + table: dto.table, + column, + column_name: dto.column_name ?? null, + columns: dto.columns ? dto.columns.map((c) => this.fullColumn(c)) : null, + primary_key: dto.primary_key ?? null, + }; + const result = await this.rustProxy.applySchemaDdl(ctx, ddl); + // The schema changed — the cached descriptor for this tenant+db is stale. + this.cache.delete(`${tenantId}:${dbId}`); + // Best-effort realtime fan-out on the SAME table channel as row_changed — + // subscribed clients refetch the schema. Fire-and-forget (never rejects). + if (this.realtime) { + void this.realtime.publishSchemaChanged(dbId, dto.table, dto.op); + } + return { ...result, dbId }; + } + + /** Defaults an (optional-attribute) DTO column into the FULL wire def. */ + private fullColumn(c: SchemaDdlColumnDto): RustDdlColumnDef { + return { + name: c.name, + normalized_type: c.normalized_type, + nullable: c.nullable ?? true, + default: c.default ?? null, + enum_values: c.enum_values ?? null, + }; + } + + /** + * For `alter_column_type`: fetch the current schema (cached describe path), + * 404 on an unknown table/column, then compose the FULL target definition — + * the requested type always wins; nullability/default/enum values are + * preserved from the current column unless the request overrides them. + */ + private async composeAlterTarget( + dbId: string, + userId: string, + dto: SchemaDdlRequestDto, + identity?: VerifiedRequestIdentity, + ): Promise { + const requested = dto.column; + if (!requested) { + throw new BadRequestException("ddl op 'alter_column_type' requires `column`"); + } + const schema = await this.describe(dbId, userId, identity); + const table = schema.tables.find((t) => t.name === dto.table); + if (!table) { + throw new NotFoundException(`table '${dto.table}' does not exist on database ${dbId}`); + } + const current = table.columns.find((c) => c.name === requested.name); + if (!current) { + throw new NotFoundException( + `column '${requested.name}' does not exist on table '${dto.table}'`, + ); + } + return { + name: requested.name, + normalized_type: requested.normalized_type, + nullable: requested.nullable ?? current.nullable, + // `undefined` = "keep the current default"; an explicit `null` clears it. + default: requested.default !== undefined ? requested.default : current.default, + enum_values: + requested.enum_values !== undefined ? requested.enum_values : current.enum_values, + }; + } + + /** Engine capabilities from the live `/v1/capabilities` shared TTL cache — + * the same source `/capabilities` and `/engines` expose. Best-effort: a + * capabilities outage must not fail the schema read itself. */ + private async capabilitiesFor(engine: string): Promise { + try { + const caps = await this.rustProxy.getCapabilitiesCached(); + return caps.engines.find((e) => e.engine === engine)?.capabilities ?? null; + } catch (error) { + this.logger.warn( + `capabilities lookup for '${engine}' failed (schema still served): ${ + error instanceof Error ? error.message : 'unknown error' + }`, + ); + return null; + } + } + + private readCache(cacheKey: string): SchemaResponse | undefined { + if (this.cacheTtlMs <= 0) return undefined; + const entry = this.cache.get(cacheKey); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + this.cache.delete(cacheKey); + return undefined; + } + return entry.value; + } + + private writeCache(cacheKey: string, value: SchemaResponse): void { + if (this.cacheTtlMs <= 0) return; + this.evictIfFull(); + this.cache.set(cacheKey, { value, expiresAt: Date.now() + this.cacheTtlMs }); + } + + /** Same policy as QueryService's DSN cache: lazy-reap expired entries, then + * drop the oldest (Map iteration is insertion order) if still over cap. */ + private evictIfFull(): void { + if (this.cache.size < this.cacheMaxEntries) return; + const now = Date.now(); + for (const [key, entry] of this.cache) { + if (entry.expiresAt <= now) this.cache.delete(key); + } + if (this.cache.size >= this.cacheMaxEntries) { + const oldest = this.cache.keys().next().value; + if (oldest) this.cache.delete(oldest); + } + } +} diff --git a/apps/baas/scripts/apply-project-sql.sh b/apps/baas/scripts/apply-project-sql.sh index 1efc1067..1eaed201 100755 --- a/apps/baas/scripts/apply-project-sql.sh +++ b/apps/baas/scripts/apply-project-sql.sh @@ -69,6 +69,10 @@ SQL $psql_base -f /project-init/07-rls-hardening.sql $psql_base -c "INSERT INTO track_binocle_runtime_migrations (marker) VALUES ('${marker}_rls_hardening') ON CONFLICT DO NOTHING" +# Chat / DMs / profiles / feed interactions (idempotent, additive). +$psql_base -f /project-init/08-osionos-chat.sql +$psql_base -c "INSERT INTO track_binocle_runtime_migrations (marker) VALUES ('${marker}_osionos_chat') ON CONFLICT DO NOTHING" + seeds_applied=$($psql_base -Atc "SELECT 1 FROM track_binocle_runtime_migrations WHERE marker = '${marker}_seeds' LIMIT 1") if [ "$seeds_applied" != "1" ]; then seeded_user_count=$($psql_base -Atc "SELECT COUNT(*) FROM users WHERE email IN ('john.doe@example.com', 'jane.doe@example.com')") diff --git a/apps/baas/scripts/vault-env.mjs b/apps/baas/scripts/vault-env.mjs index 0e62612c..4c794659 100644 --- a/apps/baas/scripts/vault-env.mjs +++ b/apps/baas/scripts/vault-env.mjs @@ -97,7 +97,7 @@ const managedFiles = [ dropKeys: baasOnlyKeys, required: ['OSIONOS_BRIDGE_SHARED_SECRET', 'OSIONOS_APP_SESSION_SECRET', 'OSIONOS_BRIDGE_EMAIL_HASH_SALT', 'OSIONOS_APP_URL', 'OSIONOS_ALLOWED_ORIGIN'], recommended: ['PUBLIC_OSIONOS_APP_URL'], - optional: ['DOCKER_PAT', 'DOCKER_USER', 'EMAIL_RECUP_ADMIN_VAULT', 'FLY_API_TOKEN', 'SONAR_TOK'], + optional: ['DOCKER_PAT', 'DOCKER_USER', 'EMAIL_RECUP_ADMIN_VAULT', 'FLY_API_TOKEN', 'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET', 'LIVEKIT_CLIENT_URL', 'LIVEKIT_URL', 'SONAR_TOK'], }, // NOTE: opposite-osiris (website + api-gateway) was externalized to its own // repo + Docker Hub images (2026-06-06). Its build-time PUBLIC_* config is now @@ -167,7 +167,7 @@ const managedFiles = [ ]; const optionalPatterns = [ - /^SMTP_/, /^GOTRUE_SMTP_/, /^TURNSTILE_/, /^PUBLIC_TURNSTILE_/, /^GOOGLE_/, /^GITHUB_/, /^FORTYTWO_/, /^GMAIL_/, /^MAIL_/, /^CALENDAR_/, /^LLM_/, /^SONAR/, /^UNSPLASH_/, /^DOCKER_/, /^FLY_/, /^MINIO_/, /^MONGO_/, /^AI_/, /^ANALYTICS_/, /^GDPR_/, /^NEWSLETTER_/, /^LOG_/, /^RUST_LOG$/, /^WAF_/, /^STUDIO_/, /^SUPABASE_/, /^SESSION_/, /^CSV_/, /^JSON_/, /^REDIS_/, /^PLAYGROUND_/, /^SRC_/, /^SYNC_/, /^CONTRACT_/, /^ADAPTER_REGISTRY_/, /^QUERY_/, /^STORAGE_/, /^PERMISSION_/, /^SCHEMA_/, /^VITE_BAAS_/, /^VITE_ALLOW_OFFLINE_MODE$/, + /^SMTP_/, /^GOTRUE_SMTP_/, /^TURNSTILE_/, /^PUBLIC_TURNSTILE_/, /^GOOGLE_/, /^GITHUB_/, /^FORTYTWO_/, /^GMAIL_/, /^MAIL_/, /^CALENDAR_/, /^LIVEKIT_/, /^LLM_/, /^SONAR/, /^UNSPLASH_/, /^DOCKER_/, /^FLY_/, /^MINIO_/, /^MONGO_/, /^AI_/, /^ANALYTICS_/, /^GDPR_/, /^NEWSLETTER_/, /^LOG_/, /^RUST_LOG$/, /^WAF_/, /^STUDIO_/, /^SUPABASE_/, /^SESSION_/, /^CSV_/, /^JSON_/, /^REDIS_/, /^PLAYGROUND_/, /^SRC_/, /^SYNC_/, /^CONTRACT_/, /^ADAPTER_REGISTRY_/, /^QUERY_/, /^STORAGE_/, /^PERMISSION_/, /^SCHEMA_/, /^VITE_BAAS_/, /^VITE_ALLOW_OFFLINE_MODE$/, ]; const secretPatterns = [/SECRET/, /TOKEN/, /PASSWORD/, /PASS$/, /_KEY$/, /PAT$/, /JWT/]; @@ -247,6 +247,10 @@ const examples = { KONG_CORS_ORIGIN_FRONTEND: 'https://localhost:4322', KONG_CORS_ORIGIN_PLAYGROUND: 'https://localhost:3100', KONG_CORS_ORIGIN_STUDIO: 'https://localhost:3001', + LIVEKIT_API_KEY: '', + LIVEKIT_API_SECRET: '', + LIVEKIT_CLIENT_URL: 'ws://127.0.0.1:7880', + LIVEKIT_URL: 'http://livekit:7880', MAIL_APP_ORIGIN: 'https://localhost:3002', MAIL_BRIDGE_PORT: '4100', MAIL_BRIDGE_PUBLIC_ORIGIN: 'https://localhost:4100', @@ -316,6 +320,10 @@ const descriptions = { AUTH_TEST_EMAIL_DOMAIN: 'Email domain used by local verification scripts when generating disposable test accounts.', KONG_PUBLIC_API_KEY: 'Public API key injected into Kong routing configuration.', KONG_SERVICE_API_KEY: 'Privileged service key injected into Kong routing configuration.', + LIVEKIT_API_KEY: 'LiveKit API key shared by the livekit SFU container and the osionos bridge video-token endpoint (Docker default: devkey).', + LIVEKIT_API_SECRET: 'LiveKit API secret used to sign HS256 video access tokens, 32+ chars (Docker default is a dev-only value).', + LIVEKIT_CLIENT_URL: 'Browser-facing LiveKit WebSocket URL returned by the bridge token endpoint.', + LIVEKIT_URL: 'In-network LiveKit server URL used by the bridge for admin (twirp) API calls.', MAIL_BRIDGE_VAULT_ENABLED: 'Enable Mail bridge OAuth credential loading from Vault.', CALENDAR_BRIDGE_VAULT_ENABLED: 'Enable Calendar bridge OAuth credential loading from Vault.', OSIONOS_BRIDGE_SHARED_SECRET: 'Shared secret used by the website auth gateway to request osionos bridge tokens.', diff --git a/apps/baas/sdk/README.md b/apps/baas/sdk/README.md index a06de154..6005af9e 100644 --- a/apps/baas/sdk/README.md +++ b/apps/baas/sdk/README.md @@ -133,6 +133,34 @@ await baas.txn.execute({ }); ``` +### Schema introspection & DDL + +Engine-agnostic live-database mode — one call describes any mount (normalized +column types + the engine's live capabilities), one call applies a single +schema operation. Engines without the surface (redis/http) reject with 422 +`unsupported_capability`; data-incompatible `alter_column_type` rejects with 409. + +```ts +const schema = await baas.schema.describe(dbId); +// → { dbId, engine: "postgresql", capabilities: { schema_ddl: true, ... }, tables: [...] } + +await baas.schema.ddl(dbId, { + op: "add_column", + table: "todos", + column: { name: "done", normalized_type: "boolean", nullable: false, default: "false" }, +}); + +// Destructive ops (drop_column / drop_table) require confirm: true — both at +// compile time and client-side before any request is sent: +await baas.schema.ddl(dbId, { op: "drop_table", table: "scratch", confirm: true }); +``` + +Realtime row changes for a table travel on the `table::
` topic: + +```ts +const wsUrl = baas.realtimeUrl(`table:${dbId}:todos`); +``` + ### Edge functions ```ts @@ -173,7 +201,7 @@ await admin.admin.migrate.run({ ```text Application code ↓ -Product SDK domains: auth / from / query / storage / analytics / realtime / txn / functions +Product SDK domains: auth / from / query / schema / storage / analytics / realtime / txn / functions (+ admin-only: webhooks / admin.tenants / admin.provision / admin.migrate) ↓ Private SDK core: session / retry / timeout / HTTP transport / route map diff --git a/apps/baas/sdk/dist/core/routes.d.ts b/apps/baas/sdk/dist/core/routes.d.ts index 73141a26..59bc4c34 100644 --- a/apps/baas/sdk/dist/core/routes.d.ts +++ b/apps/baas/sdk/dist/core/routes.d.ts @@ -18,6 +18,8 @@ export declare const routes: { readonly query: { readonly execute: "/query/v1/execute"; readonly txn: "/query/v1/txn"; + readonly schema: (dbId: string) => string; + readonly schemaDdl: (dbId: string) => string; }; readonly webhooks: { readonly root: "/admin/v1/webhooks"; @@ -46,5 +48,6 @@ export declare const routes: { }; readonly realtime: { readonly channel: (channel: string) => string; + readonly tableChannel: (dbId: string, table: string) => string; }; }; diff --git a/apps/baas/sdk/dist/core/routes.js b/apps/baas/sdk/dist/core/routes.js index 9c9999b4..3517a3d1 100644 --- a/apps/baas/sdk/dist/core/routes.js +++ b/apps/baas/sdk/dist/core/routes.js @@ -29,6 +29,8 @@ export const routes = { query: { execute: '/query/v1/execute', txn: '/query/v1/txn', + schema: (dbId) => `/query/v1/${encodeURIComponent(dbId)}/schema`, + schemaDdl: (dbId) => `/query/v1/${encodeURIComponent(dbId)}/schema/ddl`, }, webhooks: { root: '/admin/v1/webhooks', @@ -57,6 +59,9 @@ export const routes = { }, realtime: { channel: (channel) => `/realtime/v1/ws?channel=${encodeURIComponent(channel)}`, + // Topic name (NOT a URL) for one table's `row_changed` stream. Compose it + // with `client.realtimeUrl(...)` or send it in a SUBSCRIBE frame. + tableChannel: (dbId, table) => `table:${dbId}:${table}`, }, }; function encodePath(value) { diff --git a/apps/baas/sdk/dist/index.d.ts b/apps/baas/sdk/dist/index.d.ts index b5c40851..2aa643c5 100644 --- a/apps/baas/sdk/dist/index.d.ts +++ b/apps/baas/sdk/dist/index.d.ts @@ -2,6 +2,7 @@ import { AnalyticsClient } from './domains/analytics.js'; import { AuthClient } from './domains/auth.js'; import { QueryClient, ResourceQueryBuilder } from './domains/query.js'; import { RestClient, RestResourceBuilder } from './domains/rest.js'; +import { SchemaClient } from './domains/schema.js'; import { StorageClient } from './domains/storage.js'; import { TxnClient } from './domains/txn.js'; import { WebhooksClient } from './domains/webhooks.js'; @@ -15,7 +16,8 @@ import type { RestRequestOptions } from './types.js'; export type { AuthSession, ClientSession, SessionInput, User, } from './core/session.js'; export type { SessionStorageAdapter } from './core/storage.js'; export { MiniBaasError, MiniBaasTimeoutError } from './core/errors.js'; -export type { AnalyticsTrackInput, PresignInput, QueryRunInput, QueryRunResponse, RecoverInput, RestFilterOperator, RestMutationOptions, RestQueryOptions, RestRequestOptions, RestResourceBuilder as RestResourceBuilderApi, SignInWithPasswordInput, SignUpInput, UpdateUserInput, VerifyInput, TxnExecuteInput, TxnOp, TxnOperation, TxnOpResult, TxnResult, WebhookCreateInput, WebhookDelivery, WebhookSubscription, WebhookUpdateInput, Tenant, TenantApiKey, TenantApiKeyIssued, TenantBootstrapInput, TenantBootstrapResult, TenantCreateInput, TenantUpdateInput, ProvisionInput, ProvisionMountResult, ProvisionMountSpec, ProvisionResult, MigrateCredentialRef, MigrateIdentity, MigrateInput, MigrateMount, FunctionDeployInput, FunctionDeployResult, FunctionInvokeOptions, FunctionSource, FunctionSummary, } from './types.js'; +export type { AnalyticsTrackInput, PresignInput, QueryRunInput, QueryRunResponse, RecoverInput, RestFilterOperator, RestMutationOptions, RestQueryOptions, RestRequestOptions, RestResourceBuilder as RestResourceBuilderApi, SignInWithPasswordInput, SignUpInput, UpdateUserInput, VerifyInput, TxnExecuteInput, TxnOp, TxnOperation, TxnOpResult, TxnResult, WebhookCreateInput, WebhookDelivery, WebhookSubscription, WebhookUpdateInput, Tenant, TenantApiKey, TenantApiKeyIssued, TenantBootstrapInput, TenantBootstrapResult, TenantCreateInput, TenantUpdateInput, ProvisionInput, ProvisionMountResult, ProvisionMountSpec, ProvisionResult, MigrateCredentialRef, MigrateIdentity, MigrateInput, MigrateMount, FunctionDeployInput, FunctionDeployResult, FunctionInvokeOptions, FunctionSource, FunctionSummary, ColumnSchema, DdlColumnDef, DdlColumnType, NormalizedSchema, NormalizedType, SchemaDdlAddColumnInput, SchemaDdlAlterColumnTypeInput, SchemaDdlCreateTableInput, SchemaDdlDropColumnInput, SchemaDdlDropTableInput, SchemaDdlInput, SchemaDdlOp, SchemaDdlResult, SchemaEngineCapabilities, TableSchema, } from './types.js'; +export { SchemaClient } from './domains/schema.js'; export { TxnClient } from './domains/txn.js'; export { WebhooksClient } from './domains/webhooks.js'; export { AdminClient, MigrateClient, TenantsClient } from './domains/admin.js'; @@ -47,6 +49,8 @@ export declare class MiniBaasClient { readonly analytics: AnalyticsClient; /** Single-mount atomic write batches (`POST /query/v1/txn`). */ readonly txn: TxnClient; + /** Engine-agnostic schema introspection + DDL (`/query/v1/:dbId/schema`). */ + readonly schema: SchemaClient; /** Edge functions (`/functions/v1`). */ readonly functions: FunctionsClient; /** diff --git a/apps/baas/sdk/dist/index.js b/apps/baas/sdk/dist/index.js index 4eabb4f0..0137ad7a 100644 --- a/apps/baas/sdk/dist/index.js +++ b/apps/baas/sdk/dist/index.js @@ -13,6 +13,7 @@ import { AnalyticsClient } from './domains/analytics.js'; import { AuthClient } from './domains/auth.js'; import { QueryClient } from './domains/query.js'; import { RestClient } from './domains/rest.js'; +import { SchemaClient } from './domains/schema.js'; import { StorageClient } from './domains/storage.js'; import { TxnClient } from './domains/txn.js'; import { WebhooksClient } from './domains/webhooks.js'; @@ -23,6 +24,7 @@ import { makeEngineClient } from './domains/engine-clients.js'; import { ENGINE_IDS } from './generated/engines.js'; import { createBrowserStorageAdapter, createMemoryStorageAdapter, } from './core/storage.js'; export { MiniBaasError, MiniBaasTimeoutError } from './core/errors.js'; +export { SchemaClient } from './domains/schema.js'; export { TxnClient } from './domains/txn.js'; export { WebhooksClient } from './domains/webhooks.js'; export { AdminClient, MigrateClient, TenantsClient } from './domains/admin.js'; @@ -35,6 +37,8 @@ export class MiniBaasClient { analytics; /** Single-mount atomic write batches (`POST /query/v1/txn`). */ txn; + /** Engine-agnostic schema introspection + DDL (`/query/v1/:dbId/schema`). */ + schema; /** Edge functions (`/functions/v1`). */ functions; /** @@ -71,6 +75,7 @@ export class MiniBaasClient { this.storage = new StorageClient(this.http); this.analytics = new AnalyticsClient(this.http); this.txn = new TxnClient(this.http); + this.schema = new SchemaClient(this.http); this.functions = new FunctionsClient(this.http); this.webhooks = new WebhooksClient(this.http, options.serviceRoleKey); this.admin = new AdminClient(this.http, options.serviceRoleKey); diff --git a/apps/baas/sdk/dist/types.d.ts b/apps/baas/sdk/dist/types.d.ts index 6266abdd..19bfcee3 100644 --- a/apps/baas/sdk/dist/types.d.ts +++ b/apps/baas/sdk/dist/types.d.ts @@ -130,6 +130,140 @@ export interface TxnResult { mount: string; results: TxnOpResult[]; } +/** + * Engine-agnostic normalized column types returned by `schema.describe()`. + * `objectid` and `unknown` are describe-only — DDL rejects them (see + * {@link DdlColumnType}). + */ +export type NormalizedType = 'text' | 'integer' | 'float' | 'decimal' | 'boolean' | 'date' | 'datetime' | 'json' | 'uuid' | 'enum' | 'array' | 'objectid' | 'unknown'; +/** Normalized types creatable via DDL (`objectid`/`unknown` are describe-only). */ +export type DdlColumnType = Exclude; +/** One column of a described table (snake_case — the exact wire shape). */ +export interface ColumnSchema { + name: string; + /** Engine-native type, e.g. `character varying(255)` or `int unsigned`. */ + native_type: string; + normalized_type: NormalizedType; + nullable: boolean; + /** Raw engine default expression, or `null` when the column has none. */ + default: string | null; + /** Allowed values when `normalized_type` is `enum`; `null` otherwise. */ + enum_values: string[] | null; + /** Foreign-key target, or `null` when the column references nothing. */ + references: { + table: string; + column: string; + } | null; + /** `true` only for Mongo sample-based inference (no `$jsonSchema` validator). */ + inferred: boolean; +} +/** One table/collection of a described database. */ +export interface TableSchema { + name: string; + primary_key: string[]; + columns: ColumnSchema[]; +} +/** + * Live engine capability descriptor embedded in `schema.describe()` responses. + * Mirrors the Rust router's `/v1/capabilities` per-engine payload (the same + * source `introspectEngines()` validates against). Kept loose + * (`[key: string]: unknown`) so a new Rust flag does not break the SDK. + */ +export interface SchemaEngineCapabilities { + read: boolean; + write: boolean; + upsert: boolean; + batch?: boolean; + aggregate?: boolean; + /** Engine answers `GET /query/v1/:dbId/schema` (describe). */ + introspect?: boolean; + /** Engine answers `POST /query/v1/:dbId/schema/ddl` — distinct from `ddl`. */ + schema_ddl?: boolean; + stream: boolean; + /** Admin migration-batch surface (`/admin/v1/migrate`) — NOT `schema_ddl`. */ + ddl: boolean; + transactions: boolean; + [key: string]: unknown; +} +/** + * Response of `client.schema.describe()` (`GET /query/v1/:dbId/schema`) — + * the mount's tables with normalized column types plus the engine's live + * capability descriptor, so one call tells a client both what the data looks + * like and what it may do with it. + */ +export interface NormalizedSchema { + dbId: string; + engine: string; + /** `null` when the capabilities fetch failed (the schema is still served). */ + capabilities: SchemaEngineCapabilities | null; + tables: TableSchema[]; +} +/** + * One DDL column definition (snake_case — the exact wire shape). + * `nullable`/`default`/`enum_values` may be omitted: for `add_column` / + * `create_table` the server defaults them (nullable, no default); for + * `alter_column_type` an omitted attribute means "keep what the column has + * today" (the server merges with the current column). + */ +export interface DdlColumnDef { + name: string; + normalized_type: DdlColumnType; + /** Whether NULLs are allowed (defaults to `true` on create/add). */ + nullable?: boolean; + /** Raw engine default expression (`0`, `'pending'`, `now()`); `null` clears it. */ + default?: string | null; + /** Allowed values — required when `normalized_type` is `enum`. */ + enum_values?: string[] | null; +} +/** The supported schema-DDL operations (snake_case, the wire values). */ +export type SchemaDdlOp = 'add_column' | 'drop_column' | 'alter_column_type' | 'create_table' | 'drop_table'; +/** `add_column`: append `column` to `table`. */ +export interface SchemaDdlAddColumnInput { + op: 'add_column'; + table: string; + column: DdlColumnDef; +} +/** `alter_column_type`: retype `column.name` (omitted attributes are kept). */ +export interface SchemaDdlAlterColumnTypeInput { + op: 'alter_column_type'; + table: string; + column: DdlColumnDef; +} +/** `create_table`: create `table` with `columns` and a `primary_key`. */ +export interface SchemaDdlCreateTableInput { + op: 'create_table'; + table: string; + columns: DdlColumnDef[]; + primary_key: string[]; +} +/** `drop_column`: destructive — requires `confirm: true` (400 otherwise). */ +export interface SchemaDdlDropColumnInput { + op: 'drop_column'; + table: string; + column_name: string; + confirm: true; +} +/** `drop_table`: destructive — requires `confirm: true` (400 otherwise). */ +export interface SchemaDdlDropTableInput { + op: 'drop_table'; + table: string; + confirm: true; +} +/** + * Body of `client.schema.ddl()` — ONE operation per request (deliberate: + * MySQL DDL is auto-commit, a batch would fake atomicity). The union makes + * each op's required fields — and `confirm: true` on destructive ops — a + * compile-time requirement, mirroring the server's 400 guard. + */ +export type SchemaDdlInput = SchemaDdlAddColumnInput | SchemaDdlAlterColumnTypeInput | SchemaDdlCreateTableInput | SchemaDdlDropColumnInput | SchemaDdlDropTableInput; +/** Result of an applied schema-DDL operation. */ +export interface SchemaDdlResult { + op: SchemaDdlOp; + table: string; + /** `'applied'` on success (failures surface as `MiniBaasError` instead). */ + status: string; + dbId: string; +} /** Public webhook subscription view (secrets are write-only and never echoed). */ export interface WebhookSubscription { id: string; diff --git a/apps/baas/sdk/package.json b/apps/baas/sdk/package.json index b556f27b..65ebb83f 100644 --- a/apps/baas/sdk/package.json +++ b/apps/baas/sdk/package.json @@ -12,6 +12,7 @@ "build": "tsc -p tsconfig.json", "prepare": "npm run build", "typecheck": "tsc -p tsconfig.typecheck.json", + "test": "node --test tests/", "openapi:collect": "bash ../mini-baas-infra/scripts/openapi-collect.sh", "codegen": "node ./scripts/codegen.mjs", "codegen:engines": "node ./scripts/codegen-engines.mjs", diff --git a/apps/baas/sdk/src/__type_tests__/schema.test-d.ts b/apps/baas/sdk/src/__type_tests__/schema.test-d.ts new file mode 100644 index 00000000..cedede34 --- /dev/null +++ b/apps/baas/sdk/src/__type_tests__/schema.test-d.ts @@ -0,0 +1,143 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* schema.test-d.ts :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/09 00:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/09 00:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ +// +// **Compile-time** assertions for the M22 schema introspection + DDL surface. +// +// This file is **expected to compile** under `tsc --noEmit`. If TypeScript +// complains, the schema wire typing has drifted. The `// @ts-expect-error` +// lines are the inverse: they MUST trigger an error — if the line silently +// compiles, the type narrowing is broken and the SDK lies to its users. +// +// To verify locally: +// cd apps/baas/sdk && npx tsc --noEmit -p tsconfig.typecheck.json + +import type { + ColumnSchema, + NormalizedSchema, + NormalizedType, + SchemaClient, + SchemaDdlInput, + SchemaDdlResult, +} from '../index.js'; + +// ── 1) NormalizedType is a closed literal union ────────────────────────────── +const text: NormalizedType = 'text'; +text satisfies unknown; +const objectId: NormalizedType = 'objectid'; +objectId satisfies unknown; + +// @ts-expect-error 'varchar' is an engine-native name, not a normalized type +const varchar: NormalizedType = 'varchar'; +varchar satisfies unknown; + +// @ts-expect-error 'string' is not in the normalized union (use 'text') +const str: NormalizedType = 'string'; +str satisfies unknown; + +// ── 2) describe() wire shape (exact snake_case contract) ──────────────────── +const described: NormalizedSchema = { + dbId: '4ee63a30-0000-0000-0000-000000000000', + engine: 'postgresql', + capabilities: null, // the capabilities fetch may fail — null is contractual + tables: [ + { + name: 'todos', + primary_key: ['id'], + columns: [ + { + name: 'id', + native_type: 'uuid', + normalized_type: 'uuid', + nullable: false, + default: 'gen_random_uuid()', + enum_values: null, + references: null, + inferred: false, + }, + { + name: 'owner_id', + native_type: 'uuid', + normalized_type: 'uuid', + nullable: false, + default: null, + enum_values: null, + references: { table: 'users', column: 'id' }, + inferred: false, + }, + ], + }, + ], +}; +described satisfies unknown; + +// Capabilities mirror the live engine descriptor (loose for forward-compat). +const withCaps: NormalizedSchema['capabilities'] = { + read: true, write: true, upsert: false, batch: true, aggregate: true, + introspect: true, schema_ddl: true, stream: false, ddl: true, transactions: true, + savepoints: true, // unknown extra flags are allowed (index signature) +}; +withCaps satisfies unknown; + +// @ts-expect-error references must be `{ table, column } | null`, not a string +const badColumn: ColumnSchema = { name: 'x', native_type: 't', normalized_type: 'text', nullable: true, default: null, enum_values: null, references: 'todos.id', inferred: false }; +badColumn satisfies unknown; + +// ── 3) DDL inputs: per-op required fields ──────────────────────────────────── +const addColumn: SchemaDdlInput = { op: 'add_column', table: 'todos', column: { name: 'done', normalized_type: 'boolean', nullable: false, default: 'false' } }; +addColumn satisfies unknown; + +const alterType: SchemaDdlInput = { op: 'alter_column_type', table: 'todos', column: { name: 'count', normalized_type: 'integer' } }; +alterType satisfies unknown; + +const createTable: SchemaDdlInput = { op: 'create_table', table: 'notes', columns: [{ name: 'id', normalized_type: 'uuid', nullable: false }], primary_key: ['id'] }; +createTable satisfies unknown; + +// @ts-expect-error DDL cannot create describe-only types (objectid/unknown) +const addObjectId: SchemaDdlInput = { op: 'add_column', table: 'todos', column: { name: 'ref', normalized_type: 'objectid' } }; +addObjectId satisfies unknown; + +// @ts-expect-error create_table requires primary_key +const createNoPk: SchemaDdlInput = { op: 'create_table', table: 'notes', columns: [{ name: 'id', normalized_type: 'uuid' }] }; +createNoPk satisfies unknown; + +// @ts-expect-error add_column requires the column definition +const addNoColumn: SchemaDdlInput = { op: 'add_column', table: 'todos' }; +addNoColumn satisfies unknown; + +// ── 4) Destructive ops require `confirm: true` at compile time ────────────── +const dropTable: SchemaDdlInput = { op: 'drop_table', table: 'notes', confirm: true }; +dropTable satisfies unknown; + +const dropColumn: SchemaDdlInput = { op: 'drop_column', table: 'todos', column_name: 'done', confirm: true }; +dropColumn satisfies unknown; + +// @ts-expect-error drop_table without confirm must not compile +const dropNoConfirm: SchemaDdlInput = { op: 'drop_table', table: 'notes' }; +dropNoConfirm satisfies unknown; + +// @ts-expect-error confirm: false is not a confirmation (`true` literal only) +const dropFalseConfirm: SchemaDdlInput = { op: 'drop_table', table: 'notes', confirm: false }; +dropFalseConfirm satisfies unknown; + +// @ts-expect-error drop_column requires column_name +const dropColNoName: SchemaDdlInput = { op: 'drop_column', table: 'todos', confirm: true }; +dropColNoName satisfies unknown; + +// ── 5) Client surface returns the wire types ───────────────────────────────── +declare const schema: SchemaClient; +const describePromise: Promise = schema.describe('db'); +describePromise satisfies unknown; +const ddlPromise: Promise = schema.ddl('db', dropTable); +ddlPromise satisfies unknown; + +const applied: SchemaDdlResult = { op: 'drop_table', table: 'notes', status: 'applied', dbId: 'db' }; +applied satisfies unknown; diff --git a/apps/baas/sdk/src/core/routes.ts b/apps/baas/sdk/src/core/routes.ts index 165e8645..44e07043 100644 --- a/apps/baas/sdk/src/core/routes.ts +++ b/apps/baas/sdk/src/core/routes.ts @@ -30,6 +30,8 @@ export const routes = { query: { execute: '/query/v1/execute', txn: '/query/v1/txn', + schema: (dbId: string) => `/query/v1/${encodeURIComponent(dbId)}/schema`, + schemaDdl: (dbId: string) => `/query/v1/${encodeURIComponent(dbId)}/schema/ddl`, }, webhooks: { root: '/admin/v1/webhooks', @@ -59,6 +61,9 @@ export const routes = { }, realtime: { channel: (channel: string) => `/realtime/v1/ws?channel=${encodeURIComponent(channel)}`, + // Topic name (NOT a URL) for one table's `row_changed` stream. Compose it + // with `client.realtimeUrl(...)` or send it in a SUBSCRIBE frame. + tableChannel: (dbId: string, table: string) => `table:${dbId}:${table}`, }, } as const; diff --git a/apps/baas/sdk/src/domains/schema.ts b/apps/baas/sdk/src/domains/schema.ts new file mode 100644 index 00000000..dbd9cbc6 --- /dev/null +++ b/apps/baas/sdk/src/domains/schema.ts @@ -0,0 +1,56 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* schema.ts :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/09 00:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/09 00:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +import { routes } from '../core/routes.js'; +import type { HttpClient } from '../core/http.js'; +import type { NormalizedSchema, SchemaDdlInput, SchemaDdlOp, SchemaDdlResult } from '../types.js'; + +/** Ops that destroy data — refused client-side without `confirm: true`, + * mirroring the server's 400 guard (same set as the query-router). */ +const DESTRUCTIVE_DDL_OPS: ReadonlySet = new Set(['drop_column', 'drop_table']); + +/** + * Engine-agnostic schema introspection + DDL + * (`GET /query/v1/:dbId/schema`, `POST /query/v1/:dbId/schema/ddl`). + * + * `describe()` returns the mount's tables/collections with normalized column + * types plus the engine's live capability descriptor; `ddl()` applies ONE + * schema operation per request. Engines without an introspection/DDL surface + * (redis/http) reject with 422 `unsupported_capability`; an + * `alter_column_type` the existing data cannot satisfy rejects with 409. + */ +export class SchemaClient { + constructor(private readonly http: HttpClient) {} + + /** Describe `dbId`'s schema (tables, normalized columns, live capabilities). */ + describe(dbId: string): Promise { + return this.http.request(routes.query.schema(dbId), { method: 'GET' }); + } + + /** + * Apply ONE schema-DDL operation to `dbId`. Destructive ops (`drop_column`, + * `drop_table`) are refused **before any request is sent** unless + * `confirm: true` — the same contract the server enforces with a 400. + */ + ddl(dbId: string, input: SchemaDdlInput): Promise { + if (DESTRUCTIVE_DDL_OPS.has(input.op) && (input as { confirm?: boolean }).confirm !== true) { + throw new Error( + `ddl op '${input.op}' is destructive — set "confirm": true to proceed (request not sent)`, + ); + } + + return this.http.request(routes.query.schemaDdl(dbId), { + method: 'POST', + body: input, + }); + } +} diff --git a/apps/baas/sdk/src/index.ts b/apps/baas/sdk/src/index.ts index 877b025b..9419020f 100644 --- a/apps/baas/sdk/src/index.ts +++ b/apps/baas/sdk/src/index.ts @@ -14,6 +14,7 @@ import { AnalyticsClient } from './domains/analytics.js'; import { AuthClient } from './domains/auth.js'; import { QueryClient, ResourceQueryBuilder } from './domains/query.js'; import { RestClient, RestResourceBuilder } from './domains/rest.js'; +import { SchemaClient } from './domains/schema.js'; import { StorageClient } from './domains/storage.js'; import { TxnClient } from './domains/txn.js'; import { WebhooksClient } from './domains/webhooks.js'; @@ -84,8 +85,25 @@ export type { FunctionInvokeOptions, FunctionSource, FunctionSummary, + // ── M22: schema introspection + DDL ────────────────────────────────────── + ColumnSchema, + DdlColumnDef, + DdlColumnType, + NormalizedSchema, + NormalizedType, + SchemaDdlAddColumnInput, + SchemaDdlAlterColumnTypeInput, + SchemaDdlCreateTableInput, + SchemaDdlDropColumnInput, + SchemaDdlDropTableInput, + SchemaDdlInput, + SchemaDdlOp, + SchemaDdlResult, + SchemaEngineCapabilities, + TableSchema, } from './types.js'; +export { SchemaClient } from './domains/schema.js'; export { TxnClient } from './domains/txn.js'; export { WebhooksClient } from './domains/webhooks.js'; export { AdminClient, MigrateClient, TenantsClient } from './domains/admin.js'; @@ -120,6 +138,8 @@ export class MiniBaasClient { readonly analytics: AnalyticsClient; /** Single-mount atomic write batches (`POST /query/v1/txn`). */ readonly txn: TxnClient; + /** Engine-agnostic schema introspection + DDL (`/query/v1/:dbId/schema`). */ + readonly schema: SchemaClient; /** Edge functions (`/functions/v1`). */ readonly functions: FunctionsClient; /** @@ -160,6 +180,7 @@ export class MiniBaasClient { this.storage = new StorageClient(this.http); this.analytics = new AnalyticsClient(this.http); this.txn = new TxnClient(this.http); + this.schema = new SchemaClient(this.http); this.functions = new FunctionsClient(this.http); this.webhooks = new WebhooksClient(this.http, options.serviceRoleKey); this.admin = new AdminClient(this.http, options.serviceRoleKey); diff --git a/apps/baas/sdk/src/types.ts b/apps/baas/sdk/src/types.ts index 464449db..43ffcf9a 100644 --- a/apps/baas/sdk/src/types.ts +++ b/apps/baas/sdk/src/types.ts @@ -171,6 +171,177 @@ export interface TxnResult { results: TxnOpResult[]; } +// ── Schema introspection + DDL (/query/v1/:dbId/schema) ───────────────────── + +/** + * Engine-agnostic normalized column types returned by `schema.describe()`. + * `objectid` and `unknown` are describe-only — DDL rejects them (see + * {@link DdlColumnType}). + */ +export type NormalizedType = + | 'text' + | 'integer' + | 'float' + | 'decimal' + | 'boolean' + | 'date' + | 'datetime' + | 'json' + | 'uuid' + | 'enum' + | 'array' + | 'objectid' + | 'unknown'; + +/** Normalized types creatable via DDL (`objectid`/`unknown` are describe-only). */ +export type DdlColumnType = Exclude; + +/** One column of a described table (snake_case — the exact wire shape). */ +export interface ColumnSchema { + name: string; + /** Engine-native type, e.g. `character varying(255)` or `int unsigned`. */ + native_type: string; + normalized_type: NormalizedType; + nullable: boolean; + /** Raw engine default expression, or `null` when the column has none. */ + default: string | null; + /** Allowed values when `normalized_type` is `enum`; `null` otherwise. */ + enum_values: string[] | null; + /** Foreign-key target, or `null` when the column references nothing. */ + references: { table: string; column: string } | null; + /** `true` only for Mongo sample-based inference (no `$jsonSchema` validator). */ + inferred: boolean; +} + +/** One table/collection of a described database. */ +export interface TableSchema { + name: string; + primary_key: string[]; + columns: ColumnSchema[]; +} + +/** + * Live engine capability descriptor embedded in `schema.describe()` responses. + * Mirrors the Rust router's `/v1/capabilities` per-engine payload (the same + * source `introspectEngines()` validates against). Kept loose + * (`[key: string]: unknown`) so a new Rust flag does not break the SDK. + */ +export interface SchemaEngineCapabilities { + read: boolean; + write: boolean; + upsert: boolean; + batch?: boolean; + aggregate?: boolean; + /** Engine answers `GET /query/v1/:dbId/schema` (describe). */ + introspect?: boolean; + /** Engine answers `POST /query/v1/:dbId/schema/ddl` — distinct from `ddl`. */ + schema_ddl?: boolean; + stream: boolean; + /** Admin migration-batch surface (`/admin/v1/migrate`) — NOT `schema_ddl`. */ + ddl: boolean; + transactions: boolean; + [key: string]: unknown; +} + +/** + * Response of `client.schema.describe()` (`GET /query/v1/:dbId/schema`) — + * the mount's tables with normalized column types plus the engine's live + * capability descriptor, so one call tells a client both what the data looks + * like and what it may do with it. + */ +export interface NormalizedSchema { + dbId: string; + engine: string; + /** `null` when the capabilities fetch failed (the schema is still served). */ + capabilities: SchemaEngineCapabilities | null; + tables: TableSchema[]; +} + +/** + * One DDL column definition (snake_case — the exact wire shape). + * `nullable`/`default`/`enum_values` may be omitted: for `add_column` / + * `create_table` the server defaults them (nullable, no default); for + * `alter_column_type` an omitted attribute means "keep what the column has + * today" (the server merges with the current column). + */ +export interface DdlColumnDef { + name: string; + normalized_type: DdlColumnType; + /** Whether NULLs are allowed (defaults to `true` on create/add). */ + nullable?: boolean; + /** Raw engine default expression (`0`, `'pending'`, `now()`); `null` clears it. */ + default?: string | null; + /** Allowed values — required when `normalized_type` is `enum`. */ + enum_values?: string[] | null; +} + +/** The supported schema-DDL operations (snake_case, the wire values). */ +export type SchemaDdlOp = + | 'add_column' + | 'drop_column' + | 'alter_column_type' + | 'create_table' + | 'drop_table'; + +/** `add_column`: append `column` to `table`. */ +export interface SchemaDdlAddColumnInput { + op: 'add_column'; + table: string; + column: DdlColumnDef; +} + +/** `alter_column_type`: retype `column.name` (omitted attributes are kept). */ +export interface SchemaDdlAlterColumnTypeInput { + op: 'alter_column_type'; + table: string; + column: DdlColumnDef; +} + +/** `create_table`: create `table` with `columns` and a `primary_key`. */ +export interface SchemaDdlCreateTableInput { + op: 'create_table'; + table: string; + columns: DdlColumnDef[]; + primary_key: string[]; +} + +/** `drop_column`: destructive — requires `confirm: true` (400 otherwise). */ +export interface SchemaDdlDropColumnInput { + op: 'drop_column'; + table: string; + column_name: string; + confirm: true; +} + +/** `drop_table`: destructive — requires `confirm: true` (400 otherwise). */ +export interface SchemaDdlDropTableInput { + op: 'drop_table'; + table: string; + confirm: true; +} + +/** + * Body of `client.schema.ddl()` — ONE operation per request (deliberate: + * MySQL DDL is auto-commit, a batch would fake atomicity). The union makes + * each op's required fields — and `confirm: true` on destructive ops — a + * compile-time requirement, mirroring the server's 400 guard. + */ +export type SchemaDdlInput = + | SchemaDdlAddColumnInput + | SchemaDdlAlterColumnTypeInput + | SchemaDdlCreateTableInput + | SchemaDdlDropColumnInput + | SchemaDdlDropTableInput; + +/** Result of an applied schema-DDL operation. */ +export interface SchemaDdlResult { + op: SchemaDdlOp; + table: string; + /** `'applied'` on success (failures surface as `MiniBaasError` instead). */ + status: string; + dbId: string; +} + // ── Webhooks (admin-only, /admin/v1/webhooks) ──────────────────────────────── /** Public webhook subscription view (secrets are write-only and never echoed). */ diff --git a/apps/baas/sdk/tests/schema.test.mjs b/apps/baas/sdk/tests/schema.test.mjs new file mode 100644 index 00000000..2845925a --- /dev/null +++ b/apps/baas/sdk/tests/schema.test.mjs @@ -0,0 +1,164 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* schema.test.mjs :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/06/09 00:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/06/09 00:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ +// +// Unit tests for the M22 schema domain (`client.schema`) — run with the Node +// built-in test runner against the BUILT output (`npm run build` first): +// +// npm test # = node --test tests/ +// +// Docker-first (no host node): +// docker run --rm -v "$PWD":/work -w /work node:20-alpine \ +// sh -lc 'npm run build && npm test' +// +// The transport is mocked through the public `fetch` client option — no +// network, no servers. Compile-time contracts live in +// `src/__type_tests__/schema.test-d.ts` (npm run typecheck). + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createClient, MiniBaasError, SchemaClient } from '../dist/index.js'; +import { routes } from '../dist/core/routes.js'; + +const BASE_URL = 'https://baas.test'; +const DB_ID = '4ee63a30-0000-4000-8000-000000000000'; + +/** Recording fetch mock: captures every call, answers via `handler`. */ +function mockTransport(handler = () => ({})) { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url: String(url), init }); + const { status = 200, body = {} } = handler(String(url), init); + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + }; + return { calls, fetchImpl }; +} + +function makeClient(transport) { + return createClient({ + url: BASE_URL, + anonKey: 'anon-key', + persistSession: false, + fetch: transport.fetchImpl, + }); +} + +test('client.schema is a SchemaClient wired into the main client', () => { + const client = makeClient(mockTransport()); + assert.ok(client.schema instanceof SchemaClient); +}); + +test('schema.describe() GETs /query/v1/:dbId/schema and returns the wire payload', async () => { + const payload = { + dbId: DB_ID, + engine: 'postgresql', + capabilities: { read: true, write: true, upsert: false, introspect: true, schema_ddl: true, stream: false, ddl: true, transactions: true }, + tables: [ + { + name: 'todos', + primary_key: ['id'], + columns: [ + { name: 'id', native_type: 'uuid', normalized_type: 'uuid', nullable: false, default: 'gen_random_uuid()', enum_values: null, references: null, inferred: false }, + ], + }, + ], + }; + const transport = mockTransport(() => ({ body: payload })); + const client = makeClient(transport); + + const schema = await client.schema.describe(DB_ID); + + assert.deepEqual(schema, payload); + assert.equal(transport.calls.length, 1); + assert.equal(transport.calls[0].url, `${BASE_URL}/query/v1/${DB_ID}/schema`); + assert.equal(transport.calls[0].init.method, 'GET'); +}); + +test('schema.ddl() refuses drop_table without confirm BEFORE any network call', () => { + const transport = mockTransport(); + const client = makeClient(transport); + + assert.throws( + () => client.schema.ddl(DB_ID, { op: 'drop_table', table: 'notes' }), + /destructive.*"confirm": true/, + ); + assert.equal(transport.calls.length, 0, 'no request must be sent'); +}); + +test('schema.ddl() refuses drop_column with confirm:false BEFORE any network call', () => { + const transport = mockTransport(); + const client = makeClient(transport); + + assert.throws( + () => client.schema.ddl(DB_ID, { op: 'drop_column', table: 'todos', column_name: 'done', confirm: false }), + /destructive/, + ); + assert.equal(transport.calls.length, 0, 'no request must be sent'); +}); + +test('schema.ddl() POSTs the op to /query/v1/:dbId/schema/ddl and returns the result', async () => { + const result = { op: 'drop_table', table: 'notes', status: 'applied', dbId: DB_ID }; + const transport = mockTransport(() => ({ body: result })); + const client = makeClient(transport); + + const input = { op: 'drop_table', table: 'notes', confirm: true }; + const out = await client.schema.ddl(DB_ID, input); + + assert.deepEqual(out, result); + assert.equal(transport.calls.length, 1); + assert.equal(transport.calls[0].url, `${BASE_URL}/query/v1/${DB_ID}/schema/ddl`); + assert.equal(transport.calls[0].init.method, 'POST'); + assert.deepEqual(JSON.parse(transport.calls[0].init.body), input); +}); + +test('schema.ddl() forwards non-destructive ops without a confirm gate', async () => { + const transport = mockTransport(() => ({ body: { op: 'add_column', table: 'todos', status: 'applied', dbId: DB_ID } })); + const client = makeClient(transport); + + const input = { op: 'add_column', table: 'todos', column: { name: 'done', normalized_type: 'boolean', nullable: false, default: 'false' } }; + await client.schema.ddl(DB_ID, input); + + assert.equal(transport.calls.length, 1); + assert.deepEqual(JSON.parse(transport.calls[0].init.body), input); +}); + +test('schema errors surface as MiniBaasError with the gateway status', async () => { + const transport = mockTransport(() => ({ + status: 422, + body: { error: 'unsupported_capability', message: "engine 'redis' does not support introspect" }, + })); + const client = makeClient(transport); + + await assert.rejects( + client.schema.describe(DB_ID), + (err) => err instanceof MiniBaasError && err.status === 422, + ); +}); + +test('routes.realtime.tableChannel() composes with client.realtimeUrl()', () => { + const client = makeClient(mockTransport()); + + const topic = routes.realtime.tableChannel(DB_ID, 'todos'); + assert.equal(topic, `table:${DB_ID}:todos`); + + const url = new URL(client.realtimeUrl(topic)); + assert.equal(url.protocol, 'wss:'); + assert.equal(url.pathname, '/realtime/v1/ws'); + assert.equal(url.searchParams.get('channel'), topic); +}); + +test('routes.query.schema()/schemaDdl() build the documented paths', () => { + assert.equal(routes.query.schema(DB_ID), `/query/v1/${DB_ID}/schema`); + assert.equal(routes.query.schemaDdl(DB_ID), `/query/v1/${DB_ID}/schema/ddl`); +}); diff --git a/apps/calendar b/apps/calendar index 1f55db53..eee3131c 160000 --- a/apps/calendar +++ b/apps/calendar @@ -1 +1 @@ -Subproject commit 1f55db53ccc1f02b4f583f3eb6abee9d8b197548 +Subproject commit eee3131cb51c09c339934e7df51b47c5183f21a5 diff --git a/apps/opposite-osiris b/apps/opposite-osiris index 0339ec15..c81dac57 160000 --- a/apps/opposite-osiris +++ b/apps/opposite-osiris @@ -1 +1 @@ -Subproject commit 0339ec1568f5d2ed029dec963e6a5386cbb741bb +Subproject commit c81dac57c1ad5e0dd616259906bb5e435ab83bbe diff --git a/apps/osionos-electron/build-native.sh b/apps/osionos-electron/build-native.sh index f9111929..83241247 100755 --- a/apps/osionos-electron/build-native.sh +++ b/apps/osionos-electron/build-native.sh @@ -15,6 +15,7 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"; cd "$REPO" EL=apps/osionos-electron; RT="$EL/native-runtime"; CACHE="$EL/.native-cache" GW_IMAGE="${AUTH_GATEWAY_IMAGE:-dlesieur/prismatica-auth-gateway:latest}" +GOTRUE_IMAGE="${BAAS_GOTRUE_IMAGE:-public.ecr.aws/supabase/gotrue:v2.188.1}" PG_VER="${PG_VER:-16.4.0}"; PGRST_VER="${PGRST_VER:-v12.2.3}" MODE="${1:-}" @@ -46,6 +47,11 @@ if [ ! -f "$CACHE/postgrest-$PGRST_VER" ]; then tar -xJf "$CACHE/pgrst.tar.xz" -C "$CACHE"; mv "$CACHE/postgrest" "$CACHE/postgrest-$PGRST_VER"; rm -f "$CACHE/pgrst.tar.xz" fi cp "$CACHE/postgrest-$PGRST_VER" "$RT/bin/postgrest"; chmod +x "$RT/bin/postgrest" +echo " + gotrue (static Go binary) + its 69 migrations from $GOTRUE_IMAGE" +gid="$(docker create "$GOTRUE_IMAGE")" +docker cp "$gid:/usr/local/bin/auth" "$RT/bin/gotrue" >/dev/null; chmod +x "$RT/bin/gotrue" +mkdir -p "$RT/gotrue-migrations"; docker cp "$gid:/usr/local/etc/auth/migrations/." "$RT/gotrue-migrations/" >/dev/null +docker rm -f "$gid" >/dev/null echo "[5/5] bundle the pure-JS pg client (zonky ships no psql)" docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e npm_config_cache=/tmp/.npm \ diff --git a/apps/osionos-electron/main.js b/apps/osionos-electron/main.js index b0fdca9f..033cde9a 100644 --- a/apps/osionos-electron/main.js +++ b/apps/osionos-electron/main.js @@ -6,7 +6,7 @@ // the background, and provides a frameless window driven by the shared custom // titlebar (see chrome/titlebar.html, injected into renderer/index.html). // =========================================================================== -const { app, BrowserWindow, ipcMain, shell, protocol, net } = require("electron"); +const { app, BrowserWindow, ipcMain, shell, protocol, net, Menu, globalShortcut } = require("electron"); const path = require("node:path"); const os = require("node:os"); const { spawn } = require("node:child_process"); @@ -103,6 +103,7 @@ async function startNative() { node: process.execPath, nodeEnv: { ELECTRON_RUN_AS_NODE: "1" }, initdb: path.join(pgBin, "initdb"), postgres: path.join(pgBin, "postgres"), postgrest: path.join(NATIVE_DIR, "bin", "postgrest"), + gotrue: path.join(NATIVE_DIR, "bin", "gotrue"), gotrueMigrations: path.join(NATIVE_DIR, "gotrue-migrations"), gatewayDir: path.join(NATIVE_DIR, "gateway"), gatewayScript: path.join(NATIVE_DIR, "gateway", "scripts", "auth-gateway.mjs"), bridgeScript: path.join(NATIVE_DIR, "bridge", "bridge-api.mjs"), }; @@ -144,6 +145,24 @@ function createWindow() { return { action: "allow" }; }); + // Keep the main window ALWAYS on osionos. The Mail/Calendar buttons navigate via + // location.href to an external URL; in the desktop app that would replace osionos + // (losing the titlebar + stranding you on a blank page). Cancel any navigation that + // leaves app://osionos and open it in the system browser instead — so you can never + // get stuck and never lose the window controls. + win.webContents.on("will-navigate", (event, url) => { + if (!url.startsWith("app://osionos")) { + event.preventDefault(); + if (/^https?:/i.test(url)) shell.openExternal(url); + } + }); + + // Black-screen recovery: the titlebar + UI live in the renderer, so a crashed or + // hung renderer leaves you with no window controls. Reload back to the app home + // automatically; Ctrl/Cmd+Shift+R (global, below) is the manual escape hatch. + win.webContents.on("render-process-gone", () => { try { win.loadURL("app://osionos/index.html"); } catch { /* */ } }); + win.webContents.on("unresponsive", () => { try { win.webContents.reload(); } catch { /* */ } }); + ipcMain.on("win:minimize", () => win.minimize()); ipcMain.on("win:toggle-maximize", () => (win.isMaximized() ? win.unmaximize() : win.maximize())); ipcMain.on("win:close", () => win.close()); @@ -153,7 +172,11 @@ function createWindow() { const clamp = (z) => Math.max(-3, Math.min(6, z)); // Keyboard: Ctrl/Cmd + / - / 0 wc.on("before-input-event", (event, input) => { - if (input.type !== "keyDown" || !(input.control || input.meta)) return; + if (input.type !== "keyDown") return; + // Reload / back-to-home recovery (F5, Ctrl/Cmd+R, Ctrl/Cmd+Shift+H). + if (input.key === "F5" || ((input.control || input.meta) && /^r$/i.test(input.key))) { wc.reload(); event.preventDefault(); return; } + if ((input.control || input.meta) && input.shift && /^h$/i.test(input.key)) { wc.loadURL("app://osionos/index.html"); event.preventDefault(); return; } + if (!(input.control || input.meta)) return; const zoomIn = input.key === "=" || input.key === "+" || input.code === "Equal" || input.code === "NumpadAdd"; const zoomOut = input.key === "-" || input.key === "_" || input.code === "Minus" || input.code === "NumpadSubtract"; if (zoomIn) { wc.setZoomLevel(clamp(wc.getZoomLevel() + 0.5)); event.preventDefault(); } @@ -168,6 +191,9 @@ function createWindow() { } app.whenReady().then(async () => { + // No native application menu: the app uses its own custom titlebar, and the GTK + // global-menu integration otherwise spams LIBDBUSMENU-GLIB-WARNING on Linux. + Menu.setApplicationMenu(null); const rendererDir = path.join(__dirname, "renderer"); protocol.handle("app", async (request) => { const url = new URL(request.url); @@ -213,13 +239,22 @@ app.whenReady().then(async () => { bootSuite(); } createWindow(); + // OS-level recovery that works even if the renderer is completely dead (a black + // screen can't receive in-page keystrokes): Ctrl/Cmd+Shift+R reloads to the app home. + globalShortcut.register("CommandOrControl+Shift+R", () => { + const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; + if (w) w.loadURL("app://osionos/index.html"); + }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); // Tear down the bundled native backend (postgres/postgrest/gateway/bridge) on exit. -app.on("before-quit", () => { if (nativeHandle) { try { nativeHandle.stop(); } catch { /* */ } nativeHandle = null; } }); +app.on("before-quit", () => { + globalShortcut.unregisterAll(); + if (nativeHandle) { try { nativeHandle.stop(); } catch { /* */ } nativeHandle = null; } +}); app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); diff --git a/apps/osionos-electron/native-migrate.sh b/apps/osionos-electron/native-migrate.sh new file mode 100644 index 00000000..13c84764 --- /dev/null +++ b/apps/osionos-electron/native-migrate.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# =========================================================================== +# Migrate your existing osionos account + workspaces/pages from the running +# Docker stack's Postgres into the NATIVE edition's one-time import slot. +# +# 1. bash apps/osionos-electron/native-migrate.sh (Docker stack's postgres comes up) +# 2. launch the native app — it imports on first launch (after gotrue) — then log in. +# +# Dumps gotrue's auth.users/identities (your password) + public.users + your +# osionos workspaces/pages as data-only INSERTs. The native firstrun loads them +# with FK triggers relaxed, then marks the file done. +# =========================================================================== +set -euo pipefail +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"; cd "$REPO" +DATA_DIR="${OSIONOS_NATIVE_DATA:-$HOME/.config/osionos/native}" +mkdir -p "$DATA_DIR" +OUT="$DATA_DIR/import.sql" + +echo "==> ensuring the Docker stack's postgres is up…" +docker compose up -d postgres >/dev/null 2>&1 +docker compose exec -T postgres sh -c 'for i in $(seq 1 30); do pg_isready -U postgres -q && exit 0; sleep 0.5; done' >/dev/null 2>&1 + +echo "==> dumping account + data -> $OUT" +docker compose exec -T postgres pg_dump -U postgres -d postgres \ + --data-only --no-owner --column-inserts \ + -t 'auth.users' -t 'auth.identities' \ + -t 'public.users' \ + -t 'public.osionos_workspaces' -t 'public.osionos_workspace_members' \ + -t 'public.osionos_pages' -t 'public.osionos_page_configurations' \ + -t 'public.osionos_bridge_identities' \ + > "$OUT" + +echo "==> wrote $(wc -l < "$OUT") lines ($(du -h "$OUT" | cut -f1))." +echo " accounts: $(grep -c 'INSERT INTO auth.users' "$OUT" 2>/dev/null || echo '?'); pages: $(grep -c 'INSERT INTO public.osionos_pages' "$OUT" 2>/dev/null || echo '?')" +echo "Next: launch the native app (it imports on first launch), then log in with your account." diff --git a/apps/osionos-electron/native/firstrun.mjs b/apps/osionos-electron/native/firstrun.mjs index a7314aba..47bc87ee 100644 --- a/apps/osionos-electron/native/firstrun.mjs +++ b/apps/osionos-electron/native/firstrun.mjs @@ -12,20 +12,24 @@ // =========================================================================== import pg from "pg"; import { randomBytes, createHmac } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const HERE = dirname(fileURLToPath(import.meta.url)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -// PoC-validated order: user.sql before auth-security (FK to public.users); -// rls-hardening last (self-guarding for absent gdpr fns). +// Authoritative production order (apps/baas/scripts/apply-project-sql.sh): +// user -> gdpr -> auth-security -> osionos-bridge -> folder-surface -> rls-hardening. +// gdpr adds users.deletion_requested_at/deleted_at (a migrated dump needs them); +// rls-hardening last (it grants on the gdpr functions). Calendar/mail are skipped +// (separate apps). bootstrap.sql (roles) runs before all of these. const MIGRATIONS = [ - "osionos-bridge-migration.sql", - "osionos-folder-surface-migration.sql", "user.sql", + "gdpr-migration.sql", "auth-security-migration.sql", + "osionos-bridge-migration.sql", + "osionos-folder-surface-migration.sql", "rls-hardening-migration.sql", ]; @@ -65,8 +69,9 @@ async function connectWithRetry(cfg, tries = 90, delayMs = 500) { throw new Error(`postgres did not become ready: ${lastErr?.message || "unknown"}`); } -// Apply bootstrap + migrations (idempotent) + (re)assert the authenticator -// password; return the local secrets the supervisor wires into the services. +// Apply bootstrap + osionos migrations (idempotent) + (re)assert the authenticator +// password; return the local secrets. NOTE: gotrue's auth.users is created later +// (when gotrue starts), so the data import runs separately, after gotrue — see importDump. export async function firstRun(cfg) { const secrets = ensureSecrets(cfg.dataDir); const client = await connectWithRetry(cfg); @@ -84,6 +89,32 @@ export async function firstRun(cfg) { } } +// One-time data import, run AFTER gotrue (which owns auth.users). If native-migrate.sh +// placed a dump at /import.sql, load it with FK triggers relaxed (order- +// independent), then mark it done so it only runs once. Superuser → bypasses RLS. +export async function importDump(cfg) { + const file = join(cfg.dataDir, "import.sql"); + if (!existsSync(file)) return false; + const client = await connectWithRetry(cfg); + try { + // Strip psql-only meta-commands (pg_dump 16.4 emits \restrict/\unrestrict) — the + // pure-JS pg client speaks SQL, not psql backslash commands. + const sql = readFileSync(file, "utf8").split("\n").filter((l) => !/^\\/.test(l)).join("\n"); + await client.query("SET session_replication_role = replica"); + try { + await client.query(sql); + // Some source rows carry an empty aud/role (older gotrue); this edition's gotrue + // looks users up by aud='authenticated', so normalize imported accounts to match. + await client.query("UPDATE auth.users SET aud = 'authenticated' WHERE aud IS NULL OR aud = ''"); + await client.query("UPDATE auth.users SET role = 'authenticated' WHERE role IS NULL OR role = ''"); + } finally { await client.query("SET session_replication_role = DEFAULT"); } + renameSync(file, join(cfg.dataDir, `import-${Date.now()}.done.sql`)); + return true; + } finally { + await client.end(); + } +} + // CLI entrypoint (standalone testing). if (import.meta.url === `file://${process.argv[1]}`) { const cfg = { diff --git a/apps/osionos-electron/native/restProxy.mjs b/apps/osionos-electron/native/restProxy.mjs index 2714cc29..9f90b295 100644 --- a/apps/osionos-electron/native/restProxy.mjs +++ b/apps/osionos-electron/native/restProxy.mjs @@ -1,26 +1,27 @@ // =========================================================================== -// osionos NATIVE edition — tiny /rest/v1 → PostgREST rewrite shim. +// osionos NATIVE edition — loopback prefix-routing shim (replaces Kong). // -// The (unchanged) bridge calls `${OSIONOS_BAAS_URL}/rest/v1/
`; PostgREST -// serves tables at the root (`/
`). Kong does that rewrite in the Docker -// stack; the native edition drops Kong, so this ~loopback shim does it instead — -// strip the `/rest/v1` prefix and forward method/headers/body to PostgREST. -// Loopback only; no auth logic (PostgREST validates the JWT the bridge forwards). +// The gateway's @mini-baas SDK and the bridge call a single base URL with Kong's +// path conventions: `/rest/v1/*` (data → PostgREST) and `/auth/v1/*` (auth → +// gotrue). Kong does that routing in the Docker stack; the native edition drops +// Kong, so this ~loopback shim strips the prefix and forwards to the right local +// service. Loopback only; the upstreams validate the JWT the caller forwards. // // Pure Node http — no deps. // =========================================================================== import http from "node:http"; -const PREFIX = "/rest/v1"; - -export function startRestProxy({ listenPort, postgrestUrl }) { - const target = new URL(postgrestUrl); +// routes: [{ prefix: "/rest/v1", target: "http://127.0.0.1:33001" }, ...] +export function startRestProxy({ listenPort, routes }) { + const compiled = routes.map((r) => ({ prefix: r.prefix, target: new URL(r.target) })); const server = http.createServer((req, res) => { - // Strip the prefix; anything outside it is a 404 (nothing else is proxied). - const path = req.url.startsWith(PREFIX) ? req.url.slice(PREFIX.length) || "/" : null; - if (path === null) { res.writeHead(404).end(); return; } + const url = req.url || "/"; + const route = compiled.find((r) => url === r.prefix || url.startsWith(`${r.prefix}/`) || url.startsWith(`${r.prefix}?`)); + if (!route) { res.writeHead(404).end('{"error":"no route"}'); return; } + const path = url.slice(route.prefix.length) || "/"; + const t = route.target; const upstream = http.request( - { hostname: target.hostname, port: target.port, path, method: req.method, headers: { ...req.headers, host: target.host } }, + { hostname: t.hostname, port: t.port, path, method: req.method, headers: { ...req.headers, host: t.host } }, (up) => { res.writeHead(up.statusCode || 502, up.headers); up.pipe(res); }, ); upstream.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end('{"error":"rest proxy upstream failed"}'); }); @@ -31,6 +32,8 @@ export function startRestProxy({ listenPort, postgrestUrl }) { if (import.meta.url === `file://${process.argv[1]}`) { const port = Number(process.env.REST_PROXY_PORT || 4010); - startRestProxy({ listenPort: port, postgrestUrl: process.env.POSTGREST_URL || "http://127.0.0.1:3000" }) - .then(() => console.log(`[rest-proxy] :${port}${PREFIX}/* -> ${process.env.POSTGREST_URL || "http://127.0.0.1:3000"}`)); + startRestProxy({ listenPort: port, routes: [ + { prefix: "/rest/v1", target: process.env.POSTGREST_URL || "http://127.0.0.1:33001" }, + { prefix: "/auth/v1", target: process.env.GOTRUE_URL || "http://127.0.0.1:9999" }, + ] }).then(() => console.log(`[rest-proxy] :${port} /rest/v1->postgrest /auth/v1->gotrue`)); } diff --git a/apps/osionos-electron/native/supervisor-run.mjs b/apps/osionos-electron/native/supervisor-run.mjs index a4f64a41..b6037f34 100644 --- a/apps/osionos-electron/native/supervisor-run.mjs +++ b/apps/osionos-electron/native/supervisor-run.mjs @@ -18,6 +18,7 @@ const bin = { node: process.execPath, initdb: `${PGBIN}/initdb`, postgres: `${PGBIN}/postgres`, postgrest: process.env.POSTGREST_BIN || `${RT}/bin/postgrest`, + gotrue: `${RT}/bin/gotrue`, gotrueMigrations: `${RT}/gotrue-migrations`, gatewayDir: join(RT, "gateway"), gatewayScript: join(RT, "gateway/scripts/auth-gateway.mjs"), bridgeScript: join(RT, "bridge/bridge-api.mjs"), }; @@ -37,6 +38,23 @@ try { const g = await fetch(`http://127.0.0.1:${DEFAULT_PORTS.gateway}/`).catch((e) => ({ status: "ERR " + e })); ok("auth-gateway listening", typeof g.status === "number" && g.status < 500, String(g.status)); + // Full auth round-trip: register a fresh user via bridge -> gateway -> gotrue, then log in. + const email = `poc${Date.now()}@gmail.com`, password = "PocPass123!"; // gmail.com has MX (gateway checks deliverability) + const reg = await fetch(`${bridgeUrl}/api/auth/register`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, username: "poctest" }) }).catch((e) => ({ status: "ERR", text: async () => String(e) })); + const regBody = typeof reg.text === "function" ? await reg.text() : ""; + ok("register (bridge->gateway->gotrue)", reg.status === 200, `${reg.status} ${regBody.slice(0, 90)}`); + const login = await fetch(`${bridgeUrl}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password }) }).catch((e) => ({ status: "ERR", text: async () => String(e) })); + const loginBody = typeof login.text === "function" ? await login.text() : ""; + ok("login returns a session", login.status === 200 && /accessToken|session|user/.test(loginBody), `${login.status} ${loginBody.slice(0, 90)}`); + + // Imported-account login (Phase B): after a migration dump was imported, the real + // account must log in. Set TEST_LOGIN_EMAIL / TEST_LOGIN_PASSWORD to check it. + if (process.env.TEST_LOGIN_EMAIL) { + const le = await fetch(`${bridgeUrl}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: process.env.TEST_LOGIN_EMAIL, password: process.env.TEST_LOGIN_PASSWORD }) }).catch((e) => ({ status: "ERR", text: async () => String(e) })); + const lb = typeof le.text === "function" ? await le.text() : ""; + ok(`imported account login (${process.env.TEST_LOGIN_EMAIL})`, le.status === 200 && /accessToken|session|user/.test(lb), `${le.status} ${lb.slice(0, 110)}`); + } + stop(); console.log("[run] ✅ DONE — full native stack booted with NO docker-compose, data path served."); setTimeout(() => process.exit(0), 500); diff --git a/apps/osionos-electron/native/supervisor.mjs b/apps/osionos-electron/native/supervisor.mjs index 35836c75..258122c1 100644 --- a/apps/osionos-electron/native/supervisor.mjs +++ b/apps/osionos-electron/native/supervisor.mjs @@ -15,7 +15,7 @@ import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; -import { firstRun } from "./firstrun.mjs"; +import { firstRun, importDump } from "./firstrun.mjs"; import { startRestProxy } from "./restProxy.mjs"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -66,6 +66,27 @@ export async function startSuite(opts) { // `pg` client == the postgres readiness gate (zonky ships no pg_isready/psql). const { secrets } = await firstRun({ host: "127.0.0.1", port: ports.pg, db: "postgres", superUser: "postgres", superPass, migrationsDir, dataDir }); + // 2b. gotrue (static Go binary) — the password authority for real accounts. + // Runs its own migrations into the auth schema; shares the JWT secret with + // postgrest + the gateway so tokens validate everywhere. Autoconfirm = no SMTP. + const gotrue = spawn(bin.gotrue, [], { stdio: "ignore", env: { ...process.env, + GOTRUE_API_HOST: "127.0.0.1", GOTRUE_API_PORT: String(ports.gotrue), + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_DB_DATABASE_URL: `postgres://postgres:${superPass}@127.0.0.1:${ports.pg}/postgres?search_path=auth`, + GOTRUE_DB_MIGRATIONS_PATH: bin.gotrueMigrations, + GOTRUE_JWT_SECRET: secrets.jwtSecret, GOTRUE_JWT_AUD: "authenticated", + GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", GOTRUE_JWT_EXP: "3600", + GOTRUE_SITE_URL: appUrl, API_EXTERNAL_URL: `http://127.0.0.1:${ports.restProxy}/auth/v1`, + GOTRUE_MAILER_AUTOCONFIRM: "true", GOTRUE_EXTERNAL_EMAIL_ENABLED: "true", + GOTRUE_DISABLE_SIGNUP: "false", GOTRUE_LOG_LEVEL: "warn" } }); + children.push({ name: "gotrue", child: gotrue }); + await waitUntil("gotrue", () => httpOk(`http://127.0.0.1:${ports.gotrue}/health`), { tries: 90 }); + + // 2c. One-time account/data import (now that gotrue owns auth.users). + if (await importDump({ host: "127.0.0.1", port: ports.pg, db: "postgres", superUser: "postgres", superPass, dataDir })) { + console.log("[supervisor] imported account + data dump"); + } + // 3. PostgREST (connects as authenticator; JWT-gated) — proven path const rest = spawn(bin.postgrest, [], { stdio: "ignore", env: { ...process.env, PGRST_DB_URI: `postgres://authenticator:${secrets.authenticatorPassword}@127.0.0.1:${ports.pg}/postgres`, @@ -74,8 +95,12 @@ export async function startSuite(opts) { children.push({ name: "postgrest", child: rest }); await waitUntil("postgrest", () => httpOk(`http://127.0.0.1:${ports.postgrest}/osionos_workspaces`, { headers: { Authorization: "Bearer probe" } })); - // 4. /rest/v1 rewrite shim in front of PostgREST (keeps the bridge unchanged) - const proxy = await startRestProxy({ listenPort: ports.restProxy, postgrestUrl: `http://127.0.0.1:${ports.postgrest}` }); + // 4. Kong-style routing shim: /rest/v1->postgrest, /auth/v1->gotrue (the gateway + // + bridge speak to one base URL with Kong's path conventions). + const proxy = await startRestProxy({ listenPort: ports.restProxy, routes: [ + { prefix: "/rest/v1", target: `http://127.0.0.1:${ports.postgrest}` }, + { prefix: "/auth/v1", target: `http://127.0.0.1:${ports.gotrue}` }, + ] }); children.push({ name: "rest-proxy", child: { kill: () => proxy.close() } }); // 5. auth-gateway — a Node script (extracted from the prismatica image: @@ -114,4 +139,4 @@ export async function startSuite(opts) { } } -export const DEFAULT_PORTS = { pg: 54329, postgrest: 33001, restProxy: 4010, gateway: 8788, bridge: 4000 }; +export const DEFAULT_PORTS = { pg: 54329, gotrue: 9998, postgrest: 33001, restProxy: 4010, gateway: 8788, bridge: 4000 }; diff --git a/apps/osionos/app b/apps/osionos/app index 53d1649c..e1064969 160000 --- a/apps/osionos/app +++ b/apps/osionos/app @@ -1 +1 @@ -Subproject commit 53d1649cc1311f36c4d538ddb63f81ff96e124ce +Subproject commit e1064969d42bc82d0c3cc3bebacef982c681af59 diff --git a/docker-bake.hcl b/docker-bake.hcl index e69de29b..53c6e936 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -0,0 +1,92 @@ +group "default" { + targets = [ + "postgres", + "kong", + "osionos-app", + "mail", + "calendar", + "opposite-osiris-node", + ] +} + +group "secrets" { + targets = ["vault"] +} + +group "testing" { + targets = [ + "postgres", + "kong", + "osionos-app", + "mail", + "calendar", + "opposite-osiris-node", + "playground-simulation", + ] +} + +group "playground" { + targets = ["playground-simulation"] +} + +target "vault" { + context = "./apps/baas/mini-baas-infra/docker/services/vault" + dockerfile = "Dockerfile" + tags = [ + "track-binocle-vault:local", + ] +} + +target "postgres" { + context = "./apps/baas/mini-baas-infra/docker/services/postgres" + dockerfile = "Dockerfile" + tags = ["track-binocle-postgres:local"] +} + +target "kong" { + context = "./apps/baas" + dockerfile = "Dockerfile" + tags = ["track-binocle/mini-baas-kong:local"] +} + +target "osionos-app" { + context = "./apps/osionos/app" + dockerfile = "docker/services/node/Dockerfile" + tags = ["track-binocle/osionos-app:local"] +} + +target "mail" { + context = "./apps/mail" + dockerfile = "Dockerfile" + target = "dev" + tags = [ + "track-binocle/mail:local", + "track-binocle/mail-bridge:local", + ] +} + +target "calendar" { + context = "./apps/calendar" + dockerfile = "Dockerfile" + target = "dev" + tags = [ + "track-binocle/calendar:local", + "track-binocle/calendar-bridge:local", + ] +} + +target "opposite-osiris-node" { + context = "." + dockerfile = "apps/opposite-osiris/docker/services/node/Dockerfile" + tags = [ + "track-binocle/opposite-osiris-deps:local", + "track-binocle/auth-gateway:local", + "track-binocle/opposite-osiris:local", + ] +} + +target "playground-simulation" { + context = "./apps/osionos/app" + dockerfile = "docker/services/browser-tests/Dockerfile" + tags = ["track-binocle/playground-simulation:local"] +} \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml index b15f6b4e..07c56852 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -21,6 +21,12 @@ services: # The desktop renderer runs from the app:// origin. OSIONOS_APP_URL: app://osionos OSIONOS_ALLOWED_ORIGIN: app://osionos + # The lean local edition runs WITHOUT the mini-baas stack: detach from the + # external mini-baas network (chat/feed realtime publishes + the perms + # proxy degrade gracefully — chat/profile/feed persistence only needs the + # local kong). + networks: !override + - default # Park the TLS proxy on an inactive profile: not started under `--profile local` # (the bridge is reached over plain HTTP instead). pg-meta stays because kong diff --git a/docker-compose.yml b/docker-compose.yml index 1f03be2d..096bf288 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ services: volumes: - track-binocle-vault-data:/vault/data restart: unless-stopped + mem_limit: 512m healthcheck: test: ["CMD-SHELL", "vault status -address=http://127.0.0.1:8200 2>/dev/null | grep -q 'Sealed.*false' || exit 1"] interval: 5s @@ -111,6 +112,7 @@ services: start_period: 5s retries: 6 restart: unless-stopped + mem_limit: 128m postgres: image: ${BAAS_POSTGRES_IMAGE:-track-binocle-postgres:local} @@ -127,6 +129,7 @@ services: volumes: - track-binocle-postgres-data:/var/lib/postgresql/data restart: unless-stopped + mem_limit: 512m healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-postgres} -d $${POSTGRES_DB:-postgres}"] interval: 5s @@ -172,6 +175,7 @@ services: - ./models/calendar-migration.sql:/project-init/05-calendar.sql:ro - ./models/seeds.sql:/project-init/06-seeds.sql:ro - ./models/rls-hardening-migration.sql:/project-init/07-rls-hardening.sql:ro + - ./models/osionos-chat-migration.sql:/project-init/08-osionos-chat.sql:ro depends_on: db-bootstrap: condition: service_completed_successfully @@ -183,6 +187,7 @@ services: volumes: - track-binocle-redis-data:/data restart: unless-stopped + mem_limit: 128m mailpit: image: ${MAILPIT_IMAGE:-axllent/mailpit:v1.22.3} @@ -195,6 +200,7 @@ services: ports: - "${TRACK_BINOCLE_BIND_ADDR:-127.0.0.1}:${MAILPIT_HOST_PORT:-8025}:8025" restart: unless-stopped + mem_limit: 256m gotrue: image: ${BAAS_GOTRUE_IMAGE:-public.ecr.aws/supabase/gotrue:v2.188.1} @@ -206,6 +212,7 @@ services: environment: GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_PORT: 9999 + GOTRUE_LOG_LEVEL: ${GOTRUE_LOG_LEVEL:-info} API_EXTERNAL_URL: ${API_EXTERNAL_URL:-https://localhost:8000/auth/v1} GOTRUE_DB_DRIVER: postgres GOTRUE_DB_MIGRATIONS_PATH: ${GOTRUE_DB_MIGRATIONS_PATH:-/usr/local/etc/auth/migrations} @@ -231,6 +238,7 @@ services: db-bootstrap: condition: service_completed_successfully restart: unless-stopped + mem_limit: 256m healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:9999/health || exit 1"] interval: 5s @@ -254,6 +262,7 @@ services: project-db-init: condition: service_completed_successfully restart: unless-stopped + mem_limit: 256m pg-meta: image: ${BAAS_PG_META_IMAGE:-public.ecr.aws/supabase/postgres-meta:v0.91.0} @@ -268,6 +277,7 @@ services: project-db-init: condition: service_completed_successfully restart: unless-stopped + mem_limit: 256m supavisor: profiles: @@ -280,6 +290,7 @@ services: postgres: condition: service_healthy restart: unless-stopped + mem_limit: 512m kong: image: ${BAAS_KONG_IMAGE:-track-binocle/mini-baas-kong:local} @@ -340,6 +351,7 @@ services: pg-meta: condition: service_started restart: unless-stopped + mem_limit: 1g local-runtime-secrets: image: public.ecr.aws/docker/library/node:22-alpine @@ -367,15 +379,44 @@ services: OSIONOS_BRIDGE_PORT: 4000 OSIONOS_APP_URL: ${OSIONOS_APP_URL:-https://localhost:3001} OSIONOS_ALLOWED_ORIGIN: ${OSIONOS_ALLOWED_ORIGIN:-https://localhost:3001} - OSIONOS_BAAS_URL: http://kong:8000 + # NOTE: this service sits on BOTH the default and mini-baas networks and + # both stacks expose a "kong" DNS alias — use the unambiguous root + # container name here so BaaS reads/writes always hit track-binocle. + OSIONOS_BAAS_URL: http://track-binocle-kong-1:8000 AUTH_GATEWAY_URL: ${AUTH_GATEWAY_URL:-http://auth-gateway:8787} OSIONOS_BRIDGE_PERSISTENCE: ${OSIONOS_BRIDGE_PERSISTENCE:-auto} OSIONOS_BRIDGE_REQUIRE_BAAS: ${OSIONOS_BRIDGE_REQUIRE_BAAS:-false} CLAUDE_BIN: ${CLAUDE_BIN:-claude} + # LiveKit video tokens (scripts/bridge-rtc.mjs): same key pair as the + # livekit service below; LIVEKIT_URL is the in-network admin/twirp base, + # LIVEKIT_CLIENT_URL is what browsers get back to connect the room. + LIVEKIT_URL: ${LIVEKIT_URL:-http://livekit:7880} + LIVEKIT_CLIENT_URL: ${LIVEKIT_CLIENT_URL:-ws://127.0.0.1:7880} + LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-devkey} + LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-devsecret-livekit-local-0123456789abcdef} + # Permission UX proxy (scripts/bridge-perms.mjs) → mini-baas Kong over + # the external mini-baas network (container-name DNS; the bare "kong" + # alias is ambiguous from here because this service sits on BOTH + # networks). PERMS_SERVICE_APIKEY / PERMS_SERVICE_TOKEN come from + # ./.env.local via env_file (secrets stay out of compose). + PERMS_KONG_URL: ${PERMS_KONG_URL:-http://mini-baas-kong:8000} + PERMS_TENANT_ID: ${PERMS_TENANT_ID:-agency} + PERMS_PEOPLE_ENV: /seeds/.agency-people.env + PERMS_RULES_FILE: /tmp/perms-rules.json + # Chat/feed realtime publishes (scripts/bridge-chat.mjs, bridge-feed.mjs) + # go server-side to the Rust realtime gateway: POST /v1/publish. + REALTIME_PUBLISH_URL: ${REALTIME_PUBLISH_URL:-http://mini-baas-realtime:4000} + # Agency org workspace: scopes /api/people + default feed topic. + OSIONOS_ORG_WORKSPACE_ID: ${OSIONOS_ORG_WORKSPACE_ID:-b1a0c1e5-0000-4000-a000-000000000001} HOME: ${OSIONOS_BRIDGE_HOME:-/tmp} PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin expose: - "4000" + volumes: + - ./tools/seeds/.agency-people.env:/seeds/.agency-people.env:ro + networks: + - default + - mini-baas depends_on: local-runtime-secrets: condition: service_completed_successfully @@ -390,6 +431,31 @@ services: start_period: 45s retries: 24 restart: unless-stopped + mem_limit: 512m + + # livekit — self-hosted WebRTC SFU for osionos video rooms. Signalling/HTTP + # (and the twirp admin API) on 7880, TCP ICE fallback on 7881, media over a + # modest UDP range 50000-50060 published 1:1 (config advertises 127.0.0.1). + # API keys flow from LIVEKIT_API_KEY/LIVEKIT_API_SECRET (dev defaults below, + # registered in .env.example like the other optional secrets); the + # osionos-bridge mints HS256 access tokens for the SAME pair via + # apps/osionos/app/scripts/bridge-rtc.mjs (POST /api/rtc/token). + livekit: + image: ${LIVEKIT_IMAGE:-livekit/livekit-server:v1.11.0} + profiles: + - dev + - local + command: --config /etc/livekit.yaml + environment: + LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-devsecret-livekit-local-0123456789abcdef}" + volumes: + - ./infrastructure/livekit/livekit.yaml:/etc/livekit.yaml:ro + ports: + - "${TRACK_BINOCLE_BIND_ADDR:-127.0.0.1}:${LIVEKIT_HOST_PORT:-7880}:7880" + - "${TRACK_BINOCLE_BIND_ADDR:-127.0.0.1}:${LIVEKIT_TCP_HOST_PORT:-7881}:7881" + - "${TRACK_BINOCLE_BIND_ADDR:-127.0.0.1}:50000-50060:50000-50060/udp" + restart: unless-stopped + mem_limit: 512m # osionos-app — the block editor as a standalone STATIC image (Docker Hub # dlesieur/osionos-app), built FROM the submodule without modifying it. VITE_* @@ -410,6 +476,22 @@ services: VITE_REQUIRE_BRIDGE_SESSION: "true" VITE_ALLOW_OFFLINE_MODE: "false" VITE_PAGE_ACTION_SYNC_ENABLED: "true" + # mini-baas live-database + graph wiring (Vite inlines at build). + # Empty defaults keep those surfaces dormant; `make seed-live-demo` + # at the repo root rebuilds this image with the values from the app + # .env (see infrastructure/makes/baas.mk). + VITE_BAAS_URL: ${VITE_BAAS_URL:-} + VITE_BAAS_API_KEY: ${VITE_BAAS_API_KEY:-} + VITE_BAAS_KONG_KEY: ${VITE_BAAS_KONG_KEY:-} + VITE_BAAS_LIVE_MOUNTS: ${VITE_BAAS_LIVE_MOUNTS:-} + VITE_BAAS_REALTIME_TOKEN: ${VITE_BAAS_REALTIME_TOKEN:-} + VITE_BAAS_TENANT_ID: ${VITE_BAAS_TENANT_ID:-} + VITE_BAAS_EDGES_DB_ID: ${VITE_BAAS_EDGES_DB_ID:-} + VITE_BAAS_EDGES_TABLE: ${VITE_BAAS_EDGES_TABLE:-} + VITE_BAAS_GRAPH_RESOURCES: ${VITE_BAAS_GRAPH_RESOURCES:-} + VITE_BAAS_GRAPH_GENERATORS: ${VITE_BAAS_GRAPH_GENERATORS:-} + VITE_BAAS_NOTES_TABLE: ${VITE_BAAS_NOTES_TABLE:-} + VITE_BAAS_OVERLAY_TABLE: ${VITE_BAAS_OVERLAY_TABLE:-} expose: - "80" depends_on: @@ -422,6 +504,7 @@ services: start_period: 15s retries: 20 restart: unless-stopped + mem_limit: 128m mail-bridge: image: ${MAIL_BRIDGE_IMAGE:-track-binocle/mail-bridge:local} @@ -458,6 +541,7 @@ services: start_period: 20s retries: 12 restart: unless-stopped + mem_limit: 512m mail: image: ${MAIL_IMAGE:-track-binocle/mail:local} @@ -492,6 +576,7 @@ services: start_period: 30s retries: 20 restart: unless-stopped + mem_limit: 1g calendar-bridge: image: ${CALENDAR_BRIDGE_IMAGE:-track-binocle/calendar-bridge:local} @@ -541,6 +626,7 @@ services: start_period: 20s retries: 12 restart: unless-stopped + mem_limit: 512m calendar: image: ${CALENDAR_IMAGE:-track-binocle/calendar:local} @@ -576,6 +662,7 @@ services: start_period: 30s retries: 20 restart: unless-stopped + mem_limit: 1g # prismatica-auth-gateway — the custom API gateway / auth BFF, as its own # self-contained image (Docker Hub dlesieur/prismatica-auth-gateway). PULL-ONLY: @@ -648,6 +735,7 @@ services: start_period: 20s retries: 12 restart: unless-stopped + mem_limit: 256m # opposite-osiris WEBSITE — served as a STANDALONE image (Docker Hub # dlesieur/opposite-osiris-web): nginx serves the static `astro build` output @@ -682,6 +770,7 @@ services: start_period: 5s retries: 5 restart: unless-stopped + mem_limit: 128m playground-simulation: profiles: @@ -730,3 +819,12 @@ volumes: osionos-browser-test-pnpm-store: mini-baas-sdk-node-modules: opposite-osiris-node-modules: + +networks: + # The mini-baas stack's network (started by apps/baas/mini-baas-infra), + # declared external so osionos-bridge can reach the permission-engine and + # the Rust realtime gateway by container-name DNS (mini-baas-kong, + # mini-baas-realtime). Only osionos-bridge attaches to it. + mini-baas: + name: mini-baas_mini-baas + external: true diff --git a/infrastructure/docker/osionos/app.Dockerfile b/infrastructure/docker/osionos/app.Dockerfile index 44de10b7..f192dc75 100644 --- a/infrastructure/docker/osionos/app.Dockerfile +++ b/infrastructure/docker/osionos/app.Dockerfile @@ -19,11 +19,15 @@ ENV PATH=$PNPM_HOME:$PATH RUN corepack enable && corepack prepare pnpm@10.32.1 --activate WORKDIR /app -COPY . . +# Manifests first: the dependency install stays cached across source edits +# (with COPY . . before it, every source change re-ran the full pnpm install). +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ RUN --mount=type=cache,id=osionos-pnpm,target=/pnpm/store \ pnpm config set store-dir /pnpm/store \ && pnpm install --frozen-lockfile --ignore-scripts +COPY . . + # Vite inlines VITE_* at build time. Defaults target the local pipeline # (bridge at :4000, website at :4322); the committed .env supplies the rest. ARG VITE_API_URL=https://localhost:4000 @@ -55,6 +59,14 @@ ARG VITE_BAAS_GRAPH_GENERATORS= ARG VITE_BAAS_NOTES_TABLE= ARG VITE_BAAS_OVERLAY_TABLE= ARG VITE_SECOND_BRAIN_V2= +# Live-database mode (notion-database-sys × mini-baas): the mount catalog +# fallback (admin listing is internal-only, browsers can't reach it) and the +# realtime WS token minted by `make seed-live-demo`. Empty = feature dormant. +# VITE_BAAS_TENANT_ID scopes registry discovery (`X-Baas-Tenant-Id` header — +# /admin/v1/databases 401s without it and returns every tenant's mounts). +ARG VITE_BAAS_LIVE_MOUNTS= +ARG VITE_BAAS_REALTIME_TOKEN= +ARG VITE_BAAS_TENANT_ID= ENV VITE_API_URL=$VITE_API_URL \ VITE_PRISMATICA_URL=$VITE_PRISMATICA_URL \ VITE_MAIL_APP_URL=$VITE_MAIL_APP_URL \ @@ -73,9 +85,19 @@ ENV VITE_API_URL=$VITE_API_URL \ VITE_BAAS_GRAPH_GENERATORS=$VITE_BAAS_GRAPH_GENERATORS \ VITE_BAAS_NOTES_TABLE=$VITE_BAAS_NOTES_TABLE \ VITE_BAAS_OVERLAY_TABLE=$VITE_BAAS_OVERLAY_TABLE \ - VITE_SECOND_BRAIN_V2=$VITE_SECOND_BRAIN_V2 + VITE_SECOND_BRAIN_V2=$VITE_SECOND_BRAIN_V2 \ + VITE_BAAS_LIVE_MOUNTS=$VITE_BAAS_LIVE_MOUNTS \ + VITE_BAAS_REALTIME_TOKEN=$VITE_BAAS_REALTIME_TOKEN \ + VITE_BAAS_TENANT_ID=$VITE_BAAS_TENANT_ID -RUN pnpm exec vite build --base "$VITE_BASE" +# Build, then strip source maps from the shipped image (they tripled its size +# and leak source; keep them only in local builds) and precompress static +# assets so nginx's gzip_static serves them with zero CPU per request. +RUN pnpm exec vite build --base "$VITE_BASE" \ + && find build -name '*.map' -delete \ + && find build -type f \( -name '*.js' -o -name '*.css' -o -name '*.svg' \ + -o -name '*.json' -o -name '*.wasm' \) -size +1k -print0 \ + | xargs -0 -P "$(nproc)" -n 16 gzip -9k FROM public.ecr.aws/docker/library/nginx:1.27-alpine AS runtime LABEL org.opencontainers.image.title="osionos-app" diff --git a/infrastructure/docker/osionos/bridge.Dockerfile b/infrastructure/docker/osionos/bridge.Dockerfile index c86cb34f..da1285f6 100644 --- a/infrastructure/docker/osionos/bridge.Dockerfile +++ b/infrastructure/docker/osionos/bridge.Dockerfile @@ -14,8 +14,12 @@ FROM public.ecr.aws/docker/library/node:22-bookworm-slim AS runtime ENV NODE_ENV=production WORKDIR /app -# The bridge server + its sibling graph module (only runtime files it needs). -COPY scripts/bridge-api.mjs scripts/bridge-graph.mjs ./scripts/ +# The bridge server + its standalone route modules (only runtime files it +# needs): graph projection, LiveKit tokens (rtc), permission UX proxy (perms), +# and the social plane (chat/profile/feed + their shared core). +COPY scripts/bridge-api.mjs scripts/bridge-graph.mjs scripts/bridge-rtc.mjs \ + scripts/bridge-perms.mjs scripts/bridge-chat.mjs scripts/bridge-profile.mjs \ + scripts/bridge-feed.mjs scripts/bridge-social-core.mjs ./scripts/ USER node EXPOSE 4000 diff --git a/infrastructure/livekit/livekit.yaml b/infrastructure/livekit/livekit.yaml new file mode 100644 index 00000000..347a96a6 --- /dev/null +++ b/infrastructure/livekit/livekit.yaml @@ -0,0 +1,39 @@ +# **************************************************************************** # +# livekit.yaml — self-hosted LiveKit SFU config for the local Docker stack. +# +# Dev-friendly shape: +# - 7880 ws/http signalling + admin twirp API (published on the loopback +# bind address like every other service: TRACK_BINOCLE_BIND_ADDR). +# - 7881 TCP ICE fallback (works when UDP is blocked). +# - 50000-50060/udp modest media port range, published 1:1 so the +# advertised candidates (node_ip 127.0.0.1) match the host mapping. +# +# API keys are NOT stored here: they flow from the LIVEKIT_KEYS env var set in +# docker-compose.yml (${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-...}), +# the same pattern other compose secrets use. The osionos-bridge mints HS256 +# access tokens against the same pair (apps/osionos/app/scripts/bridge-rtc.mjs). +# **************************************************************************** # + +port: 7880 +bind_addresses: + - "0.0.0.0" + +rtc: + # TCP fallback so calls still connect when UDP is unavailable. + tcp_port: 7881 + # Modest UDP media range for local dev; mirrored 1:1 in docker-compose.yml. + port_range_start: 50000 + port_range_end: 50060 + # Local-only deployment: advertise the loopback the host publishes the + # ports on instead of resolving a public/external IP. + use_external_ip: false + node_ip: "127.0.0.1" + +room: + # Rooms are created on first join (the bridge token grants roomJoin). + auto_create: true + empty_timeout: 300 + +logging: + level: info + json: false diff --git a/infrastructure/makes/agency.mk b/infrastructure/makes/agency.mk new file mode 100644 index 00000000..903c316b --- /dev/null +++ b/infrastructure/makes/agency.mk @@ -0,0 +1,42 @@ +# ────────────────────────────────────────────────────────────────────────────── +# agency.mk — Binocle Intelligence Agency simulation lifecycle +# +# Seeds and verifies the full organization simulation: owner + 20 employees, +# the permanent `agency` live tenant (10 case-file tables + edges, ~950 rows), +# ABAC roles/policies in both engines, wiki/chat content, and the Playwright +# end-to-end simulation. Everything talks to the RUNNING stacks via docker +# exec/curl — stack lifecycle stays with `make all` / the BaaS Makefile. +# ────────────────────────────────────────────────────────────────────────────── + +AGENCY_SEEDS_DIR := tools/seeds +AGENCY_INFRA_DIR := apps/baas/mini-baas-infra + +.PHONY: agency-people agency-seed agency-policies agency-content agency-verify agency-sim agency-all + +agency-people: ## Agency: create owner + 20 employees (gotrue, bridge identities, org workspace) + bash $(AGENCY_SEEDS_DIR)/seed_agency_people.sh + +agency-seed: ## Agency: provision the live tenant + 10 tables + edges, seed ~950 rows + bash $(AGENCY_INFRA_DIR)/scripts/seed/agency-tenant.sh + python3 $(AGENCY_SEEDS_DIR)/seed_agency.py + docker exec -i mini-baas-postgres psql -U postgres -d agency -v ON_ERROR_STOP=1 -q < $(AGENCY_SEEDS_DIR)/seed_agency.sql + @echo "agency tenant seeded (see $(AGENCY_INFRA_DIR)/.agency-tenant.env)" + +agency-policies: ## Agency: seed ABAC roles + policies (permission-engine + osionos defaults) + bash $(AGENCY_INFRA_DIR)/scripts/seed/agency-policies.sh + +agency-content: ## Agency: seed wikis, galleries, teamspaces, channels + feed backfill + python3 $(AGENCY_SEEDS_DIR)/seed_agency_wiki.py + docker exec -i track-binocle-postgres-1 psql -U postgres -d postgres -v ON_ERROR_STOP=1 -q < $(AGENCY_SEEDS_DIR)/seed_agency_wiki.sql + @if [ -f $(AGENCY_SEEDS_DIR)/seed_agency_chat.sql ]; then \ + docker exec -i track-binocle-postgres-1 psql -U postgres -d postgres -v ON_ERROR_STOP=1 -q < $(AGENCY_SEEDS_DIR)/seed_agency_chat.sql; \ + fi + +agency-verify: ## Agency: run the m23 foundation gate (tables, accounts, policy decisions) + bash $(AGENCY_INFRA_DIR)/scripts/verify/m23-agency-foundation.sh + +agency-sim: ## Agency: run the Playwright end-to-end organization simulation + docker compose --profile testing run --rm agency-simulation + +agency-all: agency-people agency-seed agency-policies agency-verify ## Agency: full foundation (people → tenant/data → policies → gate) + @echo "agency foundation complete" diff --git a/infrastructure/makes/app.mk b/infrastructure/makes/app.mk index f9d6d0c3..3c36894b 100644 --- a/infrastructure/makes/app.mk +++ b/infrastructure/makes/app.mk @@ -66,7 +66,11 @@ healthcheck: certs fi $(CURL_HEALTH) -o /dev/null -w 'auth-gateway-https-%{http_code}\n' $(AUTH_URL)/availability $(CURL_HEALTH) $(MAILPIT_URL) >/dev/null - docker compose exec -T auth-gateway node scripts/verify-newsletter-delivery.mjs + @if docker compose exec -T auth-gateway test -f scripts/verify-newsletter-delivery.mjs 2>/dev/null; then \ + docker compose exec -T auth-gateway node scripts/verify-newsletter-delivery.mjs; \ + else \ + echo '[healthcheck] newsletter-delivery verifier not in this auth-gateway image (script moved with the prismatica split) — skipped'; \ + fi $(CURL_HEALTH) $(MAIL_BRIDGE_URL)/health >/dev/null $(CURL_HEALTH) $(MAIL_URL) >/dev/null $(CURL_HEALTH) $(CALENDAR_BRIDGE_URL)/health >/dev/null diff --git a/infrastructure/makes/baas.mk b/infrastructure/makes/baas.mk index b2a30868..25900473 100644 --- a/infrastructure/makes/baas.mk +++ b/infrastructure/makes/baas.mk @@ -43,3 +43,24 @@ baas-update: baas-smoke: # Smoke-test the currently running BaaS gateway through the frontend verifier. cd $(FRONTEND_DIR) && node scripts/verify-connection.mjs + +OSIONOS_APP_ENV := apps/osionos/app/.env + +seed-live-demo: +## Seed the live-database demo (pg-commerce + mysql-ops + mongo-activity through +## the mini-baas control plane), then rebuild osionos-app with the BaaS env +## baked in and restart it. Needs the mini-baas stack up (make -C apps/baas/mini-baas-infra up). + @$(MAKE) -C apps/baas/mini-baas-infra seed-live-demo + @$(MAKE) osionos-app-live + +osionos-app-live: +## Rebuild + restart osionos-app with the VITE_BAAS_* values from the app .env +## (vite inlines env at build time; the seeder writes the live-demo keys there). +## Two --env-file flags: the root .env keeps its port interpolations, the app +## .env supplies the VITE_BAAS_* build args (later files win). + @test -f $(OSIONOS_APP_ENV) || { echo "missing $(OSIONOS_APP_ENV) — run make seed-live-demo first"; exit 1; } + @touch .env + docker compose --env-file .env --env-file $(OSIONOS_APP_ENV) build osionos-app + docker compose --env-file .env --env-file $(OSIONOS_APP_ENV) up -d osionos-app + +.PHONY: seed-live-demo osionos-app-live diff --git a/models/osionos-chat-migration.sql b/models/osionos-chat-migration.sql new file mode 100644 index 00000000..f17d6bcc --- /dev/null +++ b/models/osionos-chat-migration.sql @@ -0,0 +1,169 @@ +-- Chat / DMs / profiles / feed interactions for the osionos bridge. +-- Same discipline as osionos-bridge-migration.sql: idempotent DDL, RLS enabled +-- everywhere, service_role policies (the bridge talks PostgREST with the +-- service key and enforces membership itself), gen_random_uuid() pks. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Profile payload (avatar dataUrl, bio, ...) lives on the existing identity row. +ALTER TABLE public.osionos_bridge_identities + ADD COLUMN IF NOT EXISTS profile JSONB NOT NULL DEFAULT '{}'::jsonb; + +CREATE TABLE IF NOT EXISTS public.osionos_channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES public.osionos_workspaces(id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'text' CHECK (kind IN ('text', 'dm', 'voice', 'video')), + name TEXT NOT NULL DEFAULT 'general', + topic TEXT, + created_by UUID, + is_private BOOLEAN NOT NULL DEFAULT false, + abac JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Deterministic find-or-create key for DMs: 'dm::' (sorted). + dm_key TEXT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS public.osionos_channel_members ( + channel_id UUID NOT NULL REFERENCES public.osionos_channels(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (channel_id, user_id) +); + +CREATE TABLE IF NOT EXISTS public.osionos_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES public.osionos_channels(id) ON DELETE CASCADE, + author_id UUID NOT NULL, + content TEXT NOT NULL DEFAULT '', + attachments JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS public.osionos_message_reactions ( + message_id UUID NOT NULL REFERENCES public.osionos_messages(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (message_id, user_id, emoji) +); + +CREATE TABLE IF NOT EXISTS public.osionos_feed_likes ( + page_id UUID NOT NULL, + user_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (page_id, user_id) +); + +CREATE TABLE IF NOT EXISTS public.osionos_feed_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + page_id UUID NOT NULL, + author_id UUID NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS osionos_channels_workspace_idx ON public.osionos_channels(workspace_id, kind); +CREATE INDEX IF NOT EXISTS osionos_channel_members_user_idx ON public.osionos_channel_members(user_id); +CREATE INDEX IF NOT EXISTS osionos_messages_channel_created_idx ON public.osionos_messages(channel_id, created_at); +CREATE INDEX IF NOT EXISTS osionos_message_reactions_message_idx ON public.osionos_message_reactions(message_id); +CREATE INDEX IF NOT EXISTS osionos_feed_likes_page_idx ON public.osionos_feed_likes(page_id); +CREATE INDEX IF NOT EXISTS osionos_feed_comments_page_idx ON public.osionos_feed_comments(page_id, created_at); + +ALTER TABLE public.osionos_channels ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.osionos_channel_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.osionos_messages ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.osionos_message_reactions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.osionos_feed_likes ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.osionos_feed_comments ENABLE ROW LEVEL SECURITY; + +-- Authenticated read access mirrors the bridge's own checks (defence in depth): +-- channel rows for members of the channel or its workspace (public text only). +DROP POLICY IF EXISTS osionos_channels_select_member ON public.osionos_channels; +CREATE POLICY osionos_channels_select_member ON public.osionos_channels + FOR SELECT TO authenticated USING ( + EXISTS ( + SELECT 1 FROM public.osionos_channel_members cm + WHERE cm.channel_id = id AND cm.user_id = auth.uid() + ) + OR ( + NOT is_private AND kind <> 'dm' + AND EXISTS ( + SELECT 1 FROM public.osionos_workspace_members wm + WHERE wm.workspace_id = public.osionos_channels.workspace_id AND wm.user_id = auth.uid() + ) + ) + ); + +DROP POLICY IF EXISTS osionos_channel_members_select_own ON public.osionos_channel_members; +CREATE POLICY osionos_channel_members_select_own ON public.osionos_channel_members + FOR SELECT TO authenticated USING (user_id = auth.uid()); + +DROP POLICY IF EXISTS osionos_messages_select_member ON public.osionos_messages; +CREATE POLICY osionos_messages_select_member ON public.osionos_messages + FOR SELECT TO authenticated USING ( + EXISTS ( + SELECT 1 FROM public.osionos_channel_members cm + WHERE cm.channel_id = public.osionos_messages.channel_id AND cm.user_id = auth.uid() + ) + ); + +DROP POLICY IF EXISTS osionos_message_reactions_select_member ON public.osionos_message_reactions; +CREATE POLICY osionos_message_reactions_select_member ON public.osionos_message_reactions + FOR SELECT TO authenticated USING ( + EXISTS ( + SELECT 1 FROM public.osionos_messages m + JOIN public.osionos_channel_members cm ON cm.channel_id = m.channel_id + WHERE m.id = message_id AND cm.user_id = auth.uid() + ) + ); + +DROP POLICY IF EXISTS osionos_feed_likes_select_all ON public.osionos_feed_likes; +CREATE POLICY osionos_feed_likes_select_all ON public.osionos_feed_likes + FOR SELECT TO authenticated USING (true); + +DROP POLICY IF EXISTS osionos_feed_comments_select_all ON public.osionos_feed_comments; +CREATE POLICY osionos_feed_comments_select_all ON public.osionos_feed_comments + FOR SELECT TO authenticated USING (true); + +DROP POLICY IF EXISTS osionos_channels_service_role_all ON public.osionos_channels; +CREATE POLICY osionos_channels_service_role_all ON public.osionos_channels + FOR ALL TO service_role USING (true) WITH CHECK (true); + +DROP POLICY IF EXISTS osionos_channel_members_service_role_all ON public.osionos_channel_members; +CREATE POLICY osionos_channel_members_service_role_all ON public.osionos_channel_members + FOR ALL TO service_role USING (true) WITH CHECK (true); + +DROP POLICY IF EXISTS osionos_messages_service_role_all ON public.osionos_messages; +CREATE POLICY osionos_messages_service_role_all ON public.osionos_messages + FOR ALL TO service_role USING (true) WITH CHECK (true); + +DROP POLICY IF EXISTS osionos_message_reactions_service_role_all ON public.osionos_message_reactions; +CREATE POLICY osionos_message_reactions_service_role_all ON public.osionos_message_reactions + FOR ALL TO service_role USING (true) WITH CHECK (true); + +DROP POLICY IF EXISTS osionos_feed_likes_service_role_all ON public.osionos_feed_likes; +CREATE POLICY osionos_feed_likes_service_role_all ON public.osionos_feed_likes + FOR ALL TO service_role USING (true) WITH CHECK (true); + +DROP POLICY IF EXISTS osionos_feed_comments_service_role_all ON public.osionos_feed_comments; +CREATE POLICY osionos_feed_comments_service_role_all ON public.osionos_feed_comments + FOR ALL TO service_role USING (true) WITH CHECK (true); + +GRANT SELECT ON public.osionos_channels TO authenticated; +GRANT SELECT ON public.osionos_channel_members TO authenticated; +GRANT SELECT ON public.osionos_messages TO authenticated; +GRANT SELECT ON public.osionos_message_reactions TO authenticated; +GRANT SELECT ON public.osionos_feed_likes TO authenticated; +GRANT SELECT ON public.osionos_feed_comments TO authenticated; +GRANT ALL ON public.osionos_channels TO service_role; +GRANT ALL ON public.osionos_channel_members TO service_role; +GRANT ALL ON public.osionos_messages TO service_role; +GRANT ALL ON public.osionos_message_reactions TO service_role; +GRANT ALL ON public.osionos_feed_likes TO service_role; +GRANT ALL ON public.osionos_feed_comments TO service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/models/osionos-wiki-surface-migration.sql b/models/osionos-wiki-surface-migration.sql new file mode 100644 index 00000000..ef420ce5 --- /dev/null +++ b/models/osionos-wiki-surface-migration.sql @@ -0,0 +1,15 @@ +-- ============================================================================ +-- osionos wikis: allow surface='wiki' on osionos_pages. +-- +-- A wiki is a governed knowledge root (Notion-style): unlike a folder it +-- OPENS onto its own content (an index/dashboard page), and like a folder it +-- groups children in the sidebar. Articles inside a wiki carry governance +-- properties (owner, verification status, last-verified date, domain) in the +-- `properties` jsonb. The previous CHECK allowed ('page','agent','home', +-- 'folder'); this widens it additively — no data loss, reversible. +-- ============================================================================ + +ALTER TABLE public.osionos_pages DROP CONSTRAINT IF EXISTS osionos_pages_surface_check; + +ALTER TABLE public.osionos_pages ADD CONSTRAINT osionos_pages_surface_check + CHECK (surface IS NULL OR surface IN ('page', 'agent', 'home', 'folder', 'wiki')); diff --git a/tools/seeds/seed_agency.py b/tools/seeds/seed_agency.py new file mode 100644 index 00000000..82c5e76c --- /dev/null +++ b/tools/seeds/seed_agency.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""seed_agency.py — deterministic case-file data for the Binocle agency tenant. + +Generates seed_agency.sql (idempotent INSERT ... ON CONFLICT DO NOTHING) +against the `agency` database created by +apps/baas/mini-baas-infra/scripts/seed/agency-tenant.sh. + +Reads tools/seeds/.agency-people.env for the owner uuid (owner_id stamp on +every row, matching the platform's write-path convention) and the employee +roster (assignments.employee_email, evidence.collected_by, reports.author). + +Row targets: cases 40, subjects 60, locations 50, evidence 120, leads 80, +transactions 150, vehicles 30, communications 200, reports 40, +assignments 100 — ~920 rows total, FK-linked so /query/v1/graph yields a +connected investigation graph. +""" +from __future__ import annotations + +import random +from datetime import datetime, timedelta +from pathlib import Path + +HERE = Path(__file__).resolve().parent +PEOPLE_ENV = HERE / ".agency-people.env" +TENANT_ENV = HERE.parent.parent / "apps" / "baas" / "mini-baas-infra" / ".agency-tenant.env" +OUT = HERE / "seed_agency.sql" + +random.seed(42) + + +def load_db_id() -> str: + for line in TENANT_ENV.read_text().splitlines(): + if line.startswith("AGENCY_DB_ID="): + return line.split("=", 1)[1].strip() + raise SystemExit("run agency-tenant.sh first (missing AGENCY_DB_ID)") + + +def load_people() -> tuple[str, list[dict]]: + owner, people = "", [] + for line in PEOPLE_ENV.read_text().splitlines(): + if line.startswith("AGENCY_OWNER_UUID="): + owner = line.split("=", 1)[1].strip() + elif line.startswith("AGENCY_PERSON_"): + if line.startswith("AGENCY_PERSON_COUNT"): + continue + parts = line.split("=", 1)[1].split("|") + people.append({ + "uuid": parts[0], "email": parts[1], "name": parts[2], + "role": parts[3], "dept": parts[4], "clearance": int(parts[5]), + "region": parts[6], + }) + if not owner or not people: + raise SystemExit("run seed_agency_people.sh first (missing .agency-people.env)") + return owner, people + + +def q(value) -> str: + if value is None: + return "NULL" + if isinstance(value, bool): + return "TRUE" if value else "FALSE" + if isinstance(value, (int, float)): + return str(value) + return "'" + str(value).replace("'", "''") + "'" + + +def ts(base: datetime, max_days: int) -> str: + return (base + timedelta(days=random.randint(0, max_days), + hours=random.randint(0, 23), + minutes=random.randint(0, 59))).strftime("%Y-%m-%d %H:%M:%S+00") + + +OPS = ["Nightfall", "Cobalt Ledger", "Silent Harbor", "Glass Orchid", "Iron Veil", + "Amber Crossing", "Hollow Crown", "Red Meridian", "Paper Tiger", "Black Tide", + "Marble Fox", "Quiet Storm", "Golden Thread", "Ash Garden", "Winter Lantern", + "Broken Compass", "Velvet Chain", "Salt Mirage", "Cedar Gate", "Phantom Ledger", + "Blue Asphalt", "Mirror Lake", "Stone Whisper", "Last Orchard", "Neon Distillery", + "Cold Archive", "Topaz Relay", "Drift Anchor", "Sable Run", "Open Window", + "Pale Horizon", "Crimson Wake", "Static Veil", "Lone Marker", "Deep Current", + "Bright Cellar", "Grey Lattice", "Final Compass", "Echo Vault", "Glass Bridge"] +CASE_STATUS = ["open", "open", "open", "active_surveillance", "analysis", "review", "closed", "cold"] +PRIORITIES = ["critical", "high", "medium", "low"] +CLASSIF = ["restricted", "confidential", "internal", "internal"] +CLIENTS = ["Meridian Bank AG", "Helios Insurance", "EU AML Taskforce", "Castellan Holdings", + "Private — Estate of R. Voss", "Northgate Logistics", "Apex Reinsurance", + "Ministry of Finance (anon.)", "Orpheus Capital", "Wexford & Pale LLP"] +FIRST = ["Viktor", "Lena", "Dmitri", "Carmen", "Hassan", "Ingrid", "Paulo", "Mei", "Aldo", + "Petra", "Sergei", "Lucia", "Farid", "Anneke", "Bruno", "Katya", "Otto", "Renata", + "Ivan", "Maribel", "Janus", "Odile", "Ricardo", "Saskia", "Emil", "Noor", "Gustav", + "Beatriz", "Anton", "Zofia"] +LAST = ["Morozov", "Keller", "Albrecht", "Duarte", "Rahimi", "Sørensen", "Vidal", "Chen", + "Romano", "Novak", "Baranov", "Esposito", "Haddad", "Visser", "Graf", "Sokolova", + "Brandt", "Costa", "Petrov", "Iglesias", "Kovac", "Marchand", "Silva", "DeVries", + "Wagner", "Hakim", "Lindgren", "Almeida", "Volkov", "Mazur"] +RISK = ["low", "medium", "medium", "high", "high", "critical"] +OCCUP = ["import/export broker", "shell company director", "art dealer", "crypto trader", + "logistics manager", "private banker", "casino host", "yacht broker", + "real-estate agent", "freight forwarder", "lawyer", "accountant"] +CITIES = [("Zurich", "CH", 47.3769, 8.5417), ("Rotterdam", "NL", 51.9244, 4.4777), + ("Marseille", "FR", 43.2965, 5.3698), ("Hamburg", "DE", 53.5511, 9.9937), + ("Vienna", "AT", 48.2082, 16.3738), ("Lisbon", "PT", 38.7223, -9.1393), + ("Valletta", "MT", 35.8989, 14.5146), ("Dubai", "AE", 25.2048, 55.2708), + ("Singapore", "SG", 1.3521, 103.8198), ("Panama City", "PA", 8.9824, -79.5199), + ("Geneva", "CH", 46.2044, 6.1432), ("Antwerp", "BE", 51.2194, 4.4025)] +LOC_KINDS = ["residence", "business", "warehouse", "safehouse", "marina", "office", + "storage unit", "restaurant", "gallery", "freeport vault"] +EV_KINDS = ["document", "photograph", "ledger", "hard drive", "phone dump", "bank statement", + "wire receipt", "surveillance tape", "shipping manifest", "contract", "burner phone"] +LEAD_SRC = ["informant", "wiretap", "open source", "bank alert", "customs flag", + "anonymous tip", "field observation", "financial analysis"] +CRED = ["unverified", "low", "medium", "medium", "high", "confirmed"] +LEAD_STATUS = ["new", "investigating", "corroborated", "dead_end", "escalated"] +CURRENCIES = ["EUR", "EUR", "USD", "CHF", "AED", "USDT"] +COUNTERPARTIES = ["Aurelia Trade FZE", "Westport Capital SA", "Lumen Art Holdings", + "Kestrel Marine Ltd", "Hyperion Commodities", "Sable Trust (BVI)", + "Goldquay Exchange", "Pelican Freight LLC", "Novum Estates", + "Cygnus Consulting GmbH"] +METHODS = ["wire", "wire", "cash deposit", "crypto", "hawala", "invoice"] +CHANNELS = ["phone", "phone", "email", "encrypted app", "sms", "in person"] +COMM_CLASS = ["routine", "routine", "relevant", "relevant", "incriminating", "privileged"] +MAKES = [("BMW", "740d"), ("Mercedes", "S580"), ("Audi", "RS6"), ("Range Rover", "Sport"), + ("Porsche", "Cayenne"), ("Toyota", "Land Cruiser"), ("VW", "Transporter"), + ("Volvo", "XC90"), ("Tesla", "Model S"), ("Ford", "Transit")] +COLORS = ["black", "anthracite", "silver", "white", "navy", "grey"] +REPORT_STATUS = ["draft", "review", "review", "final", "final"] +ROLES_ON_CASE = ["lead", "support", "surveillance", "analysis", "forensics", "legal review"] + +BASE = datetime(2025, 6, 1) + + +def main() -> None: + owner, people = load_people() + inv_names = [p["name"] for p in people if p["dept"] in ("investigations", "operations", "command")] + emails = [p["email"] for p in people] + sql: list[str] = [ + "-- generated by tools/seeds/seed_agency.py — idempotent agency case-file data", + "BEGIN;", + ] + + def emit(table: str, cols: list[str], rows: list[list]) -> None: + sql.append(f"-- {table}: {len(rows)} rows") + for row in rows: + vals = ", ".join(q(v) for v in row) + sql.append(f"INSERT INTO public.{table} ({', '.join(cols)}) " + f"VALUES ({vals}) ON CONFLICT (id) DO NOTHING;") + + # cases ────────────────────────────────────────────────────────────────── + cases = [] + for i in range(1, 41): + status = random.choice(CASE_STATUS) + opened = ts(BASE - timedelta(days=400), 380) + closed = ts(BASE, 200) if status in ("closed", "cold") else None + cases.append([i, f"BIA-{2025 + (i % 2)}-{i:03d}", f"Operation {OPS[i - 1]}", + status, random.choice(PRIORITIES), random.choice(CLASSIF), + round(random.uniform(8_000, 250_000), 2), random.choice(inv_names), + random.choice(CLIENTS), opened, closed, + f"Investigation into {random.choice(['suspected money laundering', 'asset concealment', 'procurement fraud', 'sanctions evasion', 'art-market fraud', 'insurance fraud', 'corporate espionage'])} " + f"involving {random.choice(['offshore structures', 'a shipping network', 'shell companies', 'a freeport vault', 'crypto mixers', 'trade mis-invoicing'])}.", + owner]) + emit("cases", ["id", "code", "title", "status", "priority", "classification", "budget", + "lead_investigator", "client", "opened_at", "closed_at", "summary", "owner_id"], cases) + + # subjects ─────────────────────────────────────────────────────────────── + subjects = [] + used = set() + for i in range(1, 61): + while True: + name = f"{random.choice(FIRST)} {random.choice(LAST)}" + if name not in used: + used.add(name) + break + alias = random.choice([None, None, f"“{random.choice(['The Banker', 'Marquis', 'Cashmere', 'Doc', 'The Courier', 'Magpie', 'Halcyon', 'Brick'])}”"]) + dob = (datetime(1955, 1, 1) + timedelta(days=random.randint(0, 14_000))).strftime("%Y-%m-%d") + ssn = f"{random.randint(100, 999)}-{random.randint(10, 99)}-{random.randint(1000, 9999)}" + subjects.append([i, random.randint(1, 40), name, alias, ssn, + random.choice(["CH", "DE", "RU", "FR", "IT", "NL", "AE", "MT", "PA", "CY"]), + random.choice(RISK), random.choice(OCCUP), dob, + random.choice([None, "Known to travel under secondary passport.", + "Frequent contact with case principals.", + "Cooperative witness — handle with care.", + "Surveillance authorized by client mandate."]), + owner]) + emit("subjects", ["id", "case_id", "full_name", "alias", "ssn", "nationality", + "risk_level", "occupation", "date_of_birth", "notes", "owner_id"], subjects) + + # locations ────────────────────────────────────────────────────────────── + locations = [] + for i in range(1, 51): + city, country, lat, lng = random.choice(CITIES) + kind = random.choice(LOC_KINDS) + locations.append([i, f"{kind.title()} — {city} #{i}", kind, + f"{random.randint(1, 220)} {random.choice(['Quai', 'Strasse', 'Avenue', 'Laan', 'Via', 'Rua'])} {random.choice(LAST)}", + city, country, + round(lat + random.uniform(-0.05, 0.05), 6), + round(lng + random.uniform(-0.05, 0.05), 6), + random.random() < 0.3, owner]) + emit("locations", ["id", "label", "kind", "address", "city", "country", "lat", "lng", + "surveillance_active", "owner_id"], locations) + + # evidence ─────────────────────────────────────────────────────────────── + evidence = [] + for i in range(1, 121): + kind = random.choice(EV_KINDS) + evidence.append([i, random.randint(1, 40), kind, + f"{kind.title()} recovered during {random.choice(['site visit', 'records request', 'surveillance op', 'source meeting', 'forensic imaging'])}.", + f"BIA-EV-{i:04d} sealed; custody log {random.randint(2, 6)} entries", + random.randint(1, 50), random.choice([p['name'] for p in people]), + ts(BASE - timedelta(days=300), 290), random.random() < 0.92, owner]) + emit("evidence", ["id", "case_id", "kind", "description", "chain_of_custody", + "storage_location_id", "collected_by", "collected_at", + "integrity_verified", "owner_id"], evidence) + + # leads ────────────────────────────────────────────────────────────────── + leads = [] + for i in range(1, 81): + leads.append([i, random.randint(1, 40), random.choice([None, random.randint(1, 60)]), + random.choice(LEAD_SRC), random.choice(CRED), random.choice(LEAD_STATUS), + random.choice(["Subject seen meeting unknown male at marina.", + "Invoice totals diverge from customs declaration.", + "New shell entity registered with shared director.", + "Repeated structuring just under reporting threshold.", + "Vehicle plate matched at second location.", + "Informant reports cash courier run on Fridays.", + "Encrypted app group references 'the gallery'."]), + ts(BASE - timedelta(days=250), 240), owner]) + emit("leads", ["id", "case_id", "subject_id", "source", "credibility", "status", + "detail", "received_at", "owner_id"], leads) + + # transactions ─────────────────────────────────────────────────────────── + txns = [] + for i in range(1, 151): + flagged = random.random() < 0.35 + amount = round(random.choice([random.uniform(900, 9_900), + random.uniform(9_000, 9_999), + random.uniform(10_000, 480_000)]), 2) + txns.append([i, random.randint(1, 60), amount, random.choice(CURRENCIES), + random.choice(COUNTERPARTIES), + f"{random.choice(['CH', 'MT', 'AE', 'VG', 'PA'])}{random.randint(10**10, 10**11 - 1)}", + flagged, ts(BASE - timedelta(days=365), 360), random.choice(METHODS), owner]) + emit("transactions", ["id", "subject_id", "amount", "currency", "counterparty", + "account_ref", "flagged", "executed_at", "method", "owner_id"], txns) + + # vehicles ─────────────────────────────────────────────────────────────── + vehicles = [] + for i in range(1, 31): + make, model = random.choice(MAKES) + plate = f"{random.choice(['ZH', 'GE', 'HH', 'B', 'NL', 'F'])}-{random.randint(1000, 99999)}" + vehicles.append([i, random.choice([None, random.randint(1, 60)]), plate, make, model, + random.choice(COLORS), random.randint(2012, 2025), + random.choice([None, random.randint(1, 50)]), owner]) + emit("vehicles", ["id", "owner_subject_id", "plate", "make", "model", "color", "year", + "last_seen_location_id", "owner_id"], vehicles) + + # communications ───────────────────────────────────────────────────────── + comms = [] + for i in range(1, 201): + comms.append([i, random.randint(1, 60), random.choice(CHANNELS), + random.choice([f"{random.choice(FIRST)} {random.choice(LAST)}", + "unknown number", "unregistered SIM", + random.choice(COUNTERPARTIES)]), + ts(BASE - timedelta(days=200), 195), + random.choice(["Arranged meeting; location referenced obliquely.", + "Discussed 'paperwork' for upcoming shipment.", + "Payment confirmation — amount matches txn pattern.", + "Subject anxious about 'the audit'.", + "Travel plans: short-notice flight, cash ticket.", + "Mentioned contact at the freeport by first name.", + "Routine personal call — no case relevance."]), + random.choice(COMM_CLASS), random.randint(1, 40), owner]) + emit("communications", ["id", "subject_id", "channel", "counterparty", "intercepted_at", + "summary", "classification", "case_id", "owner_id"], comms) + + # reports ──────────────────────────────────────────────────────────────── + reports = [] + for i in range(1, 41): + author = random.choice([p["name"] for p in people if p["dept"] in ("analysis", "investigations", "forensics")]) + reports.append([i, i, f"{random.choice(['Interim', 'Surveillance', 'Financial', 'Forensic', 'Closing'])} report — Operation {OPS[i - 1]}", + author, random.choice(REPORT_STATUS), random.choice(CLASSIF), + ts(BASE - timedelta(days=100), 95), + f"Findings summary for Operation {OPS[i - 1]}: " + f"{random.choice(['fund flows traced through three jurisdictions', 'subject network mapped, two new principals identified', 'evidence chain complete and verified', 'surveillance window produced actionable pattern-of-life', 'ledger reconstruction shows systematic skimming'])}.", + owner]) + emit("reports", ["id", "case_id", "title", "author", "status", "classification", + "published_at", "body", "owner_id"], reports) + + # assignments ──────────────────────────────────────────────────────────── + assignments = [] + for i in range(1, 101): + assignments.append([i, ((i - 1) % 40) + 1, random.choice(emails), + random.choice(ROLES_ON_CASE), round(random.uniform(4, 160), 1), + round(random.uniform(45, 180), 2), + (BASE - timedelta(days=random.randint(10, 300))).strftime("%Y-%m-%d"), + random.random() < 0.7, owner]) + emit("assignments", ["id", "case_id", "employee_email", "role_on_case", "hours", + "hourly_rate", "started_at", "active", "owner_id"], assignments) + + # edges — curated investigative links (PRIMARY graph edge source); node + # ids are `:
:` per the graph contract ──────────────── + db_id = load_db_id() + nid = lambda table, pk: f"{db_id}:{table}:{pk}" # noqa: E731 + edges, eid = [], 0 + + def edge(frm: str, to: str, etype: str, label: str, directed: bool = True) -> None: + nonlocal eid + eid += 1 + edges.append([eid, frm, to, etype, label, directed, owner]) + + seen_pairs = set() + for _ in range(30): # subject ↔ subject associations + a, b = random.randint(1, 60), random.randint(1, 60) + if a == b or (min(a, b), max(a, b)) in seen_pairs: + continue + seen_pairs.add((min(a, b), max(a, b))) + edge(nid("subjects", a), nid("subjects", b), "associate", + random.choice(["business partner", "family", "frequent contact", + "co-director", "courier for", "introduced by informant"]), + directed=False) + for _ in range(25): # subject → location patterns of life + edge(nid("subjects", random.randint(1, 60)), nid("locations", random.randint(1, 50)), + "frequents", random.choice(["weekly visits", "registered address", + "meeting spot", "storage access", "owns via proxy"])) + for _ in range(25): # cross-case discoveries: case → subject outside its FK chain + edge(nid("cases", random.randint(1, 40)), nid("subjects", random.randint(1, 60)), + "involves", random.choice(["person of interest", "witness", "beneficial owner", + "intermediary", "unconfirmed link"])) + emit("edges", ["id", '"from"', '"to"', "type", "label", "directed", "owner_id"], edges) + + sql.append("COMMIT;") + OUT.write_text("\n".join(sql) + "\n") + total = 40 + 60 + 50 + 120 + 80 + 150 + 30 + 200 + 40 + 100 + len(edges) + print(f"wrote {OUT} ({total} rows across 11 tables, {len(edges)} edges)") + + +if __name__ == "__main__": + main() diff --git a/tools/seeds/seed_agency.sql b/tools/seeds/seed_agency.sql new file mode 100644 index 00000000..b2c4d334 --- /dev/null +++ b/tools/seeds/seed_agency.sql @@ -0,0 +1,964 @@ +-- generated by tools/seeds/seed_agency.py — idempotent agency case-file data +BEGIN; +-- cases: 40 rows +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (1, 'BIA-2026-001', 'Operation Nightfall', 'open', 'high', 'confidential', 41768.18, 'Marcus Reed', 'Orpheus Capital', '2024-05-09 23:17:00+00', NULL, 'Investigation into suspected money laundering involving crypto mixers.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (2, 'BIA-2025-002', 'Operation Cobalt Ledger', 'closed', 'critical', 'confidential', 181276.75, 'Nadia Petrova', 'Apex Reinsurance', '2024-05-13 00:05:00+00', '2025-07-26 07:32:00+00', 'Investigation into asset concealment involving a freeport vault.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (3, 'BIA-2026-003', 'Operation Silent Harbor', 'analysis', 'low', 'internal', 75244.86, 'David Okafor', 'Northgate Logistics', '2024-04-30 05:44:00+00', NULL, 'Investigation into suspected money laundering involving offshore structures.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (4, 'BIA-2025-004', 'Operation Glass Orchid', 'closed', 'critical', 'internal', 137767.2, 'Amara Diallo', 'Helios Insurance', '2024-06-15 11:54:00+00', '2025-08-28 19:16:00+00', 'Investigation into art-market fraud involving shell companies.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (5, 'BIA-2026-005', 'Operation Iron Veil', 'review', 'critical', 'restricted', 168025.72, 'Yuki Tanaka', 'Helios Insurance', '2025-02-16 06:45:00+00', NULL, 'Investigation into corporate espionage involving a shipping network.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (6, 'BIA-2025-006', 'Operation Amber Crossing', 'open', 'medium', 'confidential', 97583.79, 'David Okafor', 'Private — Estate of R. Voss', '2024-11-07 08:29:00+00', NULL, 'Investigation into insurance fraud involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (7, 'BIA-2026-007', 'Operation Hollow Crown', 'open', 'high', 'confidential', 119866.97, 'Yuki Tanaka', 'Orpheus Capital', '2025-03-04 20:10:00+00', NULL, 'Investigation into asset concealment involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (8, 'BIA-2025-008', 'Operation Red Meridian', 'review', 'critical', 'internal', 105081.88, 'Marcus Reed', 'Castellan Holdings', '2024-05-25 07:52:00+00', NULL, 'Investigation into art-market fraud involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (9, 'BIA-2026-009', 'Operation Paper Tiger', 'review', 'low', 'internal', 42574.92, 'Sofia Lindqvist', 'Castellan Holdings', '2024-08-13 20:31:00+00', NULL, 'Investigation into insurance fraud involving crypto mixers.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (10, 'BIA-2025-010', 'Operation Black Tide', 'analysis', 'low', 'internal', 61075.62, 'Sofia Lindqvist', 'Orpheus Capital', '2025-02-20 13:57:00+00', NULL, 'Investigation into sanctions evasion involving offshore structures.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (11, 'BIA-2026-011', 'Operation Marble Fox', 'open', 'high', 'internal', 152330.99, 'Amara Diallo', 'Apex Reinsurance', '2024-06-22 04:40:00+00', NULL, 'Investigation into art-market fraud involving a freeport vault.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (12, 'BIA-2025-012', 'Operation Quiet Storm', 'analysis', 'critical', 'internal', 194000.89, 'Pierre Moreau', 'Helios Insurance', '2025-02-04 00:43:00+00', NULL, 'Investigation into procurement fraud involving a freeport vault.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (13, 'BIA-2026-013', 'Operation Golden Thread', 'open', 'medium', 'confidential', 130858.97, 'Marcus Reed', 'Private — Estate of R. Voss', '2024-12-15 00:46:00+00', NULL, 'Investigation into corporate espionage involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (14, 'BIA-2025-014', 'Operation Ash Garden', 'active_surveillance', 'high', 'restricted', 152944.59, 'Jack Sullivan', 'Meridian Bank AG', '2024-07-14 11:48:00+00', NULL, 'Investigation into suspected money laundering involving shell companies.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (15, 'BIA-2026-015', 'Operation Winter Lantern', 'analysis', 'critical', 'restricted', 185121.64, 'Marcus Reed', 'Orpheus Capital', '2024-08-27 01:15:00+00', NULL, 'Investigation into corporate espionage involving a shipping network.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (16, 'BIA-2025-016', 'Operation Broken Compass', 'open', 'high', 'internal', 135697.06, 'Tom Becker', 'Apex Reinsurance', '2025-03-30 15:35:00+00', NULL, 'Investigation into asset concealment involving crypto mixers.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (17, 'BIA-2026-017', 'Operation Velvet Chain', 'active_surveillance', 'medium', 'internal', 225706.61, 'Jack Sullivan', 'Helios Insurance', '2025-04-27 09:25:00+00', NULL, 'Investigation into asset concealment involving a shipping network.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (18, 'BIA-2025-018', 'Operation Salt Mirage', 'open', 'high', 'confidential', 9740.18, 'Leila Haddad', 'Meridian Bank AG', '2024-10-17 00:37:00+00', NULL, 'Investigation into asset concealment involving offshore structures.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (19, 'BIA-2026-019', 'Operation Cedar Gate', 'open', 'high', 'internal', 169892.62, 'David Okafor', 'Orpheus Capital', '2024-10-13 02:32:00+00', NULL, 'Investigation into asset concealment involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (20, 'BIA-2025-020', 'Operation Phantom Ledger', 'cold', 'critical', 'internal', 93740.01, 'Amara Diallo', 'Ministry of Finance (anon.)', '2024-08-29 15:51:00+00', '2025-09-13 06:06:00+00', 'Investigation into corporate espionage involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (21, 'BIA-2026-021', 'Operation Blue Asphalt', 'open', 'critical', 'restricted', 105434.35, 'Pierre Moreau', 'Helios Insurance', '2025-04-06 20:41:00+00', NULL, 'Investigation into asset concealment involving a shipping network.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (22, 'BIA-2025-022', 'Operation Mirror Lake', 'active_surveillance', 'low', 'confidential', 75407.93, 'David Okafor', 'Helios Insurance', '2025-01-26 14:08:00+00', NULL, 'Investigation into sanctions evasion involving crypto mixers.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (23, 'BIA-2026-023', 'Operation Stone Whisper', 'open', 'critical', 'restricted', 232180.81, 'David Okafor', 'EU AML Taskforce', '2024-05-22 20:34:00+00', NULL, 'Investigation into sanctions evasion involving a freeport vault.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (24, 'BIA-2025-024', 'Operation Last Orchard', 'cold', 'critical', 'internal', 72179.14, 'Jack Sullivan', 'Private — Estate of R. Voss', '2024-08-14 12:57:00+00', '2025-06-16 05:24:00+00', 'Investigation into sanctions evasion involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (25, 'BIA-2026-025', 'Operation Neon Distillery', 'cold', 'critical', 'internal', 21834.0, 'Tom Becker', 'Ministry of Finance (anon.)', '2024-07-15 06:18:00+00', '2025-07-26 01:37:00+00', 'Investigation into art-market fraud involving crypto mixers.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (26, 'BIA-2025-026', 'Operation Cold Archive', 'open', 'high', 'restricted', 151998.5, 'Leila Haddad', 'Castellan Holdings', '2024-05-26 16:05:00+00', NULL, 'Investigation into sanctions evasion involving offshore structures.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (27, 'BIA-2026-027', 'Operation Topaz Relay', 'active_surveillance', 'critical', 'internal', 167084.31, 'Tom Becker', 'Orpheus Capital', '2025-02-17 19:02:00+00', NULL, 'Investigation into procurement fraud involving shell companies.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (28, 'BIA-2025-028', 'Operation Drift Anchor', 'active_surveillance', 'high', 'internal', 103780.17, 'Leila Haddad', 'Private — Estate of R. Voss', '2025-04-04 22:20:00+00', NULL, 'Investigation into sanctions evasion involving shell companies.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (29, 'BIA-2026-029', 'Operation Sable Run', 'open', 'critical', 'restricted', 138107.53, 'Nadia Petrova', 'Private — Estate of R. Voss', '2024-05-01 14:39:00+00', NULL, 'Investigation into asset concealment involving shell companies.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (30, 'BIA-2025-030', 'Operation Open Window', 'open', 'high', 'internal', 209766.28, 'Yuki Tanaka', 'Wexford & Pale LLP', '2024-08-30 11:18:00+00', NULL, 'Investigation into corporate espionage involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (31, 'BIA-2026-031', 'Operation Pale Horizon', 'open', 'critical', 'confidential', 72000.75, 'Marcus Reed', 'Orpheus Capital', '2025-04-03 17:19:00+00', NULL, 'Investigation into asset concealment involving shell companies.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (32, 'BIA-2025-032', 'Operation Crimson Wake', 'analysis', 'medium', 'confidential', 174374.31, 'Yuki Tanaka', 'Orpheus Capital', '2025-03-02 06:45:00+00', NULL, 'Investigation into sanctions evasion involving shell companies.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (33, 'BIA-2026-033', 'Operation Static Veil', 'open', 'medium', 'restricted', 8858.06, 'Sofia Lindqvist', 'Private — Estate of R. Voss', '2024-06-13 20:27:00+00', NULL, 'Investigation into asset concealment involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (34, 'BIA-2025-034', 'Operation Lone Marker', 'cold', 'critical', 'confidential', 140032.85, 'Pierre Moreau', 'Wexford & Pale LLP', '2025-02-03 22:27:00+00', '2025-10-22 00:07:00+00', 'Investigation into art-market fraud involving a shipping network.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (35, 'BIA-2026-035', 'Operation Deep Current', 'closed', 'medium', 'confidential', 173060.14, 'Leila Haddad', 'Helios Insurance', '2024-07-01 01:19:00+00', '2025-09-02 01:57:00+00', 'Investigation into procurement fraud involving crypto mixers.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (36, 'BIA-2025-036', 'Operation Bright Cellar', 'closed', 'high', 'internal', 13998.3, 'Pierre Moreau', 'Apex Reinsurance', '2025-03-10 23:09:00+00', '2025-07-31 05:51:00+00', 'Investigation into corporate espionage involving trade mis-invoicing.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (37, 'BIA-2026-037', 'Operation Grey Lattice', 'active_surveillance', 'critical', 'internal', 219064.36, 'Jack Sullivan', 'Castellan Holdings', '2024-09-10 05:50:00+00', NULL, 'Investigation into asset concealment involving a freeport vault.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (38, 'BIA-2025-038', 'Operation Final Compass', 'review', 'critical', 'confidential', 104428.77, 'Yuki Tanaka', 'Helios Insurance', '2024-09-30 07:14:00+00', NULL, 'Investigation into corporate espionage involving shell companies.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (39, 'BIA-2026-039', 'Operation Echo Vault', 'review', 'medium', 'restricted', 35912.7, 'Yuki Tanaka', 'EU AML Taskforce', '2025-03-21 16:25:00+00', NULL, 'Investigation into art-market fraud involving shell companies.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.cases (id, code, title, status, priority, classification, budget, lead_investigator, client, opened_at, closed_at, summary, owner_id) VALUES (40, 'BIA-2025-040', 'Operation Glass Bridge', 'open', 'medium', 'internal', 113607.32, 'Nadia Petrova', 'Helios Insurance', '2024-06-21 19:27:00+00', NULL, 'Investigation into sanctions evasion involving crypto mixers.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- subjects: 60 rows +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (1, 13, 'Paulo Romano', '“The Banker”', '101-76-9821', 'NL', 'high', 'shell company director', '1974-07-24', 'Frequent contact with case principals.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (2, 21, 'Maribel Baranov', '“Marquis”', '619-49-7691', 'AE', 'critical', 'logistics manager', '1968-06-21', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (3, 26, 'Hassan Vidal', '“Halcyon”', '793-32-5930', 'PA', 'low', 'logistics manager', '1972-01-03', 'Frequent contact with case principals.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (4, 33, 'Paulo Visser', NULL, '552-96-4501', 'MT', 'critical', 'art dealer', '1974-10-26', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (5, 15, 'Petra Brandt', NULL, '869-40-6085', 'FR', 'medium', 'import/export broker', '1991-09-18', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (6, 25, 'Mei Sokolova', NULL, '744-83-4185', 'MT', 'high', 'crypto trader', '1973-08-04', 'Known to travel under secondary passport.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (7, 34, 'Janus Silva', NULL, '535-38-3881', 'MT', 'low', 'real-estate agent', '1989-12-01', 'Known to travel under secondary passport.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (8, 36, 'Zofia Almeida', NULL, '920-69-9702', 'CY', 'medium', 'yacht broker', '1960-12-24', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (9, 17, 'Gustav DeVries', '“Halcyon”', '262-70-8373', 'FR', 'critical', 'logistics manager', '1975-01-01', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (10, 18, 'Katya Kovac', NULL, '179-46-4841', 'NL', 'medium', 'real-estate agent', '1974-09-24', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (11, 27, 'Hassan Rahimi', NULL, '256-37-2052', 'AE', 'medium', 'real-estate agent', '1986-02-16', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (12, 37, 'Anneke Keller', NULL, '888-84-1320', 'AE', 'high', 'import/export broker', '1972-06-21', 'Frequent contact with case principals.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (13, 32, 'Petra Wagner', NULL, '865-79-4613', 'FR', 'medium', 'casino host', '1979-02-22', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (14, 30, 'Viktor Haddad', '“Magpie”', '917-61-3704', 'RU', 'high', 'real-estate agent', '1985-06-19', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (15, 12, 'Zofia Haddad', NULL, '538-27-8564', 'CH', 'medium', 'casino host', '1983-10-31', 'Frequent contact with case principals.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (16, 17, 'Paulo Graf', NULL, '488-45-7906', 'DE', 'high', 'import/export broker', '1989-02-22', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (17, 2, 'Lena Esposito', '“Doc”', '899-93-1659', 'FR', 'medium', 'import/export broker', '1958-01-29', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (18, 30, 'Hassan Chen', NULL, '217-82-4571', 'IT', 'medium', 'art dealer', '1985-01-11', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (19, 20, 'Maribel DeVries', NULL, '210-84-1420', 'CY', 'critical', 'casino host', '1968-12-14', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (20, 7, 'Ricardo Vidal', '“Marquis”', '950-90-4978', 'IT', 'critical', 'freight forwarder', '1985-12-24', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (21, 5, 'Noor Petrov', NULL, '538-94-7071', 'PA', 'critical', 'private banker', '1978-11-24', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (22, 10, 'Beatriz Visser', NULL, '470-91-8532', 'AE', 'medium', 'accountant', '1974-06-12', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (23, 21, 'Janus Romano', NULL, '945-85-5397', 'FR', 'low', 'logistics manager', '1974-07-16', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (24, 2, 'Mei Wagner', '“Brick”', '784-58-6511', 'MT', 'medium', 'art dealer', '1982-05-18', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (25, 36, 'Paulo Esposito', NULL, '710-99-5526', 'CH', 'high', 'crypto trader', '1967-07-18', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (26, 32, 'Mei DeVries', NULL, '876-40-8800', 'MT', 'low', 'shell company director', '1979-11-27', 'Frequent contact with case principals.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (27, 36, 'Mei Haddad', NULL, '695-57-8753', 'PA', 'medium', 'casino host', '1984-10-13', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (28, 13, 'Sergei Esposito', NULL, '357-39-2976', 'NL', 'low', 'accountant', '1968-10-02', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (29, 38, 'Emil Silva', NULL, '856-71-5530', 'PA', 'high', 'logistics manager', '1964-09-15', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (30, 35, 'Gustav Vidal', NULL, '283-48-1231', 'RU', 'medium', 'import/export broker', '1971-03-10', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (31, 37, 'Renata Novak', '“Cashmere”', '602-23-1200', 'IT', 'high', 'yacht broker', '1988-10-07', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (32, 32, 'Sergei Sørensen', NULL, '216-18-7565', 'DE', 'high', 'lawyer', '1976-06-06', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (33, 34, 'Petra Albrecht', NULL, '882-63-4697', 'AE', 'high', 'yacht broker', '1980-01-13', 'Frequent contact with case principals.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (34, 14, 'Beatriz Petrov', NULL, '735-17-2625', 'FR', 'medium', 'lawyer', '1980-07-04', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (35, 29, 'Ingrid Chen', '“Cashmere”', '260-10-7693', 'CY', 'high', 'logistics manager', '1958-05-14', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (36, 17, 'Mei Novak', '“The Courier”', '172-97-4824', 'CY', 'critical', 'crypto trader', '1975-05-14', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (37, 4, 'Carmen Costa', '“Doc”', '372-28-2169', 'RU', 'medium', 'freight forwarder', '1961-09-07', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (38, 18, 'Zofia Novak', NULL, '805-48-7594', 'PA', 'high', 'yacht broker', '1976-01-10', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (39, 2, 'Dmitri Iglesias', NULL, '430-87-5102', 'DE', 'medium', 'lawyer', '1987-12-13', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (40, 34, 'Ivan Morozov', '“The Courier”', '881-32-8708', 'MT', 'medium', 'art dealer', '1956-10-21', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (41, 21, 'Anneke Kovac', NULL, '456-62-6460', 'DE', 'medium', 'private banker', '1976-01-31', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (42, 30, 'Ricardo Sokolova', '“The Courier”', '933-80-1601', 'DE', 'medium', 'logistics manager', '1972-12-18', 'Frequent contact with case principals.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (43, 30, 'Carmen Wagner', '“Halcyon”', '101-94-9889', 'AE', 'low', 'crypto trader', '1992-01-02', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (44, 18, 'Lucia Iglesias', '“Brick”', '878-16-4335', 'PA', 'medium', 'logistics manager', '1974-10-30', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (45, 11, 'Anton Silva', NULL, '745-87-4920', 'IT', 'high', 'import/export broker', '1956-04-17', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (46, 32, 'Anneke Albrecht', NULL, '220-92-3522', 'IT', 'high', 'accountant', '1975-09-13', 'Frequent contact with case principals.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (47, 25, 'Anneke Lindgren', NULL, '567-80-3369', 'FR', 'high', 'real-estate agent', '1965-12-07', 'Known to travel under secondary passport.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (48, 1, 'Beatriz Albrecht', NULL, '906-74-5377', 'IT', 'critical', 'logistics manager', '1970-03-31', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (49, 22, 'Ivan Marchand', NULL, '651-71-6654', 'PA', 'high', 'casino host', '1975-01-12', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (50, 27, 'Zofia Baranov', '“Doc”', '685-59-4826', 'CH', 'medium', 'accountant', '1965-09-17', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (51, 32, 'Ricardo Mazur', NULL, '911-93-3492', 'CH', 'medium', 'real-estate agent', '1984-10-08', 'Surveillance authorized by client mandate.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (52, 10, 'Sergei Almeida', NULL, '638-68-1251', 'AE', 'critical', 'art dealer', '1959-06-22', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (53, 22, 'Katya Hakim', NULL, '809-60-2315', 'PA', 'high', 'private banker', '1982-12-16', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (54, 15, 'Beatriz Costa', '“The Banker”', '340-90-5708', 'DE', 'high', 'shell company director', '1958-01-26', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (55, 23, 'Bruno Sørensen', NULL, '432-17-5806', 'NL', 'high', 'art dealer', '1957-01-23', 'Known to travel under secondary passport.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (56, 40, 'Otto Visser', NULL, '180-88-7267', 'FR', 'high', 'freight forwarder', '1962-11-08', 'Known to travel under secondary passport.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (57, 19, 'Mei Graf', NULL, '782-11-8622', 'PA', 'medium', 'shell company director', '1966-06-15', 'Cooperative witness — handle with care.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (58, 20, 'Lucia Petrov', '“The Courier”', '806-42-8484', 'FR', 'high', 'yacht broker', '1974-01-12', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (59, 18, 'Ivan Esposito', '“The Courier”', '122-94-7484', 'CH', 'high', 'lawyer', '1968-03-30', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.subjects (id, case_id, full_name, alias, ssn, nationality, risk_level, occupation, date_of_birth, notes, owner_id) VALUES (60, 15, 'Zofia Iglesias', NULL, '918-39-6772', 'FR', 'high', 'logistics manager', '1989-10-18', 'Known to travel under secondary passport.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- locations: 50 rows +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (1, 'Business — Geneva #1', 'business', '161 Rua Keller', 'Geneva', 'CH', 46.185293, 6.137282, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (2, 'Warehouse — Antwerp #2', 'warehouse', '24 Avenue Baranov', 'Antwerp', 'BE', 51.244148, 4.370064, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (3, 'Office — Singapore #3', 'office', '136 Via Mazur', 'Singapore', 'SG', 1.329353, 103.786254, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (4, 'Marina — Dubai #4', 'marina', '192 Avenue Hakim', 'Dubai', 'AE', 25.166317, 55.317089, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (5, 'Storage Unit — Hamburg #5', 'storage unit', '217 Via Esposito', 'Hamburg', 'DE', 53.51013, 9.983157, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (6, 'Restaurant — Rotterdam #6', 'restaurant', '95 Rua DeVries', 'Rotterdam', 'NL', 51.941634, 4.486156, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (7, 'Business — Lisbon #7', 'business', '173 Strasse Sokolova', 'Lisbon', 'PT', 38.674802, -9.100877, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (8, 'Safehouse — Panama City #8', 'safehouse', '166 Quai Kovac', 'Panama City', 'PA', 9.014786, -79.478961, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (9, 'Business — Valletta #9', 'business', '36 Quai Keller', 'Valletta', 'MT', 35.879336, 14.513862, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (10, 'Warehouse — Singapore #10', 'warehouse', '100 Laan Esposito', 'Singapore', 'SG', 1.369149, 103.844114, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (11, 'Freeport Vault — Valletta #11', 'freeport vault', '191 Rua Rahimi', 'Valletta', 'MT', 35.937441, 14.530092, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (12, 'Storage Unit — Panama City #12', 'storage unit', '72 Quai Silva', 'Panama City', 'PA', 8.969453, -79.525562, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (13, 'Business — Lisbon #13', 'business', '176 Avenue Costa', 'Lisbon', 'PT', 38.76247, -9.124817, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (14, 'Safehouse — Vienna #14', 'safehouse', '32 Laan Albrecht', 'Vienna', 'AT', 48.224477, 16.38797, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (15, 'Residence — Zurich #15', 'residence', '202 Avenue Chen', 'Zurich', 'CH', 47.425782, 8.570398, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (16, 'Safehouse — Singapore #16', 'safehouse', '151 Strasse Lindgren', 'Singapore', 'SG', 1.38907, 103.802656, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (17, 'Residence — Panama City #17', 'residence', '71 Strasse Rahimi', 'Panama City', 'PA', 8.986422, -79.490049, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (18, 'Warehouse — Zurich #18', 'warehouse', '4 Avenue Hakim', 'Zurich', 'CH', 47.405743, 8.550585, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (19, 'Residence — Vienna #19', 'residence', '33 Rua Visser', 'Vienna', 'AT', 48.210809, 16.398367, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (20, 'Gallery — Lisbon #20', 'gallery', '152 Quai Graf', 'Lisbon', 'PT', 38.722684, -9.094758, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (21, 'Gallery — Geneva #21', 'gallery', '78 Laan Kovac', 'Geneva', 'CH', 46.250803, 6.099283, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (22, 'Storage Unit — Valletta #22', 'storage unit', '176 Quai Sokolova', 'Valletta', 'MT', 35.92014, 14.508956, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (23, 'Freeport Vault — Lisbon #23', 'freeport vault', '38 Quai Rahimi', 'Lisbon', 'PT', 38.699802, -9.125992, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (24, 'Storage Unit — Lisbon #24', 'storage unit', '153 Via Novak', 'Lisbon', 'PT', 38.717672, -9.128757, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (25, 'Business — Antwerp #25', 'business', '219 Rua Kovac', 'Antwerp', 'BE', 51.257171, 4.407634, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (26, 'Restaurant — Valletta #26', 'restaurant', '59 Laan Baranov', 'Valletta', 'MT', 35.931642, 14.504473, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (27, 'Storage Unit — Lisbon #27', 'storage unit', '81 Rua Romano', 'Lisbon', 'PT', 38.70973, -9.174036, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (28, 'Business — Rotterdam #28', 'business', '213 Quai Albrecht', 'Rotterdam', 'NL', 51.917588, 4.502159, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (29, 'Gallery — Marseille #29', 'gallery', '16 Via Costa', 'Marseille', 'FR', 43.302668, 5.386801, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (30, 'Storage Unit — Geneva #30', 'storage unit', '185 Quai Novak', 'Geneva', 'CH', 46.214441, 6.128365, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (31, 'Warehouse — Hamburg #31', 'warehouse', '169 Laan Chen', 'Hamburg', 'DE', 53.585827, 9.978711, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (32, 'Marina — Rotterdam #32', 'marina', '147 Strasse Hakim', 'Rotterdam', 'NL', 51.917311, 4.483797, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (33, 'Freeport Vault — Panama City #33', 'freeport vault', '173 Rua Costa', 'Panama City', 'PA', 8.935025, -79.476992, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (34, 'Residence — Vienna #34', 'residence', '47 Avenue Silva', 'Vienna', 'AT', 48.234441, 16.415954, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (35, 'Warehouse — Marseille #35', 'warehouse', '145 Rua Haddad', 'Marseille', 'FR', 43.253459, 5.393897, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (36, 'Gallery — Rotterdam #36', 'gallery', '56 Laan Visser', 'Rotterdam', 'NL', 51.919765, 4.443441, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (37, 'Freeport Vault — Lisbon #37', 'freeport vault', '153 Quai Volkov', 'Lisbon', 'PT', 38.677561, -9.173559, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (38, 'Business — Geneva #38', 'business', '70 Laan Marchand', 'Geneva', 'CH', 46.196801, 6.153908, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (39, 'Gallery — Hamburg #39', 'gallery', '30 Avenue Visser', 'Hamburg', 'DE', 53.512189, 10.011522, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (40, 'Marina — Singapore #40', 'marina', '12 Strasse Haddad', 'Singapore', 'SG', 1.400545, 103.775278, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (41, 'Warehouse — Hamburg #41', 'warehouse', '196 Avenue Novak', 'Hamburg', 'DE', 53.533912, 9.944473, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (42, 'Warehouse — Marseille #42', 'warehouse', '98 Via Silva', 'Marseille', 'FR', 43.269511, 5.375672, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (43, 'Business — Lisbon #43', 'business', '102 Rua Keller', 'Lisbon', 'PT', 38.715922, -9.143325, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (44, 'Freeport Vault — Lisbon #44', 'freeport vault', '110 Via Haddad', 'Lisbon', 'PT', 38.74325, -9.147531, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (45, 'Office — Zurich #45', 'office', '44 Via Graf', 'Zurich', 'CH', 47.410103, 8.583677, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (46, 'Safehouse — Rotterdam #46', 'safehouse', '112 Via Haddad', 'Rotterdam', 'NL', 51.926796, 4.467277, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (47, 'Safehouse — Lisbon #47', 'safehouse', '86 Strasse Albrecht', 'Lisbon', 'PT', 38.723348, -9.177897, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (48, 'Office — Lisbon #48', 'office', '187 Rua Lindgren', 'Lisbon', 'PT', 38.687069, -9.17902, TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (49, 'Freeport Vault — Marseille #49', 'freeport vault', '40 Rua Albrecht', 'Marseille', 'FR', 43.264215, 5.397085, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.locations (id, label, kind, address, city, country, lat, lng, surveillance_active, owner_id) VALUES (50, 'Freeport Vault — Panama City #50', 'freeport vault', '115 Rua Mazur', 'Panama City', 'PA', 9.020605, -79.505601, FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- evidence: 120 rows +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (1, 21, 'bank statement', 'Bank Statement recovered during records request.', 'BIA-EV-0001 sealed; custody log 5 entries', 5, 'Omar Farouk', '2025-03-19 20:19:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (2, 4, 'contract', 'Contract recovered during surveillance op.', 'BIA-EV-0002 sealed; custody log 6 entries', 5, 'Tom Becker', '2025-03-29 14:02:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (3, 5, 'phone dump', 'Phone Dump recovered during site visit.', 'BIA-EV-0003 sealed; custody log 6 entries', 39, 'Grace Liu', '2025-02-17 14:37:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (4, 29, 'document', 'Document recovered during forensic imaging.', 'BIA-EV-0004 sealed; custody log 3 entries', 21, 'Robert Ngata', '2025-04-05 16:09:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (5, 7, 'surveillance tape', 'Surveillance Tape recovered during surveillance op.', 'BIA-EV-0005 sealed; custody log 2 entries', 33, 'Maya Kowalski', '2024-11-01 01:15:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (6, 34, 'surveillance tape', 'Surveillance Tape recovered during forensic imaging.', 'BIA-EV-0006 sealed; custody log 6 entries', 11, 'Erik Johansson', '2025-02-11 09:24:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (7, 39, 'bank statement', 'Bank Statement recovered during site visit.', 'BIA-EV-0007 sealed; custody log 4 entries', 5, 'Leila Haddad', '2024-09-22 17:43:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (8, 39, 'phone dump', 'Phone Dump recovered during records request.', 'BIA-EV-0008 sealed; custody log 4 entries', 6, 'Isabel Romero', '2024-10-16 11:19:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (9, 26, 'burner phone', 'Burner Phone recovered during records request.', 'BIA-EV-0009 sealed; custody log 6 entries', 46, 'Sofia Lindqvist', '2025-01-10 17:24:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (10, 9, 'bank statement', 'Bank Statement recovered during forensic imaging.', 'BIA-EV-0010 sealed; custody log 2 entries', 42, 'Carlos Mendez', '2025-04-22 11:01:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (11, 14, 'ledger', 'Ledger recovered during surveillance op.', 'BIA-EV-0011 sealed; custody log 5 entries', 13, 'Jack Sullivan', '2024-10-14 04:04:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (12, 33, 'photograph', 'Photograph recovered during forensic imaging.', 'BIA-EV-0012 sealed; custody log 6 entries', 3, 'Leila Haddad', '2024-10-11 19:24:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (13, 40, 'ledger', 'Ledger recovered during records request.', 'BIA-EV-0013 sealed; custody log 5 entries', 3, 'Carlos Mendez', '2025-02-07 21:46:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (14, 40, 'surveillance tape', 'Surveillance Tape recovered during surveillance op.', 'BIA-EV-0014 sealed; custody log 5 entries', 15, 'Viktor Antonov', '2024-12-05 09:51:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (15, 24, 'hard drive', 'Hard Drive recovered during forensic imaging.', 'BIA-EV-0015 sealed; custody log 5 entries', 30, 'Tom Becker', '2025-02-16 16:33:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (16, 13, 'ledger', 'Ledger recovered during forensic imaging.', 'BIA-EV-0016 sealed; custody log 3 entries', 17, 'Marcus Reed', '2025-04-08 11:35:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (17, 8, 'shipping manifest', 'Shipping Manifest recovered during surveillance op.', 'BIA-EV-0017 sealed; custody log 2 entries', 49, 'Pierre Moreau', '2024-12-22 14:57:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (18, 6, 'wire receipt', 'Wire Receipt recovered during records request.', 'BIA-EV-0018 sealed; custody log 5 entries', 23, 'Helena Voss', '2025-03-05 01:25:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (19, 25, 'hard drive', 'Hard Drive recovered during site visit.', 'BIA-EV-0019 sealed; custody log 4 entries', 15, 'Helena Voss', '2025-01-15 03:53:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (20, 10, 'bank statement', 'Bank Statement recovered during records request.', 'BIA-EV-0020 sealed; custody log 2 entries', 19, 'Omar Farouk', '2024-10-15 22:30:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (21, 6, 'document', 'Document recovered during site visit.', 'BIA-EV-0021 sealed; custody log 4 entries', 14, 'Yuki Tanaka', '2025-05-12 23:38:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (22, 19, 'photograph', 'Photograph recovered during records request.', 'BIA-EV-0022 sealed; custody log 4 entries', 8, 'Marcus Reed', '2024-12-05 13:40:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (23, 5, 'surveillance tape', 'Surveillance Tape recovered during site visit.', 'BIA-EV-0023 sealed; custody log 5 entries', 39, 'Viktor Antonov', '2024-08-13 20:32:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (24, 19, 'ledger', 'Ledger recovered during source meeting.', 'BIA-EV-0024 sealed; custody log 2 entries', 40, 'Erik Johansson', '2024-12-06 18:26:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (25, 6, 'burner phone', 'Burner Phone recovered during forensic imaging.', 'BIA-EV-0025 sealed; custody log 4 entries', 5, 'Grace Liu', '2025-05-10 16:50:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (26, 2, 'shipping manifest', 'Shipping Manifest recovered during source meeting.', 'BIA-EV-0026 sealed; custody log 5 entries', 3, 'Maya Kowalski', '2025-02-19 11:16:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (27, 5, 'bank statement', 'Bank Statement recovered during surveillance op.', 'BIA-EV-0027 sealed; custody log 3 entries', 47, 'Maya Kowalski', '2024-09-27 18:47:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (28, 3, 'ledger', 'Ledger recovered during surveillance op.', 'BIA-EV-0028 sealed; custody log 6 entries', 22, 'Maya Kowalski', '2024-11-02 21:29:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (29, 12, 'surveillance tape', 'Surveillance Tape recovered during records request.', 'BIA-EV-0029 sealed; custody log 2 entries', 46, 'Hannah Weiss', '2024-08-23 09:12:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (30, 3, 'hard drive', 'Hard Drive recovered during surveillance op.', 'BIA-EV-0030 sealed; custody log 4 entries', 33, 'Priya Sharma', '2025-05-10 15:16:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (31, 13, 'burner phone', 'Burner Phone recovered during surveillance op.', 'BIA-EV-0031 sealed; custody log 4 entries', 50, 'Marcus Reed', '2025-01-21 08:07:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (32, 26, 'wire receipt', 'Wire Receipt recovered during source meeting.', 'BIA-EV-0032 sealed; custody log 5 entries', 22, 'Pierre Moreau', '2025-04-16 22:31:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (33, 18, 'shipping manifest', 'Shipping Manifest recovered during site visit.', 'BIA-EV-0033 sealed; custody log 5 entries', 6, 'Carlos Mendez', '2024-11-05 17:18:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (34, 21, 'photograph', 'Photograph recovered during surveillance op.', 'BIA-EV-0034 sealed; custody log 4 entries', 29, 'Robert Ngata', '2025-03-11 05:44:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (35, 3, 'surveillance tape', 'Surveillance Tape recovered during surveillance op.', 'BIA-EV-0035 sealed; custody log 6 entries', 28, 'Nadia Petrova', '2024-09-03 02:42:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (36, 33, 'bank statement', 'Bank Statement recovered during records request.', 'BIA-EV-0036 sealed; custody log 2 entries', 10, 'Robert Ngata', '2025-03-17 01:08:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (37, 24, 'burner phone', 'Burner Phone recovered during surveillance op.', 'BIA-EV-0037 sealed; custody log 5 entries', 37, 'Marcus Reed', '2024-10-22 21:28:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (38, 29, 'bank statement', 'Bank Statement recovered during site visit.', 'BIA-EV-0038 sealed; custody log 6 entries', 9, 'Grace Liu', '2025-02-08 12:20:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (39, 8, 'hard drive', 'Hard Drive recovered during site visit.', 'BIA-EV-0039 sealed; custody log 3 entries', 32, 'Grace Liu', '2025-02-19 17:07:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (40, 29, 'phone dump', 'Phone Dump recovered during records request.', 'BIA-EV-0040 sealed; custody log 6 entries', 19, 'Omar Farouk', '2024-11-15 03:08:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (41, 12, 'surveillance tape', 'Surveillance Tape recovered during source meeting.', 'BIA-EV-0041 sealed; custody log 2 entries', 44, 'Leila Haddad', '2025-01-29 22:04:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (42, 20, 'phone dump', 'Phone Dump recovered during records request.', 'BIA-EV-0042 sealed; custody log 3 entries', 24, 'Grace Liu', '2024-11-27 03:12:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (43, 32, 'hard drive', 'Hard Drive recovered during site visit.', 'BIA-EV-0043 sealed; custody log 4 entries', 36, 'Isabel Romero', '2025-02-09 14:51:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (44, 40, 'ledger', 'Ledger recovered during site visit.', 'BIA-EV-0044 sealed; custody log 2 entries', 20, 'Priya Sharma', '2025-04-07 16:26:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (45, 5, 'contract', 'Contract recovered during records request.', 'BIA-EV-0045 sealed; custody log 4 entries', 42, 'Sofia Lindqvist', '2025-03-23 14:43:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (46, 36, 'ledger', 'Ledger recovered during forensic imaging.', 'BIA-EV-0046 sealed; custody log 3 entries', 50, 'Yuki Tanaka', '2025-03-14 16:58:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (47, 34, 'photograph', 'Photograph recovered during records request.', 'BIA-EV-0047 sealed; custody log 4 entries', 11, 'Pierre Moreau', '2025-01-17 22:14:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (48, 19, 'shipping manifest', 'Shipping Manifest recovered during site visit.', 'BIA-EV-0048 sealed; custody log 4 entries', 13, 'Maya Kowalski', '2025-05-14 08:08:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (49, 35, 'contract', 'Contract recovered during site visit.', 'BIA-EV-0049 sealed; custody log 6 entries', 42, 'Pierre Moreau', '2024-10-22 05:42:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (50, 22, 'contract', 'Contract recovered during forensic imaging.', 'BIA-EV-0050 sealed; custody log 2 entries', 2, 'Sofia Lindqvist', '2024-08-28 20:49:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (51, 14, 'burner phone', 'Burner Phone recovered during forensic imaging.', 'BIA-EV-0051 sealed; custody log 5 entries', 40, 'Maya Kowalski', '2024-08-20 15:56:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (52, 20, 'phone dump', 'Phone Dump recovered during source meeting.', 'BIA-EV-0052 sealed; custody log 3 entries', 44, 'Priya Sharma', '2025-01-04 14:04:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (53, 29, 'ledger', 'Ledger recovered during source meeting.', 'BIA-EV-0053 sealed; custody log 5 entries', 30, 'Amara Diallo', '2025-01-26 19:09:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (54, 23, 'bank statement', 'Bank Statement recovered during source meeting.', 'BIA-EV-0054 sealed; custody log 3 entries', 49, 'Erik Johansson', '2025-04-25 17:06:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (55, 8, 'surveillance tape', 'Surveillance Tape recovered during surveillance op.', 'BIA-EV-0055 sealed; custody log 5 entries', 16, 'Yuki Tanaka', '2024-09-23 01:18:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (56, 27, 'contract', 'Contract recovered during records request.', 'BIA-EV-0056 sealed; custody log 3 entries', 21, 'Isabel Romero', '2025-01-12 06:48:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (57, 30, 'shipping manifest', 'Shipping Manifest recovered during source meeting.', 'BIA-EV-0057 sealed; custody log 4 entries', 32, 'Helena Voss', '2024-09-20 12:32:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (58, 14, 'hard drive', 'Hard Drive recovered during forensic imaging.', 'BIA-EV-0058 sealed; custody log 4 entries', 4, 'Marcus Reed', '2024-12-27 15:38:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (59, 31, 'burner phone', 'Burner Phone recovered during surveillance op.', 'BIA-EV-0059 sealed; custody log 6 entries', 1, 'David Okafor', '2025-03-13 04:56:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (60, 26, 'bank statement', 'Bank Statement recovered during surveillance op.', 'BIA-EV-0060 sealed; custody log 2 entries', 26, 'Marcus Reed', '2025-05-19 06:23:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (61, 25, 'photograph', 'Photograph recovered during forensic imaging.', 'BIA-EV-0061 sealed; custody log 5 entries', 49, 'Viktor Antonov', '2024-12-26 19:43:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (62, 7, 'ledger', 'Ledger recovered during source meeting.', 'BIA-EV-0062 sealed; custody log 4 entries', 22, 'Viktor Antonov', '2025-02-08 04:12:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (63, 33, 'wire receipt', 'Wire Receipt recovered during site visit.', 'BIA-EV-0063 sealed; custody log 2 entries', 3, 'Yuki Tanaka', '2025-01-22 15:33:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (64, 33, 'contract', 'Contract recovered during records request.', 'BIA-EV-0064 sealed; custody log 4 entries', 40, 'Leila Haddad', '2024-10-27 12:39:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (65, 38, 'phone dump', 'Phone Dump recovered during surveillance op.', 'BIA-EV-0065 sealed; custody log 6 entries', 33, 'Viktor Antonov', '2025-04-12 22:36:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (66, 24, 'document', 'Document recovered during surveillance op.', 'BIA-EV-0066 sealed; custody log 2 entries', 27, 'Isabel Romero', '2025-01-09 23:55:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (67, 39, 'document', 'Document recovered during source meeting.', 'BIA-EV-0067 sealed; custody log 4 entries', 42, 'Isabel Romero', '2024-11-29 23:03:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (68, 34, 'ledger', 'Ledger recovered during forensic imaging.', 'BIA-EV-0068 sealed; custody log 5 entries', 10, 'Jack Sullivan', '2024-08-21 18:44:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (69, 29, 'document', 'Document recovered during surveillance op.', 'BIA-EV-0069 sealed; custody log 5 entries', 10, 'Carlos Mendez', '2024-11-17 13:32:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (70, 4, 'surveillance tape', 'Surveillance Tape recovered during records request.', 'BIA-EV-0070 sealed; custody log 6 entries', 14, 'Viktor Antonov', '2025-01-18 21:30:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (71, 12, 'bank statement', 'Bank Statement recovered during source meeting.', 'BIA-EV-0071 sealed; custody log 6 entries', 22, 'Viktor Antonov', '2025-02-02 21:49:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (72, 17, 'burner phone', 'Burner Phone recovered during forensic imaging.', 'BIA-EV-0072 sealed; custody log 5 entries', 13, 'Jack Sullivan', '2024-12-25 17:19:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (73, 19, 'phone dump', 'Phone Dump recovered during records request.', 'BIA-EV-0073 sealed; custody log 5 entries', 21, 'Omar Farouk', '2025-01-30 17:59:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (74, 19, 'phone dump', 'Phone Dump recovered during site visit.', 'BIA-EV-0074 sealed; custody log 6 entries', 44, 'Viktor Antonov', '2025-02-15 12:52:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (75, 19, 'ledger', 'Ledger recovered during site visit.', 'BIA-EV-0075 sealed; custody log 4 entries', 46, 'Sofia Lindqvist', '2025-01-29 14:41:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (76, 14, 'surveillance tape', 'Surveillance Tape recovered during records request.', 'BIA-EV-0076 sealed; custody log 6 entries', 18, 'Viktor Antonov', '2024-12-22 04:06:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (77, 16, 'contract', 'Contract recovered during records request.', 'BIA-EV-0077 sealed; custody log 2 entries', 43, 'Grace Liu', '2024-11-28 20:14:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (78, 22, 'wire receipt', 'Wire Receipt recovered during source meeting.', 'BIA-EV-0078 sealed; custody log 2 entries', 44, 'Yuki Tanaka', '2024-08-07 17:59:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (79, 31, 'burner phone', 'Burner Phone recovered during source meeting.', 'BIA-EV-0079 sealed; custody log 3 entries', 49, 'Tom Becker', '2025-01-16 09:41:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (80, 37, 'photograph', 'Photograph recovered during records request.', 'BIA-EV-0080 sealed; custody log 6 entries', 48, 'Marcus Reed', '2024-11-02 13:56:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (81, 26, 'document', 'Document recovered during source meeting.', 'BIA-EV-0081 sealed; custody log 3 entries', 48, 'Tom Becker', '2024-08-24 00:19:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (82, 22, 'photograph', 'Photograph recovered during surveillance op.', 'BIA-EV-0082 sealed; custody log 5 entries', 42, 'Viktor Antonov', '2025-04-30 15:56:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (83, 30, 'shipping manifest', 'Shipping Manifest recovered during surveillance op.', 'BIA-EV-0083 sealed; custody log 3 entries', 8, 'Leila Haddad', '2024-10-27 23:29:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (84, 1, 'ledger', 'Ledger recovered during surveillance op.', 'BIA-EV-0084 sealed; custody log 4 entries', 37, 'Amara Diallo', '2024-11-02 19:54:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (85, 28, 'wire receipt', 'Wire Receipt recovered during forensic imaging.', 'BIA-EV-0085 sealed; custody log 4 entries', 6, 'Priya Sharma', '2024-09-22 05:08:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (86, 1, 'hard drive', 'Hard Drive recovered during surveillance op.', 'BIA-EV-0086 sealed; custody log 5 entries', 16, 'Hannah Weiss', '2024-12-19 10:19:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (87, 1, 'contract', 'Contract recovered during surveillance op.', 'BIA-EV-0087 sealed; custody log 4 entries', 45, 'Jack Sullivan', '2024-09-05 21:07:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (88, 26, 'ledger', 'Ledger recovered during forensic imaging.', 'BIA-EV-0088 sealed; custody log 4 entries', 45, 'David Okafor', '2025-01-03 11:39:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (89, 31, 'ledger', 'Ledger recovered during records request.', 'BIA-EV-0089 sealed; custody log 5 entries', 48, 'Robert Ngata', '2025-02-12 13:44:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (90, 35, 'surveillance tape', 'Surveillance Tape recovered during records request.', 'BIA-EV-0090 sealed; custody log 3 entries', 44, 'Robert Ngata', '2024-09-15 16:28:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (91, 5, 'bank statement', 'Bank Statement recovered during forensic imaging.', 'BIA-EV-0091 sealed; custody log 2 entries', 4, 'Viktor Antonov', '2025-04-20 06:36:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (92, 21, 'ledger', 'Ledger recovered during forensic imaging.', 'BIA-EV-0092 sealed; custody log 5 entries', 8, 'Amara Diallo', '2025-04-12 02:57:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (93, 30, 'document', 'Document recovered during records request.', 'BIA-EV-0093 sealed; custody log 6 entries', 27, 'Hannah Weiss', '2025-05-20 01:35:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (94, 2, 'phone dump', 'Phone Dump recovered during source meeting.', 'BIA-EV-0094 sealed; custody log 4 entries', 1, 'Amara Diallo', '2024-09-11 01:27:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (95, 35, 'photograph', 'Photograph recovered during site visit.', 'BIA-EV-0095 sealed; custody log 2 entries', 31, 'Marcus Reed', '2024-12-29 13:11:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (96, 27, 'burner phone', 'Burner Phone recovered during surveillance op.', 'BIA-EV-0096 sealed; custody log 5 entries', 29, 'Priya Sharma', '2025-02-13 02:43:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (97, 9, 'shipping manifest', 'Shipping Manifest recovered during surveillance op.', 'BIA-EV-0097 sealed; custody log 2 entries', 12, 'Viktor Antonov', '2025-02-22 16:08:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (98, 1, 'hard drive', 'Hard Drive recovered during site visit.', 'BIA-EV-0098 sealed; custody log 4 entries', 30, 'Viktor Antonov', '2025-03-10 17:24:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (99, 30, 'hard drive', 'Hard Drive recovered during surveillance op.', 'BIA-EV-0099 sealed; custody log 3 entries', 18, 'Amara Diallo', '2024-10-01 01:51:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (100, 2, 'contract', 'Contract recovered during records request.', 'BIA-EV-0100 sealed; custody log 3 entries', 5, 'David Okafor', '2024-08-22 14:38:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (101, 16, 'document', 'Document recovered during site visit.', 'BIA-EV-0101 sealed; custody log 5 entries', 29, 'Jack Sullivan', '2025-05-08 06:48:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (102, 9, 'document', 'Document recovered during forensic imaging.', 'BIA-EV-0102 sealed; custody log 4 entries', 15, 'Isabel Romero', '2025-01-15 18:38:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (103, 16, 'bank statement', 'Bank Statement recovered during surveillance op.', 'BIA-EV-0103 sealed; custody log 3 entries', 43, 'Grace Liu', '2024-11-26 13:19:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (104, 38, 'shipping manifest', 'Shipping Manifest recovered during records request.', 'BIA-EV-0104 sealed; custody log 5 entries', 36, 'Omar Farouk', '2024-08-29 11:41:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (105, 21, 'shipping manifest', 'Shipping Manifest recovered during source meeting.', 'BIA-EV-0105 sealed; custody log 5 entries', 10, 'Tom Becker', '2025-02-13 05:48:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (106, 15, 'hard drive', 'Hard Drive recovered during surveillance op.', 'BIA-EV-0106 sealed; custody log 3 entries', 30, 'Marcus Reed', '2025-05-19 13:26:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (107, 25, 'ledger', 'Ledger recovered during records request.', 'BIA-EV-0107 sealed; custody log 4 entries', 14, 'Leila Haddad', '2024-09-14 14:54:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (108, 13, 'shipping manifest', 'Shipping Manifest recovered during site visit.', 'BIA-EV-0108 sealed; custody log 4 entries', 25, 'Robert Ngata', '2024-08-25 02:12:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (109, 36, 'contract', 'Contract recovered during records request.', 'BIA-EV-0109 sealed; custody log 5 entries', 14, 'Leila Haddad', '2025-01-07 00:13:00+00', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (110, 31, 'photograph', 'Photograph recovered during records request.', 'BIA-EV-0110 sealed; custody log 6 entries', 46, 'Amara Diallo', '2025-02-24 07:35:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (111, 25, 'phone dump', 'Phone Dump recovered during source meeting.', 'BIA-EV-0111 sealed; custody log 6 entries', 42, 'Erik Johansson', '2025-01-09 08:23:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (112, 30, 'surveillance tape', 'Surveillance Tape recovered during site visit.', 'BIA-EV-0112 sealed; custody log 5 entries', 49, 'Leila Haddad', '2024-11-17 11:20:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (113, 15, 'contract', 'Contract recovered during records request.', 'BIA-EV-0113 sealed; custody log 2 entries', 17, 'Viktor Antonov', '2025-03-06 09:09:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (114, 25, 'hard drive', 'Hard Drive recovered during forensic imaging.', 'BIA-EV-0114 sealed; custody log 3 entries', 32, 'Viktor Antonov', '2025-01-24 08:48:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (115, 32, 'burner phone', 'Burner Phone recovered during source meeting.', 'BIA-EV-0115 sealed; custody log 3 entries', 47, 'Erik Johansson', '2024-10-30 04:46:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (116, 35, 'ledger', 'Ledger recovered during site visit.', 'BIA-EV-0116 sealed; custody log 6 entries', 3, 'Sofia Lindqvist', '2024-08-29 00:26:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (117, 15, 'burner phone', 'Burner Phone recovered during site visit.', 'BIA-EV-0117 sealed; custody log 3 entries', 1, 'Amara Diallo', '2025-04-20 14:23:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (118, 40, 'burner phone', 'Burner Phone recovered during source meeting.', 'BIA-EV-0118 sealed; custody log 5 entries', 2, 'Helena Voss', '2025-05-04 17:26:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (119, 18, 'shipping manifest', 'Shipping Manifest recovered during forensic imaging.', 'BIA-EV-0119 sealed; custody log 4 entries', 2, 'Grace Liu', '2025-03-13 05:06:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.evidence (id, case_id, kind, description, chain_of_custody, storage_location_id, collected_by, collected_at, integrity_verified, owner_id) VALUES (120, 10, 'shipping manifest', 'Shipping Manifest recovered during records request.', 'BIA-EV-0120 sealed; custody log 3 entries', 40, 'Grace Liu', '2024-12-12 11:17:00+00', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- leads: 80 rows +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (1, 6, 24, 'financial analysis', 'high', 'investigating', 'Informant reports cash courier run on Fridays.', '2024-11-20 09:43:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (2, 6, NULL, 'wiretap', 'medium', 'dead_end', 'Repeated structuring just under reporting threshold.', '2025-02-12 15:03:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (3, 1, NULL, 'wiretap', 'medium', 'dead_end', 'Informant reports cash courier run on Fridays.', '2025-04-12 10:36:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (4, 7, NULL, 'bank alert', 'low', 'escalated', 'Repeated structuring just under reporting threshold.', '2024-12-02 01:59:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (5, 5, 44, 'informant', 'low', 'corroborated', 'Subject seen meeting unknown male at marina.', '2024-11-16 18:09:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (6, 26, NULL, 'customs flag', 'low', 'escalated', 'Invoice totals diverge from customs declaration.', '2025-02-16 12:43:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (7, 35, 22, 'open source', 'confirmed', 'new', 'Vehicle plate matched at second location.', '2025-04-02 11:03:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (8, 7, NULL, 'wiretap', 'medium', 'escalated', 'Encrypted app group references ''the gallery''.', '2025-02-28 19:25:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (9, 21, 2, 'financial analysis', 'medium', 'investigating', 'New shell entity registered with shared director.', '2025-02-12 12:27:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (10, 12, 44, 'wiretap', 'high', 'corroborated', 'Encrypted app group references ''the gallery''.', '2024-11-26 22:04:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (11, 6, NULL, 'field observation', 'confirmed', 'investigating', 'Informant reports cash courier run on Fridays.', '2025-01-01 10:23:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (12, 7, NULL, 'customs flag', 'medium', 'corroborated', 'Encrypted app group references ''the gallery''.', '2024-12-02 03:08:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (13, 6, 12, 'financial analysis', 'high', 'escalated', 'Vehicle plate matched at second location.', '2025-01-06 03:01:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (14, 6, NULL, 'anonymous tip', 'medium', 'new', 'New shell entity registered with shared director.', '2025-01-07 12:49:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (15, 6, NULL, 'open source', 'confirmed', 'dead_end', 'Invoice totals diverge from customs declaration.', '2024-10-29 08:19:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (16, 18, NULL, 'wiretap', 'low', 'dead_end', 'New shell entity registered with shared director.', '2025-01-09 09:30:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (17, 5, 24, 'bank alert', 'confirmed', 'dead_end', 'Vehicle plate matched at second location.', '2025-02-28 06:29:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (18, 7, 9, 'informant', 'medium', 'corroborated', 'Encrypted app group references ''the gallery''.', '2025-03-01 12:51:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (19, 22, 29, 'field observation', 'confirmed', 'escalated', 'Invoice totals diverge from customs declaration.', '2024-12-09 10:38:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (20, 13, 31, 'open source', 'medium', 'corroborated', 'New shell entity registered with shared director.', '2025-03-31 22:40:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (21, 32, NULL, 'anonymous tip', 'medium', 'corroborated', 'Encrypted app group references ''the gallery''.', '2025-04-13 12:23:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (22, 8, NULL, 'open source', 'confirmed', 'escalated', 'Subject seen meeting unknown male at marina.', '2025-03-29 14:45:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (23, 14, 29, 'wiretap', 'medium', 'dead_end', 'Invoice totals diverge from customs declaration.', '2025-03-05 09:15:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (24, 17, NULL, 'field observation', 'medium', 'new', 'Repeated structuring just under reporting threshold.', '2025-02-24 15:37:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (25, 26, 35, 'informant', 'medium', 'escalated', 'Vehicle plate matched at second location.', '2025-03-06 02:06:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (26, 16, 43, 'open source', 'confirmed', 'escalated', 'Subject seen meeting unknown male at marina.', '2025-02-15 20:43:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (27, 26, 49, 'field observation', 'unverified', 'new', 'Subject seen meeting unknown male at marina.', '2024-11-29 07:32:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (28, 34, NULL, 'financial analysis', 'medium', 'dead_end', 'Repeated structuring just under reporting threshold.', '2025-04-09 21:37:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (29, 33, 10, 'informant', 'medium', 'new', 'New shell entity registered with shared director.', '2025-01-08 02:07:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (30, 10, 23, 'anonymous tip', 'medium', 'investigating', 'Vehicle plate matched at second location.', '2025-01-25 11:30:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (31, 7, 29, 'anonymous tip', 'unverified', 'corroborated', 'Subject seen meeting unknown male at marina.', '2025-04-19 22:07:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (32, 2, 55, 'wiretap', 'confirmed', 'investigating', 'Informant reports cash courier run on Fridays.', '2024-11-25 16:11:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (33, 36, 11, 'field observation', 'medium', 'investigating', 'Encrypted app group references ''the gallery''.', '2025-01-05 20:11:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (34, 12, 41, 'field observation', 'unverified', 'escalated', 'Invoice totals diverge from customs declaration.', '2025-01-17 18:27:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (35, 25, NULL, 'bank alert', 'medium', 'new', 'Vehicle plate matched at second location.', '2024-10-20 17:11:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (36, 24, NULL, 'financial analysis', 'unverified', 'corroborated', 'Informant reports cash courier run on Fridays.', '2025-05-02 15:33:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (37, 21, 39, 'field observation', 'high', 'new', 'New shell entity registered with shared director.', '2024-12-23 14:39:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (38, 12, 53, 'wiretap', 'confirmed', 'investigating', 'New shell entity registered with shared director.', '2024-10-24 07:19:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (39, 8, 12, 'open source', 'high', 'dead_end', 'Repeated structuring just under reporting threshold.', '2025-02-23 04:36:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (40, 25, NULL, 'financial analysis', 'confirmed', 'escalated', 'Informant reports cash courier run on Fridays.', '2025-05-17 20:59:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (41, 12, NULL, 'financial analysis', 'medium', 'investigating', 'Invoice totals diverge from customs declaration.', '2024-12-13 14:39:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (42, 4, 56, 'informant', 'medium', 'investigating', 'Invoice totals diverge from customs declaration.', '2025-04-20 12:35:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (43, 33, 42, 'field observation', 'confirmed', 'dead_end', 'Repeated structuring just under reporting threshold.', '2025-03-24 14:31:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (44, 11, NULL, 'bank alert', 'medium', 'new', 'New shell entity registered with shared director.', '2024-11-20 17:18:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (45, 11, 50, 'financial analysis', 'high', 'escalated', 'Subject seen meeting unknown male at marina.', '2025-02-17 03:17:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (46, 35, 53, 'informant', 'confirmed', 'dead_end', 'Vehicle plate matched at second location.', '2024-11-18 13:06:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (47, 16, NULL, 'financial analysis', 'medium', 'corroborated', 'Encrypted app group references ''the gallery''.', '2025-04-21 02:28:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (48, 8, NULL, 'bank alert', 'confirmed', 'escalated', 'Informant reports cash courier run on Fridays.', '2024-12-22 22:39:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (49, 28, NULL, 'bank alert', 'low', 'new', 'Vehicle plate matched at second location.', '2024-12-22 17:17:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (50, 39, NULL, 'anonymous tip', 'confirmed', 'corroborated', 'New shell entity registered with shared director.', '2025-02-17 08:54:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (51, 33, NULL, 'open source', 'medium', 'new', 'New shell entity registered with shared director.', '2025-05-03 20:08:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (52, 9, NULL, 'anonymous tip', 'low', 'dead_end', 'Repeated structuring just under reporting threshold.', '2024-10-30 18:40:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (53, 18, 41, 'field observation', 'medium', 'new', 'Informant reports cash courier run on Fridays.', '2025-04-15 02:25:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (54, 33, 48, 'anonymous tip', 'medium', 'dead_end', 'New shell entity registered with shared director.', '2025-02-19 00:55:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (55, 6, 47, 'anonymous tip', 'unverified', 'escalated', 'Repeated structuring just under reporting threshold.', '2024-11-18 13:52:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (56, 14, 32, 'anonymous tip', 'medium', 'corroborated', 'Vehicle plate matched at second location.', '2025-02-18 04:36:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (57, 32, 51, 'informant', 'unverified', 'new', 'Informant reports cash courier run on Fridays.', '2025-04-12 14:01:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (58, 8, NULL, 'financial analysis', 'medium', 'new', 'Repeated structuring just under reporting threshold.', '2024-11-14 22:58:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (59, 9, 57, 'open source', 'medium', 'new', 'Informant reports cash courier run on Fridays.', '2024-12-25 08:05:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (60, 24, NULL, 'informant', 'medium', 'corroborated', 'Informant reports cash courier run on Fridays.', '2025-03-22 07:27:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (61, 6, NULL, 'informant', 'low', 'dead_end', 'Subject seen meeting unknown male at marina.', '2024-10-27 18:14:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (62, 34, 44, 'informant', 'unverified', 'corroborated', 'Encrypted app group references ''the gallery''.', '2024-10-24 13:44:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (63, 9, NULL, 'bank alert', 'medium', 'new', 'Informant reports cash courier run on Fridays.', '2025-04-18 03:06:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (64, 21, 24, 'open source', 'medium', 'investigating', 'Informant reports cash courier run on Fridays.', '2025-03-16 04:04:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (65, 34, NULL, 'open source', 'medium', 'corroborated', 'Informant reports cash courier run on Fridays.', '2024-11-17 20:47:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (66, 10, 59, 'financial analysis', 'low', 'new', 'Subject seen meeting unknown male at marina.', '2024-10-29 03:37:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (67, 25, 23, 'anonymous tip', 'low', 'investigating', 'New shell entity registered with shared director.', '2025-03-08 02:15:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (68, 36, 39, 'informant', 'confirmed', 'corroborated', 'Invoice totals diverge from customs declaration.', '2025-02-03 19:32:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (69, 13, 48, 'customs flag', 'confirmed', 'new', 'Encrypted app group references ''the gallery''.', '2025-05-11 07:31:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (70, 25, NULL, 'financial analysis', 'confirmed', 'escalated', 'Subject seen meeting unknown male at marina.', '2025-02-06 00:23:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (71, 21, 9, 'field observation', 'medium', 'escalated', 'Informant reports cash courier run on Fridays.', '2024-11-07 15:49:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (72, 5, NULL, 'informant', 'medium', 'investigating', 'Subject seen meeting unknown male at marina.', '2025-05-18 01:50:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (73, 26, 33, 'field observation', 'medium', 'dead_end', 'Subject seen meeting unknown male at marina.', '2025-03-06 17:34:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (74, 40, 10, 'wiretap', 'medium', 'new', 'Vehicle plate matched at second location.', '2025-05-17 06:51:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (75, 10, 35, 'field observation', 'high', 'new', 'New shell entity registered with shared director.', '2025-03-21 13:46:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (76, 16, NULL, 'wiretap', 'medium', 'new', 'Repeated structuring just under reporting threshold.', '2025-02-27 19:03:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (77, 20, NULL, 'wiretap', 'unverified', 'investigating', 'Informant reports cash courier run on Fridays.', '2024-09-26 05:31:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (78, 23, 34, 'open source', 'medium', 'investigating', 'Informant reports cash courier run on Fridays.', '2025-04-11 08:46:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (79, 8, NULL, 'anonymous tip', 'medium', 'corroborated', 'Vehicle plate matched at second location.', '2024-10-10 08:45:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.leads (id, case_id, subject_id, source, credibility, status, detail, received_at, owner_id) VALUES (80, 37, NULL, 'financial analysis', 'medium', 'escalated', 'New shell entity registered with shared director.', '2024-10-08 15:54:00+00', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- transactions: 150 rows +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (1, 44, 58365.31, 'USDT', 'Westport Capital SA', 'MT81901102434', FALSE, '2024-06-02 01:54:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (2, 36, 5145.88, 'USDT', 'Goldquay Exchange', 'MT41446900204', TRUE, '2024-12-31 23:20:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (3, 12, 6043.82, 'EUR', 'Novum Estates', 'CH65637347400', FALSE, '2024-07-06 14:55:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (4, 13, 9086.3, 'USD', 'Cygnus Consulting GmbH', 'AE94780833284', FALSE, '2024-10-03 14:23:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (5, 49, 354257.77, 'EUR', 'Lumen Art Holdings', 'CH69839191762', FALSE, '2024-06-13 07:34:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (6, 25, 167458.16, 'USDT', 'Hyperion Commodities', 'CH79600779841', FALSE, '2024-09-30 13:31:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (7, 58, 2181.36, 'AED', 'Goldquay Exchange', 'AE21802138071', FALSE, '2025-03-25 03:32:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (8, 9, 39272.95, 'EUR', 'Hyperion Commodities', 'AE56604319490', FALSE, '2024-12-19 22:53:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (9, 50, 4974.82, 'AED', 'Westport Capital SA', 'MT31928633067', TRUE, '2025-01-17 14:20:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (10, 17, 9769.57, 'EUR', 'Westport Capital SA', 'MT16886131772', TRUE, '2024-09-19 10:09:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (11, 17, 9395.85, 'EUR', 'Aurelia Trade FZE', 'VG73800026806', FALSE, '2025-03-12 23:35:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (12, 54, 1817.17, 'EUR', 'Lumen Art Holdings', 'MT65077257928', TRUE, '2024-12-08 21:40:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (13, 13, 6175.07, 'USDT', 'Pelican Freight LLC', 'MT56423817061', TRUE, '2024-08-02 01:29:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (14, 25, 6064.96, 'USDT', 'Goldquay Exchange', 'MT37289978489', TRUE, '2024-09-04 08:17:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (15, 32, 9618.56, 'CHF', 'Novum Estates', 'VG18851837696', FALSE, '2024-10-22 12:08:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (16, 51, 306454.56, 'EUR', 'Goldquay Exchange', 'AE85074673008', TRUE, '2025-02-04 11:58:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (17, 2, 4395.43, 'USD', 'Aurelia Trade FZE', 'VG59518534637', FALSE, '2025-03-27 07:10:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (18, 4, 381881.49, 'EUR', 'Cygnus Consulting GmbH', 'VG35105032610', TRUE, '2024-08-30 07:37:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (19, 23, 23257.09, 'AED', 'Cygnus Consulting GmbH', 'MT85128541725', FALSE, '2024-11-06 05:31:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (20, 22, 2993.61, 'AED', 'Kestrel Marine Ltd', 'MT56678637003', TRUE, '2024-07-01 00:09:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (21, 5, 9110.81, 'USD', 'Goldquay Exchange', 'PA53383602828', FALSE, '2024-11-02 10:08:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (22, 12, 9638.63, 'USDT', 'Novum Estates', 'PA60624744274', FALSE, '2024-09-23 21:37:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (23, 45, 7505.95, 'AED', 'Goldquay Exchange', 'PA31613919307', FALSE, '2025-04-05 10:58:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (24, 5, 76102.38, 'EUR', 'Sable Trust (BVI)', 'AE96620416891', TRUE, '2024-07-16 22:42:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (25, 5, 108551.61, 'USD', 'Hyperion Commodities', 'CH13094152811', FALSE, '2024-06-25 12:28:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (26, 35, 9413.61, 'CHF', 'Westport Capital SA', 'VG98382075344', FALSE, '2025-05-18 07:10:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (27, 5, 8511.86, 'USD', 'Lumen Art Holdings', 'VG83336576969', TRUE, '2024-07-18 10:38:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (28, 57, 317208.53, 'CHF', 'Lumen Art Holdings', 'CH53384571945', FALSE, '2024-09-14 06:26:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (29, 16, 9539.59, 'EUR', 'Aurelia Trade FZE', 'VG85470176665', FALSE, '2025-02-07 04:03:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (30, 13, 9823.07, 'EUR', 'Aurelia Trade FZE', 'MT96633064605', TRUE, '2024-10-28 02:30:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (31, 42, 4477.21, 'AED', 'Goldquay Exchange', 'MT58882934852', TRUE, '2024-09-04 14:02:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (32, 49, 3270.61, 'USDT', 'Westport Capital SA', 'CH60114284741', FALSE, '2025-03-05 13:48:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (33, 44, 255613.67, 'CHF', 'Cygnus Consulting GmbH', 'MT50700236604', TRUE, '2024-07-11 12:57:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (34, 10, 278598.14, 'EUR', 'Pelican Freight LLC', 'MT28007621418', TRUE, '2024-06-22 13:05:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (35, 21, 9626.6, 'USDT', 'Westport Capital SA', 'CH84681317213', FALSE, '2024-12-21 17:20:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (36, 34, 339193.7, 'CHF', 'Westport Capital SA', 'VG27822578077', FALSE, '2025-03-18 18:37:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (37, 57, 144463.64, 'USD', 'Goldquay Exchange', 'AE97544505099', FALSE, '2025-02-04 18:38:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (38, 26, 296067.97, 'EUR', 'Goldquay Exchange', 'AE43030505080', TRUE, '2024-06-26 23:30:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (39, 54, 9816.57, 'CHF', 'Westport Capital SA', 'MT49231887075', FALSE, '2025-01-13 10:17:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (40, 48, 9187.75, 'CHF', 'Novum Estates', 'AE17821210150', FALSE, '2025-02-19 05:04:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (41, 2, 256414.52, 'EUR', 'Kestrel Marine Ltd', 'MT17889754781', FALSE, '2024-10-06 01:29:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (42, 26, 397047.96, 'USDT', 'Westport Capital SA', 'CH58283756474', FALSE, '2025-02-06 19:28:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (43, 48, 9550.78, 'USDT', 'Westport Capital SA', 'AE61657506498', FALSE, '2024-06-26 05:54:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (44, 18, 2828.87, 'USDT', 'Goldquay Exchange', 'PA74462725984', FALSE, '2024-08-07 20:11:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (45, 46, 453212.33, 'USDT', 'Hyperion Commodities', 'VG65385677644', FALSE, '2024-11-26 14:16:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (46, 1, 6994.77, 'AED', 'Sable Trust (BVI)', 'PA94384691859', FALSE, '2025-04-08 09:19:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (47, 17, 9987.37, 'USDT', 'Hyperion Commodities', 'MT79363418150', TRUE, '2024-10-02 01:51:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (48, 19, 9139.79, 'USD', 'Goldquay Exchange', 'VG87461105299', FALSE, '2025-03-20 06:21:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (49, 23, 233595.54, 'AED', 'Hyperion Commodities', 'MT86778960479', FALSE, '2024-09-01 09:06:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (50, 44, 3227.87, 'USD', 'Westport Capital SA', 'MT41590701658', TRUE, '2024-11-05 13:50:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (51, 56, 6170.14, 'USDT', 'Cygnus Consulting GmbH', 'PA95660120472', FALSE, '2024-07-07 15:56:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (52, 56, 8707.48, 'EUR', 'Lumen Art Holdings', 'MT67306330800', FALSE, '2025-03-19 14:09:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (53, 21, 2523.21, 'USDT', 'Aurelia Trade FZE', 'CH46301292204', FALSE, '2024-09-16 05:36:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (54, 28, 298805.48, 'CHF', 'Goldquay Exchange', 'VG11646331504', FALSE, '2024-06-20 17:12:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (55, 2, 1026.69, 'USDT', 'Aurelia Trade FZE', 'MT61011262744', FALSE, '2024-11-07 04:06:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (56, 30, 9065.22, 'USDT', 'Novum Estates', 'PA55200741117', FALSE, '2025-01-09 21:08:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (57, 2, 9749.37, 'EUR', 'Kestrel Marine Ltd', 'MT87403528096', FALSE, '2025-02-14 05:07:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (58, 40, 310950.91, 'EUR', 'Cygnus Consulting GmbH', 'CH25728041037', FALSE, '2025-05-01 00:03:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (59, 10, 128787.0, 'EUR', 'Aurelia Trade FZE', 'AE46594449524', TRUE, '2025-04-15 19:44:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (60, 8, 9511.07, 'EUR', 'Pelican Freight LLC', 'MT38385446636', FALSE, '2024-10-13 16:12:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (61, 45, 3558.16, 'CHF', 'Sable Trust (BVI)', 'MT10011759631', FALSE, '2025-05-10 02:58:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (62, 5, 52338.86, 'USDT', 'Westport Capital SA', 'AE42832176517', FALSE, '2025-01-20 09:16:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (63, 52, 9873.27, 'USDT', 'Sable Trust (BVI)', 'VG26056907900', TRUE, '2024-07-19 01:00:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (64, 18, 215725.82, 'USDT', 'Westport Capital SA', 'AE56449917928', FALSE, '2024-09-02 03:25:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (65, 42, 9385.33, 'EUR', 'Westport Capital SA', 'MT23569499934', FALSE, '2024-12-31 18:30:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (66, 56, 2531.35, 'USDT', 'Lumen Art Holdings', 'VG78377588683', FALSE, '2024-07-28 13:28:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (67, 50, 418116.17, 'USDT', 'Cygnus Consulting GmbH', 'PA56699796019', TRUE, '2025-02-21 16:44:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (68, 44, 236506.95, 'USDT', 'Goldquay Exchange', 'MT95846911329', FALSE, '2024-06-28 00:41:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (69, 21, 9495.34, 'EUR', 'Sable Trust (BVI)', 'AE78935731400', FALSE, '2024-09-23 17:41:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (70, 6, 9458.64, 'CHF', 'Pelican Freight LLC', 'AE12806215682', FALSE, '2024-07-23 12:26:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (71, 41, 293975.54, 'USD', 'Novum Estates', 'PA96787765252', FALSE, '2025-05-10 14:21:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (72, 39, 166027.13, 'CHF', 'Sable Trust (BVI)', 'VG23374628496', FALSE, '2025-05-04 07:00:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (73, 45, 9799.11, 'EUR', 'Aurelia Trade FZE', 'MT29294806194', FALSE, '2024-12-27 03:16:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (74, 46, 1986.71, 'USD', 'Westport Capital SA', 'VG90196238251', TRUE, '2024-08-03 14:29:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (75, 4, 12038.69, 'AED', 'Goldquay Exchange', 'PA33959708666', FALSE, '2024-08-05 03:24:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (76, 59, 190791.44, 'USDT', 'Kestrel Marine Ltd', 'AE71393007875', FALSE, '2024-08-08 04:26:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (77, 46, 9298.61, 'USDT', 'Novum Estates', 'MT36632615814', FALSE, '2025-04-16 22:18:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (78, 33, 6951.36, 'AED', 'Sable Trust (BVI)', 'CH60512144127', TRUE, '2025-03-15 15:37:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (79, 19, 430117.02, 'CHF', 'Aurelia Trade FZE', 'PA10457287198', FALSE, '2024-12-13 04:03:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (80, 17, 472260.23, 'EUR', 'Aurelia Trade FZE', 'AE16619283717', TRUE, '2024-12-01 14:06:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (81, 25, 358933.99, 'EUR', 'Novum Estates', 'AE63178492476', FALSE, '2024-12-31 21:20:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (82, 54, 9202.88, 'EUR', 'Sable Trust (BVI)', 'PA38361331106', TRUE, '2024-09-08 09:28:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (83, 25, 1570.22, 'EUR', 'Goldquay Exchange', 'AE46750311258', FALSE, '2024-08-06 05:37:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (84, 39, 9936.56, 'EUR', 'Cygnus Consulting GmbH', 'AE85474072966', TRUE, '2024-12-01 02:50:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (85, 11, 42042.94, 'CHF', 'Novum Estates', 'PA20322597699', TRUE, '2024-11-26 07:56:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (86, 34, 456644.89, 'CHF', 'Novum Estates', 'CH71615632146', FALSE, '2024-10-02 20:39:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (87, 25, 1258.9, 'USD', 'Novum Estates', 'AE60553888179', TRUE, '2025-02-19 16:28:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (88, 11, 8984.53, 'AED', 'Novum Estates', 'CH21282591665', TRUE, '2024-09-16 21:40:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (89, 57, 262744.48, 'USDT', 'Goldquay Exchange', 'CH10660370908', FALSE, '2025-01-22 22:21:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (90, 53, 272299.58, 'EUR', 'Hyperion Commodities', 'VG71796129501', TRUE, '2025-04-19 12:27:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (91, 5, 311468.69, 'USDT', 'Cygnus Consulting GmbH', 'CH57556409567', TRUE, '2025-02-13 03:18:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (92, 53, 9764.51, 'USDT', 'Aurelia Trade FZE', 'VG83685848236', TRUE, '2024-09-21 04:47:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (93, 44, 9294.68, 'USDT', 'Sable Trust (BVI)', 'CH29563875591', FALSE, '2024-08-03 01:00:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (94, 48, 153204.84, 'USDT', 'Sable Trust (BVI)', 'MT29381266264', FALSE, '2024-08-22 05:14:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (95, 55, 2648.94, 'AED', 'Pelican Freight LLC', 'PA48968859216', FALSE, '2025-05-10 02:16:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (96, 42, 177855.03, 'EUR', 'Westport Capital SA', 'AE21250734621', TRUE, '2024-09-08 10:31:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (97, 24, 81625.63, 'USD', 'Goldquay Exchange', 'MT52970425752', TRUE, '2024-10-05 07:45:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (98, 57, 9359.13, 'CHF', 'Pelican Freight LLC', 'VG75736500804', FALSE, '2025-03-10 03:11:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (99, 13, 75800.78, 'USD', 'Westport Capital SA', 'CH13831562592', FALSE, '2025-02-15 20:01:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (100, 35, 9212.55, 'AED', 'Pelican Freight LLC', 'MT63110786618', FALSE, '2024-09-23 18:46:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (101, 43, 5410.11, 'EUR', 'Cygnus Consulting GmbH', 'MT81971652051', TRUE, '2024-08-11 14:10:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (102, 31, 78713.55, 'USD', 'Aurelia Trade FZE', 'MT77695008395', FALSE, '2024-09-27 02:16:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (103, 31, 9517.37, 'CHF', 'Aurelia Trade FZE', 'CH24276776735', TRUE, '2025-05-27 16:40:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (104, 25, 91709.41, 'USD', 'Cygnus Consulting GmbH', 'CH46578788612', FALSE, '2024-12-10 07:33:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (105, 1, 236591.74, 'AED', 'Lumen Art Holdings', 'VG40776042671', FALSE, '2024-07-10 16:41:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (106, 43, 2582.15, 'EUR', 'Goldquay Exchange', 'AE25978376387', TRUE, '2025-02-02 21:30:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (107, 9, 2351.22, 'AED', 'Cygnus Consulting GmbH', 'MT96477981280', TRUE, '2025-02-18 01:57:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (108, 39, 9921.52, 'AED', 'Aurelia Trade FZE', 'AE22658358999', TRUE, '2024-09-19 14:38:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (109, 53, 292394.2, 'EUR', 'Cygnus Consulting GmbH', 'PA32610944239', FALSE, '2025-05-18 10:59:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (110, 13, 1519.59, 'AED', 'Novum Estates', 'VG30729720586', FALSE, '2025-04-07 03:11:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (111, 6, 6298.32, 'CHF', 'Lumen Art Holdings', 'PA98813736180', FALSE, '2025-01-07 16:26:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (112, 51, 9428.53, 'CHF', 'Sable Trust (BVI)', 'PA25359321980', FALSE, '2025-01-04 04:16:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (113, 27, 291197.4, 'USD', 'Kestrel Marine Ltd', 'CH13390543744', FALSE, '2025-03-23 16:07:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (114, 7, 9687.08, 'EUR', 'Goldquay Exchange', 'PA45449082653', TRUE, '2025-03-02 13:36:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (115, 51, 105736.02, 'AED', 'Pelican Freight LLC', 'MT84604496619', FALSE, '2025-03-12 15:16:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (116, 8, 74892.2, 'USDT', 'Kestrel Marine Ltd', 'VG35551813484', FALSE, '2024-07-22 06:32:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (117, 11, 8107.68, 'CHF', 'Aurelia Trade FZE', 'CH30715724482', FALSE, '2025-01-28 19:27:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (118, 60, 9079.96, 'CHF', 'Cygnus Consulting GmbH', 'PA95742707683', TRUE, '2024-06-30 05:40:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (119, 13, 9656.23, 'EUR', 'Cygnus Consulting GmbH', 'PA92599397877', FALSE, '2024-08-26 21:02:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (120, 23, 9157.29, 'USDT', 'Goldquay Exchange', 'VG38286998926', TRUE, '2025-02-21 00:47:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (121, 23, 399989.46, 'USD', 'Hyperion Commodities', 'MT70965021496', TRUE, '2024-12-24 18:00:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (122, 8, 9558.07, 'AED', 'Hyperion Commodities', 'PA46828888777', TRUE, '2025-05-22 14:24:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (123, 30, 374531.44, 'AED', 'Aurelia Trade FZE', 'VG44859106926', FALSE, '2024-07-05 12:40:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (124, 35, 459059.47, 'EUR', 'Hyperion Commodities', 'MT19823833959', FALSE, '2024-12-22 21:47:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (125, 18, 176162.83, 'EUR', 'Kestrel Marine Ltd', 'VG38935676781', TRUE, '2024-09-29 23:15:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (126, 50, 2724.9, 'EUR', 'Westport Capital SA', 'CH77273894157', TRUE, '2025-04-11 00:48:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (127, 56, 158245.95, 'USD', 'Pelican Freight LLC', 'MT82653696125', FALSE, '2024-10-22 12:14:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (128, 33, 5631.62, 'USD', 'Goldquay Exchange', 'MT72355216265', TRUE, '2024-11-09 22:00:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (129, 12, 9100.65, 'AED', 'Hyperion Commodities', 'MT75581608986', FALSE, '2025-04-10 10:23:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (130, 18, 9700.01, 'CHF', 'Lumen Art Holdings', 'PA84027219522', FALSE, '2025-01-08 12:12:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (131, 32, 9472.51, 'EUR', 'Novum Estates', 'PA56044456021', FALSE, '2025-02-05 09:16:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (132, 53, 1329.38, 'USD', 'Pelican Freight LLC', 'CH45797077254', FALSE, '2024-12-02 11:35:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (133, 39, 66981.62, 'EUR', 'Cygnus Consulting GmbH', 'PA98690496910', FALSE, '2025-03-03 05:49:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (134, 25, 6958.74, 'AED', 'Hyperion Commodities', 'VG96619077366', FALSE, '2024-11-20 13:40:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (135, 21, 8540.11, 'USD', 'Hyperion Commodities', 'AE98499454567', FALSE, '2024-12-20 08:56:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (136, 27, 9747.15, 'EUR', 'Lumen Art Holdings', 'CH21810042269', FALSE, '2024-09-16 05:25:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (137, 34, 9472.71, 'EUR', 'Westport Capital SA', 'AE29834597811', TRUE, '2024-07-14 20:59:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (138, 34, 3126.57, 'EUR', 'Aurelia Trade FZE', 'CH60094562086', TRUE, '2024-11-14 15:29:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (139, 12, 455788.48, 'AED', 'Cygnus Consulting GmbH', 'VG68256869775', FALSE, '2025-02-24 20:40:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (140, 20, 9539.13, 'USD', 'Goldquay Exchange', 'AE49323507396', TRUE, '2024-06-06 00:33:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (141, 20, 9008.31, 'EUR', 'Goldquay Exchange', 'AE42628882677', TRUE, '2024-06-04 17:23:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (142, 42, 86736.66, 'CHF', 'Goldquay Exchange', 'MT19719426282', FALSE, '2025-01-03 20:15:00+00', 'hawala', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (143, 42, 9806.77, 'USDT', 'Novum Estates', 'AE37543212436', FALSE, '2024-07-06 05:08:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (144, 13, 9452.31, 'USD', 'Aurelia Trade FZE', 'MT12297681898', TRUE, '2024-09-04 03:18:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (145, 32, 4845.75, 'EUR', 'Sable Trust (BVI)', 'VG77858174089', TRUE, '2024-12-08 14:44:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (146, 5, 9950.69, 'AED', 'Pelican Freight LLC', 'MT47974122085', TRUE, '2024-12-13 00:07:00+00', 'invoice', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (147, 13, 7841.03, 'CHF', 'Westport Capital SA', 'PA75319783068', TRUE, '2024-12-17 21:19:00+00', 'cash deposit', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (148, 17, 7454.13, 'CHF', 'Hyperion Commodities', 'MT38633236078', TRUE, '2024-07-28 07:40:00+00', 'crypto', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (149, 1, 2406.24, 'EUR', 'Cygnus Consulting GmbH', 'VG94561062196', TRUE, '2024-08-19 11:49:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.transactions (id, subject_id, amount, currency, counterparty, account_ref, flagged, executed_at, method, owner_id) VALUES (150, 4, 169058.39, 'EUR', 'Sable Trust (BVI)', 'MT86026625457', TRUE, '2024-07-25 04:31:00+00', 'wire', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- vehicles: 30 rows +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (1, 49, 'NL-85495', 'Toyota', 'Land Cruiser', 'white', 2023, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (2, 9, 'HH-95948', 'VW', 'Transporter', 'black', 2019, 46, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (3, 45, 'HH-72137', 'Tesla', 'Model S', 'anthracite', 2021, 12, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (4, NULL, 'F-62393', 'Toyota', 'Land Cruiser', 'black', 2020, 10, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (5, 24, 'HH-71989', 'Porsche', 'Cayenne', 'black', 2024, 41, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (6, 55, 'ZH-82229', 'Audi', 'RS6', 'navy', 2015, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (7, 34, 'B-25886', 'Tesla', 'Model S', 'navy', 2014, 40, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (8, NULL, 'B-85004', 'Toyota', 'Land Cruiser', 'black', 2020, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (9, NULL, 'GE-24414', 'BMW', '740d', 'anthracite', 2013, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (10, 42, 'ZH-75843', 'Mercedes', 'S580', 'grey', 2019, 34, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (11, NULL, 'HH-29930', 'BMW', '740d', 'black', 2015, 42, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (12, 4, 'B-84744', 'Mercedes', 'S580', 'navy', 2023, 27, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (13, NULL, 'NL-35840', 'BMW', '740d', 'grey', 2013, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (14, NULL, 'NL-13729', 'Range Rover', 'Sport', 'navy', 2018, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (15, NULL, 'F-33605', 'Tesla', 'Model S', 'navy', 2023, 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (16, 23, 'F-76571', 'Mercedes', 'S580', 'grey', 2022, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (17, 26, 'GE-73085', 'Audi', 'RS6', 'grey', 2014, 35, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (18, 21, 'HH-73100', 'Toyota', 'Land Cruiser', 'white', 2024, 41, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (19, NULL, 'NL-32551', 'Toyota', 'Land Cruiser', 'white', 2024, 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (20, NULL, 'B-59654', 'Toyota', 'Land Cruiser', 'grey', 2012, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (21, 1, 'GE-40562', 'Tesla', 'Model S', 'anthracite', 2015, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (22, NULL, 'NL-68013', 'Porsche', 'Cayenne', 'black', 2012, 30, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (23, NULL, 'GE-35732', 'Audi', 'RS6', 'anthracite', 2016, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (24, NULL, 'B-75588', 'Ford', 'Transit', 'grey', 2013, 47, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (25, NULL, 'GE-64999', 'Volvo', 'XC90', 'navy', 2022, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (26, NULL, 'B-11286', 'Ford', 'Transit', 'silver', 2017, 28, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (27, NULL, 'GE-98999', 'Mercedes', 'S580', 'grey', 2024, 41, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (28, 3, 'HH-69731', 'BMW', '740d', 'navy', 2021, 10, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (29, NULL, 'F-29940', 'Tesla', 'Model S', 'grey', 2023, 26, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.vehicles (id, owner_subject_id, plate, make, model, color, year, last_seen_location_id, owner_id) VALUES (30, NULL, 'NL-67155', 'Ford', 'Transit', 'white', 2016, NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- communications: 200 rows +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (1, 9, 'email', 'Dmitri Sokolova', '2025-04-02 12:16:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 33, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (2, 28, 'in person', 'Lumen Art Holdings', '2025-04-05 06:31:00+00', 'Subject anxious about ''the audit''.', 'privileged', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (3, 13, 'sms', 'unknown number', '2025-01-14 02:17:00+00', 'Routine personal call — no case relevance.', 'relevant', 21, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (4, 44, 'sms', 'Goldquay Exchange', '2025-02-14 11:12:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 39, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (5, 42, 'phone', 'unregistered SIM', '2025-04-19 14:39:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 3, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (6, 60, 'phone', 'unknown number', '2025-05-25 23:54:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 4, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (7, 16, 'sms', 'Zofia Rahimi', '2025-02-08 22:51:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 29, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (8, 31, 'phone', 'unknown number', '2025-01-04 17:33:00+00', 'Routine personal call — no case relevance.', 'relevant', 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (9, 31, 'sms', 'Aurelia Trade FZE', '2025-04-11 21:33:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 11, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (10, 4, 'email', 'unregistered SIM', '2024-11-26 14:41:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 25, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (11, 43, 'email', 'Odile Almeida', '2024-11-17 09:55:00+00', 'Mentioned contact at the freeport by first name.', 'relevant', 15, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (12, 25, 'sms', 'Sable Trust (BVI)', '2025-04-14 17:05:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 33, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (13, 30, 'sms', 'Zofia Silva', '2025-01-15 16:19:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 26, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (14, 48, 'phone', 'unregistered SIM', '2025-04-22 09:15:00+00', 'Travel plans: short-notice flight, cash ticket.', 'relevant', 4, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (15, 41, 'email', 'unregistered SIM', '2025-01-09 04:21:00+00', 'Subject anxious about ''the audit''.', 'privileged', 9, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (16, 6, 'in person', 'unknown number', '2025-02-27 07:36:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 20, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (17, 59, 'sms', 'unknown number', '2025-01-04 20:08:00+00', 'Travel plans: short-notice flight, cash ticket.', 'incriminating', 13, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (18, 56, 'phone', 'unknown number', '2025-03-03 04:41:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 21, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (19, 5, 'sms', 'unknown number', '2025-02-02 04:46:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 27, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (20, 27, 'email', 'unknown number', '2024-12-31 03:08:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 40, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (21, 40, 'email', 'unknown number', '2025-04-14 07:14:00+00', 'Mentioned contact at the freeport by first name.', 'routine', 23, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (22, 6, 'in person', 'Lumen Art Holdings', '2025-05-14 20:23:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 32, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (23, 31, 'phone', 'Ingrid Vidal', '2025-02-25 00:59:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 15, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (24, 48, 'in person', 'Paulo Wagner', '2024-12-28 14:55:00+00', 'Travel plans: short-notice flight, cash ticket.', 'relevant', 21, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (25, 56, 'encrypted app', 'Goldquay Exchange', '2024-11-23 19:47:00+00', 'Routine personal call — no case relevance.', 'relevant', 21, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (26, 26, 'sms', 'Saskia Petrov', '2025-03-26 03:28:00+00', 'Subject anxious about ''the audit''.', 'routine', 16, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (27, 34, 'sms', 'Aurelia Trade FZE', '2025-03-25 20:03:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 29, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (28, 26, 'email', 'Hyperion Commodities', '2024-12-25 13:18:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 34, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (29, 58, 'phone', 'Bruno Lindgren', '2025-05-16 16:07:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'incriminating', 35, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (30, 9, 'encrypted app', 'Anneke Baranov', '2024-12-28 14:37:00+00', 'Mentioned contact at the freeport by first name.', 'routine', 30, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (31, 6, 'email', 'Sergei Silva', '2024-12-06 03:45:00+00', 'Subject anxious about ''the audit''.', 'privileged', 24, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (32, 6, 'sms', 'unknown number', '2025-02-17 17:24:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 22, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (33, 37, 'in person', 'unknown number', '2024-12-28 22:03:00+00', 'Subject anxious about ''the audit''.', 'relevant', 20, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (34, 47, 'encrypted app', 'Petra Kovac', '2024-12-16 06:00:00+00', 'Travel plans: short-notice flight, cash ticket.', 'relevant', 1, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (35, 30, 'encrypted app', 'Beatriz Sokolova', '2025-04-17 15:15:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'incriminating', 31, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (36, 30, 'encrypted app', 'Bruno Albrecht', '2025-01-14 17:17:00+00', 'Travel plans: short-notice flight, cash ticket.', 'privileged', 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (37, 53, 'phone', 'Lena Sørensen', '2025-04-29 11:02:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (38, 54, 'sms', 'Petra Baranov', '2024-12-10 13:11:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'privileged', 19, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (39, 40, 'in person', 'unknown number', '2025-03-09 23:37:00+00', 'Payment confirmation — amount matches txn pattern.', 'routine', 6, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (40, 50, 'email', 'Sergei Esposito', '2025-05-26 13:53:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 19, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (41, 31, 'phone', 'unregistered SIM', '2025-03-27 13:33:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'privileged', 14, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (42, 40, 'phone', 'Carmen Petrov', '2025-03-16 23:44:00+00', 'Travel plans: short-notice flight, cash ticket.', 'privileged', 19, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (43, 28, 'sms', 'unregistered SIM', '2024-12-16 19:36:00+00', 'Subject anxious about ''the audit''.', 'incriminating', 32, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (44, 32, 'phone', 'unregistered SIM', '2025-01-01 00:07:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 8, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (45, 48, 'phone', 'Goldquay Exchange', '2024-11-24 10:22:00+00', 'Travel plans: short-notice flight, cash ticket.', 'privileged', 14, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (46, 1, 'in person', 'unregistered SIM', '2024-11-30 09:19:00+00', 'Travel plans: short-notice flight, cash ticket.', 'incriminating', 21, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (47, 50, 'phone', 'unregistered SIM', '2025-02-22 23:36:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 12, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (48, 13, 'email', 'Pelican Freight LLC', '2024-11-22 20:08:00+00', 'Subject anxious about ''the audit''.', 'routine', 30, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (49, 58, 'sms', 'Pelican Freight LLC', '2025-05-22 01:53:00+00', 'Travel plans: short-notice flight, cash ticket.', 'relevant', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (50, 47, 'in person', 'Sergei Costa', '2025-01-05 18:43:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 5, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (51, 10, 'email', 'Sable Trust (BVI)', '2025-04-22 17:53:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'incriminating', 39, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (52, 25, 'phone', 'unregistered SIM', '2025-05-13 01:17:00+00', 'Subject anxious about ''the audit''.', 'routine', 32, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (53, 57, 'phone', 'unknown number', '2025-03-27 09:11:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 3, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (54, 50, 'phone', 'Zofia Albrecht', '2025-03-16 16:09:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 31, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (55, 47, 'sms', 'unknown number', '2024-12-08 16:43:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 3, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (56, 30, 'encrypted app', 'Ricardo Almeida', '2024-11-14 09:59:00+00', 'Arranged meeting; location referenced obliquely.', 'privileged', 39, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (57, 56, 'phone', 'Sergei Graf', '2025-03-26 21:47:00+00', 'Payment confirmation — amount matches txn pattern.', 'routine', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (58, 49, 'email', 'unregistered SIM', '2025-01-03 06:18:00+00', 'Payment confirmation — amount matches txn pattern.', 'incriminating', 21, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (59, 50, 'encrypted app', 'unknown number', '2024-12-23 08:07:00+00', 'Arranged meeting; location referenced obliquely.', 'incriminating', 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (60, 33, 'phone', 'unregistered SIM', '2025-01-21 04:45:00+00', 'Routine personal call — no case relevance.', 'relevant', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (61, 16, 'phone', 'unregistered SIM', '2025-03-31 14:03:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 31, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (62, 58, 'in person', 'Lena Duarte', '2024-12-05 13:25:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'privileged', 39, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (63, 58, 'sms', 'unregistered SIM', '2025-01-31 10:52:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'privileged', 35, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (64, 6, 'encrypted app', 'Westport Capital SA', '2024-12-07 14:19:00+00', 'Mentioned contact at the freeport by first name.', 'privileged', 30, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (65, 31, 'phone', 'unregistered SIM', '2025-02-14 12:11:00+00', 'Mentioned contact at the freeport by first name.', 'privileged', 22, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (66, 51, 'email', 'Bruno Sokolova', '2025-03-14 03:22:00+00', 'Subject anxious about ''the audit''.', 'relevant', 11, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (67, 19, 'email', 'Maribel Petrov', '2025-02-24 22:50:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (68, 42, 'phone', 'Novum Estates', '2025-02-23 18:26:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'incriminating', 16, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (69, 36, 'phone', 'unregistered SIM', '2025-01-05 22:37:00+00', 'Subject anxious about ''the audit''.', 'relevant', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (70, 51, 'email', 'unknown number', '2025-05-01 21:04:00+00', 'Mentioned contact at the freeport by first name.', 'privileged', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (71, 54, 'encrypted app', 'Cygnus Consulting GmbH', '2025-02-04 17:06:00+00', 'Routine personal call — no case relevance.', 'relevant', 12, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (72, 20, 'encrypted app', 'Westport Capital SA', '2025-02-04 15:41:00+00', 'Mentioned contact at the freeport by first name.', 'routine', 6, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (73, 14, 'phone', 'unknown number', '2025-05-01 04:28:00+00', 'Travel plans: short-notice flight, cash ticket.', 'relevant', 25, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (74, 40, 'sms', 'unregistered SIM', '2025-01-28 18:50:00+00', 'Mentioned contact at the freeport by first name.', 'relevant', 28, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (75, 46, 'email', 'Aurelia Trade FZE', '2024-12-15 09:03:00+00', 'Payment confirmation — amount matches txn pattern.', 'incriminating', 4, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (76, 1, 'in person', 'Renata Lindgren', '2025-01-24 00:15:00+00', 'Subject anxious about ''the audit''.', 'privileged', 38, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (77, 26, 'in person', 'Goldquay Exchange', '2025-02-18 13:34:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'privileged', 12, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (78, 31, 'phone', 'unregistered SIM', '2025-02-27 04:18:00+00', 'Routine personal call — no case relevance.', 'privileged', 9, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (79, 9, 'phone', 'unknown number', '2025-05-16 15:42:00+00', 'Subject anxious about ''the audit''.', 'routine', 6, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (80, 9, 'in person', 'Pelican Freight LLC', '2025-04-21 05:29:00+00', 'Travel plans: short-notice flight, cash ticket.', 'relevant', 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (81, 53, 'encrypted app', 'unknown number', '2025-03-01 19:00:00+00', 'Travel plans: short-notice flight, cash ticket.', 'relevant', 16, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (82, 46, 'sms', 'unregistered SIM', '2024-11-21 01:36:00+00', 'Subject anxious about ''the audit''.', 'privileged', 24, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (83, 8, 'sms', 'unknown number', '2025-05-18 23:33:00+00', 'Routine personal call — no case relevance.', 'relevant', 30, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (84, 34, 'phone', 'Otto Volkov', '2025-01-14 15:40:00+00', 'Mentioned contact at the freeport by first name.', 'privileged', 5, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (85, 44, 'in person', 'unregistered SIM', '2025-04-18 01:57:00+00', 'Payment confirmation — amount matches txn pattern.', 'incriminating', 1, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (86, 31, 'encrypted app', 'unregistered SIM', '2025-02-28 11:51:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 11, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (87, 3, 'in person', 'unregistered SIM', '2024-11-28 20:30:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (88, 4, 'in person', 'Petra Hakim', '2025-01-25 15:05:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'incriminating', 5, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (89, 55, 'email', 'unregistered SIM', '2025-01-22 08:00:00+00', 'Mentioned contact at the freeport by first name.', 'routine', 39, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (90, 12, 'sms', 'unregistered SIM', '2024-12-27 08:50:00+00', 'Mentioned contact at the freeport by first name.', 'routine', 32, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (91, 12, 'sms', 'Beatriz Keller', '2024-12-20 19:59:00+00', 'Routine personal call — no case relevance.', 'routine', 32, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (92, 49, 'encrypted app', 'Kestrel Marine Ltd', '2025-05-17 20:10:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'incriminating', 23, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (93, 4, 'in person', 'Aldo Wagner', '2024-12-25 14:25:00+00', 'Subject anxious about ''the audit''.', 'relevant', 31, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (94, 48, 'sms', 'Goldquay Exchange', '2025-01-19 04:33:00+00', 'Routine personal call — no case relevance.', 'relevant', 1, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (95, 19, 'encrypted app', 'Farid Marchand', '2024-12-07 17:40:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 40, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (96, 59, 'in person', 'unregistered SIM', '2025-01-20 03:16:00+00', 'Mentioned contact at the freeport by first name.', 'incriminating', 26, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (97, 30, 'in person', 'unknown number', '2025-03-26 10:43:00+00', 'Mentioned contact at the freeport by first name.', 'routine', 21, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (98, 36, 'phone', 'Odile Morozov', '2025-02-26 03:33:00+00', 'Arranged meeting; location referenced obliquely.', 'privileged', 29, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (99, 23, 'phone', 'unknown number', '2024-11-21 00:39:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 39, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (100, 43, 'phone', 'Maribel Kovac', '2024-12-05 19:08:00+00', 'Subject anxious about ''the audit''.', 'privileged', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (101, 52, 'phone', 'unregistered SIM', '2025-01-27 14:42:00+00', 'Arranged meeting; location referenced obliquely.', 'privileged', 30, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (102, 60, 'email', 'unregistered SIM', '2024-12-24 02:17:00+00', 'Routine personal call — no case relevance.', 'routine', 25, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (103, 25, 'email', 'unknown number', '2025-03-05 02:31:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'incriminating', 26, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (104, 12, 'in person', 'Lumen Art Holdings', '2025-01-07 19:41:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 17, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (105, 7, 'phone', 'unregistered SIM', '2025-03-07 00:57:00+00', 'Subject anxious about ''the audit''.', 'routine', 1, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (106, 60, 'phone', 'unknown number', '2025-05-11 10:57:00+00', 'Subject anxious about ''the audit''.', 'incriminating', 1, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (107, 33, 'in person', 'Pelican Freight LLC', '2025-02-24 23:49:00+00', 'Routine personal call — no case relevance.', 'routine', 35, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (108, 15, 'phone', 'Farid Silva', '2025-04-07 17:59:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 20, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (109, 26, 'phone', 'unregistered SIM', '2025-03-29 10:05:00+00', 'Routine personal call — no case relevance.', 'relevant', 19, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (110, 42, 'phone', 'Aurelia Trade FZE', '2025-05-01 06:19:00+00', 'Mentioned contact at the freeport by first name.', 'relevant', 7, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (111, 41, 'in person', 'Westport Capital SA', '2025-05-20 06:08:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 2, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (112, 47, 'sms', 'Mei Sokolova', '2025-03-31 20:19:00+00', 'Subject anxious about ''the audit''.', 'incriminating', 29, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (113, 2, 'phone', 'Sergei Keller', '2025-02-24 03:13:00+00', 'Routine personal call — no case relevance.', 'privileged', 34, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (114, 30, 'in person', 'unknown number', '2025-05-21 18:14:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 10, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (115, 32, 'in person', 'Viktor Sokolova', '2025-01-14 22:41:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 12, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (116, 41, 'phone', 'Westport Capital SA', '2025-04-18 05:53:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 7, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (117, 8, 'encrypted app', 'unknown number', '2024-11-29 06:27:00+00', 'Payment confirmation — amount matches txn pattern.', 'privileged', 26, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (118, 21, 'phone', 'unknown number', '2025-02-03 06:48:00+00', 'Arranged meeting; location referenced obliquely.', 'incriminating', 12, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (119, 22, 'phone', 'Lumen Art Holdings', '2024-12-26 22:23:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 29, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (120, 16, 'sms', 'Westport Capital SA', '2024-12-23 00:56:00+00', 'Subject anxious about ''the audit''.', 'routine', 1, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (121, 1, 'phone', 'unregistered SIM', '2025-02-27 22:51:00+00', 'Routine personal call — no case relevance.', 'relevant', 9, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (122, 14, 'phone', 'Novum Estates', '2025-02-26 07:11:00+00', 'Travel plans: short-notice flight, cash ticket.', 'privileged', 3, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (123, 39, 'phone', 'Ricardo Sokolova', '2025-04-22 09:22:00+00', 'Subject anxious about ''the audit''.', 'routine', 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (124, 16, 'phone', 'unregistered SIM', '2025-05-26 17:32:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 14, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (125, 18, 'phone', 'Lumen Art Holdings', '2025-03-20 05:10:00+00', 'Mentioned contact at the freeport by first name.', 'routine', 4, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (126, 42, 'email', 'unregistered SIM', '2025-02-06 19:51:00+00', 'Routine personal call — no case relevance.', 'relevant', 3, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (127, 7, 'in person', 'unknown number', '2024-12-23 00:31:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 5, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (128, 58, 'phone', 'unregistered SIM', '2025-02-28 06:47:00+00', 'Mentioned contact at the freeport by first name.', 'relevant', 15, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (129, 50, 'phone', 'unknown number', '2025-05-20 13:55:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 15, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (130, 5, 'encrypted app', 'Aurelia Trade FZE', '2024-12-20 06:59:00+00', 'Travel plans: short-notice flight, cash ticket.', 'relevant', 12, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (131, 25, 'email', 'unregistered SIM', '2025-01-21 06:21:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (132, 4, 'email', 'Pelican Freight LLC', '2025-02-28 05:48:00+00', 'Payment confirmation — amount matches txn pattern.', 'routine', 19, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (133, 39, 'phone', 'unknown number', '2025-04-09 17:54:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 27, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (134, 55, 'sms', 'Kestrel Marine Ltd', '2025-03-11 08:00:00+00', 'Mentioned contact at the freeport by first name.', 'relevant', 16, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (135, 16, 'email', 'unknown number', '2025-04-08 00:26:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 31, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (136, 47, 'sms', 'Novum Estates', '2025-03-15 22:52:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 32, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (137, 16, 'phone', 'Aurelia Trade FZE', '2025-01-07 04:22:00+00', 'Payment confirmation — amount matches txn pattern.', 'routine', 6, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (138, 2, 'encrypted app', 'Lumen Art Holdings', '2025-04-08 23:37:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 30, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (139, 50, 'phone', 'unknown number', '2025-02-15 20:29:00+00', 'Mentioned contact at the freeport by first name.', 'privileged', 1, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (140, 56, 'in person', 'Dmitri Haddad', '2025-01-27 13:48:00+00', 'Payment confirmation — amount matches txn pattern.', 'privileged', 40, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (141, 35, 'sms', 'unregistered SIM', '2025-05-20 01:32:00+00', 'Mentioned contact at the freeport by first name.', 'relevant', 4, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (142, 8, 'encrypted app', 'Lucia Graf', '2025-02-14 11:11:00+00', 'Mentioned contact at the freeport by first name.', 'incriminating', 5, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (143, 36, 'in person', 'unregistered SIM', '2025-01-13 17:07:00+00', 'Mentioned contact at the freeport by first name.', 'relevant', 38, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (144, 56, 'in person', 'unregistered SIM', '2025-02-11 08:32:00+00', 'Mentioned contact at the freeport by first name.', 'relevant', 18, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (145, 4, 'phone', 'Pelican Freight LLC', '2025-03-21 16:53:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 35, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (146, 44, 'sms', 'unregistered SIM', '2025-05-04 22:51:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 30, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (147, 6, 'phone', 'unregistered SIM', '2024-12-02 03:10:00+00', 'Payment confirmation — amount matches txn pattern.', 'routine', 10, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (148, 32, 'phone', 'Beatriz Vidal', '2025-01-27 13:43:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 31, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (149, 23, 'encrypted app', 'unregistered SIM', '2024-12-31 02:01:00+00', 'Routine personal call — no case relevance.', 'routine', 34, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (150, 42, 'email', 'unregistered SIM', '2025-03-17 18:42:00+00', 'Mentioned contact at the freeport by first name.', 'incriminating', 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (151, 29, 'encrypted app', 'unregistered SIM', '2024-12-26 08:14:00+00', 'Subject anxious about ''the audit''.', 'relevant', 19, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (152, 50, 'phone', 'Hyperion Commodities', '2024-12-25 19:59:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'privileged', 35, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (153, 19, 'phone', 'unknown number', '2025-01-12 03:02:00+00', 'Payment confirmation — amount matches txn pattern.', 'incriminating', 9, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (154, 29, 'email', 'Lucia Sørensen', '2025-05-02 10:22:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 29, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (155, 35, 'phone', 'Renata Novak', '2025-02-24 15:49:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 21, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (156, 10, 'phone', 'unregistered SIM', '2025-04-27 22:18:00+00', 'Subject anxious about ''the audit''.', 'relevant', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (157, 43, 'phone', 'unregistered SIM', '2025-05-08 03:46:00+00', 'Subject anxious about ''the audit''.', 'routine', 9, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (158, 44, 'sms', 'Aurelia Trade FZE', '2025-04-20 07:28:00+00', 'Payment confirmation — amount matches txn pattern.', 'routine', 21, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (159, 4, 'sms', 'unknown number', '2025-01-23 06:01:00+00', 'Arranged meeting; location referenced obliquely.', 'incriminating', 29, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (160, 44, 'in person', 'unknown number', '2025-03-18 10:38:00+00', 'Travel plans: short-notice flight, cash ticket.', 'incriminating', 4, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (161, 59, 'in person', 'Lumen Art Holdings', '2025-02-15 12:23:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 23, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (162, 23, 'email', 'Bruno Almeida', '2025-01-07 23:56:00+00', 'Subject anxious about ''the audit''.', 'privileged', 27, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (163, 52, 'encrypted app', 'Goldquay Exchange', '2024-12-18 18:38:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 14, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (164, 20, 'encrypted app', 'Beatriz Brandt', '2025-04-13 12:05:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 18, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (165, 27, 'phone', 'unknown number', '2025-01-03 05:53:00+00', 'Subject anxious about ''the audit''.', 'relevant', 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (166, 32, 'phone', 'unregistered SIM', '2025-03-19 17:47:00+00', 'Subject anxious about ''the audit''.', 'routine', 2, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (167, 51, 'phone', 'Hyperion Commodities', '2024-12-14 20:42:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 12, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (168, 32, 'in person', 'Janus Chen', '2025-05-27 08:31:00+00', 'Arranged meeting; location referenced obliquely.', 'incriminating', 24, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (169, 38, 'encrypted app', 'Hyperion Commodities', '2025-05-09 03:23:00+00', 'Travel plans: short-notice flight, cash ticket.', 'privileged', 37, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (170, 2, 'email', 'unknown number', '2025-02-02 14:56:00+00', 'Mentioned contact at the freeport by first name.', 'routine', 23, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (171, 11, 'phone', 'unregistered SIM', '2024-11-25 09:18:00+00', 'Travel plans: short-notice flight, cash ticket.', 'privileged', 5, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (172, 33, 'phone', 'Emil Kovac', '2025-04-15 05:34:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 20, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (173, 18, 'in person', 'Ricardo Silva', '2025-03-29 18:22:00+00', 'Routine personal call — no case relevance.', 'routine', 1, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (174, 9, 'sms', 'Pelican Freight LLC', '2025-02-08 05:06:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 33, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (175, 15, 'phone', 'Noor Volkov', '2025-02-27 14:58:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 9, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (176, 60, 'phone', 'unregistered SIM', '2024-12-18 14:56:00+00', 'Travel plans: short-notice flight, cash ticket.', 'incriminating', 22, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (177, 12, 'in person', 'unknown number', '2024-12-15 22:09:00+00', 'Mentioned contact at the freeport by first name.', 'routine', 20, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (178, 9, 'phone', 'Hassan Albrecht', '2024-12-02 00:49:00+00', 'Subject anxious about ''the audit''.', 'relevant', 3, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (179, 13, 'phone', 'Lena Hakim', '2025-03-13 03:58:00+00', 'Payment confirmation — amount matches txn pattern.', 'routine', 5, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (180, 20, 'in person', 'Viktor Wagner', '2025-04-16 06:37:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'routine', 23, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (181, 2, 'phone', 'unregistered SIM', '2025-02-11 05:54:00+00', 'Travel plans: short-notice flight, cash ticket.', 'privileged', 40, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (182, 16, 'sms', 'Ivan Albrecht', '2025-03-28 06:45:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 11, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (183, 58, 'sms', 'unknown number', '2025-03-22 22:30:00+00', 'Payment confirmation — amount matches txn pattern.', 'privileged', 38, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (184, 12, 'encrypted app', 'unregistered SIM', '2025-02-18 13:13:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 27, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (185, 8, 'email', 'unknown number', '2025-03-19 11:45:00+00', 'Payment confirmation — amount matches txn pattern.', 'incriminating', 3, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (186, 6, 'email', 'unregistered SIM', '2025-03-09 19:47:00+00', 'Payment confirmation — amount matches txn pattern.', 'privileged', 29, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (187, 15, 'sms', 'unknown number', '2024-11-30 15:43:00+00', 'Payment confirmation — amount matches txn pattern.', 'routine', 1, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (188, 44, 'encrypted app', 'Goldquay Exchange', '2025-03-26 21:43:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 10, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (189, 26, 'encrypted app', 'unknown number', '2024-11-29 11:12:00+00', 'Payment confirmation — amount matches txn pattern.', 'routine', 39, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (190, 55, 'encrypted app', 'Goldquay Exchange', '2025-01-23 22:49:00+00', 'Travel plans: short-notice flight, cash ticket.', 'incriminating', 18, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (191, 49, 'in person', 'Pelican Freight LLC', '2025-03-17 16:17:00+00', 'Discussed ''paperwork'' for upcoming shipment.', 'relevant', 7, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (192, 8, 'email', 'unknown number', '2024-11-20 03:47:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 32, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (193, 44, 'phone', 'unregistered SIM', '2024-12-22 09:39:00+00', 'Travel plans: short-notice flight, cash ticket.', 'routine', 5, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (194, 43, 'sms', 'unknown number', '2025-01-27 21:31:00+00', 'Subject anxious about ''the audit''.', 'incriminating', 17, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (195, 35, 'email', 'Noor Almeida', '2025-05-27 01:27:00+00', 'Arranged meeting; location referenced obliquely.', 'relevant', 29, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (196, 21, 'encrypted app', 'Bruno Graf', '2024-12-28 17:42:00+00', 'Routine personal call — no case relevance.', 'relevant', 11, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (197, 32, 'encrypted app', 'Goldquay Exchange', '2025-05-27 03:36:00+00', 'Mentioned contact at the freeport by first name.', 'relevant', 36, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (198, 44, 'sms', 'unknown number', '2025-03-22 01:25:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 38, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (199, 25, 'sms', 'unregistered SIM', '2024-11-28 00:37:00+00', 'Arranged meeting; location referenced obliquely.', 'routine', 24, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.communications (id, subject_id, channel, counterparty, intercepted_at, summary, classification, case_id, owner_id) VALUES (200, 23, 'in person', 'Sable Trust (BVI)', '2024-12-07 13:22:00+00', 'Payment confirmation — amount matches txn pattern.', 'relevant', 23, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- reports: 40 rows +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (1, 1, 'Surveillance report — Operation Nightfall', 'Yuki Tanaka', 'review', 'confidential', '2025-05-14 09:37:00+00', 'Findings summary for Operation Nightfall: fund flows traced through three jurisdictions.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (2, 2, 'Interim report — Operation Cobalt Ledger', 'Omar Farouk', 'review', 'restricted', '2025-04-05 05:01:00+00', 'Findings summary for Operation Cobalt Ledger: ledger reconstruction shows systematic skimming.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (3, 3, 'Interim report — Operation Silent Harbor', 'Tom Becker', 'final', 'internal', '2025-05-04 16:57:00+00', 'Findings summary for Operation Silent Harbor: surveillance window produced actionable pattern-of-life.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (4, 4, 'Interim report — Operation Glass Orchid', 'Nadia Petrova', 'final', 'restricted', '2025-04-02 12:36:00+00', 'Findings summary for Operation Glass Orchid: surveillance window produced actionable pattern-of-life.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (5, 5, 'Closing report — Operation Iron Veil', 'Tom Becker', 'draft', 'internal', '2025-03-05 22:43:00+00', 'Findings summary for Operation Iron Veil: subject network mapped, two new principals identified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (6, 6, 'Forensic report — Operation Amber Crossing', 'Tom Becker', 'final', 'internal', '2025-03-14 19:19:00+00', 'Findings summary for Operation Amber Crossing: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (7, 7, 'Surveillance report — Operation Hollow Crown', 'Leila Haddad', 'final', 'restricted', '2025-04-30 06:18:00+00', 'Findings summary for Operation Hollow Crown: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (8, 8, 'Financial report — Operation Red Meridian', 'Yuki Tanaka', 'final', 'internal', '2025-03-21 19:47:00+00', 'Findings summary for Operation Red Meridian: fund flows traced through three jurisdictions.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (9, 9, 'Closing report — Operation Paper Tiger', 'Omar Farouk', 'draft', 'internal', '2025-03-27 02:36:00+00', 'Findings summary for Operation Paper Tiger: ledger reconstruction shows systematic skimming.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (10, 10, 'Forensic report — Operation Black Tide', 'Erik Johansson', 'review', 'internal', '2025-03-02 20:52:00+00', 'Findings summary for Operation Black Tide: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (11, 11, 'Financial report — Operation Marble Fox', 'Priya Sharma', 'final', 'internal', '2025-03-18 13:25:00+00', 'Findings summary for Operation Marble Fox: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (12, 12, 'Closing report — Operation Quiet Storm', 'Carlos Mendez', 'final', 'confidential', '2025-02-23 20:45:00+00', 'Findings summary for Operation Quiet Storm: ledger reconstruction shows systematic skimming.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (13, 13, 'Closing report — Operation Golden Thread', 'Amara Diallo', 'final', 'confidential', '2025-05-16 23:29:00+00', 'Findings summary for Operation Golden Thread: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (14, 14, 'Closing report — Operation Ash Garden', 'Hannah Weiss', 'final', 'internal', '2025-05-04 23:40:00+00', 'Findings summary for Operation Ash Garden: fund flows traced through three jurisdictions.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (15, 15, 'Surveillance report — Operation Winter Lantern', 'Priya Sharma', 'review', 'internal', '2025-03-11 07:09:00+00', 'Findings summary for Operation Winter Lantern: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (16, 16, 'Forensic report — Operation Broken Compass', 'Leila Haddad', 'review', 'restricted', '2025-05-13 06:54:00+00', 'Findings summary for Operation Broken Compass: surveillance window produced actionable pattern-of-life.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (17, 17, 'Financial report — Operation Velvet Chain', 'Nadia Petrova', 'review', 'internal', '2025-05-19 19:15:00+00', 'Findings summary for Operation Velvet Chain: surveillance window produced actionable pattern-of-life.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (18, 18, 'Interim report — Operation Salt Mirage', 'Jack Sullivan', 'review', 'confidential', '2025-02-24 07:51:00+00', 'Findings summary for Operation Salt Mirage: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (19, 19, 'Forensic report — Operation Cedar Gate', 'Erik Johansson', 'final', 'internal', '2025-04-16 20:57:00+00', 'Findings summary for Operation Cedar Gate: fund flows traced through three jurisdictions.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (20, 20, 'Surveillance report — Operation Phantom Ledger', 'Jack Sullivan', 'review', 'confidential', '2025-02-24 08:50:00+00', 'Findings summary for Operation Phantom Ledger: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (21, 21, 'Closing report — Operation Blue Asphalt', 'Hannah Weiss', 'review', 'restricted', '2025-05-24 15:50:00+00', 'Findings summary for Operation Blue Asphalt: fund flows traced through three jurisdictions.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (22, 22, 'Interim report — Operation Mirror Lake', 'Yuki Tanaka', 'review', 'internal', '2025-03-01 13:57:00+00', 'Findings summary for Operation Mirror Lake: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (23, 23, 'Interim report — Operation Stone Whisper', 'Hannah Weiss', 'final', 'confidential', '2025-03-23 03:36:00+00', 'Findings summary for Operation Stone Whisper: ledger reconstruction shows systematic skimming.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (24, 24, 'Closing report — Operation Last Orchard', 'Pierre Moreau', 'final', 'internal', '2025-04-25 02:15:00+00', 'Findings summary for Operation Last Orchard: ledger reconstruction shows systematic skimming.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (25, 25, 'Financial report — Operation Neon Distillery', 'Pierre Moreau', 'review', 'internal', '2025-05-27 23:15:00+00', 'Findings summary for Operation Neon Distillery: surveillance window produced actionable pattern-of-life.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (26, 26, 'Surveillance report — Operation Cold Archive', 'Jack Sullivan', 'draft', 'confidential', '2025-03-20 08:28:00+00', 'Findings summary for Operation Cold Archive: surveillance window produced actionable pattern-of-life.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (27, 27, 'Closing report — Operation Topaz Relay', 'Nadia Petrova', 'draft', 'internal', '2025-04-30 11:44:00+00', 'Findings summary for Operation Topaz Relay: fund flows traced through three jurisdictions.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (28, 28, 'Closing report — Operation Drift Anchor', 'Tom Becker', 'final', 'internal', '2025-05-19 07:17:00+00', 'Findings summary for Operation Drift Anchor: subject network mapped, two new principals identified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (29, 29, 'Forensic report — Operation Sable Run', 'Tom Becker', 'review', 'internal', '2025-02-23 20:57:00+00', 'Findings summary for Operation Sable Run: ledger reconstruction shows systematic skimming.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (30, 30, 'Interim report — Operation Open Window', 'Yuki Tanaka', 'review', 'internal', '2025-05-02 12:19:00+00', 'Findings summary for Operation Open Window: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (31, 31, 'Interim report — Operation Pale Horizon', 'Hannah Weiss', 'final', 'confidential', '2025-04-18 12:00:00+00', 'Findings summary for Operation Pale Horizon: surveillance window produced actionable pattern-of-life.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (32, 32, 'Financial report — Operation Crimson Wake', 'Nadia Petrova', 'draft', 'confidential', '2025-03-31 06:55:00+00', 'Findings summary for Operation Crimson Wake: subject network mapped, two new principals identified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (33, 33, 'Financial report — Operation Static Veil', 'Erik Johansson', 'final', 'restricted', '2025-03-28 02:52:00+00', 'Findings summary for Operation Static Veil: surveillance window produced actionable pattern-of-life.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (34, 34, 'Forensic report — Operation Lone Marker', 'Erik Johansson', 'review', 'internal', '2025-03-05 05:44:00+00', 'Findings summary for Operation Lone Marker: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (35, 35, 'Interim report — Operation Deep Current', 'Priya Sharma', 'review', 'internal', '2025-05-09 23:52:00+00', 'Findings summary for Operation Deep Current: ledger reconstruction shows systematic skimming.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (36, 36, 'Financial report — Operation Bright Cellar', 'Omar Farouk', 'final', 'restricted', '2025-02-27 20:41:00+00', 'Findings summary for Operation Bright Cellar: subject network mapped, two new principals identified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (37, 37, 'Forensic report — Operation Grey Lattice', 'Hannah Weiss', 'review', 'restricted', '2025-04-13 06:40:00+00', 'Findings summary for Operation Grey Lattice: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (38, 38, 'Closing report — Operation Final Compass', 'Tom Becker', 'draft', 'restricted', '2025-03-13 07:33:00+00', 'Findings summary for Operation Final Compass: subject network mapped, two new principals identified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (39, 39, 'Forensic report — Operation Echo Vault', 'Carlos Mendez', 'review', 'internal', '2025-04-23 23:08:00+00', 'Findings summary for Operation Echo Vault: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.reports (id, case_id, title, author, status, classification, published_at, body, owner_id) VALUES (40, 40, 'Closing report — Operation Glass Bridge', 'Amara Diallo', 'review', 'internal', '2025-03-18 02:26:00+00', 'Findings summary for Operation Glass Bridge: evidence chain complete and verified.', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- assignments: 100 rows +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (1, 1, 'e05.moreau@agency.local', 'analysis', 140.7, 172.54, '2025-01-28', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (2, 2, 'owner@agency.local', 'forensics', 104.9, 129.59, '2025-04-07', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (3, 3, 'e06.diallo@agency.local', 'surveillance', 113.4, 67.78, '2024-10-29', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (4, 4, 'e19.ngata@agency.local', 'surveillance', 27.3, 174.33, '2025-02-24', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (5, 5, 'e03.okafor@agency.local', 'lead', 116.1, 103.75, '2024-10-02', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (6, 6, 'e15.farouk@agency.local', 'lead', 39.0, 72.42, '2025-01-06', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (7, 7, 'e15.farouk@agency.local', 'analysis', 24.3, 160.61, '2025-02-01', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (8, 8, 'e09.becker@agency.local', 'legal review', 109.6, 101.47, '2024-10-25', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (9, 9, 'owner@agency.local', 'surveillance', 156.3, 76.79, '2024-10-04', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (10, 10, 'e11.johansson@agency.local', 'forensics', 110.7, 106.85, '2025-05-17', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (11, 11, 'e10.haddad@agency.local', 'legal review', 66.0, 73.65, '2024-11-07', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (12, 12, 'e19.ngata@agency.local', 'surveillance', 15.4, 120.19, '2025-04-26', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (13, 13, 'e07.sullivan@agency.local', 'analysis', 145.7, 179.56, '2025-04-29', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (14, 14, 'e16.liu@agency.local', 'forensics', 63.4, 176.97, '2024-11-14', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (15, 15, 'e17.antonov@agency.local', 'lead', 94.6, 80.14, '2024-09-07', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (16, 16, 'e10.haddad@agency.local', 'analysis', 24.6, 87.84, '2024-08-24', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (17, 17, 'e08.petrova@agency.local', 'lead', 52.9, 177.1, '2024-09-09', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (18, 18, 'e08.petrova@agency.local', 'legal review', 60.3, 103.06, '2025-01-08', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (19, 19, 'e08.petrova@agency.local', 'forensics', 52.8, 165.51, '2024-12-12', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (20, 20, 'e15.farouk@agency.local', 'support', 90.7, 169.59, '2024-10-09', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (21, 21, 'e04.tanaka@agency.local', 'lead', 39.9, 49.84, '2025-01-09', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (22, 22, 'e14.weiss@agency.local', 'analysis', 22.1, 165.85, '2024-09-11', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (23, 23, 'e11.johansson@agency.local', 'legal review', 91.5, 106.8, '2025-03-26', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (24, 24, 'e04.tanaka@agency.local', 'lead', 58.5, 72.5, '2024-12-28', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (25, 25, 'e05.moreau@agency.local', 'legal review', 83.4, 81.83, '2025-02-03', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (26, 26, 'owner@agency.local', 'forensics', 123.6, 69.38, '2025-04-13', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (27, 27, 'e15.farouk@agency.local', 'legal review', 18.7, 89.37, '2024-12-28', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (28, 28, 'e03.okafor@agency.local', 'analysis', 62.3, 170.44, '2025-03-29', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (29, 29, 'e04.tanaka@agency.local', 'lead', 16.6, 157.14, '2024-09-19', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (30, 30, 'e08.petrova@agency.local', 'forensics', 106.6, 89.47, '2025-04-30', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (31, 31, 'e14.weiss@agency.local', 'surveillance', 151.8, 179.98, '2025-03-17', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (32, 32, 'e01.reed@agency.local', 'legal review', 48.1, 152.75, '2025-04-16', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (33, 33, 'e19.ngata@agency.local', 'analysis', 18.4, 79.45, '2025-01-20', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (34, 34, 'e12.sharma@agency.local', 'lead', 7.2, 73.11, '2024-11-05', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (35, 35, 'e09.becker@agency.local', 'legal review', 121.4, 133.3, '2025-03-22', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (36, 36, 'e13.mendez@agency.local', 'legal review', 129.8, 86.86, '2025-03-16', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (37, 37, 'e03.okafor@agency.local', 'surveillance', 84.0, 111.1, '2024-11-05', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (38, 38, 'e18.romero@agency.local', 'forensics', 70.0, 147.13, '2024-11-15', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (39, 39, 'e13.mendez@agency.local', 'legal review', 70.5, 50.46, '2024-11-29', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (40, 40, 'e07.sullivan@agency.local', 'legal review', 111.1, 65.93, '2024-09-11', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (41, 1, 'e20.kowalski@agency.local', 'forensics', 17.1, 108.21, '2025-04-12', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (42, 2, 'e15.farouk@agency.local', 'surveillance', 27.2, 62.25, '2024-12-20', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (43, 3, 'e16.liu@agency.local', 'analysis', 50.1, 51.44, '2025-04-13', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (44, 4, 'e16.liu@agency.local', 'lead', 83.7, 100.76, '2024-09-27', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (45, 5, 'e18.romero@agency.local', 'surveillance', 59.5, 124.05, '2024-09-07', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (46, 6, 'e14.weiss@agency.local', 'support', 126.6, 158.79, '2025-04-29', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (47, 7, 'e09.becker@agency.local', 'surveillance', 95.6, 106.42, '2024-10-04', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (48, 8, 'e08.petrova@agency.local', 'surveillance', 154.8, 162.63, '2025-02-16', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (49, 9, 'e02.lindqvist@agency.local', 'legal review', 157.2, 80.01, '2025-03-29', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (50, 10, 'owner@agency.local', 'lead', 65.6, 46.8, '2024-09-20', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (51, 11, 'e12.sharma@agency.local', 'legal review', 154.7, 126.25, '2024-09-15', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (52, 12, 'e19.ngata@agency.local', 'surveillance', 111.7, 175.02, '2024-11-01', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (53, 13, 'e02.lindqvist@agency.local', 'support', 25.1, 70.76, '2025-05-14', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (54, 14, 'e06.diallo@agency.local', 'lead', 36.6, 132.56, '2025-01-19', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (55, 15, 'e20.kowalski@agency.local', 'forensics', 33.8, 85.62, '2024-09-27', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (56, 16, 'e13.mendez@agency.local', 'lead', 22.2, 166.83, '2024-10-07', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (57, 17, 'e15.farouk@agency.local', 'lead', 138.1, 124.01, '2025-02-08', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (58, 18, 'e12.sharma@agency.local', 'lead', 56.1, 58.86, '2024-10-13', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (59, 19, 'e15.farouk@agency.local', 'legal review', 94.3, 143.7, '2024-12-25', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (60, 20, 'e12.sharma@agency.local', 'analysis', 84.1, 142.82, '2024-09-15', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (61, 21, 'e01.reed@agency.local', 'forensics', 7.0, 122.81, '2024-08-12', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (62, 22, 'e20.kowalski@agency.local', 'analysis', 142.7, 60.79, '2024-11-26', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (63, 23, 'e13.mendez@agency.local', 'lead', 29.2, 143.04, '2024-09-24', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (64, 24, 'e07.sullivan@agency.local', 'forensics', 38.3, 167.72, '2024-11-30', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (65, 25, 'e14.weiss@agency.local', 'support', 141.8, 142.6, '2024-11-04', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (66, 26, 'e20.kowalski@agency.local', 'legal review', 60.2, 108.76, '2025-03-01', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (67, 27, 'e09.becker@agency.local', 'legal review', 120.9, 135.05, '2025-03-17', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (68, 28, 'e05.moreau@agency.local', 'forensics', 80.8, 152.12, '2024-09-12', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (69, 29, 'e06.diallo@agency.local', 'forensics', 11.3, 47.45, '2025-01-09', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (70, 30, 'e05.moreau@agency.local', 'support', 133.2, 165.75, '2025-05-06', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (71, 31, 'e10.haddad@agency.local', 'analysis', 21.8, 75.6, '2024-08-08', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (72, 32, 'e08.petrova@agency.local', 'surveillance', 126.4, 148.87, '2024-08-10', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (73, 33, 'e04.tanaka@agency.local', 'legal review', 140.3, 126.58, '2025-03-30', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (74, 34, 'e14.weiss@agency.local', 'analysis', 154.2, 147.03, '2025-03-29', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (75, 35, 'e19.ngata@agency.local', 'lead', 132.5, 50.67, '2025-03-30', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (76, 36, 'e03.okafor@agency.local', 'support', 146.1, 66.2, '2024-12-24', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (77, 37, 'e16.liu@agency.local', 'analysis', 118.3, 58.4, '2024-11-17', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (78, 38, 'e04.tanaka@agency.local', 'lead', 136.4, 104.28, '2024-10-22', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (79, 39, 'owner@agency.local', 'support', 134.2, 108.94, '2024-12-27', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (80, 40, 'e02.lindqvist@agency.local', 'surveillance', 62.1, 146.9, '2024-11-19', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (81, 1, 'e07.sullivan@agency.local', 'support', 121.5, 116.5, '2025-01-26', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (82, 2, 'e13.mendez@agency.local', 'analysis', 147.2, 166.24, '2024-08-31', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (83, 3, 'e11.johansson@agency.local', 'lead', 73.0, 148.97, '2024-08-31', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (84, 4, 'e02.lindqvist@agency.local', 'surveillance', 53.1, 177.56, '2025-02-27', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (85, 5, 'e12.sharma@agency.local', 'lead', 107.0, 131.6, '2025-04-23', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (86, 6, 'e16.liu@agency.local', 'surveillance', 69.4, 164.58, '2024-08-12', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (87, 7, 'e10.haddad@agency.local', 'surveillance', 5.2, 82.77, '2024-08-25', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (88, 8, 'e04.tanaka@agency.local', 'legal review', 67.0, 131.92, '2024-08-17', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (89, 9, 'e19.ngata@agency.local', 'legal review', 73.0, 91.61, '2024-10-27', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (90, 10, 'e09.becker@agency.local', 'lead', 9.8, 90.16, '2025-03-19', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (91, 11, 'e17.antonov@agency.local', 'surveillance', 37.3, 114.08, '2024-12-14', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (92, 12, 'e20.kowalski@agency.local', 'support', 7.7, 165.19, '2024-08-19', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (93, 13, 'e07.sullivan@agency.local', 'surveillance', 117.3, 178.24, '2025-03-27', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (94, 14, 'e11.johansson@agency.local', 'surveillance', 101.8, 92.85, '2025-04-28', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (95, 15, 'e03.okafor@agency.local', 'analysis', 50.5, 144.48, '2024-10-02', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (96, 16, 'e07.sullivan@agency.local', 'legal review', 158.3, 121.36, '2024-08-22', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (97, 17, 'e07.sullivan@agency.local', 'surveillance', 50.7, 135.03, '2025-03-12', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (98, 18, 'e16.liu@agency.local', 'legal review', 42.6, 89.26, '2025-02-13', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (99, 19, 'e11.johansson@agency.local', 'analysis', 19.3, 141.07, '2024-10-24', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.assignments (id, case_id, employee_email, role_on_case, hours, hourly_rate, started_at, active, owner_id) VALUES (100, 20, 'e01.reed@agency.local', 'analysis', 142.6, 109.29, '2024-10-06', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +-- edges: 80 rows +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (1, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:44', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:34', 'associate', 'co-director', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (2, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:38', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:36', 'associate', 'courier for', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (3, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:50', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:26', 'associate', 'frequent contact', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (4, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:38', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:32', 'associate', 'introduced by informant', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (5, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:28', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:29', 'associate', 'introduced by informant', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (6, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:60', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:32', 'associate', 'introduced by informant', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (7, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:27', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:53', 'associate', 'family', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (8, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:10', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:33', 'associate', 'family', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (9, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:32', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:46', 'associate', 'co-director', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (10, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:9', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:46', 'associate', 'courier for', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (11, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:56', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:34', 'associate', 'business partner', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (12, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:45', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:14', 'associate', 'co-director', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (13, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:31', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:3', 'associate', 'introduced by informant', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (14, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:11', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:36', 'associate', 'frequent contact', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (15, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:59', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:8', 'associate', 'courier for', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (16, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:55', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:27', 'associate', 'business partner', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (17, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:8', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:24', 'associate', 'courier for', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (18, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:19', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:3', 'associate', 'courier for', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (19, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:10', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:8', 'associate', 'frequent contact', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (20, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:20', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:24', 'associate', 'frequent contact', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (21, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:19', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:32', 'associate', 'business partner', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (22, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:51', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:10', 'associate', 'family', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (23, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:51', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:38', 'associate', 'co-director', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (24, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:4', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:60', 'associate', 'frequent contact', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (25, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:57', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:19', 'associate', 'family', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (26, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:12', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:53', 'associate', 'introduced by informant', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (27, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:32', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:1', 'associate', 'business partner', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (28, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:36', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:23', 'associate', 'business partner', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (29, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:56', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:3', 'associate', 'co-director', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (30, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:31', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:37', 'associate', 'frequent contact', FALSE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (31, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:37', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:29', 'frequents', 'meeting spot', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (32, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:51', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:25', 'frequents', 'storage access', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (33, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:54', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:47', 'frequents', 'owns via proxy', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (34, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:9', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:49', 'frequents', 'registered address', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (35, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:33', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:41', 'frequents', 'storage access', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (36, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:49', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:29', 'frequents', 'storage access', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (37, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:10', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:47', 'frequents', 'meeting spot', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (38, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:6', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:40', 'frequents', 'owns via proxy', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (39, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:27', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:25', 'frequents', 'registered address', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (40, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:58', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:26', 'frequents', 'meeting spot', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (41, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:35', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:3', 'frequents', 'storage access', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (42, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:48', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:18', 'frequents', 'weekly visits', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (43, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:56', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:11', 'frequents', 'weekly visits', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (44, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:37', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:41', 'frequents', 'meeting spot', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (45, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:36', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:42', 'frequents', 'weekly visits', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (46, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:42', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:36', 'frequents', 'weekly visits', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (47, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:13', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:40', 'frequents', 'owns via proxy', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (48, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:37', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:49', 'frequents', 'weekly visits', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (49, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:37', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:34', 'frequents', 'registered address', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (50, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:49', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:31', 'frequents', 'owns via proxy', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (51, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:58', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:2', 'frequents', 'weekly visits', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (52, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:43', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:35', 'frequents', 'registered address', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (53, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:2', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:18', 'frequents', 'registered address', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (54, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:16', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:46', 'frequents', 'owns via proxy', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (55, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:29', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:locations:6', 'frequents', 'owns via proxy', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (56, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:39', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:21', 'involves', 'intermediary', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (57, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:38', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:3', 'involves', 'intermediary', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (58, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:2', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:22', 'involves', 'intermediary', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (59, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:12', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:27', 'involves', 'person of interest', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (60, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:34', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:21', 'involves', 'intermediary', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (61, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:17', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:6', 'involves', 'person of interest', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (62, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:7', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:35', 'involves', 'witness', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (63, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:19', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:10', 'involves', 'witness', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (64, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:17', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:50', 'involves', 'person of interest', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (65, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:10', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:20', 'involves', 'intermediary', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (66, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:11', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:4', 'involves', 'unconfirmed link', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (67, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:21', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:12', 'involves', 'person of interest', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (68, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:4', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:20', 'involves', 'intermediary', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (69, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:7', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:21', 'involves', 'witness', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (70, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:27', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:58', 'involves', 'beneficial owner', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (71, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:17', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:25', 'involves', 'person of interest', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (72, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:4', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:13', 'involves', 'witness', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (73, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:39', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:38', 'involves', 'witness', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (74, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:32', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:33', 'involves', 'unconfirmed link', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (75, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:25', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:8', 'involves', 'beneficial owner', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (76, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:4', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:21', 'involves', 'intermediary', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (77, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:36', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:27', 'involves', 'person of interest', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (78, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:31', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:2', 'involves', 'intermediary', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (79, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:15', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:59', 'involves', 'person of interest', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.edges (id, "from", "to", type, label, directed, owner_id) VALUES (80, 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:cases:18', 'd3ecb3e1-9947-41a6-a0d3-ff2063b4adee:subjects:36', 'involves', 'unconfirmed link', TRUE, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1') ON CONFLICT (id) DO NOTHING; +COMMIT; diff --git a/tools/seeds/seed_agency_chat.sql b/tools/seeds/seed_agency_chat.sql new file mode 100644 index 00000000..9ad4d55d --- /dev/null +++ b/tools/seeds/seed_agency_chat.sql @@ -0,0 +1,348 @@ +-- Generated by tools/seeds/seed_agency_wiki.py — chat + feed backfill. +-- Idempotent: fixed uuid5 ids, ON CONFLICT DO NOTHING everywhere. Safe to re-run. +BEGIN; +INSERT INTO public.osionos_channels (id, workspace_id, kind, name, topic, created_by, is_private, abac, created_at, updated_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'b1a0c1e5-0000-4000-a000-000000000001', 'text', 'general', 'Agency-wide announcements & day-to-day', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', false, '{}'::jsonb, now() - interval '15 days', now()) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'owner', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '9878850d-3024-4012-b03a-d3d7ccef377c', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7d93f34c-0b8e-4e2c-91f1-acf03d8842e2', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '88e73a58-ffa3-4601-9be2-aaeb6559af28', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '39072d25-7ed8-442d-a26b-e93771ba9192', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '920b7e0b-a87d-400d-8169-f2f521bfe5ad', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '99389d8f-5186-4bbb-a426-faf8fb91bc69', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'e100fa34-6da5-4212-863b-55d041585e96', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'fc15e717-3cdf-4862-abce-8ea2e3be771f', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channels (id, workspace_id, kind, name, topic, created_by, is_private, abac, created_at, updated_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'b1a0c1e5-0000-4000-a000-000000000001', 'text', 'case-ops', 'Operational coordination across live cases', '9878850d-3024-4012-b03a-d3d7ccef377c', false, '{}'::jsonb, now() - interval '15 days', now()) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'owner', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '7d93f34c-0b8e-4e2c-91f1-acf03d8842e2', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '88e73a58-ffa3-4601-9be2-aaeb6559af28', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '39072d25-7ed8-442d-a26b-e93771ba9192', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channels (id, workspace_id, kind, name, topic, created_by, is_private, abac, created_at, updated_at) VALUES ('394d18f9-8646-5b63-a003-454d2604085b', 'b1a0c1e5-0000-4000-a000-000000000001', 'text', 'intel-analysts', 'Analysis threads — patterns, graphs, dashboards', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', false, '{}'::jsonb, now() - interval '15 days', now()) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'owner', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('394d18f9-8646-5b63-a003-454d2604085b', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('394d18f9-8646-5b63-a003-454d2604085b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channels (id, workspace_id, kind, name, topic, created_by, is_private, abac, created_at, updated_at) VALUES ('238832b0-ba44-52f2-bfa1-a73980f4da06', 'b1a0c1e5-0000-4000-a000-000000000001', 'text', 'field', 'Field & surveillance — rotations, sightings, logistics', 'a3cefadb-2668-4f12-830c-14518e0a08f3', false, '{}'::jsonb, now() - interval '15 days', now()) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('238832b0-ba44-52f2-bfa1-a73980f4da06', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'owner', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('238832b0-ba44-52f2-bfa1-a73980f4da06', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('238832b0-ba44-52f2-bfa1-a73980f4da06', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('238832b0-ba44-52f2-bfa1-a73980f4da06', '39072d25-7ed8-442d-a26b-e93771ba9192', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('238832b0-ba44-52f2-bfa1-a73980f4da06', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('238832b0-ba44-52f2-bfa1-a73980f4da06', '99389d8f-5186-4bbb-a426-faf8fb91bc69', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('238832b0-ba44-52f2-bfa1-a73980f4da06', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('238832b0-ba44-52f2-bfa1-a73980f4da06', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channels (id, workspace_id, kind, name, topic, created_by, is_private, abac, created_at, updated_at) VALUES ('5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', 'b1a0c1e5-0000-4000-a000-000000000001', 'video', 'War Room', 'Live command coordination (video)', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', true, '{}'::jsonb, now() - interval '15 days', now()) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'owner', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', '9878850d-3024-4012-b03a-d3d7ccef377c', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) VALUES ('5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'member', now() - interval '15 days') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channels (id, workspace_id, kind, name, created_by, is_private, abac, dm_key, created_at, updated_at) VALUES ('38c085ea-b0f7-58a7-bb4e-319f272b9a73', 'b1a0c1e5-0000-4000-a000-000000000001', 'dm', 'Erik Johansson & Priya Sharma', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', true, '{}'::jsonb, 'dm:4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2:fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', now() - interval '13 days', now()) ON CONFLICT (dm_key) DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) SELECT id, '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'member', now() - interval '13 days' FROM public.osionos_channels WHERE dm_key = 'dm:4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2:fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57' ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) SELECT id, 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'member', now() - interval '13 days' FROM public.osionos_channels WHERE dm_key = 'dm:4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2:fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57' ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channels (id, workspace_id, kind, name, created_by, is_private, abac, dm_key, created_at, updated_at) VALUES ('e9526689-3baa-579a-8fd6-7ac3b18b7bc3', 'b1a0c1e5-0000-4000-a000-000000000001', 'dm', 'Marcus Reed & Sofia Lindqvist', '9878850d-3024-4012-b03a-d3d7ccef377c', true, '{}'::jsonb, 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', now() - interval '13 days', now()) ON CONFLICT (dm_key) DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) SELECT id, '9878850d-3024-4012-b03a-d3d7ccef377c', 'member', now() - interval '13 days' FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:9946155f-cfcb-4ad4-99a4-6b5ed49ad24c' ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) SELECT id, '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'member', now() - interval '13 days' FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:9946155f-cfcb-4ad4-99a4-6b5ed49ad24c' ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channels (id, workspace_id, kind, name, created_by, is_private, abac, dm_key, created_at, updated_at) VALUES ('19e35bf0-a9b1-54f4-b8e8-6ecec41891d6', 'b1a0c1e5-0000-4000-a000-000000000001', 'dm', 'Jack Sullivan & Grace Liu', 'a3cefadb-2668-4f12-830c-14518e0a08f3', true, '{}'::jsonb, 'dm:a3cefadb-2668-4f12-830c-14518e0a08f3:edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', now() - interval '13 days', now()) ON CONFLICT (dm_key) DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) SELECT id, 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'member', now() - interval '13 days' FROM public.osionos_channels WHERE dm_key = 'dm:a3cefadb-2668-4f12-830c-14518e0a08f3:edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0' ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) SELECT id, 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'member', now() - interval '13 days' FROM public.osionos_channels WHERE dm_key = 'dm:a3cefadb-2668-4f12-830c-14518e0a08f3:edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0' ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channels (id, workspace_id, kind, name, created_by, is_private, abac, dm_key, created_at, updated_at) VALUES ('a9410362-e483-5fa1-b30d-a5bff09758b2', 'b1a0c1e5-0000-4000-a000-000000000001', 'dm', 'Helena Voss & Marcus Reed', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', true, '{}'::jsonb, 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:faf9aeda-e21e-42ab-80be-9ce0fd8662a1', now() - interval '13 days', now()) ON CONFLICT (dm_key) DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) SELECT id, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'member', now() - interval '13 days' FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:faf9aeda-e21e-42ab-80be-9ce0fd8662a1' ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) SELECT id, '9878850d-3024-4012-b03a-d3d7ccef377c', 'member', now() - interval '13 days' FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:faf9aeda-e21e-42ab-80be-9ce0fd8662a1' ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c3bb4b25-fdc9-5a23-a1cf-d421483efe5b', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Welcome to the Binocle org workspace, everyone. The 📖 Agency Handbook is live — SOPs, custody protocol, clearance policy and the onboarding checklist. Read it this week.', '[]'::jsonb, now() - interval '19785 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f35d6df8-0275-5bdd-a2b5-3c36b3724f06', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Clearance Levels & Access Policy is the one to internalise — the masks you see in the live tables are explained there.', '[]'::jsonb, now() - interval '19783 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e505238a-957c-58f1-9654-11b0bef74f9e', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', 'If a table renders `••••` where you expect a number, that is ABAC, not a bug. Elevation requests go through your case manager, not IT.', '[]'::jsonb, now() - interval '19777 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('358e03c1-e305-597c-aa33-a77fad4c2dab', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'Handbook looks great. The custody protocol toggle about common mistakes should be mandatory reading.', '[]'::jsonb, now() - interval '19774 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('418cad79-bc41-5d8c-bf4c-657a9286663c', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'It is now. Custody quiz moves to Fridays, first session this week.', '[]'::jsonb, now() - interval '19772 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('5075b219-3fb6-5907-a795-4479bb3b731d', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Reminder: case board triage every Monday 10:00. Mission Control page has the live board embedded — statuses must be accurate before the meeting.', '[]'::jsonb, now() - interval '19478 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('73107038-baff-5d28-8443-d2055a6f4b21', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'NA cases are triaged. Broken Compass stays open-high; Tom has the conn.', '[]'::jsonb, now() - interval '19472 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f25474a0-68a8-5d36-94a2-e04b0f5e8d53', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Ack. Wiki updated with the Kestrel Marine angle.', '[]'::jsonb, now() - interval '19469 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('acb3c72d-1311-57da-9f91-2e16ce6e1b12', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', 'Maintenance window tonight 22:00–22:30 CET: gateway certs rotate. Sessions survive, video calls will drop for ~2 minutes.', '[]'::jsonb, now() - interval '19097 minutes', now() - interval '19088 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('3b48a19c-b5f3-507e-8e0f-b9c6c03801fd', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '99389d8f-5186-4bbb-a426-faf8fb91bc69', 'Noted — we''ll keep the static post on radio during the window.', '[]'::jsonb, now() - interval '19094 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('23e9aa6d-38b2-5d57-bf5a-04796e931583', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', 'Done early. All services green.', '[]'::jsonb, now() - interval '19089 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('6bd7f1b4-00c9-59fa-b86c-b5dc2380bb76', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'e100fa34-6da5-4212-863b-55d041585e96', 'Legal reminder: anything classified *privileged* (lawyer–client intercepts) does not get quoted in chat, wikis, or reports. Reference the COMM id and route through me.', '[]'::jsonb, now() - interval '18733 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('aaac0cc8-2d2c-5755-bcf4-a157cd7d4a99', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Ash Garden team is aware — all surveillance product on Petrov goes through your screen first.', '[]'::jsonb, now() - interval '18728 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('045654c1-f792-591d-b023-cf8bbb7b00c1', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'e100fa34-6da5-4212-863b-55d041585e96', 'Appreciated. Same applies to COMM-0139 on Nightfall.', '[]'::jsonb, now() - interval '18726 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c0bae524-af17-5003-9352-1e3e2c470635', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '920b7e0b-a87d-400d-8169-f2f521bfe5ad', 'Evidence Gallery pages are up: Financial, Digital, Surveillance, Documents + the Q2 custody audit. Sanitised scans only — the live register stays the record.', '[]'::jsonb, now() - interval '18348 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('a7268e1c-e08d-5be4-ab7b-dee3a15aecb2', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'The quarantine flag on EV-0055 is very visible. Good.', '[]'::jsonb, now() - interval '18342 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('346a190e-c654-5c34-80d2-651dce594a4d', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'That''s the point. Deep verification is running; update lands on the audit page.', '[]'::jsonb, now() - interval '18336 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('0dfffc47-ba3a-568d-b6c8-6206dc1a848b', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'fc15e717-3cdf-4862-abce-8ea2e3be771f', 'Finance note: Q2 budget reconciliation needs case budgets confirmed by Thursday. Case managers, check your rows on the live board.', '[]'::jsonb, now() - interval '17970 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('fc54f9c4-a9c0-5b89-bc0d-652ff6a4a03d', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'EU cases confirmed.', '[]'::jsonb, now() - interval '17966 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('22e298ef-460f-57a2-b4fe-0e4ba35f5f7a', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'NA confirmed.', '[]'::jsonb, now() - interval '17962 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f19a9f88-1b3c-5772-8f91-cdb5ab6e609b', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'fc15e717-3cdf-4862-abce-8ea2e3be771f', 'Thanks both. Quiet Storm''s budget line moves to critical-priority pool.', '[]'::jsonb, now() - interval '17957 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f7717ac0-0539-55db-b783-01c1f05c4509', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '39072d25-7ed8-442d-a26b-e93771ba9192', 'The espresso machine on 3 is doing the thing again where it dispenses sadness.', '[]'::jsonb, now() - interval '17640 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('25900c31-bcbe-5ee7-8a41-9c24aceb1102', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Filed under: cold case.', '[]'::jsonb, now() - interval '17636 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f11e280b-6d33-51e5-a18b-c563d4a2ff3f', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', 'I fixed the firmware once. I will not be doing that again.', '[]'::jsonb, now() - interval '17634 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('39327a0e-98f8-5f04-b84b-caff0573b592', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Procurement of a new machine is approved before someone opens an operation on it.', '[]'::jsonb, now() - interval '17628 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('cb6b6b22-70a9-51f0-8504-6194f14c29b1', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7d93f34c-0b8e-4e2c-91f1-acf03d8842e2', 'APAC sync moves to 08:00 CET on Wednesdays so Priya and I overlap with the EU morning.', '[]'::jsonb, now() - interval '17296 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('6f7adfc9-26e8-55d5-9dca-3a3eb0e6ea18', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Works for me — calendar updated.', '[]'::jsonb, now() - interval '17290 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('6b48e353-d29f-58bf-b7e4-19b5906c4de1', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '9878850d-3024-4012-b03a-d3d7ccef377c', 'All-hands Friday 15:00: Red Meridian referral status, Nightfall progress, and the Q2 custody findings. War Room for command, stream for everyone else.', '[]'::jsonb, now() - interval '16961 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('ff47fb56-642e-5092-b802-b99c8e7322d5', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '88e73a58-ffa3-4601-9be2-aaeb6559af28', 'Will the custody refresher be scheduled there too?', '[]'::jsonb, now() - interval '16956 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('553d3b4e-42e5-568c-ade7-84ec322033ae', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'Yes — transport couriers first, everyone else within the month.', '[]'::jsonb, now() - interval '16950 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('885b4cb3-af2d-50c5-a75a-26330a5ee7c1', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'PSA from analysis: if you cite a transaction in a wiki, use the TXN id. Amounts differ per clearance view, ids do not.', '[]'::jsonb, now() - interval '16598 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e85fa6b1-b6c2-5f95-98c1-bc4189ec549a', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'b371611d-139f-454b-9195-2b655e1fe8a6', '+1. Same for subjects — alias collisions are real, S-ids only.', '[]'::jsonb, now() - interval '16593 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('cb1db51c-fec2-5130-8e23-c273a8acd213', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Two different people both called “Halcyon”. Ask me how I know.', '[]'::jsonb, now() - interval '16587 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('3ddfb22a-3b57-5c50-9673-543139d9e67b', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', 'Password rotation due this week for everyone with clearance 4+. The policy engine will start nagging you Wednesday.', '[]'::jsonb, now() - interval '16197 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e60d470a-fec9-51b3-8a14-97b1ce36546b', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Rotated. The nag is effective and mildly threatening — well done.', '[]'::jsonb, now() - interval '16195 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f2672b1e-4203-5b4a-b30a-bea3fc7bc357', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', 'It escalates. You do not want to see level three.', '[]'::jsonb, now() - interval '16193 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('cd0fe2b2-e89d-5290-ab9f-bdb68c773df5', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Board review went well. Special mention for the custody audit and the watchlist memo — the two-signal rule got quoted back at me approvingly.', '[]'::jsonb, now() - interval '15878 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('69b253ac-f8c1-5811-a073-5c520d5b950a', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'Team effort — the field teams are the ones logging both ends of every hand-off now.', '[]'::jsonb, now() - interval '15876 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('80536713-a709-50b8-a945-420495b4804b', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'And nobody has tried to add a counterparty on vibes for three whole weeks.', '[]'::jsonb, now() - interval '15870 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('89b82f78-12c5-5e3c-aa05-74f7d5e90136', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Progress.', '[]'::jsonb, now() - interval '15868 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f99d5c08-0163-5fe5-b2c1-862a2356f992', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', 'e100fa34-6da5-4212-863b-55d041585e96', 'New template for records requests is in Report Writing Standards. Use it — the old one had a paragraph a judge described as ''optimistic''.', '[]'::jsonb, now() - interval '15489 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('88d774b7-4061-5fac-8b65-dc0390a600fb', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', 'Using it for the freeport access-log request today.', '[]'::jsonb, now() - interval '15484 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('435fbc15-e188-5df1-8f93-352656660864', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '7d93f34c-0b8e-4e2c-91f1-acf03d8842e2', 'Same for the APAC registry pulls.', '[]'::jsonb, now() - interval '15482 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('b260e063-9dc7-540c-b5f1-18753f7b5127', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'Friendly reminder the all-hands stream starts 15:00 sharp. Last week we lost four minutes to someone''s bluetooth headphones.', '[]'::jsonb, now() - interval '15166 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('053ed06e-de3d-5e66-afc5-14d695e67013', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'It was not me.', '[]'::jsonb, now() - interval '15160 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('3750d349-1daa-55ba-b0ea-d2f91542330d', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '39072d25-7ed8-442d-a26b-e93771ba9192', 'It was absolutely you.', '[]'::jsonb, now() - interval '15157 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('06df84a4-8e08-586b-915b-d33415a926f0', '6d34d98d-71f6-53e0-8fcb-3c4ca7375dbd', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'It was absolutely me.', '[]'::jsonb, now() - interval '15155 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f30061ae-5cd7-5b84-91f3-7e93433f1c69', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Nightfall standup: EV-0084 ledger analysis is in — paired phantom-freight entries confirmed. Erik''s pattern memo is on my desk before Thursday''s War Room.', '[]'::jsonb, now() - interval '14787 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('bfb3726e-b10c-5106-8dbb-b1a05e0dab20', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '88e73a58-ffa3-4601-9be2-aaeb6559af28', 'Custody on EV-0084 is clean, four entries, verified. The ledger pages are imaged and in the vault.', '[]'::jsonb, now() - interval '14781 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('565dbd3e-f1f8-5a39-a31d-dc44a3ad7c16', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'EV-0086 hard drive imaging done. Wallet artefacts extracted and handed to analysis.', '[]'::jsonb, now() - interval '14777 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('1d161697-83aa-5cfe-9a0f-1dfe0a2c1ea5', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Good. Jack — the contract (EV-0087)?', '[]'::jsonb, now() - interval '14771 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('b857fc11-1b2a-55d3-a4d8-a767e2537d4f', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Phantom freight terms confirmed: cargo never moved on any of the six bookings. Annotated copy is on the case wiki.', '[]'::jsonb, now() - interval '14765 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('99665e2b-546d-579d-966c-0b97fbed62c2', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Red Meridian review: legal needs the exhibit list final by Wednesday. EV-0055 stays OUT of the package until forensics clears it.', '[]'::jsonb, now() - interval '14449 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('ff989574-767e-5214-9e5d-902bf0a282b2', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', 'Manifest set EV-0017 is in and verified — the export valuation model holds without the tape.', '[]'::jsonb, now() - interval '14445 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('144ad783-91b9-5c8d-944a-d3dc04aa8289', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'e100fa34-6da5-4212-863b-55d041585e96', 'Confirmed. The referral is stronger with a shorter, cleaner exhibit list anyway.', '[]'::jsonb, now() - interval '14439 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('864ffc79-4810-55e1-8dd4-80f44d208c2d', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Agreed. If EV-0055 clears later we amend; we do not wait for it.', '[]'::jsonb, now() - interval '14437 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('743d39a5-934a-5264-b128-51987e7cf0ac', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Ash Garden: subject did the Friday pattern again — notary, then the Goldquay storefront. Third consecutive week. Surveillance log updated.', '[]'::jsonb, now() - interval '14031 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('338f9a2c-0116-58a7-ab0e-9184945471cb', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Static post footage (EV-0076 continuation) is timestamped and sealed.', '[]'::jsonb, now() - interval '14025 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('0167243b-c76a-54dd-a31d-c505f4c017dc', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'e100fa34-6da5-4212-863b-55d041585e96', 'Reminder — Petrov is a lawyer. Everything she says to a client is privileged until I screen it. Route the audio to me first.', '[]'::jsonb, now() - interval '14019 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f4d59150-f90e-5edb-9ac6-18e754c023b9', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Understood, same drill as last week.', '[]'::jsonb, now() - interval '14017 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('84a969c4-072e-55a1-a0f9-1bcbaf4562d6', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'Burner phone graph (EV-0051) — any movement on the recurring SIM?', '[]'::jsonb, now() - interval '14015 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('5db5aee8-c6eb-57c3-8942-aeb8aba9c0fa', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Forensics says same device family as the Nightfall COMM-0085 source. If that link verifies, we have one fixer on two cases.', '[]'::jsonb, now() - interval '14012 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('9a0e2160-214d-59d9-b2cb-43150c3949ba', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'That would change the org chart of this whole thing. Keep it tight until verified.', '[]'::jsonb, now() - interval '14009 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('73e3099c-6be0-5352-9eef-dcaf38cb38c6', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Broken Compass: charter payments to Kestrel Marine land within 48h of each property closing — EV-0103 statement lines it up. Subpoena for charter manifests is drafting.', '[]'::jsonb, now() - interval '13693 minutes', now() - interval '13684 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('27844e3d-e503-5998-9cdb-e275b976e0fd', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'Coordinate the Kestrel angle with Red Meridian — Grace''s marina lead touches the same shop.', '[]'::jsonb, now() - interval '13689 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('0616d7fe-d427-57fb-b402-1257b02c9a98', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Already cross-referenced on the wiki. Priya''s graph has Kestrel at elevated watch.', '[]'::jsonb, now() - interval '13683 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('39b050db-4314-520c-b150-185898d174c1', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '7d93f34c-0b8e-4e2c-91f1-acf03d8842e2', 'Quiet Storm: vendor #3''s incorporation date is 21 days before the award window. The pattern Carlos flagged holds for all three vendors.', '[]'::jsonb, now() - interval '13311 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('dc2af86d-24e1-5a16-9713-db0a96068a06', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', 'Then the formation-agent comparison is the next move. Carlos and Priya have the registered-agent overlap memo due Friday.', '[]'::jsonb, now() - interval '13309 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('814693cd-a669-5853-b2bc-7c0faf3ef34c', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Booking it for Monday''s triage. Also: casino credit reconciliation (EV-0071) — Robert needs the clearance-4 totals.', '[]'::jsonb, now() - interval '13307 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('600b9e7c-3427-566b-a676-9024bb46bbc9', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'fc15e717-3cdf-4862-abce-8ea2e3be771f', 'On it — residual is down to two unexplained lines.', '[]'::jsonb, now() - interval '13301 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('bb567563-1f1f-5ec4-80ad-03ad2ff61cc9', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '88e73a58-ffa3-4601-9be2-aaeb6559af28', 'Evidence note for everyone: when you hand off to transport, BOTH ends log. Origin-only logging is what bit us on EV-0055.', '[]'::jsonb, now() - interval '12957 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f37c2d15-84a9-5168-b9b2-7649f5200d9a', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'Cobalt Ledger ran three exhibits through trial without a scratch because of exactly that discipline.', '[]'::jsonb, now() - interval '12953 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('71ec18e0-7d04-53d3-89f1-228b99cbe1dc', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'And the refresher session is now mandatory. Friday, no exceptions.', '[]'::jsonb, now() - interval '12951 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('b148792f-ac2a-5653-a891-a1a33c4c6d26', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Nightfall: legal is drafting the Goldquay KYC subpoena package. Once served, analysis gets the exit-cluster confirmation within the week.', '[]'::jsonb, now() - interval '12613 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e4939065-1cc8-58c4-8994-ad07e8438e31', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '39072d25-7ed8-442d-a26b-e93771ba9192', 'Field support ready if service needs an address verification on the Goldquay storefront.', '[]'::jsonb, now() - interval '12608 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('0a541513-f4bb-5934-b8d5-44c34384f1a1', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Noted — hold until legal gives the word.', '[]'::jsonb, now() - interval '12606 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('be958738-c8c0-51c8-8eb8-add9d114c117', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'Assignments check: everyone''s rows in the live `assignments` table must match reality before Friday. If you are on a case and not in the table, you do not exist.', '[]'::jsonb, now() - interval '12285 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('3e4fb30e-3d38-504a-b035-4501ababce83', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Fixed mine — Broken Compass plus the Kestrel cross-support.', '[]'::jsonb, now() - interval '12280 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('125894fd-0d60-5dc3-8936-edd1d2b1c298', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Mine show Ash Garden static + Red Meridian marina follow-up. Correct.', '[]'::jsonb, now() - interval '12276 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c18e90cf-cd39-59e3-aa1a-a5bcd3a455fc', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Heads-up: Director wants a one-page brief per featured case for the board. Case wikis already carry the summary callout — keep them current and the briefs write themselves.', '[]'::jsonb, now() - interval '11877 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('11859c14-07d9-5b22-9ab2-38aabdaad583', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', 'Quiet Storm wiki is current as of this morning.', '[]'::jsonb, now() - interval '11874 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e494b2e1-4a0f-5dc0-b3b0-44c48208592e', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Ash Garden updated after tonight''s rotation.', '[]'::jsonb, now() - interval '11872 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('d663def6-9f5a-50d3-98c9-bee91b398ba9', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '88e73a58-ffa3-4601-9be2-aaeb6559af28', 'Nightfall evidence run complete: EV-0098 (second hard drive, site visit) is imaged. Wallet artefact set is larger than EV-0086''s — analysis has both now.', '[]'::jsonb, now() - interval '11548 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f391ea02-c689-597b-b2e2-d61d13f05a59', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'Hashes recorded at collection and transfer. Chain is clean.', '[]'::jsonb, now() - interval '11545 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('375b6a89-3c98-575e-876d-cae4f8851fbe', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Erik — fold EV-0098 into the exit-cluster work before Thursday if you can.', '[]'::jsonb, now() - interval '11540 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('0c4f1307-58a7-569c-a9d6-46d5442e5df9', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '88e73a58-ffa3-4601-9be2-aaeb6559af28', 'He''s already pulling it, saw the forensics ticket close an hour ago.', '[]'::jsonb, now() - interval '11536 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('7d0c53ed-e29f-51ec-8f1f-0c6f8e87ff3e', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', 'Quiet Storm: freeport access-log request served using Isabel''s new template. Expecting the logs within ten working days.', '[]'::jsonb, now() - interval '11207 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('b2506cfb-3a15-59d3-9452-c99ed6a0fa6b', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '7d93f34c-0b8e-4e2c-91f1-acf03d8842e2', 'The award-week windows are listed on the wiki so we can cross the logs the day they arrive.', '[]'::jsonb, now() - interval '11201 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('00b03099-4a56-5703-b7e8-e25e6dad99f3', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Good. That closes the last open records item on Quiet Storm.', '[]'::jsonb, now() - interval '11198 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('33ac491a-eebe-57d8-8429-eeee9eca666e', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'Cobalt Ledger archive request: prosecution wants the custody bundle for the appeal. All three exhibits, full logs.', '[]'::jsonb, now() - interval '10822 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('4decaacd-e7b5-5b09-8c07-11396f7e2833', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'Bundle prepared and sealed — collecting signature from legal tomorrow.', '[]'::jsonb, now() - interval '10816 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c96ddc0f-a109-5d0b-8f21-aee4abd14ce6', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'e100fa34-6da5-4212-863b-55d041585e96', 'I''ll sign at 09:00. The appeal has nothing on custody; this is belt and braces.', '[]'::jsonb, now() - interval '10810 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('6deb5a9c-68c3-5ba5-aa18-8735af099710', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'Broken Compass: Novum Estates closed two more flips this month. If the mixer deposits follow within 48h again, that is cycle five and six.', '[]'::jsonb, now() - interval '10502 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('571baa48-f4d0-5b72-8be8-57e3dcaca642', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Watching the deposit window now. EV-0103 statement format makes the matching almost mechanical.', '[]'::jsonb, now() - interval '10498 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('9f04e5c9-5a11-584e-98de-10713837a67e', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Ping me the timestamps when they land — they go straight into the exit-cluster correlation.', '[]'::jsonb, now() - interval '10494 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('aaeb5ba9-6d2a-58a4-a641-9a9032f0d9fe', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Will do.', '[]'::jsonb, now() - interval '10491 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('d9b733da-fbfb-5881-87ce-5ecd449daf0f', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Reminder: minutes from last Thursday''s War Room are on the case wikis. If you were mentioned with an action item, it is now in the assignments table too.', '[]'::jsonb, now() - interval '10162 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e1028ab9-6d31-5994-b232-3262037f1ab2', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', '39072d25-7ed8-442d-a26b-e93771ba9192', 'Seen mine — address verification on the Goldquay storefront, holding for legal.', '[]'::jsonb, now() - interval '10157 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('0a1ce090-62c6-58a4-9e49-5244bf1ef45f', 'dd1a79bc-d1f4-5f9b-a303-a0993459fd0b', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Mine too. Kestrel charter manager identity confirmation, with Priya.', '[]'::jsonb, now() - interval '10152 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('87b0eda2-bcc8-5b49-b00e-3609d8196e07', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'TXN pattern memo is up in my notebook: structuring under 10k via Goldquay — TXN-0010 (9,769.57 EUR wire), TXN-0002, TXN-0014. Sub-threshold density 3.1× corridor baseline.', '[]'::jsonb, now() - interval '9753 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('fd02603c-610d-5fe0-b0b7-568e9b045464', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'That density figure is the headline. Baseline computed on which window?', '[]'::jsonb, now() - interval '9748 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('30488e46-350a-56d1-aa62-462d574c4772', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Rolling 90 days, masked series bucketed to 500-unit bands. Method note is in the toggle.', '[]'::jsonb, now() - interval '9746 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('71b8234b-b877-5c1c-a101-a5e5d421937c', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'And then TXN-0016 walks in — 306,454.56 EUR by hawala TO an exchange. Who settles six figures by hawala into Goldquay?', '[]'::jsonb, now() - interval '9743 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('ce991633-e50e-5362-9b85-a5886f5414a5', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Either operator-to-operator settlement or a mistake. Escalated to Marcus with the memo.', '[]'::jsonb, now() - interval '9738 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('04dff332-aace-5543-8f81-688698a83513', '394d18f9-8646-5b63-a003-454d2604085b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Read it. Bring it to the War Room Thursday — that outlier reframes Nightfall''s scale.', '[]'::jsonb, now() - interval '9732 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('3c767f11-d9b5-5894-a360-d4917d1cddf7', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Counterparty graph refresh: Goldquay now touches FOUR cases (Nightfall, Ash Garden, Broken Compass, Quiet Storm). Westport Capital SA recurs post-conviction. Watchlist updated.', '[]'::jsonb, now() - interval '9432 minutes', now() - interval '9423 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('0be1ce48-69cb-5733-a015-a3892cc456c8', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Kestrel Marine at elevated too — Red Meridian marina lead plus Broken Compass charter trail.', '[]'::jsonb, now() - interval '9427 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('a811097e-e3ab-5bae-8a13-438319625472', '394d18f9-8646-5b63-a003-454d2604085b', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'This is the picture I want on one page for the board. Watchlist memo is exactly right — keep ids on every claim.', '[]'::jsonb, now() - interval '9423 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c3f4efc5-4036-5c58-89c6-8d886a15446e', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Will do. FIXER-01 stays an unresolved node until the SIM comparison lands.', '[]'::jsonb, now() - interval '9417 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('aad854bd-05e0-56f7-9c2f-392be76fc7d4', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Hawala corridor: Pelican Freight''s invoice cancellation rate is 31% vs 4% industry norm. Freight invoices as settlement memos — cancellation IS the settlement confirmation.', '[]'::jsonb, now() - interval '9067 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('6d3fe672-dac3-50b1-9ca4-7d08e23837c5', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'That inversion is beautiful. Does the 72h settlement window hold across both corridors?', '[]'::jsonb, now() - interval '9062 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c0e64e0e-0985-5e6e-92e5-f3f516e5a05f', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'NA leg yes, EU leg has two outliers — both brush TXN-0016''s week.', '[]'::jsonb, now() - interval '9059 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('4b647007-7665-54e3-86ef-86520fe98187', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Everything touches that week. Flagging it as an event window in the graph.', '[]'::jsonb, now() - interval '9056 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('a416601d-c374-5a0a-9657-74a535e83def', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Alias collision resolved: “Halcyon” is S-0003 (Hassan Vidal) AND S-0009 (Gustav DeVries). Two people, one alias, zero relation. The dashboard join was silently merging them.', '[]'::jsonb, now() - interval '8715 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('cd71dae3-3027-5ebb-b9e1-05dc1d16e6d4', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'How long was it wrong?', '[]'::jsonb, now() - interval '8709 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('5d83f3ee-c446-5e57-82e4-d98916d91fb1', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Since 02 June. Re-keyed everything to subject_id. Post-mortem note is in my notebook.', '[]'::jsonb, now() - interval '8706 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('97fd538b-bb4f-5cb1-8582-31190befa95d', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Good catch — that merge would have poisoned the Quiet Storm vendor analysis.', '[]'::jsonb, now() - interval '8701 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('1c695e32-fcd0-5678-8868-a39686c41030', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Mixer exits: 03:00–05:00 UTC cluster correlates 0.78 with Goldquay settlement batches. If Thursday''s batch repeats the offsets, it''s automation.', '[]'::jsonb, now() - interval '8373 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('19796b30-8068-509f-be38-50d1824d315d', '394d18f9-8646-5b63-a003-454d2604085b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'And if it''s automation, subpoenaing the scheduler config becomes very interesting. Keep me posted.', '[]'::jsonb, now() - interval '8369 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('5fccce07-fbc9-5ff4-8146-ea426781d38e', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Watch is booked. Results land here Thursday night.', '[]'::jsonb, now() - interval '8366 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('10e72ebd-16de-5a68-ae02-6b65ded34a83', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Sable Trust (BVI) profile check: TXN-0024 (76,102.38 EUR, invoice method) fits the residual-sink pattern exactly. Third independent signal.', '[]'::jsonb, now() - interval '7936 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('a54c57d5-d66a-515e-ba8d-38d3bda23377', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Then it graduates from monitor to elevated next sweep, per the watchlist rules.', '[]'::jsonb, now() - interval '7933 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('a8a4588c-5e63-5937-9b8f-6a25c50fca0e', '394d18f9-8646-5b63-a003-454d2604085b', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Approved. The two-signal rule is doing its job.', '[]'::jsonb, now() - interval '7931 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('8a516e27-0dd7-5b30-a5d4-08216fb7210d', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Dashboard note: aggregate views (count/sum by counterparty) come from the live tables, so they respect masks. Finance sees totals; we see densities. Reconcile through Robert, not screenshots.', '[]'::jsonb, now() - interval '7577 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('43aa99b3-9737-5ce7-b8d9-d54b1c104a0c', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'The mask follows the data, not the pixels. It''s in the clearance policy verbatim.', '[]'::jsonb, now() - interval '7571 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('58c71ca4-96d0-56e7-8240-6dcbb17b4608', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Quoting the handbook in chat. We''ve become management.', '[]'::jsonb, now() - interval '7567 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('358110e3-ba6b-5f22-91b3-0c8bd2818160', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'EV-0098 artefacts are in. The second drive''s wallet set overlaps EV-0086 on three addresses — same operator, different machines.', '[]'::jsonb, now() - interval '7283 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('2beaea5e-633b-58cf-a8ce-8776c49732d3', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Three shared addresses is enough to collapse them into one node in the graph.', '[]'::jsonb, now() - interval '7280 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('d9fcbe27-1af5-581b-90ba-9935328f6462', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Collapsed. Nightfall''s subject cluster just got simpler and scarier at the same time.', '[]'::jsonb, now() - interval '7276 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('abc2c402-a0e2-5f27-abde-a454789ea14b', '394d18f9-8646-5b63-a003-454d2604085b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'That sentence is going in my Thursday brief verbatim.', '[]'::jsonb, now() - interval '7273 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c88235ed-bdf8-5fee-8860-4bb6914e63ac', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Kestrel charter manager identity: Grace''s marina log plus the charter registry gives us a name. Holding it off-channel until verified — it goes in `subjects` first, chat second.', '[]'::jsonb, now() - interval '6934 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('4ad0273d-3c5c-5e67-8fee-45c6d1a958e0', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Correct order. The registry pull is archived under the case so the sourcing survives review.', '[]'::jsonb, now() - interval '6932 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('8c6c369d-d90f-5c09-99fc-c944208a1872', '394d18f9-8646-5b63-a003-454d2604085b', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'This thread is why the analysts channel exists. Carry on.', '[]'::jsonb, now() - interval '6930 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f21a1106-1c00-5242-9c55-00fdcb77385d', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Methodology share: the 31% cancellation figure for Pelican came from comparing issued vs settled invoices in the manifest set — happy to walk anyone through the query.', '[]'::jsonb, now() - interval '6552 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('7b58ec09-ea09-5fe6-abc0-fb63d615ec48', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Do a ten-minute walkthrough Friday after the all-hands?', '[]'::jsonb, now() - interval '6546 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('d74131cf-4541-5e14-ae64-def4f7a57197', '394d18f9-8646-5b63-a003-454d2604085b', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Booked. Bring your own masked views.', '[]'::jsonb, now() - interval '6542 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('079b442c-4957-515f-8e07-0d3cac67b4c7', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Attending. I want the cancellation-as-settlement-confirmation logic on the watchlist page.', '[]'::jsonb, now() - interval '6536 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('77713e74-1fc4-5738-aad8-31f61164838e', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Thursday batch result: the mixer exit offsets repeated within 90 seconds. It is automation.', '[]'::jsonb, now() - interval '6221 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('3e8bcb4c-01fa-55e9-b864-971ffabe8128', '394d18f9-8646-5b63-a003-454d2604085b', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Then the scheduler config subpoena goes in the Goldquay package. Outstanding work.', '[]'::jsonb, now() - interval '6218 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('eab1d608-cb87-570a-b14f-b0278c1635a9', '394d18f9-8646-5b63-a003-454d2604085b', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Event window thesis strengthens too — automated settlement plus one manual six-figure hawala the same week reads like someone pressing the override button.', '[]'::jsonb, now() - interval '6212 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('8f7e024d-b0c0-5afb-a97d-ef74049a6548', '394d18f9-8646-5b63-a003-454d2604085b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Agreed on all counts. Memo updated before the War Room.', '[]'::jsonb, now() - interval '6206 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e73ee41c-60ba-5884-a030-d6a4ce3c3b37', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Marina follow-up (L-0022): second meeting confirmed, same berth. Counterparty matches a Kestrel Marine charter manager — photos logged, context only, no faces.', '[]'::jsonb, now() - interval '5845 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('98884f93-e48f-50ea-adeb-926ae8df101f', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Textbook. Tag goes to the Red Meridian analyst thread.', '[]'::jsonb, now() - interval '5840 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('03cc0735-2365-58bf-984b-8252e0f5ec6c', '238832b0-ba44-52f2-bfa1-a73980f4da06', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Logged. Identity confirmation task is with Priya.', '[]'::jsonb, now() - interval '5834 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c5ffc978-8040-5a23-b4d7-516ea4b1f015', '238832b0-ba44-52f2-bfa1-a73980f4da06', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Plate from L-0079 spotted again at the freight forwarder''s yard, 14:20. Second corroboration.', '[]'::jsonb, now() - interval '5458 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('ce7b703a-4737-5982-85a5-300b15b28055', '238832b0-ba44-52f2-bfa1-a73980f4da06', '99389d8f-5186-4bbb-a426-faf8fb91bc69', 'Matches my earlier log at location L-21. The vehicle is the thread between subject and yard.', '[]'::jsonb, now() - interval '5454 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('af919a88-03f8-5630-8468-c13c7647a969', '238832b0-ba44-52f2-bfa1-a73980f4da06', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'Both entries are in `leads`. That''s how it''s done — 30 seconds of logging, weeks of value.', '[]'::jsonb, now() - interval '5448 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('1d66170c-9140-5da3-97c9-67b207e1fb73', '238832b0-ba44-52f2-bfa1-a73980f4da06', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'Friday courier watch (L-0048 / Red Meridian): bank alert corroborated across two cycles now. Third cycle this Friday completes the warrant package threshold.', '[]'::jsonb, now() - interval '5110 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('a7949b39-ceae-50ce-bb1c-15af6be3fee4', '238832b0-ba44-52f2-bfa1-a73980f4da06', '39072d25-7ed8-442d-a26b-e93771ba9192', 'I''ll take the second eye on Friday. Rotation board updated.', '[]'::jsonb, now() - interval '5106 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('b959e3e0-ffef-547a-a0f8-e1de8ebdb753', '238832b0-ba44-52f2-bfa1-a73980f4da06', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'Confirmed. Radio checks on the quarter hour, abort criteria per the field guide.', '[]'::jsonb, now() - interval '5102 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e2280e33-1dbe-5781-8df5-87c4fe7e4b11', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Ash Garden rotation: Grace and Viktor on static this week. Subject is counter-surveillance aware — eyes rotate every 40, no exceptions.', '[]'::jsonb, now() - interval '4746 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e1b9e535-c68a-5806-aa01-b97e7e666893', '238832b0-ba44-52f2-bfa1-a73980f4da06', '99389d8f-5186-4bbb-a426-faf8fb91bc69', 'Footage from last night sealed and logged as EV-0076 continuation. Hand-off logged at BOTH ends, before anyone asks.', '[]'::jsonb, now() - interval '4743 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('14f219ab-9101-53e4-a6aa-dffd3fb9a663', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'I saw. Custody compliance has visibly improved since the audit. Keep it up.', '[]'::jsonb, now() - interval '4741 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('694c4823-448a-5df4-a29b-cec9d1c10d24', '238832b0-ba44-52f2-bfa1-a73980f4da06', '39072d25-7ed8-442d-a26b-e93771ba9192', 'Reminder from the SOP: contact reports within 12 hours. Mine from the records office visit is filed.', '[]'::jsonb, now() - interval '4366 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('a083455a-34cb-5955-a2e7-3dcaf5f26c0c', '238832b0-ba44-52f2-bfa1-a73980f4da06', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Filed mine too. The notary clerk talks a lot when you ask about parking.', '[]'::jsonb, now() - interval '4363 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f8d294ef-35f9-5a2a-97e6-532f1ebaccd7', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'The best sources don''t know they are sources.', '[]'::jsonb, now() - interval '4358 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('5b4612dd-4cb4-5be7-b06e-51b874bddd18', '238832b0-ba44-52f2-bfa1-a73980f4da06', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Ops note: no seizures on pattern alone. The Friday courier package waits for legal''s warrant — anyone moving early answers to me and then to Isabel, in that order.', '[]'::jsonb, now() - interval '4048 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('6f802163-1d40-5ade-8fad-1f0e9dfe29a3', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Understood. Field holds.', '[]'::jsonb, now() - interval '4042 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('a5993d3c-2a39-54f0-ab71-6a8a4f6ed9f3', '238832b0-ba44-52f2-bfa1-a73980f4da06', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'And if anyone gets burned: SUNSET in this channel, fallback point, nothing else.', '[]'::jsonb, now() - interval '4036 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('7f9d3d91-a0fd-59c0-aee4-cc8e1cc07260', '238832b0-ba44-52f2-bfa1-a73980f4da06', '99389d8f-5186-4bbb-a426-faf8fb91bc69', 'Kit check: long-lens rig back from forensics, locker 7. Radio batteries rotated. The static post chair remains a war crime.', '[]'::jsonb, now() - interval '3635 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('4a8e72da-264a-54ed-b48b-30ee11b8383a', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Confirmed on all three. Bringing my own cushion.', '[]'::jsonb, now() - interval '3633 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c43853f9-9738-5a58-bde0-f2cbdffe5ae5', '238832b0-ba44-52f2-bfa1-a73980f4da06', '39072d25-7ed8-442d-a26b-e93771ba9192', 'Requisition for a new chair filed under ''operational necessity''.', '[]'::jsonb, now() - interval '3630 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('af288cdd-d871-5104-9126-da1b4b88d39a', '238832b0-ba44-52f2-bfa1-a73980f4da06', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'Approved. Morale is operational.', '[]'::jsonb, now() - interval '3624 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('43c174d6-ae5d-57f3-b7ad-1485635c066a', '238832b0-ba44-52f2-bfa1-a73980f4da06', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'Friday cycle three complete: courier, same route, same timing, bag swap at the kiosk. Warrant threshold met — package goes to legal Monday.', '[]'::jsonb, now() - interval '3324 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('a9e2ada6-00c6-50a9-a38c-aa95b41f73cb', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Clean work. Log both plates and the kiosk vendor''s hours in `leads` before you stand down.', '[]'::jsonb, now() - interval '3321 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('5cd1e6c9-dee1-5b1c-9ca9-e66eeed44607', '238832b0-ba44-52f2-bfa1-a73980f4da06', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'Already in. L-0048 updated with all three cycles.', '[]'::jsonb, now() - interval '3315 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('43d1a910-1f64-500d-9d61-a80a16ecbf9d', '238832b0-ba44-52f2-bfa1-a73980f4da06', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Seen. Legal has it first thing Monday.', '[]'::jsonb, now() - interval '3311 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('58e6f076-071f-599d-9b3b-7cc89038b775', '238832b0-ba44-52f2-bfa1-a73980f4da06', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Yard watch: the L-0079 vehicle left with a trailer tonight, first time. Followed to the ring road per the pre-cleared route, then broke off.', '[]'::jsonb, now() - interval '2997 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('364d2dbe-5a56-5678-8b75-32e9457db0a3', '238832b0-ba44-52f2-bfa1-a73980f4da06', '99389d8f-5186-4bbb-a426-faf8fb91bc69', 'Correct call — the route past the ring road is not cleared and the subject checks mirrors.', '[]'::jsonb, now() - interval '2993 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('1b30b6e2-a947-57cc-bb8a-a40980156c23', '238832b0-ba44-52f2-bfa1-a73980f4da06', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'Trailer detail goes to Red Meridian''s analyst thread. That yard keeps earning its surveillance budget.', '[]'::jsonb, now() - interval '2988 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('fe5d993a-cc98-58c4-811b-2d4dc5b26bcf', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Ash Garden Friday report: notary, then Goldquay storefront, fourth consecutive week. Pattern threshold met. Footage sealed, hand-off logged both ends.', '[]'::jsonb, now() - interval '2608 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('b3b377fd-a59c-58a6-beec-0715d4388533', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Four for four. It goes on the wiki tonight and to Isabel''s screening queue first.', '[]'::jsonb, now() - interval '2602 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('8ed0d61e-aefe-5733-b9e7-e0d4c5c383a8', '238832b0-ba44-52f2-bfa1-a73980f4da06', 'e100fa34-6da5-4212-863b-55d041585e96', 'Queue position one. You''ll have the release decision by Tuesday.', '[]'::jsonb, now() - interval '2600 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('34816eb0-ac4b-5cab-9d9d-8aaa7ad37d74', '5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'War Room Thursday 09:00 — agenda: Nightfall TXN-0016 outlier, Red Meridian referral, Q2 custody findings. Erik presents first ten minutes.', '[]'::jsonb, now() - interval '2206 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('5141e7f9-946a-59f1-b80d-9366d5533892', '5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Confirmed. Pre-read: Erik''s notebook memo + the watchlist page.', '[]'::jsonb, now() - interval '2201 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('cb1f04db-d54e-54e4-8c8c-dc8ccb81ccfc', '5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Joining from the EU room. Red Meridian exhibit list will be final by then.', '[]'::jsonb, now() - interval '2195 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('485469bf-bc71-5d96-8b26-85a0c4be8934', '5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'NA dialling in. I''ll bring the Broken Compass charter subpoena status.', '[]'::jsonb, now() - interval '2193 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f140c4c8-0d46-5b6d-8943-317e512b3256', '5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Minutes go on the case wikis within 24h, per SOP.', '[]'::jsonb, now() - interval '2190 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('e0917cc1-cc09-5d54-bf33-4d14802e3415', '5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Link check for Thursday — everyone confirm the video channel works from your kit.', '[]'::jsonb, now() - interval '1916 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('3bbd05a9-5ccb-5e60-8aef-2b4a4f414b37', '5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Working here.', '[]'::jsonb, now() - interval '1912 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('0fae90f6-45cd-5c56-9d89-19bd9df562e4', '5c1aeaa7-fecc-50ab-b399-c80b3cd0e225', '729e8625-6938-42aa-9f89-f4cdb4dab70e', 'Same.', '[]'::jsonb, now() - interval '1908 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('20ab46e7-8cbe-5784-9fa2-b1f3cd63e69b', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2:fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57'), '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Your alias-collision catch saved my density model — I was about to bucket by alias for the dedupe pass.', '[]'::jsonb, now() - interval '1487 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('1a644c13-ff43-55f9-a4bc-c36e73a33459', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2:fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57'), 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'We all almost did. subject_id or nothing now.', '[]'::jsonb, now() - interval '1482 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('44a96c10-c4b6-55be-a527-93f48190e08d', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2:fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57'), '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Deal. Also — look at TXN-0016''s week in your graph. Everything converges on it.', '[]'::jsonb, now() - interval '1480 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('35ddb327-8664-5cc0-a794-d01e9ddae969', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2:fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57'), 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Saw it. I''m calling it the event window. If the Thursday batch confirms automation, that week is when someone settled the books.', '[]'::jsonb, now() - interval '1477 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('76ede061-c95a-5feb-ab79-4010d4aa4e02', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2:fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57'), '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'If you put ''event window'' on the watchlist page, attach both our ids to it.', '[]'::jsonb, now() - interval '1472 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('aa3568e4-c0d9-530f-b911-258653881c90', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2:fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57'), 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Two signals, written down. I know the rules — I wrote them.', '[]'::jsonb, now() - interval '1469 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('b8ace820-8722-55fc-9898-dc50ea0563cd', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:9946155f-cfcb-4ad4-99a4-6b5ed49ad24c'), '9878850d-3024-4012-b03a-d3d7ccef377c', 'Red Meridian: can you hold the referral package until Hannah''s deep verification report? If EV-0055 clears, we amend; if not, the package is already clean.', '[]'::jsonb, now() - interval '1153 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('f4a627fa-49fa-5d50-80d6-9759bbb65e51', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:9946155f-cfcb-4ad4-99a4-6b5ed49ad24c'), '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Holding until Wednesday noon, then it ships regardless. Legal agrees the shorter list is stronger.', '[]'::jsonb, now() - interval '1149 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('2d59c7d9-58e6-5793-a833-75c8a6bc609a', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:9946155f-cfcb-4ad4-99a4-6b5ed49ad24c'), '9878850d-3024-4012-b03a-d3d7ccef377c', 'Agreed. One more thing — staffing. If the SIM link between Ash Garden and Nightfall verifies, I want one analyst across both.', '[]'::jsonb, now() - interval '1144 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('38f27dda-2894-5931-a5d7-283d09676958', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:9946155f-cfcb-4ad4-99a4-6b5ed49ad24c'), '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Erik is the obvious pick but he''s at capacity. Priya takes the cross-case node, Erik keeps the patterns.', '[]'::jsonb, now() - interval '1140 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('3e9e41c5-aea1-5653-957c-7a6d1e31c25f', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:9946155f-cfcb-4ad4-99a4-6b5ed49ad24c'), '9878850d-3024-4012-b03a-d3d7ccef377c', 'Good split. Put it in Monday''s triage.', '[]'::jsonb, now() - interval '1137 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('fbd7a2cb-97c1-51ca-bc57-89c018eb1ccd', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:a3cefadb-2668-4f12-830c-14518e0a08f3:edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0'), 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Friday rotation — can you take the early shift at the static post? Viktor has the courier watch with Nadia.', '[]'::jsonb, now() - interval '783 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('1db11854-c021-51a3-9ee2-cf33e525d6d3', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:a3cefadb-2668-4f12-830c-14518e0a08f3:edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0'), 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Yes, but I need the long-lens kit back from forensics by Thursday.', '[]'::jsonb, now() - interval '780 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('05766a13-4f88-5f9b-aba0-b983cfe8e873', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:a3cefadb-2668-4f12-830c-14518e0a08f3:edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0'), 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Maya signed it back in this morning. It''s in locker 7.', '[]'::jsonb, now() - interval '776 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('6f4765ee-16eb-5fa6-ba9e-cd951cda38dd', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:a3cefadb-2668-4f12-830c-14518e0a08f3:edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0'), 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Then we''re set. If the subject does the notary-Goldquay run again, that''s four consecutive Fridays — pattern threshold met.', '[]'::jsonb, now() - interval '773 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('b74cef5f-4e4f-511b-9876-e8fd1769d49d', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:a3cefadb-2668-4f12-830c-14518e0a08f3:edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0'), 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'And THEN we let legal do their thing. By the book on this one — she''s a lawyer, she''ll see anything sloppy.', '[]'::jsonb, now() - interval '770 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('c08339d3-30f0-5c95-8bdb-6a3a1c63f1ff', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:faf9aeda-e21e-42ab-80be-9ce0fd8662a1'), 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Before Thursday: the TXN-0016 outlier — does it change Nightfall''s budget line? Six-figure settlements mean a bigger ring than we scoped.', '[]'::jsonb, now() - interval '424 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('73c73786-5b84-5971-ba98-b0042abf7cf7', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:faf9aeda-e21e-42ab-80be-9ce0fd8662a1'), '9878850d-3024-4012-b03a-d3d7ccef377c', 'Honestly, yes. If the fixer link verifies we''re looking at one network behind three operations. I''d rather re-scope now than mid-referral.', '[]'::jsonb, now() - interval '419 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('ca030bbd-b164-52b9-a4fa-b0242d822e45', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:faf9aeda-e21e-42ab-80be-9ce0fd8662a1'), 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Then bring a consolidated proposal Thursday. One network, one task force, shared analyst pool.', '[]'::jsonb, now() - interval '414 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('0fa85b13-c522-5cd3-bb66-efb050e98109', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:faf9aeda-e21e-42ab-80be-9ce0fd8662a1'), '9878850d-3024-4012-b03a-d3d7ccef377c', 'Drafting it tonight. Working name: MERIDIAN GATE.', '[]'::jsonb, now() - interval '412 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, created_at, edited_at) VALUES ('263d97bc-f031-55ea-ae3a-880195184dc9', (SELECT id FROM public.osionos_channels WHERE dm_key = 'dm:9878850d-3024-4012-b03a-d3d7ccef377c:faf9aeda-e21e-42ab-80be-9ce0fd8662a1'), 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Approved as a working name. And Marcus — good catch escalating the outlier fast.', '[]'::jsonb, now() - interval '409 minutes', NULL) ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('c3bb4b25-fdc9-5a23-a1cf-d421483efe5b', '88e73a58-ffa3-4601-9be2-aaeb6559af28', '👍', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('c3bb4b25-fdc9-5a23-a1cf-d421483efe5b', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', '👍', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('c3bb4b25-fdc9-5a23-a1cf-d421483efe5b', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', '🎉', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('f7717ac0-0539-55db-b783-01c1f05c4509', '5540b1cb-b294-414d-a2c4-03087dd44a91', '😂', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('25900c31-bcbe-5ee7-8a41-9c24aceb1102', '39072d25-7ed8-442d-a26b-e93771ba9192', '😂', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('39327a0e-98f8-5f04-b84b-caff0573b592', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', '👍', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('5db5aee8-c6eb-57c3-8942-aeb8aba9c0fa', '9878850d-3024-4012-b03a-d3d7ccef377c', '👀', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('5db5aee8-c6eb-57c3-8942-aeb8aba9c0fa', '729e8625-6938-42aa-9f89-f4cdb4dab70e', '🔥', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('b857fc11-1b2a-55d3-a4d8-a767e2537d4f', '9878850d-3024-4012-b03a-d3d7ccef377c', '✅', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('ce991633-e50e-5362-9b85-a5886f5414a5', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', '🔥', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('ce991633-e50e-5362-9b85-a5886f5414a5', 'b371611d-139f-454b-9195-2b655e1fe8a6', '🔥', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('a416601d-c374-5a0a-9657-74a535e83def', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', '🎯', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('a416601d-c374-5a0a-9657-74a535e83def', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', '👍', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('af919a88-03f8-5630-8468-c13c7647a969', 'a3cefadb-2668-4f12-830c-14518e0a08f3', '✅', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('e73ee41c-60ba-5884-a030-d6a4ce3c3b37', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', '👍', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('34816eb0-ac4b-5cab-9d9d-8aaa7ad37d74', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', '✅', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('34816eb0-ac4b-5cab-9d9d-8aaa7ad37d74', '729e8625-6938-42aa-9f89-f4cdb4dab70e', '✅', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) VALUES ('263d97bc-f031-55ea-ae3a-880195184dc9', '9878850d-3024-4012-b03a-d3d7ccef377c', '🙏', now() - interval '1 day') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('0123ae7a-c7c3-587e-a3c5-758e8a5a43b1', '9878850d-3024-4012-b03a-d3d7ccef377c', now() - interval '5977 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('0123ae7a-c7c3-587e-a3c5-758e8a5a43b1', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', now() - interval '8836 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('0123ae7a-c7c3-587e-a3c5-758e8a5a43b1', '88e73a58-ffa3-4601-9be2-aaeb6559af28', now() - interval '3960 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('0123ae7a-c7c3-587e-a3c5-758e8a5a43b1', 'a3cefadb-2668-4f12-830c-14518e0a08f3', now() - interval '1825 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('0123ae7a-c7c3-587e-a3c5-758e8a5a43b1', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', now() - interval '2860 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('0123ae7a-c7c3-587e-a3c5-758e8a5a43b1', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', now() - interval '5708 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('d9f751ad-5813-523b-811c-2ffed905da54', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', now() - interval '5173 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('d9f751ad-5813-523b-811c-2ffed905da54', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', now() - interval '6539 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('d9f751ad-5813-523b-811c-2ffed905da54', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', now() - interval '3160 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('d9f751ad-5813-523b-811c-2ffed905da54', 'e100fa34-6da5-4212-863b-55d041585e96', now() - interval '5851 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('61b17d73-9a46-5f10-a4d3-0f9218814336', '9878850d-3024-4012-b03a-d3d7ccef377c', now() - interval '4593 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('61b17d73-9a46-5f10-a4d3-0f9218814336', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', now() - interval '3467 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('61b17d73-9a46-5f10-a4d3-0f9218814336', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', now() - interval '1791 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('61b17d73-9a46-5f10-a4d3-0f9218814336', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', now() - interval '3221 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('61b17d73-9a46-5f10-a4d3-0f9218814336', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', now() - interval '2632 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('c50c3ce2-5071-532d-82fe-e44e7fe9118f', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', now() - interval '6831 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('c50c3ce2-5071-532d-82fe-e44e7fe9118f', 'b371611d-139f-454b-9195-2b655e1fe8a6', now() - interval '2309 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('c50c3ce2-5071-532d-82fe-e44e7fe9118f', 'fc15e717-3cdf-4862-abce-8ea2e3be771f', now() - interval '8766 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('c50c3ce2-5071-532d-82fe-e44e7fe9118f', '7d93f34c-0b8e-4e2c-91f1-acf03d8842e2', now() - interval '8801 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('b7307548-1e39-58b9-b3c3-e67f64700a35', 'a3cefadb-2668-4f12-830c-14518e0a08f3', now() - interval '1251 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('b7307548-1e39-58b9-b3c3-e67f64700a35', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', now() - interval '1756 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('b7307548-1e39-58b9-b3c3-e67f64700a35', '99389d8f-5186-4bbb-a426-faf8fb91bc69', now() - interval '7166 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('b7307548-1e39-58b9-b3c3-e67f64700a35', 'e100fa34-6da5-4212-863b-55d041585e96', now() - interval '875 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('0a02865f-15a9-516c-a54d-11448621a04a', '5540b1cb-b294-414d-a2c4-03087dd44a91', now() - interval '7072 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('0a02865f-15a9-516c-a54d-11448621a04a', '729e8625-6938-42aa-9f89-f4cdb4dab70e', now() - interval '4731 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('0a02865f-15a9-516c-a54d-11448621a04a', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', now() - interval '1188 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('248cfe48-81c6-51f9-92ca-61a049a6ad0a', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', now() - interval '3875 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('248cfe48-81c6-51f9-92ca-61a049a6ad0a', '9878850d-3024-4012-b03a-d3d7ccef377c', now() - interval '8931 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('248cfe48-81c6-51f9-92ca-61a049a6ad0a', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', now() - interval '8935 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('248cfe48-81c6-51f9-92ca-61a049a6ad0a', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', now() - interval '1553 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('248cfe48-81c6-51f9-92ca-61a049a6ad0a', '39072d25-7ed8-442d-a26b-e93771ba9192', now() - interval '4814 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('248cfe48-81c6-51f9-92ca-61a049a6ad0a', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', now() - interval '698 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('9971834b-9a3a-503f-8d21-b6ecf5458285', '88e73a58-ffa3-4601-9be2-aaeb6559af28', now() - interval '1715 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('9971834b-9a3a-503f-8d21-b6ecf5458285', '5540b1cb-b294-414d-a2c4-03087dd44a91', now() - interval '2746 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('9971834b-9a3a-503f-8d21-b6ecf5458285', 'e100fa34-6da5-4212-863b-55d041585e96', now() - interval '3932 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('bc41efa6-83ba-50a9-a086-c71554f7b9dc', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', now() - interval '7227 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('bc41efa6-83ba-50a9-a086-c71554f7b9dc', '920b7e0b-a87d-400d-8169-f2f521bfe5ad', now() - interval '5480 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('bc41efa6-83ba-50a9-a086-c71554f7b9dc', '88e73a58-ffa3-4601-9be2-aaeb6559af28', now() - interval '7887 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('bc41efa6-83ba-50a9-a086-c71554f7b9dc', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', now() - interval '6311 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('515676eb-f9d1-5fb8-870b-ec0664ec46b7', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', now() - interval '6762 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('515676eb-f9d1-5fb8-870b-ec0664ec46b7', '9878850d-3024-4012-b03a-d3d7ccef377c', now() - interval '2089 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('515676eb-f9d1-5fb8-870b-ec0664ec46b7', '729e8625-6938-42aa-9f89-f4cdb4dab70e', now() - interval '8900 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('515676eb-f9d1-5fb8-870b-ec0664ec46b7', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', now() - interval '5717 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) VALUES ('515676eb-f9d1-5fb8-870b-ec0664ec46b7', 'fc15e717-3cdf-4862-abce-8ea2e3be771f', now() - interval '2295 minutes') ON CONFLICT DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('fd434992-a5d3-594a-a8d7-5f1160e33f23', 'bc41efa6-83ba-50a9-a086-c71554f7b9dc', '88e73a58-ffa3-4601-9be2-aaeb6559af28', 'Custody log for EV-0042 updated — please re-verify.', now() - interval '4300 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('d406c7fb-4471-517a-9591-920485547dc4', 'bc41efa6-83ba-50a9-a086-c71554f7b9dc', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'Re-verification booked for Thursday. EV-0055 deep check still running.', now() - interval '4180 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('a6504738-53b0-525b-9b7e-87f7ba305386', '61b17d73-9a46-5f10-a4d3-0f9218814336', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'Reminder: EV-0055 stays out of the referral package until my report lands.', now() - interval '3900 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('3f840176-a0d0-5208-9a86-51132e2a680e', '61b17d73-9a46-5f10-a4d3-0f9218814336', 'e100fa34-6da5-4212-863b-55d041585e96', 'Exhibit list reviewed — clean without the tape. Ship it Wednesday.', now() - interval '3700 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('b57a9e96-7454-5608-9367-3b695e87ffcb', '0123ae7a-c7c3-587e-a3c5-758e8a5a43b1', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'TXN pattern memo linked — the 3.1× density figure is the headline for Thursday.', now() - interval '5200 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('7e8a9a84-6a34-5b74-b98a-558a4df5dbbe', '0123ae7a-c7c3-587e-a3c5-758e8a5a43b1', 'e100fa34-6da5-4212-863b-55d041585e96', 'COMM-0139 is privileged — do not quote it here, reference the id only.', now() - interval '5100 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('a0fe35f9-2dca-50f0-bf82-f49239722708', 'b7307548-1e39-58b9-b3c3-e67f64700a35', 'e100fa34-6da5-4212-863b-55d041585e96', 'All Petrov intercepts screened this week — two released to analysis, one held.', now() - interval '2900 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('c42933c9-0c47-5a4b-9923-f6d4bce213a7', 'b7307548-1e39-58b9-b3c3-e67f64700a35', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Static post log sealed and handed off — both ends signed this time.', now() - interval '2800 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('46ff3f9f-61ab-5ff7-b988-12a1ff5a5e26', 'c50c3ce2-5071-532d-82fe-e44e7fe9118f', 'fc15e717-3cdf-4862-abce-8ea2e3be771f', 'Casino credit reconciliation: residual down to two lines. Update on the wiki.', now() - interval '2400 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('c08c32b9-d9eb-5123-9bd1-33d04ae7c36e', '0a02865f-15a9-516c-a54d-11448621a04a', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Kestrel Marine raised to elevated on the watchlist — two-signal rule met.', now() - interval '2000 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('2f529989-be6e-5341-93fa-cf6922d77011', 'd9f751ad-5813-523b-811c-2ffed905da54', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Excellent retrospective. The Westport recurrence note directly feeds Nightfall.', now() - interval '6100 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('081762bd-0ef5-5400-9cc9-b567aca00caa', '248cfe48-81c6-51f9-92ca-61a049a6ad0a', '39072d25-7ed8-442d-a26b-e93771ba9192', 'Onboarding checklist is genuinely useful — finished week one with zero surprises.', now() - interval '7000 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('af16bfa7-95db-5fd8-90bc-c173e9a7978d', '515676eb-f9d1-5fb8-870b-ec0664ec46b7', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Live board is now the agenda for Monday triage — keep statuses current.', now() - interval '8000 minutes') ON CONFLICT (id) DO NOTHING; +INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) VALUES ('91646c25-89d0-5cba-b6e8-40ae55d554e7', '9971834b-9a3a-503f-8d21-b6ecf5458285', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Contact-report-within-12h rule acknowledged. Filed tonight''s from the yard.', now() - interval '3300 minutes') ON CONFLICT (id) DO NOTHING; +COMMIT; diff --git a/tools/seeds/seed_agency_people.sh b/tools/seeds/seed_agency_people.sh new file mode 100755 index 00000000..c29635d5 --- /dev/null +++ b/tools/seeds/seed_agency_people.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# **************************************************************************** # +# seed_agency_people.sh — Binocle Intelligence Agency identity seeding # +# # +# Creates the agency owner + 20 employees against the RUNNING root stack: # +# 1) gotrue accounts (signup; 3 via the real /invite path so Mailpit # +# holds assertable invitation emails, then an admin password set so # +# those accounts stay usable) # +# 2) bridge identities + private workspaces via the same SQL function # +# the bridge itself calls (osionos_bridge_upsert_workspace) # +# 3) the shared org workspace "Binocle Intelligence Agency" + 21 members # +# 4) roster snapshot to tools/seeds/.agency-people.env (uuid/role/dept/ # +# clearance/region per person) for the data + policy seeders # +# # +# Idempotent: existing accounts are resolved from auth.users, all SQL is # +# upsert-shaped. Secrets are read from the running containers, never from # +# the caller's shell (lib-live-tenant.sh discipline). # +# **************************************************************************** # +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PG_CTN="${AGENCY_PG_CONTAINER:-track-binocle-postgres-1}" +BRIDGE_CTN="${AGENCY_BRIDGE_CONTAINER:-track-binocle-osionos-bridge-1}" +ORG_WS_ID="b1a0c1e5-0000-4000-a000-000000000001" +ORG_WS_NAME="Binocle Intelligence Agency" +ORG_WS_SLUG="binocle-intelligence-agency" +OUT_ENV="${SCRIPT_DIR}/.agency-people.env" + +cyan() { printf '\033[0;36m[people] %s\033[0m\n' "$*"; } +fail() { printf '\033[0;31m[people] FAIL: %s\033[0m\n' "$*" >&2; exit 1; } + +docker inspect "${PG_CTN}" >/dev/null 2>&1 || fail "postgres container ${PG_CTN} not running" +docker inspect "${BRIDGE_CTN}" >/dev/null 2>&1 || fail "bridge container ${BRIDGE_CTN} not running" + +_env() { docker inspect "$1" --format '{{range .Config.Env}}{{println .}}{{end}}' | grep "^$2=" | head -1 | cut -d= -f2-; } +EMAIL_SALT="$(_env "${BRIDGE_CTN}" OSIONOS_BRIDGE_EMAIL_HASH_SALT)" +[[ -n "${EMAIL_SALT}" ]] || EMAIL_SALT="$(_env "${BRIDGE_CTN}" OSIONOS_BRIDGE_SHARED_SECRET)" +[[ -n "${EMAIL_SALT}" ]] || fail "email hash salt not found on bridge container" + +PSQL() { docker exec -i "${PG_CTN}" psql -U postgres -d postgres -v ON_ERROR_STOP=1 "$@"; } + +# email|display name|agency_role|department|clearance|region|ws_role|mode +# mode: signup | invite (invite = real gotrue /invite → Mailpit email, then admin password) +ROSTER=( + "owner@agency.local|Helena Voss|director|command|5|EU|owner|signup" + "e01.reed@agency.local|Marcus Reed|deputy_director|command|5|EU|admin|signup" + "e02.lindqvist@agency.local|Sofia Lindqvist|case_manager|operations|4|EU|admin|signup" + "e03.okafor@agency.local|David Okafor|case_manager|operations|4|NA|admin|signup" + "e04.tanaka@agency.local|Yuki Tanaka|senior_investigator|investigations|4|APAC|editor|signup" + "e05.moreau@agency.local|Pierre Moreau|senior_investigator|investigations|4|EU|editor|signup" + "e06.diallo@agency.local|Amara Diallo|senior_investigator|investigations|4|EU|editor|signup" + "e07.sullivan@agency.local|Jack Sullivan|field_agent|investigations|2|NA|editor|signup" + "e08.petrova@agency.local|Nadia Petrova|field_agent|investigations|2|EU|editor|signup" + "e09.becker@agency.local|Tom Becker|field_agent|investigations|2|EU|editor|signup" + "e10.haddad@agency.local|Leila Haddad|field_agent|investigations|2|EU|editor|signup" + "e11.johansson@agency.local|Erik Johansson|analyst|analysis|3|EU|editor|signup" + "e12.sharma@agency.local|Priya Sharma|analyst|analysis|3|APAC|editor|signup" + "e13.mendez@agency.local|Carlos Mendez|analyst|analysis|3|NA|editor|signup" + "e14.weiss@agency.local|Hannah Weiss|forensics|forensics|3|EU|editor|signup" + "e15.farouk@agency.local|Omar Farouk|forensics|forensics|3|EU|editor|signup" + "e16.liu@agency.local|Grace Liu|surveillance|surveillance|2|APAC|editor|signup" + "e17.antonov@agency.local|Viktor Antonov|surveillance|surveillance|2|EU|editor|signup" + "e18.romero@agency.local|Isabel Romero|legal|legal|3|EU|viewer|invite" + "e19.ngata@agency.local|Robert Ngata|accountant|finance|3|APAC|viewer|invite" + "e20.kowalski@agency.local|Maya Kowalski|it_admin|it|4|EU|admin|invite" +) +DEFAULT_PASSWORD="${AGENCY_PASSWORD:-AgencyDemo1!}" +OWNER_PASSWORD="${AGENCY_OWNER_PASSWORD:-BinocleOwner1!}" + +# ── 1) gotrue accounts (node inside the bridge container — it owns the net) ── +cyan "ensuring ${#ROSTER[@]} gotrue accounts (signup/invite, idempotent)" +ROSTER_LINES="$(printf '%s\n' "${ROSTER[@]}")" +docker exec -i \ + -e ROSTER="${ROSTER_LINES}" -e DEFAULT_PASSWORD="${DEFAULT_PASSWORD}" \ + -e OWNER_PASSWORD="${OWNER_PASSWORD}" \ + "${BRIDGE_CTN}" node - <<'JS' || fail "gotrue account creation failed" +// Container name, not the `gotrue` service alias: the bridge sits on both the +// root and mini-baas networks, and BOTH have a `gotrue` — the alias would +// round-robin into the wrong stack. +const GOTRUE = 'http://track-binocle-gotrue-1:9999'; +const KEY = process.env.SERVICE_ROLE_KEY; +const rows = process.env.ROSTER.trim().split('\n').map((l) => l.split('|')); +const auth = { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}` }; + +async function adminFindByEmail(email) { + // gotrue admin list supports ?filter= on email + const r = await fetch(`${GOTRUE}/admin/users?filter=${encodeURIComponent(email)}&per_page=10`, { headers: auth }); + if (!r.ok) return null; + const b = await r.json().catch(() => null); + const users = b?.users ?? []; + return users.find((u) => u.email === email) ?? null; +} + +async function ensure(email, name, mode, password) { + let user = await adminFindByEmail(email); + if (!user) { + if (mode === 'invite') { + const r = await fetch(`${GOTRUE}/invite`, { method: 'POST', headers: auth, body: JSON.stringify({ email, data: { name } }) }); + if (!r.ok) throw new Error(`invite ${email}: ${r.status} ${await r.text()}`); + user = await r.json(); + } else { + const r = await fetch(`${GOTRUE}/signup`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, data: { name } }), + }); + if (!r.ok) throw new Error(`signup ${email}: ${r.status} ${await r.text()}`); + const b = await r.json(); + user = b.user ?? b; + } + } + if (!user?.id) throw new Error(`no uuid for ${email}`); + // invited users have no password — set one via admin so the account is usable + if (mode === 'invite') { + const r = await fetch(`${GOTRUE}/admin/users/${user.id}`, { + method: 'PUT', headers: auth, + body: JSON.stringify({ password, email_confirm: true }), + }); + if (!r.ok) throw new Error(`admin password ${email}: ${r.status} ${await r.text()}`); + } + return user.id; +} + +const run = async () => { + for (const [email, name, , , , , , mode] of rows) { + const password = email === 'owner@agency.local' ? process.env.OWNER_PASSWORD : process.env.DEFAULT_PASSWORD; + const id = await ensure(email, name, mode, password); + console.log(`OK ${email} ${id}`); + } +}; +run().catch((e) => { console.error('ERR', e.message); process.exit(1); }); +JS + +# ── 2) resolve uuids from auth.users (source of truth) ─────────────────────── +cyan "resolving uuids from auth.users" +declare -A UUID_OF +while IFS='|' read -r email uuid; do + email="${email// /}"; uuid="${uuid// /}" + [[ -n "${email}" && -n "${uuid}" ]] && UUID_OF["${email}"]="${uuid}" +done < <(PSQL -At -F'|' -c "SELECT email, id FROM auth.users WHERE email LIKE '%@agency.local'") +for entry in "${ROSTER[@]}"; do + email="${entry%%|*}" + [[ -n "${UUID_OF[${email}]:-}" ]] || fail "no auth.users row for ${email}" +done + +# ── 3) bridge identities + private workspaces (the bridge's own upsert fn) ─── +cyan "upserting bridge identities + private workspaces" +{ + for entry in "${ROSTER[@]}"; do + IFS='|' read -r email name _rest <<<"${entry}" + uuid="${UUID_OF[${email}]}" + # email_hash matches scripts/bridge-api.mjs emailHash(): HMAC-SHA256(email, salt) + printf "SELECT public.osionos_bridge_upsert_workspace('prismatica', '%s'::uuid, encode(hmac('%s', :'salt', 'sha256'), 'hex'), '%s');\n" \ + "${uuid}" "${email}" "${name//\'/\'\'}" + done +} | PSQL -q -v salt="${EMAIL_SALT}" >/dev/null || fail "bridge identity upsert failed" + +# ── 4) shared org workspace + members ──────────────────────────────────────── +cyan "upserting org workspace '${ORG_WS_NAME}' + 21 members" +OWNER_UUID="${UUID_OF[owner@agency.local]}" +{ + cat </dev/null || fail "org workspace/member upsert failed" + +# ── 5) roster snapshot for the data + policy seeders ───────────────────────── +cyan "writing ${OUT_ENV}" +{ + echo "# generated by seed_agency_people.sh — $(date -Iseconds)" + echo "AGENCY_ORG_WORKSPACE_ID=${ORG_WS_ID}" + echo "AGENCY_OWNER_UUID=${OWNER_UUID}" + echo "AGENCY_OWNER_EMAIL=owner@agency.local" + i=0 + for entry in "${ROSTER[@]}"; do + IFS='|' read -r email name role dept clr region ws_role mode <<<"${entry}" + echo "AGENCY_PERSON_${i}=${UUID_OF[${email}]}|${email}|${name}|${role}|${dept}|${clr}|${region}|${ws_role}" + i=$((i+1)) + done + echo "AGENCY_PERSON_COUNT=${i}" +} > "${OUT_ENV}" + +MEMBERS=$(PSQL -At -c "SELECT count(*) FROM public.osionos_workspace_members WHERE workspace_id='${ORG_WS_ID}'") +[[ "${MEMBERS}" == "21" ]] || fail "expected 21 org members, found ${MEMBERS}" +cyan "DONE: 21 accounts, 21 identities/private workspaces, org workspace ${ORG_WS_ID} (21 members)" diff --git a/tools/seeds/seed_agency_wiki.py b/tools/seeds/seed_agency_wiki.py new file mode 100644 index 00000000..5c7052db --- /dev/null +++ b/tools/seeds/seed_agency_wiki.py @@ -0,0 +1,1355 @@ +#!/usr/bin/env python3 +"""Binocle Intelligence Agency — Wave 2 content seeder. + +Emits TWO idempotent SQL artifacts (fixed uuid5 ids, ON CONFLICT guards): + tools/seeds/seed_agency_wiki.sql — ~26 wiki/notebook/gallery pages in the + org workspace (osionos_pages) + tools/seeds/seed_agency_chat.sql — channels + members + ~210 messages + + reactions + feed likes/comments + +Block JSON shapes mirror tools/seeds/seed_arch.py, which matches the +entities/block ReadOnlyBlock renderer exactly. Live database embeds use +`database_inline` blocks with databaseId `baas::
` +(widgets/database-view DatabaseBlock → getLiveDatabaseAdapter). + +People + tenant ids are read from tools/seeds/.agency-people.env and +apps/baas/mini-baas-infra/.agency-tenant.env so re-provisioning stays in sync. +""" +import base64 +import json +import os +import random +import sys +import uuid +from urllib.parse import quote as urlquote + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + +# --------------------------------------------------------------------------- # +# Environment: roster + live tenant # +# --------------------------------------------------------------------------- # +def read_env(path): + out = {} + try: + with open(path) as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k] = v + except FileNotFoundError: + pass + return out + +PEOPLE_ENV = read_env(os.path.join(HERE, ".agency-people.env")) +TENANT_ENV = read_env(os.path.join(ROOT, "apps/baas/mini-baas-infra/.agency-tenant.env")) + +WS = PEOPLE_ENV.get("AGENCY_ORG_WORKSPACE_ID", "b1a0c1e5-0000-4000-a000-000000000001") +DB_ID = TENANT_ENV.get("AGENCY_DB_ID", "d3ecb3e1-9947-41a6-a0d3-ff2063b4adee") + +# key -> (uuid, name, role, dept) +PEOPLE = {} +_KEY_BY_EMAIL = { + "owner@agency.local": "helena", "e01.reed@agency.local": "marcus", + "e02.lindqvist@agency.local": "sofia", "e03.okafor@agency.local": "david", + "e04.tanaka@agency.local": "yuki", "e05.moreau@agency.local": "pierre", + "e06.diallo@agency.local": "amara", "e07.sullivan@agency.local": "jack", + "e08.petrova@agency.local": "nadia", "e09.becker@agency.local": "tom", + "e10.haddad@agency.local": "leila", "e11.johansson@agency.local": "erik", + "e12.sharma@agency.local": "priya", "e13.mendez@agency.local": "carlos", + "e14.weiss@agency.local": "hannah", "e15.farouk@agency.local": "omar", + "e16.liu@agency.local": "grace", "e17.antonov@agency.local": "viktor", + "e18.romero@agency.local": "isabel", "e19.ngata@agency.local": "robert", + "e20.kowalski@agency.local": "maya", +} +for k, v in PEOPLE_ENV.items(): + if not k.startswith("AGENCY_PERSON_") or k.endswith("_COUNT"): + continue + parts = v.split("|") + if len(parts) < 5: + continue + pid, email, name, role, dept = parts[0], parts[1], parts[2], parts[3], parts[4] + key = _KEY_BY_EMAIL.get(email) + if key: + PEOPLE[key] = (pid, name, role, dept) + +def P(key): # uuid of a person + return PEOPLE[key][0] + +def PN(key): # display name + return PEOPLE[key][1] + +assert len(PEOPLE) == 21, f"roster incomplete: {sorted(PEOPLE)}" + +# --------------------------------------------------------------------------- # +# Deterministic ids # +# --------------------------------------------------------------------------- # +NS = uuid.uuid5(uuid.NAMESPACE_DNS, "binocle-intelligence-agency.local") +def uid(key): + return str(uuid.uuid5(NS, key)) + +# --------------------------------------------------------------------------- # +# Block toolkit (matches entities/block ReadOnlyBlock — see seed_arch.py) # +# --------------------------------------------------------------------------- # +_bid = 0 +def bid(): + global _bid + _bid += 1 + return f"aw-{_bid}" + +def B(t, content="", **x): + return {"id": bid(), "type": t, "content": content, **x} + +def h1(t): return B("heading_1", t) +def h2(t): return B("heading_2", t) +def h3(t): return B("heading_3", t) +def p(t): return B("paragraph", t) +def bullet(t, kids=None): return B("bulleted_list", t, **({"children": kids} if kids else {})) +def numbered(t, kids=None): return B("numbered_list", t, **({"children": kids} if kids else {})) +def todo(t, done=False): return B("to_do", t, checked=done) +def quote(t, level=None): return B("quote", t, **({"headingLevel": level} if level else {})) +def divider(): return B("divider") + +def callout(icon, t, kids=None, level=None): + b = B("callout", t, color=icon) + if kids: + b["children"] = kids + if level: + b["headingLevel"] = level + return b + +def toggle(t, kids, collapsed=True): + return B("toggle", t, children=kids, collapsed=collapsed) + +def columns(*cols): # cols = (widthRatio, [blocks]) + return B("column_list", children=[B("column", widthRatio=r, children=bl) for r, bl in cols]) + +def table(headers, rows, aligns=None): + cfg = {"headerRow": True, "showBorders": True, "stripedRows": True} + if aligns: + cfg["columnAlignments"] = aligns + return B("table_block", tableData=[list(map(str, headers))] + [list(map(str, r)) for r in rows], + tableConfig=cfg) + +def live_db(tablename): + """Embedded LIVE database block — baas::
id, resolved by + getLiveDatabaseAdapter in widgets/database-view.""" + return B("database_inline", "", databaseId=f"baas:{DB_ID}:{tablename}") + +def evidence_card(label, sub, color): + """Offline-rendering SVG 'evidence scan' as a data: URL image block.""" + svg = (f"" + f"" + f"" + f"{label}" + f"{sub}" + f"BINOCLE INTELLIGENCE AGENCY — SECURE SCAN" + f"") + return B("image", f"{label} — {sub}", asset="data:image/svg+xml," + urlquote(svg, safe="'/= :;,()")) + +def relation(ids): + return [{"key": "related", "label": "Related", "type": "relation", + "value": ids, "relationTarget": "page"}] + +# --------------------------------------------------------------------------- # +# Covers (proven URLs from backfill_covers.sql) # +# --------------------------------------------------------------------------- # +COVER = { + "ops": "https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1200&q=80", + "handbook": "https://images.unsplash.com/photo-1517077304055-6e89abbf09b0?auto=format&fit=crop&w=1200&q=80", + "cases": "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?auto=format&fit=crop&w=1200&q=80", + "gallery": "https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=1200&q=80", + "notebooks": "https://images.unsplash.com/photo-1542831371-29b0f74f9713?auto=format&fit=crop&w=1200&q=80", + "night": "https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?auto=format&fit=crop&w=1200&q=80", + "vault": "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&w=1200&q=80", + "storm": "https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&w=1200&q=80", + "garden": "https://images.unsplash.com/photo-1635070041078-e363dbe005cb?auto=format&fit=crop&w=1200&q=80", + "compass": "https://images.unsplash.com/photo-1564865878688-9a244444042a?auto=format&fit=crop&w=1200&q=80", + "meridian": "https://images.unsplash.com/photo-1639322537228-f710d846310a?auto=format&fit=crop&w=1200&q=80", + "ledger": "https://images.unsplash.com/photo-1487058792275-0ad4aaf24ca7?auto=format&fit=crop&w=1200&q=80", +} + +# --------------------------------------------------------------------------- # +# PAGES # +# --------------------------------------------------------------------------- # +PAGES = [] # (key, parent_key|None, owner_key, title, icon, cover|None, properties, blocks) + +def page(key, parent, owner, title, icon, cover, blocks, props=None): + PAGES.append((key, parent, owner, title, icon, cover, props or [], blocks)) + +# ---- 🧭 Mission Control ------------------------------------------------------ +page("mission-control", None, "helena", "🧭 Mission Control", "🧭", COVER["ops"], [ + h1("Binocle Intelligence Agency — Mission Control"), + callout("🎯", "**Welcome to the org workspace.** Everything the agency runs on lives here: " + "the Handbook, live Case Wikis, the Evidence Gallery and the Analyst Notebooks. " + "If you are new, start with the Onboarding Checklist in the 📖 Agency Handbook."), + columns( + (1, [h3("📖 Handbook"), p("SOPs, custody protocol, clearance & ABAC policy, onboarding.")]), + (1, [h3("🗂️ Case Wikis"), p("One wiki per active operation, wired to the live case tables.")]), + (1, [h3("🖼️ Evidence"), p("Galleries per evidence kind plus the Q2 custody audit.")]), + ), + divider(), + h2("Agency at a glance"), + table(["Plane", "Holdings", "Source of truth"], + [["Cases", "40 operations (BIA-202x-NNN)", "live `cases` table"], + ["Subjects", "60 persons of interest", "live `subjects` table"], + ["Evidence", "120 sealed items", "live `evidence` table"], + ["Transactions", "150 monitored movements", "live `transactions` table"], + ["Communications", "200 intercept summaries", "live `communications` table"]]), + callout("⚠️", "Clearance applies everywhere: what you see in the live tables below is already " + "filtered and masked by your role. See **Clearance Levels & Access Policy**."), + h2("Live case board"), + p("The board below is the live `cases` table — not a copy. Edits here write back to the " + "operational database and broadcast to everyone watching."), + live_db("cases"), + divider(), + quote("We connect dots that were never supposed to meet. — H. Voss, Director"), +]) + +# ---- 📖 Agency Handbook ------------------------------------------------------ +page("handbook", None, "helena", "📖 Agency Handbook", "📖", COVER["handbook"], [ + h1("Agency Handbook"), + callout("📘", "The Handbook is the operating contract of the Binocle Intelligence Agency. " + "Every section is binding policy unless marked as guidance."), + h2("Contents"), + bullet("Standard Operating Procedures — how casework actually flows."), + bullet("Evidence Custody Protocol — sealing, logging, verification."), + bullet("Clearance Levels & Access Policy — who sees what, and why."), + bullet("Onboarding Checklist — your first week, step by step."), + bullet("Field Operations Guide — surveillance & approach discipline."), + bullet("Report Writing Standards — language that survives a courtroom."), + divider(), + callout("✅", "Policy questions go to #general; case-specific questions to #case-ops."), +]) + +page("sop", "handbook", "helena", "Standard Operating Procedures", "📋", None, [ + h1("Standard Operating Procedures"), + callout("📌", "**Scope** — every BIA operation, from intake to archive. Deviations require " + "sign-off from the Director or Deputy Director and a note on the case wiki."), + h2("1 · Case lifecycle"), + numbered("**Intake** — a lead or client referral is logged in `leads` with source and credibility."), + numbered("**Open** — a case manager assigns a code (BIA-YYYY-NNN), a lead investigator and a budget."), + numbered("**Investigate** — field, surveillance, forensics and analysis work the case in parallel; " + "every artefact lands in the live tables, never in private files.", kids=[ + bullet("Field agents file contact reports within 12 hours."), + bullet("Analysts keep working notes in their Analyst Notebook."), + ]), + numbered("**Review** — legal + the case manager check the evidentiary chain before any referral."), + numbered("**Close / Cold** — outcomes are recorded in `reports`; cold cases keep their assignments."), + h2("2 · Communication discipline"), + bullet("Operational chatter stays in #case-ops; analysis threads in #intel-analysts."), + bullet("Never paste raw intercept content into chat — link the `communications` row instead."), + bullet("War Room (video) is for live coordination only; decisions made there are minuted " + "on the case wiki within 24 hours."), + callout("⚠️", "**Warning** — discussing privileged material (lawyer–client intercepts) outside " + "the legal review loop is a custody breach. Isabel Romero must clear it first."), + h2("3 · Data hygiene"), + bullet("The live tables are the single source of truth — wiki pages cite ids (EV-0084, BIA-2026-001)."), + bullet("Amounts, account refs and SSNs are masked per clearance; do not transcribe unmasked values."), + bullet("Use the watchlist memo before adding a new counterparty alias."), + callout("❗", "**Hard rule** — no operational data leaves the workspace. Exports require the " + "Director's written approval and an entry in the custody audit."), + divider(), + quote("Slow is smooth, smooth is fast."), +]) + +page("custody", "handbook", "hannah", "Evidence Custody Protocol", "🔐", None, [ + h1("Evidence Custody Protocol"), + callout("🔐", "**Why this exists** — a single broken custody link voids an exhibit. The protocol " + "below is the minimum; forensics may add stricter handling per item."), + h2("Custody chain — the five mandatory entries"), + numbered("**Collection** — collecting officer, timestamp, location id, method (e.g. “records request”)."), + numbered("**Sealing** — tamper-evident bag id + the BIA-EV-NNNN label; photograph the seal."), + numbered("**Transport** — courier + route; hand-offs are logged at both ends."), + numbered("**Storage** — vault location id from the `locations` table; access list snapshot."), + numbered("**Verification** — integrity check (hash for digital media, seal audit for physical)."), + h2("Verification states"), + table(["State", "Meaning", "Action"], + [["verified ✅", "seal/hash intact at last audit", "none"], + ["pending 🕐", "new item, first audit not run", "audit within 72h"], + ["failed ❌", "seal broken or hash mismatch", "quarantine + forensic review + memo to legal"]]), + callout("❌", "**Live alert** — EV-0055 (surveillance tape, Operation Red Meridian) failed its last " + "integrity verification. It is quarantined; see the Custody Audit — Q2 page."), + h2("Digital media specifics"), + bullet("Image first, analyse the copy — originals are write-blocked and vaulted."), + bullet("Hashes (SHA-256) recorded at collection and at every transfer."), + bullet("Phone dumps and hard drives get a forensics ticket before any analyst touches them."), + toggle("▸ Common custody mistakes (read this twice)", [ + bullet("Logging transport but not the hand-off at the receiving end."), + bullet("Re-sealing with a new bag id without cross-referencing the old one."), + bullet("Analysts working on originals because “the copy was slow”."), + callout("⚠️", "Every one of these has cost an agency a case. Not ours, not again."), + ]), + divider(), + quote("Custody is a chain. Chains have links. Links have names on them. — H. Weiss"), +]) + +page("clearance", "handbook", "marcus", "Clearance Levels & Access Policy", "🛂", None, [ + h1("Clearance Levels & Access Policy"), + callout("🛂", "**Model** — attribute-based access control (ABAC). A decision combines your " + "**role**, **department**, **clearance level (1–5)** and **region** against the " + "sensitivity of each table, row and column."), + h2("Clearance ladder"), + table(["Level", "Who", "What it unlocks"], + [["5", "Director, Deputy Director", "everything, including budgets and informant identities"], + ["4", "Case managers, senior investigators, IT admin", "full case rows, unmasked transactions"], + ["3", "Analysts, forensics, legal, finance", "case rows, masked amounts/SSNs, no informants"], + ["2", "Field agents, surveillance", "own-case rows, location + vehicle data, no financials"]]), + h2("Column masks in practice"), + bullet("**Analysts (clearance 3)** see transaction *patterns* but redacted exact amounts above " + "the reporting threshold — enough to flag structuring, not enough to leak balances."), + bullet("**Field agents** see subjects and locations for their assignments; account refs render as `••••`."), + bullet("**Legal** sees privileged communications; nobody else does, including command, until released."), + bullet("**Finance** sees aggregates (sums, counts by counterparty) without per-row drill-down."), + callout("💡", "If a cell renders as `••••` or a row you expect is missing, that is the policy " + "engine working — not a bug. Request elevation via your case manager."), + h2("Row scoping"), + numbered("Region first — APAC staff see APAC-tagged rows plus global rows."), + numbered("Department second — surveillance sees `communications` and `vehicles`, not `transactions`."), + numbered("Assignment last — clearance 2 requires an `assignments` row linking you to the case."), + callout("⚠️", "Sharing a screenshot of data the recipient could not query themselves is treated " + "as an access violation. The mask follows the data, not the pixels."), + divider(), + quote("Need-to-know is not gatekeeping; it is how we protect sources. — M. Reed"), +]) + +page("onboarding", "handbook", "sofia", "Onboarding Checklist", "✅", None, [ + h1("Onboarding Checklist"), + callout("👋", "Welcome aboard. Work through this top to bottom during week one. Your buddy is " + "your department's senior — ping them in #general if anything is unclear."), + h2("Day 1 — access"), + todo("Log in to the workspace and set your profile (photo, role, region).", True), + todo("Verify your clearance level renders correctly on the Mission Control live board.", True), + todo("Join your channels: #general plus your department channel.", True), + todo("Read the Standard Operating Procedures end to end.", False), + h2("Week 1 — context"), + todo("Read the Clearance Levels & Access Policy and confirm your masks with IT (Maya).", False), + todo("Shadow one custody hand-off with forensics (Hannah or Omar).", False), + todo("Read the case wiki of every operation you are assigned to.", False), + todo("Add yourself to the on-call rota if you are field or surveillance.", False), + h2("Before your first solo task"), + todo("Pass the custody quiz (forensics runs it Fridays).", False), + todo("File a practice contact report and have it reviewed by your case manager.", False), + todo("Confirm you can reach the War Room video channel from your kit.", False), + callout("✅", "When every box above is ticked, your case manager flips your status to operational."), +]) + +page("fieldguide", "handbook", "jack", "Field Operations Guide", "🕶️", None, [ + h1("Field Operations Guide"), + callout("🕶️", "Guidance, not law — but deviating without telling your case manager *is* a " + "violation of the SOP. Stay boring, stay invisible."), + h2("Surveillance discipline"), + bullet("Two-person minimum on static observation; rotate eyes every 40 minutes."), + bullet("Log plate sightings immediately — the second-location match on Operation Red Meridian " + "came from a 30-second log entry (lead L-0079)."), + bullet("Radio checks on the quarter hour; missed check = abort criteria after 30 minutes."), + toggle("▸ Scenario: subject meets unknown party (like the marina contact, L-0022)", [ + numbered("Do not approach. Photograph context, not faces, if exposure risk is high."), + numbered("Log time, location id, and a one-line description in `leads`."), + numbered("Tag the case analyst in #case-ops within the hour."), + ]), + toggle("▸ Scenario: cash courier pattern (Friday runs, L-0032)", [ + numbered("Confirm the pattern across three cycles before requesting an intercept."), + numbered("Coordinate with finance (Robert) so the amounts line up with the txn record."), + numbered("Never seize on pattern alone — wait for the warrant package from legal."), + ]), + h2("Approach & contact"), + bullet("Covers are issued by operations; never improvise an affiliation."), + bullet("If burned, break contact, signal in #field with the word **SUNSET**, and head to the fallback."), + callout("⚠️", "A burned cover is recoverable. A burned informant is not."), + divider(), + quote("The best surveillance log reads like the most boring novel ever written. — J. Sullivan"), +]) + +page("reporting", "handbook", "isabel", "Report Writing Standards", "⚖️", None, [ + h1("Report Writing Standards"), + callout("⚖️", "Reports outlive operations. Write every sentence as if opposing counsel will " + "read it aloud, slowly, to a jury."), + h2("Structure"), + numbered("**Header** — case code, report id, author, date, classification."), + numbered("**Summary** — three sentences max; findings, not narrative."), + numbered("**Facts** — numbered observations, each citing its source row (EV/TXN/COMM id)."), + numbered("**Analysis** — clearly separated from facts; hedge with calibrated language."), + numbered("**Annexes** — exhibit list with custody references."), + h2("Language rules"), + table(["Write", "Not", "Why"], + [["“consistent with”", "“proves”", "analysis is inference, not verdict"], + ["“subject stated”", "“subject admitted”", "characterisation is the court's job"], + ["“EV-0084 (ledger)”", "“the ledger we found”", "exhibits are cited by id"], + ["“assessed as likely”", "“obviously”", "calibrated confidence survives cross"]]), + callout("❗", "Privileged material (lawyer–client comms) is never quoted in a report body — " + "reference the row id and route through legal review."), + quote("If it is not in the record, it did not happen."), +]) + +# ---- 🗂️ Case Wikis ----------------------------------------------------------- +page("cases-root", None, "marcus", "🗂️ Case Wikis", "🗂️", COVER["cases"], [ + h1("Case Wikis"), + callout("🗂️", "One wiki per operation. The wiki is the *narrative* layer — context, hypotheses, " + "decisions — while the live tables stay the factual record. Keep ids in every claim."), + h2("Live case register"), + p("Embedded view of the live `cases` table. Open an operation's wiki below for the working notes."), + live_db("cases"), + h2("Featured operations"), + bullet("Operation Nightfall (BIA-2026-001) — crypto-mixer laundering, our newest open case."), + bullet("Operation Cobalt Ledger (BIA-2025-002) — closed; freeport vault concealment, full retro."), + bullet("Operation Red Meridian (BIA-2025-008) — in review; art-market fraud, custody alert."), + bullet("Operation Quiet Storm (BIA-2025-012) — analysis; procurement fraud via freeport."), + bullet("Operation Ash Garden (BIA-2025-014) — active surveillance; shell-company laundering."), + bullet("Operation Broken Compass (BIA-2025-016) — open; asset concealment through mixers."), +]) + +page("case-nightfall", "cases-root", "marcus", "Operation Nightfall — BIA-2026-001", "🌒", COVER["night"], [ + h1("Operation Nightfall"), + callout("🌒", "**BIA-2026-001 · OPEN · HIGH** — suspected money laundering through crypto " + "mixers. Lead investigator: Marcus Reed. Primary subject: **Beatriz Albrecht** " + "(logistics manager, IT national, risk: critical).", level=3), + h2("Working hypothesis"), + p("Albrecht's logistics firm books phantom freight, settles in USDT through **Goldquay Exchange**, " + "and recycles the proceeds via mixer hops before they resurface as 'consulting income' from " + "**Westport Capital SA**. The ledger seized in March (EV-0084) shows paired entries that match " + "the flagged transaction pattern Erik documented in his notebook."), + h2("Evidence on hand"), + bullet("**EV-0084** — ledger, recovered during surveillance op (Amara Diallo), custody log 4 entries, verified."), + bullet("**EV-0086** — hard drive, surveillance op (Hannah Weiss), custody log 5 entries, verified."), + bullet("**EV-0087** — contract, surveillance op (Jack Sullivan), custody log 4 entries, verified."), + bullet("**EV-0098** — hard drive, site visit (Viktor Antonov), custody log 4 entries, verified."), + h2("Intercept of note"), + quote("Payment confirmation — amount matches txn pattern. (COMM-0085, in person, unregistered " + "SIM, classified: incriminating)"), + callout("⚠️", "COMM-0139 mentions a freeport contact *by first name* and is classified privileged — " + "route any use through Isabel before it appears in a report."), + h2("Lead tracker"), + toggle("▸ L-0003 — cash courier on Fridays (wiretap, medium) — DEAD END", [ + p("Three observation cycles produced nothing; the courier pattern belongs to a " + "legitimate cash-intensive business next door. Closed 14 May, do not re-open without " + "new signal."), + ]), + toggle("▸ Mixer exit clustering (analysis, working)", [ + p("Erik's clustering of mixer exits puts ~64% of the traced value re-entering through " + "two Goldquay sub-accounts. Need the exchange's KYC packet — legal drafting the request."), + todo("Subpoena package for Goldquay KYC (legal)", False), + todo("Cross-reference EV-0086 wallet artefacts with the exit cluster", False), + ]), + h2("Linked subjects (live)"), + live_db("subjects"), + divider(), + callout("✅", "Next sync: War Room, Thursday 09:00 — bring the Goldquay timeline."), +], props=relation([uid("page:nb-erik"), uid("page:nb-watchlist")])) + +page("case-cobalt", "cases-root", "nadia", "Operation Cobalt Ledger — BIA-2025-002", "🧊", COVER["vault"], [ + h1("Operation Cobalt Ledger"), + callout("🧊", "**BIA-2025-002 · CLOSED · CRITICAL** — asset concealment via a freeport vault. " + "Lead investigator: Nadia Petrova. This wiki is the closing retrospective.", level=3), + h2("What happened"), + p("An import/export broker (**Lena Esposito**, “Doc”) and an art dealer (**Mei Wagner**, “Brick”) " + "moved undeclared assets into a freeport vault using shipping manifests that understated crate " + "contents. Their lawyer, **Dmitri Iglesias**, structured ownership through nominee paper. The " + "manifest seized at the source meeting (EV-0026) broke the case open."), + h2("How it closed"), + numbered("EV-0026 (shipping manifest) showed weight/insurance mismatches across nine shipments."), + numbered("The confirmed wiretap lead L-0032 — the Friday cash courier — tied Esposito to the vault custodian."), + numbered("EV-0094 (phone dump) recovered deleted coordination messages; EV-0100 (contract) " + "established the nominee structure."), + numbered("Referral accepted; convictions on 4 of 5 counts. Vault contents repatriated."), + h2("Intercept that mattered"), + quote("Arranged meeting; location referenced obliquely. (COMM-0111, in person, Westport Capital SA " + "counterparty — the 'oblique' location was the freeport mezzanine.)"), + h2("Lessons learned"), + columns( + (1, [h3("✅ What worked"), + bullet("Manifest forensics before subject interviews — they never saw it coming."), + bullet("Custody discipline: all 3 exhibits survived defence challenges."), + bullet("Joint finance/field review of the courier pattern.")]), + (1, [h3("⚠️ What we'd change"), + bullet("Earlier legal review — the nominee paper took weeks we did not have."), + bullet("Westport Capital SA appears again in Nightfall: open a standing watchlist entry."), + bullet("Vault CCTV request went out late; footage cycle had purged 11 days.")]), + ), + divider(), + quote("Closed cases teach more than open ones, if you let them. — N. Petrova"), +], props=relation([uid("page:case-nightfall"), uid("page:nb-watchlist")])) + +page("case-redmeridian", "cases-root", "marcus", "Operation Red Meridian — BIA-2025-008", "🧭", COVER["meridian"], [ + h1("Operation Red Meridian"), + callout("🧭", "**BIA-2025-008 · REVIEW · CRITICAL** — art-market fraud via trade mis-invoicing. " + "Lead investigator: Marcus Reed. Status: evidentiary review before referral.", level=3), + h2("Theory of the case"), + p("Canvas valuations are inflated on export and deflated on import, with the spread settled " + "offshore. The structuring leads (L-0039, L-0058) show repeated movements just under the " + "reporting threshold — textbook smurfing against the art invoices."), + h2("Lead tracker"), + toggle("▸ L-0022 — subject met unknown male at the marina (open source, confirmed) — ESCALATED", [ + p("Surveillance (Grace) confirmed a second meeting at the same berth. Counterparty " + "tentatively matched to a Kestrel Marine Ltd charter manager."), + ]), + toggle("▸ L-0039 — structuring under the reporting threshold (open source, high) — DEAD END", [ + p("The sub-threshold pattern traced to an unrelated remittance corridor. Useful " + "methodology, wrong subject. Filed for the analyst playbook."), + ]), + toggle("▸ L-0048 — cash courier run on Fridays (bank alert, confirmed) — ESCALATED", [ + p("Bank alert corroborated by two field cycles. Warrant package in legal review."), + ]), + toggle("▸ L-0079 — vehicle plate matched at second location (anonymous tip) — CORROBORATED", [ + p("Plate logged by Tom at location L-21 matched Viktor's earlier sighting. The vehicle " + "links our subject to the freight forwarder's yard."), + ]), + h2("Custody alert"), + callout("❌", "**EV-0055 (surveillance tape) failed integrity verification.** Quarantined; " + "forensic review running. The referral package must NOT cite EV-0055 until " + "Hannah clears it — see Custody Audit — Q2."), + h2("Evidence (clean)"), + bullet("**EV-0017** — shipping manifest (Pierre Moreau), verified."), + bullet("**EV-0039** — hard drive (Grace Liu), verified."), + h2("Live leads register"), + live_db("leads"), + divider(), + quote("Arranged meeting; location referenced obliquely. (COMM-0044, phone, unregistered SIM)"), +], props=relation([uid("page:gal-custody"), uid("page:nb-priya")])) + +page("case-quietstorm", "cases-root", "pierre", "Operation Quiet Storm — BIA-2025-012", "⛈️", COVER["storm"], [ + h1("Operation Quiet Storm"), + callout("⛈️", "**BIA-2025-012 · ANALYSIS · CRITICAL** — procurement fraud routed through a " + "freeport vault. Lead investigator: Pierre Moreau. Subject of record: **Zofia " + "Haddad** (casino host, CH).", level=3), + h2("Where the analysis stands"), + p("Procurement awards cluster around three vendors that share a registered agent. Settlement " + "flows pass **Cygnus Consulting GmbH** and **Aurelia Trade FZE** before landing as casino " + "credit — which is where Haddad's host ledger comes in (EV-0071, bank statement, verified)."), + h2("Intercepts of note"), + quote("Discussed 'paperwork' for upcoming shipment. (COMM-0077, in person, Goldquay Exchange " + "counterparty — privileged, legal hold)"), + quote("Arranged meeting; location referenced obliquely. (COMM-0118, phone, unknown number — " + "classified incriminating)"), + callout("💡", "The 'paperwork' phrasing repeats across Quiet Storm and Ash Garden intercepts. " + "Priya is testing whether it is corridor slang or the same fixer."), + h2("Evidence on hand"), + bullet("**EV-0029** — surveillance tape (Hannah Weiss), verified."), + bullet("**EV-0041** — surveillance tape (Leila Haddad), verified."), + bullet("**EV-0071** — bank statement (Viktor Antonov), custody log 6 entries, verified."), + h2("Open analysis tasks"), + todo("Map the registered-agent overlap across the three vendors (Carlos)", False), + todo("Reconcile casino credit issuance vs. EV-0071 statement lines (Robert + Erik)", False), + todo("Request freeport access logs for the award weeks (legal)", False), + divider(), + quote("Fraud at this layer is just logistics with better stationery. — P. Moreau"), +], props=relation([uid("page:nb-carlos"), uid("page:nb-priya")])) + +page("case-ashgarden", "cases-root", "jack", "Operation Ash Garden — BIA-2025-014", "🌫️", COVER["garden"], [ + h1("Operation Ash Garden"), + callout("🌫️", "**BIA-2025-014 · ACTIVE SURVEILLANCE · HIGH** — suspected laundering through " + "shell companies. Lead investigator: Jack Sullivan. Subject of record: **Beatriz " + "Petrov** (lawyer, FR).", level=3), + callout("⚠️", "**Privilege warning** — the subject is a practising lawyer. COMM-0041 and " + "COMM-0045 are classified privileged. Surveillance product must be screened by " + "Isabel BEFORE it reaches the analysts. No exceptions."), + h2("Surveillance posture"), + bullet("Static observation on the office block: two-person rotation (Grace / Viktor)."), + bullet("Mobile follow only on pre-cleared routes; subject is counter-surveillance aware."), + bullet("Friday pattern: subject visits the notary, then the Goldquay storefront (COMM-0045 " + "travel-plan intercept matches)."), + h2("Evidence on hand"), + bullet("**EV-0011** — ledger (Jack Sullivan), custody log 5 entries, verified."), + bullet("**EV-0051** — burner phone (Maya Kowalski), forensic imaging complete, verified."), + bullet("**EV-0058** — hard drive (Marcus Reed), forensic imaging complete, verified."), + bullet("**EV-0076** — surveillance tape (Viktor Antonov), custody log 6 entries, verified."), + h2("Working notes"), + toggle("▸ Shell layering pattern (working)", [ + p("Three shells rotate the same nominee director. Incorporation dates precede each " + "procurement window by 18–24 days. Carlos is checking whether the pattern matches the " + "Quiet Storm vendors."), + ]), + toggle("▸ Burner phone (EV-0051) extraction highlights", [ + bullet("Contact graph: 9 numbers, 7 single-use."), + bullet("Recurring number resolves to an unregistered SIM — same device family as the " + "Nightfall COMM-0085 source."), + callout("🔥", "If the SIM link holds, Ash Garden and Nightfall share a fixer."), + ]), + divider(), + quote("Discussed 'paperwork' for upcoming shipment. (COMM-0041, phone, unregistered SIM — privileged)"), +], props=relation([uid("page:case-nightfall"), uid("page:case-quietstorm")])) + +page("case-brokencompass", "cases-root", "tom", "Operation Broken Compass — BIA-2025-016", "🧿", COVER["compass"], [ + h1("Operation Broken Compass"), + callout("🧿", "**BIA-2025-016 · OPEN · HIGH** — asset concealment through crypto mixers. " + "Lead investigator: Tom Becker.", level=3), + h2("Situation"), + p("Proceeds from a property flip scheme (**Novum Estates** is the recurring counterparty — see " + "COMM-0068, classified incriminating) disappear into mixer deposits within 48 hours of each " + "closing. Exit liquidity surfaces as marine charter payments to **Kestrel Marine Ltd** — the " + "same charter shop that brushed against Red Meridian's marina lead."), + h2("Evidence on hand"), + bullet("**EV-0077** — contract (Grace Liu), verified."), + bullet("**EV-0101** — document (Jack Sullivan), custody log 5 entries, verified."), + bullet("**EV-0103** — bank statement (Grace Liu), verified."), + h2("Intercepts of note"), + quote("Discussed 'paperwork' for upcoming shipment. (COMM-0068, phone, Novum Estates — incriminating)"), + quote("Mentioned contact at the freeport by first name. (COMM-0134, sms, Kestrel Marine Ltd)"), + h2("Open questions"), + toggle("▸ Is Kestrel Marine a coincidence or a hub?", [ + p("Two operations, one charter company. Priya's counterparty graph ranks Kestrel in the " + "top decile by cross-case degree. Watchlist entry opened."), + ]), + toggle("▸ Mixer exit timing", [ + p("Exits cluster 03:00–05:00 UTC. Either automation or an operator in an eastern " + "timezone. Erik is correlating with Goldquay's settlement batches."), + ]), + todo("Charter manifests subpoena (legal, drafting)", False), + todo("Cross-case SIM comparison with Ash Garden EV-0051 graph (forensics)", False), + divider(), + quote("Follow the boats. Money gets seasick too. — T. Becker"), +], props=relation([uid("page:case-redmeridian"), uid("page:nb-watchlist")])) + +# ---- 🖼️ Evidence Gallery ---------------------------------------------------- +page("gallery-root", None, "omar", "🖼️ Evidence Gallery", "🖼️", COVER["gallery"], [ + h1("Evidence Gallery"), + callout("🖼️", "Visual index of the evidence vault, grouped by kind. Cards are sanitised scans — " + "the factual record stays in the live `evidence` table below. 120 items under custody."), + h2("Collections"), + columns( + (1, [h3("🏦 Financial Records"), p("Bank statements, ledgers, wire receipts.")]), + (1, [h3("💾 Digital Forensics"), p("Hard drives, phone dumps, burner phones.")]), + ), + columns( + (1, [h3("🎞️ Surveillance Materials"), p("Tapes and photographs from observation posts.")]), + (1, [h3("📜 Documents & Contracts"), p("Contracts, manifests, corporate paper.")]), + ), + h2("Live evidence register"), + live_db("evidence"), + callout("⚠️", "One item is currently quarantined (EV-0055). See Custody Audit — Q2."), +]) + +page("gal-financial", "gallery-root", "robert", "Financial Records", "🏦", None, [ + h1("Financial Records"), + callout("🏦", "Bank statements, ledgers and wire receipts. Amounts render masked for clearance " + "below 4 — the scans here are sanitised previews."), + evidence_card("EV-0084 · LEDGER", "Operation Nightfall — paired phantom-freight entries", "#1f3a5f"), + p("Recovered by Amara Diallo during a surveillance op. The paired entries match Erik's flagged " + "Goldquay pattern; referenced throughout the Nightfall wiki."), + evidence_card("EV-0071 · BANK STATEMENT", "Operation Quiet Storm — casino credit reconciliation", "#274060"), + p("Six custody entries, verified. Statement lines reconcile against casino credit issuance with " + "a residual the analysts are still chasing."), + evidence_card("EV-0103 · BANK STATEMENT", "Operation Broken Compass — charter payment trail", "#30506b"), + p("Collected by Grace Liu. Charter payments to Kestrel Marine Ltd land within 48h of each " + "property closing."), + divider(), + table(["Item", "Case", "State"], + [["EV-0084 ledger", "BIA-2026-001", "verified ✅"], + ["EV-0071 bank statement", "BIA-2025-012", "verified ✅"], + ["EV-0103 bank statement", "BIA-2025-016", "verified ✅"]]), +]) + +page("gal-digital", "gallery-root", "maya", "Digital Forensics", "💾", None, [ + h1("Digital Forensics"), + callout("💾", "Imaged media only — originals are write-blocked and vaulted. Every image carries " + "a SHA-256 recorded at collection and at each transfer."), + evidence_card("EV-0086 · HARD DRIVE", "Operation Nightfall — wallet artefacts", "#3b2f5f"), + p("Imaged by Hannah Weiss. Wallet artefacts feed the mixer exit clustering work."), + evidence_card("EV-0051 · BURNER PHONE", "Operation Ash Garden — contact graph, 9 numbers", "#46396b"), + p("Forensic imaging complete (Maya Kowalski). The recurring unregistered SIM may link Ash " + "Garden to Nightfall's COMM-0085 source."), + evidence_card("EV-0094 · PHONE DUMP", "Operation Cobalt Ledger — recovered deletions", "#503f73"), + p("Deleted coordination messages recovered; admitted as exhibit at trial. Case closed."), + divider(), + callout("✅", "Imaging queue is clear. New media gets a forensics ticket before any analyst touches it."), +]) + +page("gal-surveillance", "gallery-root", "viktor", "Surveillance Materials", "🎞️", None, [ + h1("Surveillance Materials"), + callout("🎞️", "Observation-post product: tapes and photographs. Location ids reference the live " + "`locations` table."), + evidence_card("EV-0076 · SURVEILLANCE TAPE", "Operation Ash Garden — office block static", "#5f2f3a"), + p("Six custody entries, verified. Covers the Friday notary-then-Goldquay pattern."), + evidence_card("EV-0029 · SURVEILLANCE TAPE", "Operation Quiet Storm — records request capture", "#6b3946"), + p("Verified. Paired with EV-0041 for the vendor-meeting timeline."), + evidence_card("EV-0055 · SURVEILLANCE TAPE", "Operation Red Meridian — QUARANTINED", "#7a2e2e"), + callout("❌", "EV-0055 failed integrity verification — do not cite until forensics clears it."), + divider(), + quote("Film what is there, log what you filmed, touch nothing else. — V. Antonov"), +]) + +page("gal-documents", "gallery-root", "grace", "Documents & Contracts", "📜", None, [ + h1("Documents & Contracts"), + callout("📜", "Corporate paper: contracts, manifests, incorporation documents. The mis-invoicing " + "cases live and die on these."), + evidence_card("EV-0026 · SHIPPING MANIFEST", "Operation Cobalt Ledger — the case-breaker", "#2f5f3a"), + p("Weight/insurance mismatches across nine shipments. The exhibit that closed Cobalt Ledger."), + evidence_card("EV-0017 · SHIPPING MANIFEST", "Operation Red Meridian — export valuation set", "#396b46"), + p("Collected by Pierre Moreau. Export valuations feed the inflated-spread model."), + evidence_card("EV-0087 · CONTRACT", "Operation Nightfall — phantom freight terms", "#3f7350"), + p("Recovered by Jack Sullivan. Freight terms with no corresponding cargo movements."), + divider(), + table(["Item", "Kind", "Case", "State"], + [["EV-0026", "shipping manifest", "BIA-2025-002", "verified ✅"], + ["EV-0017", "shipping manifest", "BIA-2025-008", "verified ✅"], + ["EV-0087", "contract", "BIA-2026-001", "verified ✅"], + ["EV-0100", "contract", "BIA-2025-002", "verified ✅"]]), +]) + +page("gal-custody", "gallery-root", "hannah", "Custody Audit — Q2", "🧾", None, [ + h1("Custody Audit — Q2"), + callout("🧾", "Quarterly verification sweep across the evidence vault. 120 items audited; one " + "failure, three pending re-checks."), + h2("Findings"), + table(["Exhibit", "Kind", "Case", "Result", "Action"], + [["EV-0055", "surveillance tape", "BIA-2025-008", "FAILED ❌", "quarantine + forensic review"], + ["EV-0042", "wire receipt", "BIA-2025-009", "re-verify 🕐", "custody log updated — second check booked"], + ["EV-0011", "ledger", "BIA-2025-014", "verified ✅", "none"], + ["EV-0084", "ledger", "BIA-2026-001", "verified ✅", "none"], + ["EV-0026", "shipping manifest", "BIA-2025-002", "verified ✅", "archived (case closed)"]]), + h2("EV-0055 incident"), + numbered("Seal audit found the outer bag id re-logged without cross-reference to the original."), + numbered("Tape itself shows no splice artefacts on first pass; deep verification running."), + numbered("Until cleared, Red Meridian's referral package excludes EV-0055 (Marcus notified)."), + callout("⚠️", "Root cause was a transport hand-off logged at origin only — exactly the mistake " + "called out in the Evidence Custody Protocol. Refresher session scheduled."), + todo("Deep verification of EV-0055 (forensics)", False), + todo("Re-verify EV-0042 custody log", False), + todo("Custody refresher for transport couriers", False), +]) + +# ---- 📓 Analyst Notebooks ---------------------------------------------------- +page("notebooks-root", None, "erik", "📓 Analyst Notebooks", "📓", COVER["notebooks"], [ + h1("Analyst Notebooks"), + callout("📓", "Working notes, one notebook per analyst. Notebooks are *thinking space* — " + "hypotheses live here until they earn a place on a case wiki with an id attached."), + bullet("Erik Johansson — transaction patterns, structuring, mixer exits."), + bullet("Priya Sharma — counterparty graph, cross-case entity resolution."), + bullet("Carlos Mendez — corridors (hawala, freight), NA region focus."), + bullet("Watchlist — Counterparties: the shared memo the three keep current."), +]) + +page("nb-erik", "notebooks-root", "erik", "Erik Johansson — Working Notes", "🧮", None, [ + h1("Working Notes — Erik Johansson"), + callout("🧮", "Analysis dept · clearance 3 · EU. Amounts above threshold render masked on my " + "view; patterns below are computed on the masked series (which is the point)."), + h2("TXN pattern: structuring under 10k via Goldquay"), + p("Recurring movements sit just under the reporting threshold: TXN-0010 (9,769.57 EUR, wire, " + "Westport Capital SA), TXN-0002 (5,145.88 USDT, cash deposit, Goldquay Exchange), TXN-0014 " + "(6,064.96 USDT, crypto, Goldquay). Sub-threshold density against Goldquay is 3.1× the " + "corridor baseline — that is not noise."), + table(["TXN", "Amount", "Method", "Counterparty", "Flag"], + [["TXN-0010", "9,769.57 EUR", "wire", "Westport Capital SA", "🚩"], + ["TXN-0002", "5,145.88 USDT", "cash deposit", "Goldquay Exchange", "🚩"], + ["TXN-0014", "6,064.96 USDT", "crypto", "Goldquay Exchange", "🚩"], + ["TXN-0009", "4,974.82 AED", "crypto", "Westport Capital SA", "🚩"]]), + callout("🔥", "Outlier: **TXN-0016 — 306,454.56 EUR via hawala to Goldquay Exchange.** A six-figure " + "hawala movement to an *exchange* breaks the structuring profile entirely. Either a " + "settlement between operators or someone got sloppy. Escalated to Marcus."), + h2("Mixer exit timing (Broken Compass support)"), + bullet("Exit cluster 03:00–05:00 UTC correlates with Goldquay settlement batches at 0.78."), + bullet("If automation, expect the same offsets next cycle — watch booked for Thursday."), + toggle("▸ Method note: working on masked series", [ + p("Clearance 3 masks exact amounts above threshold, so I bucket to 500-unit bands and " + "test density, not values. Pattern conclusions survive the mask; totals go to Robert " + "for the clearance-4 reconciliation."), + ]), + h2("Live transactions (my masked view)"), + live_db("transactions"), + todo("Thursday: re-run exit-cluster correlation after Goldquay batch", False), + todo("Hand TXN-0016 memo to Marcus before War Room", True), +], props=relation([uid("page:case-nightfall"), uid("page:case-brokencompass")])) + +page("nb-priya", "notebooks-root", "priya", "Priya Sharma — Working Notes", "🕸️", None, [ + h1("Working Notes — Priya Sharma"), + callout("🕸️", "Analysis dept · clearance 3 · APAC. Entity resolution across cases — who is " + "actually the same node wearing different paperwork."), + h2("Counterparty graph — cross-case degree"), + p("Ranking counterparties by how many distinct operations they brush against. Anything ≥3 is " + "no longer a coincidence; it is infrastructure."), + table(["Counterparty", "Cases touched", "Degree", "Note"], + [["Goldquay Exchange", "Nightfall, Ash Garden, Broken Compass, Quiet Storm", "4", "settlement hub"], + ["Westport Capital SA", "Nightfall, Cobalt Ledger (closed)", "2+", "re-offender — standing watch"], + ["Kestrel Marine Ltd", "Red Meridian, Broken Compass", "2", "charter shop, marina link"], + ["Aurelia Trade FZE", "Quiet Storm", "1", "registered-agent overlap pending"], + ["Novum Estates", "Broken Compass", "1", "property-flip counterpart"]]), + callout("💡", "The 'paperwork' phrasing in COMM-0077 (Quiet Storm) and COMM-0041 (Ash Garden) " + "plus the shared unregistered-SIM device family suggests one fixer servicing " + "multiple rings. Naming the node FIXER-01 until we have better."), + h2("Entity resolution queue"), + todo("Merge candidate: 'Halcyon' alias appears on two different subjects (S-0003, S-0009) — " + "true duplicate or alias collision?", False), + todo("Confirm Kestrel charter manager identity from Grace's marina log (L-0022)", False), + todo("Aurelia ↔ Cygnus registered-agent comparison (with Carlos)", False), + toggle("▸ Why alias collisions matter", [ + p("Two subjects sharing the alias “Halcyon” will silently merge in any naive graph. Keys " + "must be (subject_id), never (alias) — the dashboard join was quietly wrong until 02 June."), + ]), + divider(), + quote("A network is just a list of people who answer the same phone. — P. Sharma"), +], props=relation([uid("page:nb-watchlist"), uid("page:case-quietstorm")])) + +page("nb-carlos", "notebooks-root", "carlos", "Carlos Mendez — Working Notes", "🚢", None, [ + h1("Working Notes — Carlos Mendez"), + callout("🚢", "Analysis dept · clearance 3 · NA. Corridors: hawala, freight, anything that moves " + "value without moving paperwork."), + h2("Hawala corridor analysis"), + p("Hawala-method transactions cluster on two corridors. The NA leg consistently routes via " + "**Pelican Freight LLC** (e.g. TXN-0013, 6,175.07 USDT, wire) — freight invoices act as the " + "settlement memo between hawaladars. The EU leg is where TXN-0016's six-figure movement " + "(Erik's outlier) crossed."), + bullet("Pattern: freight invoice issued → hawala settlement within 72h → invoice cancelled or " + "credited the following week."), + bullet("Cancellation rate at Pelican: 31% of invoices vs. 4% industry norm."), + bullet("Sable Trust (BVI) takes the residuals: TXN-0024 (76,102.38 EUR, invoice method) fits " + "the residual profile exactly."), + h2("Shell incorporation timing (Ash Garden assist)"), + p("Jack's three shells incorporate 18–24 days before each procurement window. Quiet Storm's " + "vendors show 19, 21 and 23-day offsets. Same playbook, possibly the same formation agent — " + "checking the registered-agent overlap with Priya."), + toggle("▸ Working query (masked view)", [ + p("Group transactions by method × counterparty, flag where hawala share exceeds 20% and " + "the counterparty also appears on a freight manifest. Three hits: Pelican, Kestrel, and " + "one new name held back until verified."), + ]), + callout("⚠️", "Clearance note: I cannot see informant identities behind the bank alerts — if a " + "lead needs source context, it goes through Sofia."), + todo("Registered-agent overlap memo with Priya (due Friday)", False), + todo("Pull Pelican cancellation ledger for Q1 (records request drafted)", False), + divider(), + quote("Paper doesn't move money. People move money; paper apologises for it. — C. Mendez"), +], props=relation([uid("page:case-quietstorm"), uid("page:case-ashgarden")])) + +page("nb-watchlist", "notebooks-root", "priya", "Watchlist — Counterparties (Joint Memo)", "👁️", None, [ + h1("Watchlist — Counterparties"), + callout("👁️", "Joint memo (Erik · Priya · Carlos). A counterparty enters the watchlist on two " + "independent signals; it leaves only by case closure or proven innocence."), + table(["Entity", "Signals", "Watch level", "Owner"], + [["Goldquay Exchange", "structuring density, mixer exit correlation, 4-case degree", "🔴 high", "Erik"], + ["Westport Capital SA", "Cobalt Ledger conviction record, Nightfall recurrence", "🔴 high", "Priya"], + ["Kestrel Marine Ltd", "marina lead L-0022, Broken Compass charter payments", "🟠 elevated", "Carlos"], + ["Pelican Freight LLC", "31% invoice cancellation, hawala settlement memo pattern", "🟠 elevated", "Carlos"], + ["Sable Trust (BVI)", "residual sink, TXN-0024 profile", "🟡 monitor", "Erik"], + ["Aurelia Trade FZE", "Quiet Storm settlement hop", "🟡 monitor", "Priya"], + ["Cygnus Consulting GmbH", "TXN-0018 (381,881.49 EUR cash deposit), vendor overlap", "🟠 elevated", "Priya"]]), + callout("❗", "Adding an entity? Two signals, written down, with ids. Gut feelings go in your own " + "notebook until they grow up."), + h2("Recent changes"), + bullet("Kestrel Marine Ltd raised to elevated after the Broken Compass charter trail (08 Jun)."), + bullet("Cygnus added after the TXN-0018 cash deposit anomaly cleared de-duplication (05 Jun)."), + bullet("FIXER-01 (unresolved entity) tracked separately until identity resolution lands."), +], props=relation([uid("page:nb-erik"), uid("page:nb-priya"), uid("page:nb-carlos")])) + +# --------------------------------------------------------------------------- # +# SQL helpers # +# --------------------------------------------------------------------------- # +def b64(obj): + return base64.b64encode(json.dumps(obj, ensure_ascii=False).encode("utf-8")).decode() + +def jcol(obj): + return f"convert_from(decode('{b64(obj)}','base64'),'utf8')::jsonb" + +def sqlstr(s): + return "'" + s.replace("'", "''") + "'" + +# --------------------------------------------------------------------------- # +# Emit seed_agency_wiki.sql # +# --------------------------------------------------------------------------- # +wiki_lines = [ + "-- Generated by tools/seeds/seed_agency_wiki.py — idempotent (fixed uuid5 ids,", + "-- ON CONFLICT (id) DO UPDATE refreshes content). Safe to re-run.", + "BEGIN;", +] +# created_at offsets keep sidebar ordering stable: roots oldest, children in order. +for i, (key, parent, owner, title, icon, cover, props, blocks) in enumerate(PAGES): + pid = uid(f"page:{key}") + parent_sql = f"'{uid('page:' + parent)}'" if parent else "NULL" + cover_sql = sqlstr(cover) if cover else "NULL" + created = f"now() - interval '{30 - i} days'" + wiki_lines.append( + "INSERT INTO public.osionos_pages " + "(id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, " + "collaborators, properties, content, created_at, updated_at) VALUES (" + f"'{pid}', '{WS}', {parent_sql}, '{P(owner)}', {sqlstr(title)}, {sqlstr(icon)}, {cover_sql}, " + f"NULL, 'shared', '[]'::jsonb, {jcol(props)}, {jcol(blocks)}, {created}, now()) " + "ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, " + "owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, " + "cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, " + "content=EXCLUDED.content, archived_at=NULL, updated_at=now();") +wiki_lines.append("COMMIT;") + +wiki_out = os.path.join(HERE, "seed_agency_wiki.sql") +with open(wiki_out, "w") as fh: + fh.write("\n".join(wiki_lines) + "\n") + +# --------------------------------------------------------------------------- # +# CHAT — channels, members, messages, reactions # +# --------------------------------------------------------------------------- # +rng = random.Random(20260610) + +ALL = list(PEOPLE.keys()) +CH = { + "general": dict(name="general", topic="Agency-wide announcements & day-to-day", + kind="text", private=False, created_by="helena", members=ALL), + "case-ops": dict(name="case-ops", topic="Operational coordination across live cases", + kind="text", private=False, created_by="marcus", + members=["helena", "marcus", "sofia", "david", "yuki", "pierre", "amara", + "jack", "nadia", "tom", "leila"]), + "intel-analysts": dict(name="intel-analysts", topic="Analysis threads — patterns, graphs, dashboards", + kind="text", private=False, created_by="erik", + members=["erik", "priya", "carlos", "helena", "marcus"]), + "field": dict(name="field", topic="Field & surveillance — rotations, sightings, logistics", + kind="text", private=False, created_by="jack", + members=["jack", "nadia", "tom", "leila", "grace", "viktor", "sofia", "david"]), + "war-room": dict(name="War Room", topic="Live command coordination (video)", + kind="video", private=True, created_by="helena", + members=["helena", "marcus", "sofia", "david"]), +} +DMS = [ # (key, a, b) + ("dm-erik-priya", "erik", "priya"), + ("dm-marcus-sofia", "marcus", "sofia"), + ("dm-jack-grace", "jack", "grace"), + ("dm-helena-marcus", "helena", "marcus"), +] + +def dm_key(a, b): + lo, hi = sorted([P(a), P(b)]) + return f"dm:{lo}:{hi}" + +# ---- message threads: (channel, [(author, text), ...]) ---------------------- +THREADS = [] +def T(channel, *msgs): + THREADS.append((channel, list(msgs))) + +# ======================= #general (≈48) ======================= +T("general", + ("helena", "Welcome to the Binocle org workspace, everyone. The 📖 Agency Handbook is live — SOPs, custody protocol, clearance policy and the onboarding checklist. Read it this week."), + ("marcus", "Clearance Levels & Access Policy is the one to internalise — the masks you see in the live tables are explained there."), + ("maya", "If a table renders `••••` where you expect a number, that is ABAC, not a bug. Elevation requests go through your case manager, not IT."), + ("nadia", "Handbook looks great. The custody protocol toggle about common mistakes should be mandatory reading."), + ("hannah", "It is now. Custody quiz moves to Fridays, first session this week.")) +T("general", + ("sofia", "Reminder: case board triage every Monday 10:00. Mission Control page has the live board embedded — statuses must be accurate before the meeting."), + ("david", "NA cases are triaged. Broken Compass stays open-high; Tom has the conn."), + ("tom", "Ack. Wiki updated with the Kestrel Marine angle.")) +T("general", + ("maya", "Maintenance window tonight 22:00–22:30 CET: gateway certs rotate. Sessions survive, video calls will drop for ~2 minutes."), + ("viktor", "Noted — we'll keep the static post on radio during the window."), + ("maya", "Done early. All services green.")) +T("general", + ("isabel", "Legal reminder: anything classified *privileged* (lawyer–client intercepts) does not get quoted in chat, wikis, or reports. Reference the COMM id and route through me."), + ("jack", "Ash Garden team is aware — all surveillance product on Petrov goes through your screen first."), + ("isabel", "Appreciated. Same applies to COMM-0139 on Nightfall.")) +T("general", + ("omar", "Evidence Gallery pages are up: Financial, Digital, Surveillance, Documents + the Q2 custody audit. Sanitised scans only — the live register stays the record."), + ("grace", "The quarantine flag on EV-0055 is very visible. Good."), + ("hannah", "That's the point. Deep verification is running; update lands on the audit page.")) +T("general", + ("robert", "Finance note: Q2 budget reconciliation needs case budgets confirmed by Thursday. Case managers, check your rows on the live board."), + ("sofia", "EU cases confirmed."), + ("david", "NA confirmed."), + ("robert", "Thanks both. Quiet Storm's budget line moves to critical-priority pool.")) +T("general", + ("leila", "The espresso machine on 3 is doing the thing again where it dispenses sadness."), + ("tom", "Filed under: cold case."), + ("maya", "I fixed the firmware once. I will not be doing that again."), + ("helena", "Procurement of a new machine is approved before someone opens an operation on it.")) +T("general", + ("yuki", "APAC sync moves to 08:00 CET on Wednesdays so Priya and I overlap with the EU morning."), + ("priya", "Works for me — calendar updated.")) +T("general", + ("marcus", "All-hands Friday 15:00: Red Meridian referral status, Nightfall progress, and the Q2 custody findings. War Room for command, stream for everyone else."), + ("amara", "Will the custody refresher be scheduled there too?"), + ("hannah", "Yes — transport couriers first, everyone else within the month.")) +T("general", + ("erik", "PSA from analysis: if you cite a transaction in a wiki, use the TXN id. Amounts differ per clearance view, ids do not."), + ("carlos", "+1. Same for subjects — alias collisions are real, S-ids only."), + ("priya", "Two different people both called “Halcyon”. Ask me how I know.")) +T("general", + ("maya", "Password rotation due this week for everyone with clearance 4+. The policy engine will start nagging you Wednesday."), + ("marcus", "Rotated. The nag is effective and mildly threatening — well done."), + ("maya", "It escalates. You do not want to see level three.")) +T("general", + ("helena", "Board review went well. Special mention for the custody audit and the watchlist memo — the two-signal rule got quoted back at me approvingly."), + ("hannah", "Team effort — the field teams are the ones logging both ends of every hand-off now."), + ("priya", "And nobody has tried to add a counterparty on vibes for three whole weeks."), + ("helena", "Progress.")) +T("general", + ("isabel", "New template for records requests is in Report Writing Standards. Use it — the old one had a paragraph a judge described as 'optimistic'."), + ("pierre", "Using it for the freeport access-log request today."), + ("yuki", "Same for the APAC registry pulls.")) +T("general", + ("david", "Friendly reminder the all-hands stream starts 15:00 sharp. Last week we lost four minutes to someone's bluetooth headphones."), + ("tom", "It was not me."), + ("leila", "It was absolutely you."), + ("tom", "It was absolutely me.")) + +# ======================= #case-ops (≈55) ======================= +T("case-ops", + ("marcus", "Nightfall standup: EV-0084 ledger analysis is in — paired phantom-freight entries confirmed. Erik's pattern memo is on my desk before Thursday's War Room."), + ("amara", "Custody on EV-0084 is clean, four entries, verified. The ledger pages are imaged and in the vault."), + ("hannah", "EV-0086 hard drive imaging done. Wallet artefacts extracted and handed to analysis."), + ("marcus", "Good. Jack — the contract (EV-0087)?"), + ("jack", "Phantom freight terms confirmed: cargo never moved on any of the six bookings. Annotated copy is on the case wiki.")) +T("case-ops", + ("sofia", "Red Meridian review: legal needs the exhibit list final by Wednesday. EV-0055 stays OUT of the package until forensics clears it."), + ("pierre", "Manifest set EV-0017 is in and verified — the export valuation model holds without the tape."), + ("isabel", "Confirmed. The referral is stronger with a shorter, cleaner exhibit list anyway."), + ("marcus", "Agreed. If EV-0055 clears later we amend; we do not wait for it.")) +T("case-ops", + ("jack", "Ash Garden: subject did the Friday pattern again — notary, then the Goldquay storefront. Third consecutive week. Surveillance log updated."), + ("grace", "Static post footage (EV-0076 continuation) is timestamped and sealed."), + ("isabel", "Reminder — Petrov is a lawyer. Everything she says to a client is privileged until I screen it. Route the audio to me first."), + ("jack", "Understood, same drill as last week."), + ("david", "Burner phone graph (EV-0051) — any movement on the recurring SIM?"), + ("jack", "Forensics says same device family as the Nightfall COMM-0085 source. If that link verifies, we have one fixer on two cases."), + ("marcus", "That would change the org chart of this whole thing. Keep it tight until verified.")) +T("case-ops", + ("tom", "Broken Compass: charter payments to Kestrel Marine land within 48h of each property closing — EV-0103 statement lines it up. Subpoena for charter manifests is drafting."), + ("david", "Coordinate the Kestrel angle with Red Meridian — Grace's marina lead touches the same shop."), + ("tom", "Already cross-referenced on the wiki. Priya's graph has Kestrel at elevated watch.")) +T("case-ops", + ("yuki", "Quiet Storm: vendor #3's incorporation date is 21 days before the award window. The pattern Carlos flagged holds for all three vendors."), + ("pierre", "Then the formation-agent comparison is the next move. Carlos and Priya have the registered-agent overlap memo due Friday."), + ("sofia", "Booking it for Monday's triage. Also: casino credit reconciliation (EV-0071) — Robert needs the clearance-4 totals."), + ("robert", "On it — residual is down to two unexplained lines.")) +T("case-ops", + ("amara", "Evidence note for everyone: when you hand off to transport, BOTH ends log. Origin-only logging is what bit us on EV-0055."), + ("nadia", "Cobalt Ledger ran three exhibits through trial without a scratch because of exactly that discipline."), + ("hannah", "And the refresher session is now mandatory. Friday, no exceptions.")) +T("case-ops", + ("marcus", "Nightfall: legal is drafting the Goldquay KYC subpoena package. Once served, analysis gets the exit-cluster confirmation within the week."), + ("leila", "Field support ready if service needs an address verification on the Goldquay storefront."), + ("marcus", "Noted — hold until legal gives the word.")) +T("case-ops", + ("david", "Assignments check: everyone's rows in the live `assignments` table must match reality before Friday. If you are on a case and not in the table, you do not exist."), + ("tom", "Fixed mine — Broken Compass plus the Kestrel cross-support."), + ("grace", "Mine show Ash Garden static + Red Meridian marina follow-up. Correct.")) +T("case-ops", + ("sofia", "Heads-up: Director wants a one-page brief per featured case for the board. Case wikis already carry the summary callout — keep them current and the briefs write themselves."), + ("pierre", "Quiet Storm wiki is current as of this morning."), + ("jack", "Ash Garden updated after tonight's rotation.")) +T("case-ops", + ("amara", "Nightfall evidence run complete: EV-0098 (second hard drive, site visit) is imaged. Wallet artefact set is larger than EV-0086's — analysis has both now."), + ("hannah", "Hashes recorded at collection and transfer. Chain is clean."), + ("marcus", "Erik — fold EV-0098 into the exit-cluster work before Thursday if you can."), + ("amara", "He's already pulling it, saw the forensics ticket close an hour ago.")) +T("case-ops", + ("pierre", "Quiet Storm: freeport access-log request served using Isabel's new template. Expecting the logs within ten working days."), + ("yuki", "The award-week windows are listed on the wiki so we can cross the logs the day they arrive."), + ("sofia", "Good. That closes the last open records item on Quiet Storm.")) +T("case-ops", + ("nadia", "Cobalt Ledger archive request: prosecution wants the custody bundle for the appeal. All three exhibits, full logs."), + ("hannah", "Bundle prepared and sealed — collecting signature from legal tomorrow."), + ("isabel", "I'll sign at 09:00. The appeal has nothing on custody; this is belt and braces.")) +T("case-ops", + ("david", "Broken Compass: Novum Estates closed two more flips this month. If the mixer deposits follow within 48h again, that is cycle five and six."), + ("tom", "Watching the deposit window now. EV-0103 statement format makes the matching almost mechanical."), + ("erik", "Ping me the timestamps when they land — they go straight into the exit-cluster correlation."), + ("tom", "Will do.")) +T("case-ops", + ("marcus", "Reminder: minutes from last Thursday's War Room are on the case wikis. If you were mentioned with an action item, it is now in the assignments table too."), + ("leila", "Seen mine — address verification on the Goldquay storefront, holding for legal."), + ("grace", "Mine too. Kestrel charter manager identity confirmation, with Priya.")) + +# ======================= #intel-analysts (≈40) ======================= +T("intel-analysts", + ("erik", "TXN pattern memo is up in my notebook: structuring under 10k via Goldquay — TXN-0010 (9,769.57 EUR wire), TXN-0002, TXN-0014. Sub-threshold density 3.1× corridor baseline."), + ("priya", "That density figure is the headline. Baseline computed on which window?"), + ("erik", "Rolling 90 days, masked series bucketed to 500-unit bands. Method note is in the toggle."), + ("carlos", "And then TXN-0016 walks in — 306,454.56 EUR by hawala TO an exchange. Who settles six figures by hawala into Goldquay?"), + ("erik", "Either operator-to-operator settlement or a mistake. Escalated to Marcus with the memo."), + ("marcus", "Read it. Bring it to the War Room Thursday — that outlier reframes Nightfall's scale.")) +T("intel-analysts", + ("priya", "Counterparty graph refresh: Goldquay now touches FOUR cases (Nightfall, Ash Garden, Broken Compass, Quiet Storm). Westport Capital SA recurs post-conviction. Watchlist updated."), + ("carlos", "Kestrel Marine at elevated too — Red Meridian marina lead plus Broken Compass charter trail."), + ("helena", "This is the picture I want on one page for the board. Watchlist memo is exactly right — keep ids on every claim."), + ("priya", "Will do. FIXER-01 stays an unresolved node until the SIM comparison lands.")) +T("intel-analysts", + ("carlos", "Hawala corridor: Pelican Freight's invoice cancellation rate is 31% vs 4% industry norm. Freight invoices as settlement memos — cancellation IS the settlement confirmation."), + ("erik", "That inversion is beautiful. Does the 72h settlement window hold across both corridors?"), + ("carlos", "NA leg yes, EU leg has two outliers — both brush TXN-0016's week."), + ("priya", "Everything touches that week. Flagging it as an event window in the graph.")) +T("intel-analysts", + ("priya", "Alias collision resolved: “Halcyon” is S-0003 (Hassan Vidal) AND S-0009 (Gustav DeVries). Two people, one alias, zero relation. The dashboard join was silently merging them."), + ("erik", "How long was it wrong?"), + ("priya", "Since 02 June. Re-keyed everything to subject_id. Post-mortem note is in my notebook."), + ("carlos", "Good catch — that merge would have poisoned the Quiet Storm vendor analysis.")) +T("intel-analysts", + ("erik", "Mixer exits: 03:00–05:00 UTC cluster correlates 0.78 with Goldquay settlement batches. If Thursday's batch repeats the offsets, it's automation."), + ("marcus", "And if it's automation, subpoenaing the scheduler config becomes very interesting. Keep me posted."), + ("erik", "Watch is booked. Results land here Thursday night.")) +T("intel-analysts", + ("carlos", "Sable Trust (BVI) profile check: TXN-0024 (76,102.38 EUR, invoice method) fits the residual-sink pattern exactly. Third independent signal."), + ("priya", "Then it graduates from monitor to elevated next sweep, per the watchlist rules."), + ("helena", "Approved. The two-signal rule is doing its job.")) +T("intel-analysts", + ("erik", "Dashboard note: aggregate views (count/sum by counterparty) come from the live tables, so they respect masks. Finance sees totals; we see densities. Reconcile through Robert, not screenshots."), + ("priya", "The mask follows the data, not the pixels. It's in the clearance policy verbatim."), + ("carlos", "Quoting the handbook in chat. We've become management.")) +T("intel-analysts", + ("erik", "EV-0098 artefacts are in. The second drive's wallet set overlaps EV-0086 on three addresses — same operator, different machines."), + ("carlos", "Three shared addresses is enough to collapse them into one node in the graph."), + ("priya", "Collapsed. Nightfall's subject cluster just got simpler and scarier at the same time."), + ("marcus", "That sentence is going in my Thursday brief verbatim.")) +T("intel-analysts", + ("priya", "Kestrel charter manager identity: Grace's marina log plus the charter registry gives us a name. Holding it off-channel until verified — it goes in `subjects` first, chat second."), + ("carlos", "Correct order. The registry pull is archived under the case so the sourcing survives review."), + ("helena", "This thread is why the analysts channel exists. Carry on.")) +T("intel-analysts", + ("carlos", "Methodology share: the 31% cancellation figure for Pelican came from comparing issued vs settled invoices in the manifest set — happy to walk anyone through the query."), + ("erik", "Do a ten-minute walkthrough Friday after the all-hands?"), + ("carlos", "Booked. Bring your own masked views."), + ("priya", "Attending. I want the cancellation-as-settlement-confirmation logic on the watchlist page.")) +T("intel-analysts", + ("erik", "Thursday batch result: the mixer exit offsets repeated within 90 seconds. It is automation."), + ("marcus", "Then the scheduler config subpoena goes in the Goldquay package. Outstanding work."), + ("priya", "Event window thesis strengthens too — automated settlement plus one manual six-figure hawala the same week reads like someone pressing the override button."), + ("erik", "Agreed on all counts. Memo updated before the War Room.")) + +# ======================= #field (≈35) ======================= +T("field", + ("grace", "Marina follow-up (L-0022): second meeting confirmed, same berth. Counterparty matches a Kestrel Marine charter manager — photos logged, context only, no faces."), + ("jack", "Textbook. Tag goes to the Red Meridian analyst thread."), + ("sofia", "Logged. Identity confirmation task is with Priya.")) +T("field", + ("tom", "Plate from L-0079 spotted again at the freight forwarder's yard, 14:20. Second corroboration."), + ("viktor", "Matches my earlier log at location L-21. The vehicle is the thread between subject and yard."), + ("david", "Both entries are in `leads`. That's how it's done — 30 seconds of logging, weeks of value.")) +T("field", + ("nadia", "Friday courier watch (L-0048 / Red Meridian): bank alert corroborated across two cycles now. Third cycle this Friday completes the warrant package threshold."), + ("leila", "I'll take the second eye on Friday. Rotation board updated."), + ("nadia", "Confirmed. Radio checks on the quarter hour, abort criteria per the field guide.")) +T("field", + ("jack", "Ash Garden rotation: Grace and Viktor on static this week. Subject is counter-surveillance aware — eyes rotate every 40, no exceptions."), + ("viktor", "Footage from last night sealed and logged as EV-0076 continuation. Hand-off logged at BOTH ends, before anyone asks."), + ("hannah", "I saw. Custody compliance has visibly improved since the audit. Keep it up.")) +T("field", + ("leila", "Reminder from the SOP: contact reports within 12 hours. Mine from the records office visit is filed."), + ("tom", "Filed mine too. The notary clerk talks a lot when you ask about parking."), + ("grace", "The best sources don't know they are sources.")) +T("field", + ("sofia", "Ops note: no seizures on pattern alone. The Friday courier package waits for legal's warrant — anyone moving early answers to me and then to Isabel, in that order."), + ("jack", "Understood. Field holds."), + ("david", "And if anyone gets burned: SUNSET in this channel, fallback point, nothing else.")) +T("field", + ("viktor", "Kit check: long-lens rig back from forensics, locker 7. Radio batteries rotated. The static post chair remains a war crime."), + ("grace", "Confirmed on all three. Bringing my own cushion."), + ("leila", "Requisition for a new chair filed under 'operational necessity'."), + ("david", "Approved. Morale is operational.")) +T("field", + ("nadia", "Friday cycle three complete: courier, same route, same timing, bag swap at the kiosk. Warrant threshold met — package goes to legal Monday."), + ("jack", "Clean work. Log both plates and the kiosk vendor's hours in `leads` before you stand down."), + ("nadia", "Already in. L-0048 updated with all three cycles."), + ("sofia", "Seen. Legal has it first thing Monday.")) +T("field", + ("tom", "Yard watch: the L-0079 vehicle left with a trailer tonight, first time. Followed to the ring road per the pre-cleared route, then broke off."), + ("viktor", "Correct call — the route past the ring road is not cleared and the subject checks mirrors."), + ("david", "Trailer detail goes to Red Meridian's analyst thread. That yard keeps earning its surveillance budget.")) +T("field", + ("grace", "Ash Garden Friday report: notary, then Goldquay storefront, fourth consecutive week. Pattern threshold met. Footage sealed, hand-off logged both ends."), + ("jack", "Four for four. It goes on the wiki tonight and to Isabel's screening queue first."), + ("isabel", "Queue position one. You'll have the release decision by Tuesday.")) + +# ======================= War Room (video, ≈8) ======================= +T("war-room", + ("helena", "War Room Thursday 09:00 — agenda: Nightfall TXN-0016 outlier, Red Meridian referral, Q2 custody findings. Erik presents first ten minutes."), + ("marcus", "Confirmed. Pre-read: Erik's notebook memo + the watchlist page."), + ("sofia", "Joining from the EU room. Red Meridian exhibit list will be final by then."), + ("david", "NA dialling in. I'll bring the Broken Compass charter subpoena status."), + ("helena", "Minutes go on the case wikis within 24h, per SOP.")) +T("war-room", + ("marcus", "Link check for Thursday — everyone confirm the video channel works from your kit."), + ("sofia", "Working here."), + ("david", "Same.")) + +# ======================= DMs (≈24) ======================= +T("dm-erik-priya", + ("erik", "Your alias-collision catch saved my density model — I was about to bucket by alias for the dedupe pass."), + ("priya", "We all almost did. subject_id or nothing now."), + ("erik", "Deal. Also — look at TXN-0016's week in your graph. Everything converges on it."), + ("priya", "Saw it. I'm calling it the event window. If the Thursday batch confirms automation, that week is when someone settled the books."), + ("erik", "If you put 'event window' on the watchlist page, attach both our ids to it."), + ("priya", "Two signals, written down. I know the rules — I wrote them.")) +T("dm-marcus-sofia", + ("marcus", "Red Meridian: can you hold the referral package until Hannah's deep verification report? If EV-0055 clears, we amend; if not, the package is already clean."), + ("sofia", "Holding until Wednesday noon, then it ships regardless. Legal agrees the shorter list is stronger."), + ("marcus", "Agreed. One more thing — staffing. If the SIM link between Ash Garden and Nightfall verifies, I want one analyst across both."), + ("sofia", "Erik is the obvious pick but he's at capacity. Priya takes the cross-case node, Erik keeps the patterns."), + ("marcus", "Good split. Put it in Monday's triage.")) +T("dm-jack-grace", + ("jack", "Friday rotation — can you take the early shift at the static post? Viktor has the courier watch with Nadia."), + ("grace", "Yes, but I need the long-lens kit back from forensics by Thursday."), + ("jack", "Maya signed it back in this morning. It's in locker 7."), + ("grace", "Then we're set. If the subject does the notary-Goldquay run again, that's four consecutive Fridays — pattern threshold met."), + ("jack", "And THEN we let legal do their thing. By the book on this one — she's a lawyer, she'll see anything sloppy.")) +T("dm-helena-marcus", + ("helena", "Before Thursday: the TXN-0016 outlier — does it change Nightfall's budget line? Six-figure settlements mean a bigger ring than we scoped."), + ("marcus", "Honestly, yes. If the fixer link verifies we're looking at one network behind three operations. I'd rather re-scope now than mid-referral."), + ("helena", "Then bring a consolidated proposal Thursday. One network, one task force, shared analyst pool."), + ("marcus", "Drafting it tonight. Working name: MERIDIAN GATE."), + ("helena", "Approved as a working name. And Marcus — good catch escalating the outlier fast.")) + +# ---- timestamping ------------------------------------------------------------ +# Spread threads over the last 14 days, oldest first, messages 2-6 min apart. +MIN_14D = 14 * 24 * 60 +events = [] # (msg_uuid, channel_key, author, text, minutes_ago) +n_threads = len(THREADS) +for ti, (chan, msgs) in enumerate(THREADS): + base = MIN_14D - int((ti + 1) * (MIN_14D - 90) / (n_threads + 1)) + rng.randint(-40, 40) + offset = max(base, 75) + for mi, (author, text) in enumerate(msgs): + mid = uid(f"msg:{chan}:{ti}:{mi}") + events.append((mid, chan, author, text, offset)) + offset -= rng.randint(2, 6) + +# reactions: (msg uuid5 key parts, reactor, emoji) +REACTIONS = [ + (("general", 0, 0), "amara", "👍"), (("general", 0, 0), "erik", "👍"), + (("general", 0, 0), "grace", "🎉"), + (("general", 6, 0), "tom", "😂"), (("general", 6, 1), "leila", "😂"), + (("general", 6, 3), "maya", "👍"), + (("case-ops", 2, 5), "marcus", "👀"), (("case-ops", 2, 5), "david", "🔥"), + (("case-ops", 0, 4), "marcus", "✅"), + (("intel-analysts", 0, 4), "priya", "🔥"), (("intel-analysts", 0, 4), "carlos", "🔥"), + (("intel-analysts", 3, 0), "erik", "🎯"), (("intel-analysts", 3, 0), "helena", "👍"), + (("field", 1, 2), "jack", "✅"), (("field", 0, 0), "sofia", "👍"), + (("war-room", 0, 0), "sofia", "✅"), (("war-room", 0, 0), "david", "✅"), + (("dm-helena-marcus", 0, 4), "marcus", "🙏"), +] +# index events by (chan, thread, msg) for reactions: rebuild mapping +_msg_uuid = {} +for ti, (chan, msgs) in enumerate(THREADS): + for mi in range(len(msgs)): + _msg_uuid[(chan, ti, mi)] = uid(f"msg:{chan}:{ti}:{mi}") + +# thread-local indexes are global across THREADS list; recompute per-channel thread index +_chan_threads = {} +_key_fix = {} +for ti, (chan, msgs) in enumerate(THREADS): + local = _chan_threads.setdefault(chan, 0) + for mi in range(len(msgs)): + _key_fix[(chan, local, mi)] = (chan, ti, mi) + _chan_threads[chan] = local + 1 + +# edited messages: (channel, local thread, msg index) +EDITED = [("general", 2, 0), ("case-ops", 3, 0), ("intel-analysts", 1, 0)] + +# ---- emit chat SQL ----------------------------------------------------------- +chat = [ + "-- Generated by tools/seeds/seed_agency_wiki.py — chat + feed backfill.", + "-- Idempotent: fixed uuid5 ids, ON CONFLICT DO NOTHING everywhere. Safe to re-run.", + "BEGIN;", +] + +for key, c in CH.items(): + cid = uid(f"channel:{key}") + chat.append( + "INSERT INTO public.osionos_channels (id, workspace_id, kind, name, topic, created_by, " + "is_private, abac, created_at, updated_at) VALUES (" + f"'{cid}', '{WS}', '{c['kind']}', {sqlstr(c['name'])}, {sqlstr(c['topic'])}, " + f"'{P(c['created_by'])}', {'true' if c['private'] else 'false'}, '{{}}'::jsonb, " + f"now() - interval '15 days', now()) ON CONFLICT (id) DO NOTHING;") + for m in c["members"]: + role = "owner" if m == c["created_by"] else "member" + chat.append( + "INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) " + f"VALUES ('{cid}', '{P(m)}', '{role}', now() - interval '15 days') " + "ON CONFLICT DO NOTHING;") + +for key, a, b in DMS: + dk = dm_key(a, b) + cid = uid(f"channel:{key}") + name = f"{PN(a)} & {PN(b)}" + chat.append( + "INSERT INTO public.osionos_channels (id, workspace_id, kind, name, created_by, " + "is_private, abac, dm_key, created_at, updated_at) VALUES (" + f"'{cid}', '{WS}', 'dm', {sqlstr(name)}, '{P(a)}', true, '{{}}'::jsonb, {sqlstr(dk)}, " + "now() - interval '13 days', now()) ON CONFLICT (dm_key) DO NOTHING;") + for m in (a, b): + chat.append( + "INSERT INTO public.osionos_channel_members (channel_id, user_id, role, joined_at) " + f"SELECT id, '{P(m)}', 'member', now() - interval '13 days' " + f"FROM public.osionos_channels WHERE dm_key = {sqlstr(dk)} " + "ON CONFLICT DO NOTHING;") + +DM_KEY_BY_CHAN = {key: dm_key(a, b) for key, a, b in DMS} +edited_uuids = {_msg_uuid[_key_fix[k]] for k in EDITED} + +msg_count = 0 +for mid, chan, author, text, minutes in events: + created = f"now() - interval '{minutes} minutes'" + edited = f"now() - interval '{max(minutes - 9, 5)} minutes'" if mid in edited_uuids else "NULL" + body = text + if chan in DM_KEY_BY_CHAN: + chan_sql = (f"(SELECT id FROM public.osionos_channels WHERE dm_key = " + f"{sqlstr(DM_KEY_BY_CHAN[chan])})") + else: + chan_sql = f"'{uid('channel:' + chan)}'" + chat.append( + "INSERT INTO public.osionos_messages (id, channel_id, author_id, content, attachments, " + f"created_at, edited_at) VALUES ('{mid}', {chan_sql}, '{P(author)}', {sqlstr(body)}, " + f"'[]'::jsonb, {created}, {edited}) ON CONFLICT (id) DO NOTHING;") + msg_count += 1 + +react_count = 0 +for local_key, reactor, emoji in REACTIONS: + real = _key_fix.get(local_key) + if not real: + continue + chat.append( + "INSERT INTO public.osionos_message_reactions (message_id, user_id, emoji, created_at) " + f"VALUES ('{_msg_uuid[real]}', '{P(reactor)}', {sqlstr(emoji)}, now() - interval '1 day') " + "ON CONFLICT DO NOTHING;") + react_count += 1 + +# ---- feed backfill: likes + comments on the wiki pages ------------------------ +LIKES = { + "case-nightfall": ["marcus", "erik", "amara", "jack", "helena", "priya"], + "case-cobalt": ["nadia", "helena", "sofia", "isabel"], + "case-redmeridian": ["marcus", "sofia", "pierre", "hannah", "grace"], + "case-quietstorm": ["pierre", "carlos", "robert", "yuki"], + "case-ashgarden": ["jack", "grace", "viktor", "isabel"], + "case-brokencompass": ["tom", "david", "priya"], + "handbook": ["helena", "marcus", "sofia", "nadia", "leila", "maya"], + "sop": ["amara", "tom", "isabel"], + "custody": ["hannah", "omar", "amara", "nadia"], + "mission-control": ["helena", "marcus", "david", "erik", "robert"], +} +COMMENTS = [ + ("custody", "amara", "Custody log for EV-0042 updated — please re-verify.", 4300), + ("custody", "hannah", "Re-verification booked for Thursday. EV-0055 deep check still running.", 4180), + ("case-redmeridian", "hannah", "Reminder: EV-0055 stays out of the referral package until my report lands.", 3900), + ("case-redmeridian", "isabel", "Exhibit list reviewed — clean without the tape. Ship it Wednesday.", 3700), + ("case-nightfall", "erik", "TXN pattern memo linked — the 3.1× density figure is the headline for Thursday.", 5200), + ("case-nightfall", "isabel", "COMM-0139 is privileged — do not quote it here, reference the id only.", 5100), + ("case-ashgarden", "isabel", "All Petrov intercepts screened this week — two released to analysis, one held.", 2900), + ("case-ashgarden", "grace", "Static post log sealed and handed off — both ends signed this time.", 2800), + ("case-quietstorm", "robert", "Casino credit reconciliation: residual down to two lines. Update on the wiki.", 2400), + ("case-brokencompass", "priya", "Kestrel Marine raised to elevated on the watchlist — two-signal rule met.", 2000), + ("case-cobalt", "helena", "Excellent retrospective. The Westport recurrence note directly feeds Nightfall.", 6100), + ("handbook", "leila", "Onboarding checklist is genuinely useful — finished week one with zero surprises.", 7000), + ("mission-control", "marcus", "Live board is now the agenda for Monday triage — keep statuses current.", 8000), + ("sop", "tom", "Contact-report-within-12h rule acknowledged. Filed tonight's from the yard.", 3300), +] +like_count = 0 +for pkey, users in LIKES.items(): + pid = uid(f"page:{pkey}") + for i, u in enumerate(users): + chat.append( + "INSERT INTO public.osionos_feed_likes (page_id, user_id, created_at) " + f"VALUES ('{pid}', '{P(u)}', now() - interval '{rng.randint(600, 9000)} minutes') " + "ON CONFLICT DO NOTHING;") + like_count += 1 +for i, (pkey, author, text, minutes) in enumerate(COMMENTS): + cid = uid(f"feed-comment:{i}:{pkey}") + chat.append( + "INSERT INTO public.osionos_feed_comments (id, page_id, author_id, content, created_at) " + f"VALUES ('{cid}', '{uid('page:' + pkey)}', '{P(author)}', {sqlstr(text)}, " + f"now() - interval '{minutes} minutes') ON CONFLICT (id) DO NOTHING;") + +chat.append("COMMIT;") +chat_out = os.path.join(HERE, "seed_agency_chat.sql") +with open(chat_out, "w") as fh: + fh.write("\n".join(chat) + "\n") + +print(f"pages: {len(PAGES)} channels: {len(CH) + len(DMS)} messages: {msg_count} " + f"reactions: {react_count} likes: {like_count} comments: {len(COMMENTS)}", file=sys.stderr) +print(wiki_out) +print(chat_out) diff --git a/tools/seeds/seed_agency_wiki.sql b/tools/seeds/seed_agency_wiki.sql new file mode 100644 index 00000000..483a613d --- /dev/null +++ b/tools/seeds/seed_agency_wiki.sql @@ -0,0 +1,30 @@ +-- Generated by tools/seeds/seed_agency_wiki.py — idempotent (fixed uuid5 ids, +-- ON CONFLICT (id) DO UPDATE refreshes content). Safe to re-run. +BEGIN; +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('515676eb-f9d1-5fb8-870b-ec0664ec46b7', 'b1a0c1e5-0000-4000-a000-000000000001', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', '🧭 Mission Control', '🧭', 'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMSIsICJ0eXBlIjogImhlYWRpbmdfMSIsICJjb250ZW50IjogIkJpbm9jbGUgSW50ZWxsaWdlbmNlIEFnZW5jeSDigJQgTWlzc2lvbiBDb250cm9sIn0sIHsiaWQiOiAiYXctMiIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICIqKldlbGNvbWUgdG8gdGhlIG9yZyB3b3Jrc3BhY2UuKiogRXZlcnl0aGluZyB0aGUgYWdlbmN5IHJ1bnMgb24gbGl2ZXMgaGVyZTogdGhlIEhhbmRib29rLCBsaXZlIENhc2UgV2lraXMsIHRoZSBFdmlkZW5jZSBHYWxsZXJ5IGFuZCB0aGUgQW5hbHlzdCBOb3RlYm9va3MuIElmIHlvdSBhcmUgbmV3LCBzdGFydCB3aXRoIHRoZSBPbmJvYXJkaW5nIENoZWNrbGlzdCBpbiB0aGUg8J+TliBBZ2VuY3kgSGFuZGJvb2suIiwgImNvbG9yIjogIvCfjq8ifSwgeyJpZCI6ICJhdy0xMiIsICJ0eXBlIjogImNvbHVtbl9saXN0IiwgImNvbnRlbnQiOiAiIiwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctOSIsICJ0eXBlIjogImNvbHVtbiIsICJjb250ZW50IjogIiIsICJ3aWR0aFJhdGlvIjogMSwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMyIsICJ0eXBlIjogImhlYWRpbmdfMyIsICJjb250ZW50IjogIvCfk5YgSGFuZGJvb2sifSwgeyJpZCI6ICJhdy00IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiU09QcywgY3VzdG9keSBwcm90b2NvbCwgY2xlYXJhbmNlICYgQUJBQyBwb2xpY3ksIG9uYm9hcmRpbmcuIn1dfSwgeyJpZCI6ICJhdy0xMCIsICJ0eXBlIjogImNvbHVtbiIsICJjb250ZW50IjogIiIsICJ3aWR0aFJhdGlvIjogMSwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctNSIsICJ0eXBlIjogImhlYWRpbmdfMyIsICJjb250ZW50IjogIvCfl4LvuI8gQ2FzZSBXaWtpcyJ9LCB7ImlkIjogImF3LTYiLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJPbmUgd2lraSBwZXIgYWN0aXZlIG9wZXJhdGlvbiwgd2lyZWQgdG8gdGhlIGxpdmUgY2FzZSB0YWJsZXMuIn1dfSwgeyJpZCI6ICJhdy0xMSIsICJ0eXBlIjogImNvbHVtbiIsICJjb250ZW50IjogIiIsICJ3aWR0aFJhdGlvIjogMSwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctNyIsICJ0eXBlIjogImhlYWRpbmdfMyIsICJjb250ZW50IjogIvCflrzvuI8gRXZpZGVuY2UifSwgeyJpZCI6ICJhdy04IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiR2FsbGVyaWVzIHBlciBldmlkZW5jZSBraW5kIHBsdXMgdGhlIFEyIGN1c3RvZHkgYXVkaXQuIn1dfV19LCB7ImlkIjogImF3LTEzIiwgInR5cGUiOiAiZGl2aWRlciIsICJjb250ZW50IjogIiJ9LCB7ImlkIjogImF3LTE0IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiQWdlbmN5IGF0IGEgZ2xhbmNlIn0sIHsiaWQiOiAiYXctMTUiLCAidHlwZSI6ICJ0YWJsZV9ibG9jayIsICJjb250ZW50IjogIiIsICJ0YWJsZURhdGEiOiBbWyJQbGFuZSIsICJIb2xkaW5ncyIsICJTb3VyY2Ugb2YgdHJ1dGgiXSwgWyJDYXNlcyIsICI0MCBvcGVyYXRpb25zIChCSUEtMjAyeC1OTk4pIiwgImxpdmUgYGNhc2VzYCB0YWJsZSJdLCBbIlN1YmplY3RzIiwgIjYwIHBlcnNvbnMgb2YgaW50ZXJlc3QiLCAibGl2ZSBgc3ViamVjdHNgIHRhYmxlIl0sIFsiRXZpZGVuY2UiLCAiMTIwIHNlYWxlZCBpdGVtcyIsICJsaXZlIGBldmlkZW5jZWAgdGFibGUiXSwgWyJUcmFuc2FjdGlvbnMiLCAiMTUwIG1vbml0b3JlZCBtb3ZlbWVudHMiLCAibGl2ZSBgdHJhbnNhY3Rpb25zYCB0YWJsZSJdLCBbIkNvbW11bmljYXRpb25zIiwgIjIwMCBpbnRlcmNlcHQgc3VtbWFyaWVzIiwgImxpdmUgYGNvbW11bmljYXRpb25zYCB0YWJsZSJdXSwgInRhYmxlQ29uZmlnIjogeyJoZWFkZXJSb3ciOiB0cnVlLCAic2hvd0JvcmRlcnMiOiB0cnVlLCAic3RyaXBlZFJvd3MiOiB0cnVlfX0sIHsiaWQiOiAiYXctMTYiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiQ2xlYXJhbmNlIGFwcGxpZXMgZXZlcnl3aGVyZTogd2hhdCB5b3Ugc2VlIGluIHRoZSBsaXZlIHRhYmxlcyBiZWxvdyBpcyBhbHJlYWR5IGZpbHRlcmVkIGFuZCBtYXNrZWQgYnkgeW91ciByb2xlLiBTZWUgKipDbGVhcmFuY2UgTGV2ZWxzICYgQWNjZXNzIFBvbGljeSoqLiIsICJjb2xvciI6ICLimqDvuI8ifSwgeyJpZCI6ICJhdy0xNyIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkxpdmUgY2FzZSBib2FyZCJ9LCB7ImlkIjogImF3LTE4IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiVGhlIGJvYXJkIGJlbG93IGlzIHRoZSBsaXZlIGBjYXNlc2AgdGFibGUg4oCUIG5vdCBhIGNvcHkuIEVkaXRzIGhlcmUgd3JpdGUgYmFjayB0byB0aGUgb3BlcmF0aW9uYWwgZGF0YWJhc2UgYW5kIGJyb2FkY2FzdCB0byBldmVyeW9uZSB3YXRjaGluZy4ifSwgeyJpZCI6ICJhdy0xOSIsICJ0eXBlIjogImRhdGFiYXNlX2lubGluZSIsICJjb250ZW50IjogIiIsICJkYXRhYmFzZUlkIjogImJhYXM6ZDNlY2IzZTEtOTk0Ny00MWE2LWEwZDMtZmYyMDYzYjRhZGVlOmNhc2VzIn0sIHsiaWQiOiAiYXctMjAiLCAidHlwZSI6ICJkaXZpZGVyIiwgImNvbnRlbnQiOiAiIn0sIHsiaWQiOiAiYXctMjEiLCAidHlwZSI6ICJxdW90ZSIsICJjb250ZW50IjogIldlIGNvbm5lY3QgZG90cyB0aGF0IHdlcmUgbmV2ZXIgc3VwcG9zZWQgdG8gbWVldC4g4oCUIEguIFZvc3MsIERpcmVjdG9yIn1d','base64'),'utf8')::jsonb, now() - interval '30 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('248cfe48-81c6-51f9-92ca-61a049a6ad0a', 'b1a0c1e5-0000-4000-a000-000000000001', NULL, 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', '📖 Agency Handbook', '📖', 'https://images.unsplash.com/photo-1517077304055-6e89abbf09b0?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMjIiLCAidHlwZSI6ICJoZWFkaW5nXzEiLCAiY29udGVudCI6ICJBZ2VuY3kgSGFuZGJvb2sifSwgeyJpZCI6ICJhdy0yMyIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJUaGUgSGFuZGJvb2sgaXMgdGhlIG9wZXJhdGluZyBjb250cmFjdCBvZiB0aGUgQmlub2NsZSBJbnRlbGxpZ2VuY2UgQWdlbmN5LiBFdmVyeSBzZWN0aW9uIGlzIGJpbmRpbmcgcG9saWN5IHVubGVzcyBtYXJrZWQgYXMgZ3VpZGFuY2UuIiwgImNvbG9yIjogIvCfk5gifSwgeyJpZCI6ICJhdy0yNCIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkNvbnRlbnRzIn0sIHsiaWQiOiAiYXctMjUiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiU3RhbmRhcmQgT3BlcmF0aW5nIFByb2NlZHVyZXMg4oCUIGhvdyBjYXNld29yayBhY3R1YWxseSBmbG93cy4ifSwgeyJpZCI6ICJhdy0yNiIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJFdmlkZW5jZSBDdXN0b2R5IFByb3RvY29sIOKAlCBzZWFsaW5nLCBsb2dnaW5nLCB2ZXJpZmljYXRpb24uIn0sIHsiaWQiOiAiYXctMjciLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiQ2xlYXJhbmNlIExldmVscyAmIEFjY2VzcyBQb2xpY3kg4oCUIHdobyBzZWVzIHdoYXQsIGFuZCB3aHkuIn0sIHsiaWQiOiAiYXctMjgiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiT25ib2FyZGluZyBDaGVja2xpc3Qg4oCUIHlvdXIgZmlyc3Qgd2Vlaywgc3RlcCBieSBzdGVwLiJ9LCB7ImlkIjogImF3LTI5IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkZpZWxkIE9wZXJhdGlvbnMgR3VpZGUg4oCUIHN1cnZlaWxsYW5jZSAmIGFwcHJvYWNoIGRpc2NpcGxpbmUuIn0sIHsiaWQiOiAiYXctMzAiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiUmVwb3J0IFdyaXRpbmcgU3RhbmRhcmRzIOKAlCBsYW5ndWFnZSB0aGF0IHN1cnZpdmVzIGEgY291cnRyb29tLiJ9LCB7ImlkIjogImF3LTMxIiwgInR5cGUiOiAiZGl2aWRlciIsICJjb250ZW50IjogIiJ9LCB7ImlkIjogImF3LTMyIiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIlBvbGljeSBxdWVzdGlvbnMgZ28gdG8gI2dlbmVyYWw7IGNhc2Utc3BlY2lmaWMgcXVlc3Rpb25zIHRvICNjYXNlLW9wcy4iLCAiY29sb3IiOiAi4pyFIn1d','base64'),'utf8')::jsonb, now() - interval '29 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('9971834b-9a3a-503f-8d21-b6ecf5458285', 'b1a0c1e5-0000-4000-a000-000000000001', '248cfe48-81c6-51f9-92ca-61a049a6ad0a', 'faf9aeda-e21e-42ab-80be-9ce0fd8662a1', 'Standard Operating Procedures', '📋', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzMiLCAidHlwZSI6ICJoZWFkaW5nXzEiLCAiY29udGVudCI6ICJTdGFuZGFyZCBPcGVyYXRpbmcgUHJvY2VkdXJlcyJ9LCB7ImlkIjogImF3LTM0IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIioqU2NvcGUqKiDigJQgZXZlcnkgQklBIG9wZXJhdGlvbiwgZnJvbSBpbnRha2UgdG8gYXJjaGl2ZS4gRGV2aWF0aW9ucyByZXF1aXJlIHNpZ24tb2ZmIGZyb20gdGhlIERpcmVjdG9yIG9yIERlcHV0eSBEaXJlY3RvciBhbmQgYSBub3RlIG9uIHRoZSBjYXNlIHdpa2kuIiwgImNvbG9yIjogIvCfk4wifSwgeyJpZCI6ICJhdy0zNSIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIjEgwrcgQ2FzZSBsaWZlY3ljbGUifSwgeyJpZCI6ICJhdy0zNiIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICIqKkludGFrZSoqIOKAlCBhIGxlYWQgb3IgY2xpZW50IHJlZmVycmFsIGlzIGxvZ2dlZCBpbiBgbGVhZHNgIHdpdGggc291cmNlIGFuZCBjcmVkaWJpbGl0eS4ifSwgeyJpZCI6ICJhdy0zNyIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICIqKk9wZW4qKiDigJQgYSBjYXNlIG1hbmFnZXIgYXNzaWducyBhIGNvZGUgKEJJQS1ZWVlZLU5OTiksIGEgbGVhZCBpbnZlc3RpZ2F0b3IgYW5kIGEgYnVkZ2V0LiJ9LCB7ImlkIjogImF3LTQwIiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIioqSW52ZXN0aWdhdGUqKiDigJQgZmllbGQsIHN1cnZlaWxsYW5jZSwgZm9yZW5zaWNzIGFuZCBhbmFseXNpcyB3b3JrIHRoZSBjYXNlIGluIHBhcmFsbGVsOyBldmVyeSBhcnRlZmFjdCBsYW5kcyBpbiB0aGUgbGl2ZSB0YWJsZXMsIG5ldmVyIGluIHByaXZhdGUgZmlsZXMuIiwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMzgiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiRmllbGQgYWdlbnRzIGZpbGUgY29udGFjdCByZXBvcnRzIHdpdGhpbiAxMiBob3Vycy4ifSwgeyJpZCI6ICJhdy0zOSIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJBbmFseXN0cyBrZWVwIHdvcmtpbmcgbm90ZXMgaW4gdGhlaXIgQW5hbHlzdCBOb3RlYm9vay4ifV19LCB7ImlkIjogImF3LTQxIiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIioqUmV2aWV3Kiog4oCUIGxlZ2FsICsgdGhlIGNhc2UgbWFuYWdlciBjaGVjayB0aGUgZXZpZGVudGlhcnkgY2hhaW4gYmVmb3JlIGFueSByZWZlcnJhbC4ifSwgeyJpZCI6ICJhdy00MiIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICIqKkNsb3NlIC8gQ29sZCoqIOKAlCBvdXRjb21lcyBhcmUgcmVjb3JkZWQgaW4gYHJlcG9ydHNgOyBjb2xkIGNhc2VzIGtlZXAgdGhlaXIgYXNzaWdubWVudHMuIn0sIHsiaWQiOiAiYXctNDMiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICIyIMK3IENvbW11bmljYXRpb24gZGlzY2lwbGluZSJ9LCB7ImlkIjogImF3LTQ0IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIk9wZXJhdGlvbmFsIGNoYXR0ZXIgc3RheXMgaW4gI2Nhc2Utb3BzOyBhbmFseXNpcyB0aHJlYWRzIGluICNpbnRlbC1hbmFseXN0cy4ifSwgeyJpZCI6ICJhdy00NSIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJOZXZlciBwYXN0ZSByYXcgaW50ZXJjZXB0IGNvbnRlbnQgaW50byBjaGF0IOKAlCBsaW5rIHRoZSBgY29tbXVuaWNhdGlvbnNgIHJvdyBpbnN0ZWFkLiJ9LCB7ImlkIjogImF3LTQ2IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIldhciBSb29tICh2aWRlbykgaXMgZm9yIGxpdmUgY29vcmRpbmF0aW9uIG9ubHk7IGRlY2lzaW9ucyBtYWRlIHRoZXJlIGFyZSBtaW51dGVkIG9uIHRoZSBjYXNlIHdpa2kgd2l0aGluIDI0IGhvdXJzLiJ9LCB7ImlkIjogImF3LTQ3IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIioqV2FybmluZyoqIOKAlCBkaXNjdXNzaW5nIHByaXZpbGVnZWQgbWF0ZXJpYWwgKGxhd3llcuKAk2NsaWVudCBpbnRlcmNlcHRzKSBvdXRzaWRlIHRoZSBsZWdhbCByZXZpZXcgbG9vcCBpcyBhIGN1c3RvZHkgYnJlYWNoLiBJc2FiZWwgUm9tZXJvIG11c3QgY2xlYXIgaXQgZmlyc3QuIiwgImNvbG9yIjogIuKaoO+4jyJ9LCB7ImlkIjogImF3LTQ4IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiMyDCtyBEYXRhIGh5Z2llbmUifSwgeyJpZCI6ICJhdy00OSIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJUaGUgbGl2ZSB0YWJsZXMgYXJlIHRoZSBzaW5nbGUgc291cmNlIG9mIHRydXRoIOKAlCB3aWtpIHBhZ2VzIGNpdGUgaWRzIChFVi0wMDg0LCBCSUEtMjAyNi0wMDEpLiJ9LCB7ImlkIjogImF3LTUwIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkFtb3VudHMsIGFjY291bnQgcmVmcyBhbmQgU1NOcyBhcmUgbWFza2VkIHBlciBjbGVhcmFuY2U7IGRvIG5vdCB0cmFuc2NyaWJlIHVubWFza2VkIHZhbHVlcy4ifSwgeyJpZCI6ICJhdy01MSIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJVc2UgdGhlIHdhdGNobGlzdCBtZW1vIGJlZm9yZSBhZGRpbmcgYSBuZXcgY291bnRlcnBhcnR5IGFsaWFzLiJ9LCB7ImlkIjogImF3LTUyIiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIioqSGFyZCBydWxlKiog4oCUIG5vIG9wZXJhdGlvbmFsIGRhdGEgbGVhdmVzIHRoZSB3b3Jrc3BhY2UuIEV4cG9ydHMgcmVxdWlyZSB0aGUgRGlyZWN0b3IncyB3cml0dGVuIGFwcHJvdmFsIGFuZCBhbiBlbnRyeSBpbiB0aGUgY3VzdG9keSBhdWRpdC4iLCAiY29sb3IiOiAi4p2XIn0sIHsiaWQiOiAiYXctNTMiLCAidHlwZSI6ICJkaXZpZGVyIiwgImNvbnRlbnQiOiAiIn0sIHsiaWQiOiAiYXctNTQiLCAidHlwZSI6ICJxdW90ZSIsICJjb250ZW50IjogIlNsb3cgaXMgc21vb3RoLCBzbW9vdGggaXMgZmFzdC4ifV0=','base64'),'utf8')::jsonb, now() - interval '28 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('bc41efa6-83ba-50a9-a086-c71554f7b9dc', 'b1a0c1e5-0000-4000-a000-000000000001', '248cfe48-81c6-51f9-92ca-61a049a6ad0a', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'Evidence Custody Protocol', '🔐', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctNTUiLCAidHlwZSI6ICJoZWFkaW5nXzEiLCAiY29udGVudCI6ICJFdmlkZW5jZSBDdXN0b2R5IFByb3RvY29sIn0sIHsiaWQiOiAiYXctNTYiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiKipXaHkgdGhpcyBleGlzdHMqKiDigJQgYSBzaW5nbGUgYnJva2VuIGN1c3RvZHkgbGluayB2b2lkcyBhbiBleGhpYml0LiBUaGUgcHJvdG9jb2wgYmVsb3cgaXMgdGhlIG1pbmltdW07IGZvcmVuc2ljcyBtYXkgYWRkIHN0cmljdGVyIGhhbmRsaW5nIHBlciBpdGVtLiIsICJjb2xvciI6ICLwn5SQIn0sIHsiaWQiOiAiYXctNTciLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJDdXN0b2R5IGNoYWluIOKAlCB0aGUgZml2ZSBtYW5kYXRvcnkgZW50cmllcyJ9LCB7ImlkIjogImF3LTU4IiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIioqQ29sbGVjdGlvbioqIOKAlCBjb2xsZWN0aW5nIG9mZmljZXIsIHRpbWVzdGFtcCwgbG9jYXRpb24gaWQsIG1ldGhvZCAoZS5nLiDigJxyZWNvcmRzIHJlcXVlc3TigJ0pLiJ9LCB7ImlkIjogImF3LTU5IiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIioqU2VhbGluZyoqIOKAlCB0YW1wZXItZXZpZGVudCBiYWcgaWQgKyB0aGUgQklBLUVWLU5OTk4gbGFiZWw7IHBob3RvZ3JhcGggdGhlIHNlYWwuIn0sIHsiaWQiOiAiYXctNjAiLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiKipUcmFuc3BvcnQqKiDigJQgY291cmllciArIHJvdXRlOyBoYW5kLW9mZnMgYXJlIGxvZ2dlZCBhdCBib3RoIGVuZHMuIn0sIHsiaWQiOiAiYXctNjEiLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiKipTdG9yYWdlKiog4oCUIHZhdWx0IGxvY2F0aW9uIGlkIGZyb20gdGhlIGBsb2NhdGlvbnNgIHRhYmxlOyBhY2Nlc3MgbGlzdCBzbmFwc2hvdC4ifSwgeyJpZCI6ICJhdy02MiIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICIqKlZlcmlmaWNhdGlvbioqIOKAlCBpbnRlZ3JpdHkgY2hlY2sgKGhhc2ggZm9yIGRpZ2l0YWwgbWVkaWEsIHNlYWwgYXVkaXQgZm9yIHBoeXNpY2FsKS4ifSwgeyJpZCI6ICJhdy02MyIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIlZlcmlmaWNhdGlvbiBzdGF0ZXMifSwgeyJpZCI6ICJhdy02NCIsICJ0eXBlIjogInRhYmxlX2Jsb2NrIiwgImNvbnRlbnQiOiAiIiwgInRhYmxlRGF0YSI6IFtbIlN0YXRlIiwgIk1lYW5pbmciLCAiQWN0aW9uIl0sIFsidmVyaWZpZWQg4pyFIiwgInNlYWwvaGFzaCBpbnRhY3QgYXQgbGFzdCBhdWRpdCIsICJub25lIl0sIFsicGVuZGluZyDwn5WQIiwgIm5ldyBpdGVtLCBmaXJzdCBhdWRpdCBub3QgcnVuIiwgImF1ZGl0IHdpdGhpbiA3MmgiXSwgWyJmYWlsZWQg4p2MIiwgInNlYWwgYnJva2VuIG9yIGhhc2ggbWlzbWF0Y2giLCAicXVhcmFudGluZSArIGZvcmVuc2ljIHJldmlldyArIG1lbW8gdG8gbGVnYWwiXV0sICJ0YWJsZUNvbmZpZyI6IHsiaGVhZGVyUm93IjogdHJ1ZSwgInNob3dCb3JkZXJzIjogdHJ1ZSwgInN0cmlwZWRSb3dzIjogdHJ1ZX19LCB7ImlkIjogImF3LTY1IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIioqTGl2ZSBhbGVydCoqIOKAlCBFVi0wMDU1IChzdXJ2ZWlsbGFuY2UgdGFwZSwgT3BlcmF0aW9uIFJlZCBNZXJpZGlhbikgZmFpbGVkIGl0cyBsYXN0IGludGVncml0eSB2ZXJpZmljYXRpb24uIEl0IGlzIHF1YXJhbnRpbmVkOyBzZWUgdGhlIEN1c3RvZHkgQXVkaXQg4oCUIFEyIHBhZ2UuIiwgImNvbG9yIjogIuKdjCJ9LCB7ImlkIjogImF3LTY2IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiRGlnaXRhbCBtZWRpYSBzcGVjaWZpY3MifSwgeyJpZCI6ICJhdy02NyIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJJbWFnZSBmaXJzdCwgYW5hbHlzZSB0aGUgY29weSDigJQgb3JpZ2luYWxzIGFyZSB3cml0ZS1ibG9ja2VkIGFuZCB2YXVsdGVkLiJ9LCB7ImlkIjogImF3LTY4IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkhhc2hlcyAoU0hBLTI1NikgcmVjb3JkZWQgYXQgY29sbGVjdGlvbiBhbmQgYXQgZXZlcnkgdHJhbnNmZXIuIn0sIHsiaWQiOiAiYXctNjkiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiUGhvbmUgZHVtcHMgYW5kIGhhcmQgZHJpdmVzIGdldCBhIGZvcmVuc2ljcyB0aWNrZXQgYmVmb3JlIGFueSBhbmFseXN0IHRvdWNoZXMgdGhlbS4ifSwgeyJpZCI6ICJhdy03NCIsICJ0eXBlIjogInRvZ2dsZSIsICJjb250ZW50IjogIuKWuCBDb21tb24gY3VzdG9keSBtaXN0YWtlcyAocmVhZCB0aGlzIHR3aWNlKSIsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTcwIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkxvZ2dpbmcgdHJhbnNwb3J0IGJ1dCBub3QgdGhlIGhhbmQtb2ZmIGF0IHRoZSByZWNlaXZpbmcgZW5kLiJ9LCB7ImlkIjogImF3LTcxIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIlJlLXNlYWxpbmcgd2l0aCBhIG5ldyBiYWcgaWQgd2l0aG91dCBjcm9zcy1yZWZlcmVuY2luZyB0aGUgb2xkIG9uZS4ifSwgeyJpZCI6ICJhdy03MiIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJBbmFseXN0cyB3b3JraW5nIG9uIG9yaWdpbmFscyBiZWNhdXNlIOKAnHRoZSBjb3B5IHdhcyBzbG934oCdLiJ9LCB7ImlkIjogImF3LTczIiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIkV2ZXJ5IG9uZSBvZiB0aGVzZSBoYXMgY29zdCBhbiBhZ2VuY3kgYSBjYXNlLiBOb3Qgb3Vycywgbm90IGFnYWluLiIsICJjb2xvciI6ICLimqDvuI8ifV0sICJjb2xsYXBzZWQiOiB0cnVlfSwgeyJpZCI6ICJhdy03NSIsICJ0eXBlIjogImRpdmlkZXIiLCAiY29udGVudCI6ICIifSwgeyJpZCI6ICJhdy03NiIsICJ0eXBlIjogInF1b3RlIiwgImNvbnRlbnQiOiAiQ3VzdG9keSBpcyBhIGNoYWluLiBDaGFpbnMgaGF2ZSBsaW5rcy4gTGlua3MgaGF2ZSBuYW1lcyBvbiB0aGVtLiDigJQgSC4gV2Vpc3MifV0=','base64'),'utf8')::jsonb, now() - interval '27 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('c1456f85-3143-573d-8244-56b649d43080', 'b1a0c1e5-0000-4000-a000-000000000001', '248cfe48-81c6-51f9-92ca-61a049a6ad0a', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Clearance Levels & Access Policy', '🛂', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctNzciLCAidHlwZSI6ICJoZWFkaW5nXzEiLCAiY29udGVudCI6ICJDbGVhcmFuY2UgTGV2ZWxzICYgQWNjZXNzIFBvbGljeSJ9LCB7ImlkIjogImF3LTc4IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIioqTW9kZWwqKiDigJQgYXR0cmlidXRlLWJhc2VkIGFjY2VzcyBjb250cm9sIChBQkFDKS4gQSBkZWNpc2lvbiBjb21iaW5lcyB5b3VyICoqcm9sZSoqLCAqKmRlcGFydG1lbnQqKiwgKipjbGVhcmFuY2UgbGV2ZWwgKDHigJM1KSoqIGFuZCAqKnJlZ2lvbioqIGFnYWluc3QgdGhlIHNlbnNpdGl2aXR5IG9mIGVhY2ggdGFibGUsIHJvdyBhbmQgY29sdW1uLiIsICJjb2xvciI6ICLwn5uCIn0sIHsiaWQiOiAiYXctNzkiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJDbGVhcmFuY2UgbGFkZGVyIn0sIHsiaWQiOiAiYXctODAiLCAidHlwZSI6ICJ0YWJsZV9ibG9jayIsICJjb250ZW50IjogIiIsICJ0YWJsZURhdGEiOiBbWyJMZXZlbCIsICJXaG8iLCAiV2hhdCBpdCB1bmxvY2tzIl0sIFsiNSIsICJEaXJlY3RvciwgRGVwdXR5IERpcmVjdG9yIiwgImV2ZXJ5dGhpbmcsIGluY2x1ZGluZyBidWRnZXRzIGFuZCBpbmZvcm1hbnQgaWRlbnRpdGllcyJdLCBbIjQiLCAiQ2FzZSBtYW5hZ2Vycywgc2VuaW9yIGludmVzdGlnYXRvcnMsIElUIGFkbWluIiwgImZ1bGwgY2FzZSByb3dzLCB1bm1hc2tlZCB0cmFuc2FjdGlvbnMiXSwgWyIzIiwgIkFuYWx5c3RzLCBmb3JlbnNpY3MsIGxlZ2FsLCBmaW5hbmNlIiwgImNhc2Ugcm93cywgbWFza2VkIGFtb3VudHMvU1NOcywgbm8gaW5mb3JtYW50cyJdLCBbIjIiLCAiRmllbGQgYWdlbnRzLCBzdXJ2ZWlsbGFuY2UiLCAib3duLWNhc2Ugcm93cywgbG9jYXRpb24gKyB2ZWhpY2xlIGRhdGEsIG5vIGZpbmFuY2lhbHMiXV0sICJ0YWJsZUNvbmZpZyI6IHsiaGVhZGVyUm93IjogdHJ1ZSwgInNob3dCb3JkZXJzIjogdHJ1ZSwgInN0cmlwZWRSb3dzIjogdHJ1ZX19LCB7ImlkIjogImF3LTgxIiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiQ29sdW1uIG1hc2tzIGluIHByYWN0aWNlIn0sIHsiaWQiOiAiYXctODIiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipBbmFseXN0cyAoY2xlYXJhbmNlIDMpKiogc2VlIHRyYW5zYWN0aW9uICpwYXR0ZXJucyogYnV0IHJlZGFjdGVkIGV4YWN0IGFtb3VudHMgYWJvdmUgdGhlIHJlcG9ydGluZyB0aHJlc2hvbGQg4oCUIGVub3VnaCB0byBmbGFnIHN0cnVjdHVyaW5nLCBub3QgZW5vdWdoIHRvIGxlYWsgYmFsYW5jZXMuIn0sIHsiaWQiOiAiYXctODMiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipGaWVsZCBhZ2VudHMqKiBzZWUgc3ViamVjdHMgYW5kIGxvY2F0aW9ucyBmb3IgdGhlaXIgYXNzaWdubWVudHM7IGFjY291bnQgcmVmcyByZW5kZXIgYXMgYOKAouKAouKAouKAomAuIn0sIHsiaWQiOiAiYXctODQiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipMZWdhbCoqIHNlZXMgcHJpdmlsZWdlZCBjb21tdW5pY2F0aW9uczsgbm9ib2R5IGVsc2UgZG9lcywgaW5jbHVkaW5nIGNvbW1hbmQsIHVudGlsIHJlbGVhc2VkLiJ9LCB7ImlkIjogImF3LTg1IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIioqRmluYW5jZSoqIHNlZXMgYWdncmVnYXRlcyAoc3VtcywgY291bnRzIGJ5IGNvdW50ZXJwYXJ0eSkgd2l0aG91dCBwZXItcm93IGRyaWxsLWRvd24uIn0sIHsiaWQiOiAiYXctODYiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiSWYgYSBjZWxsIHJlbmRlcnMgYXMgYOKAouKAouKAouKAomAgb3IgYSByb3cgeW91IGV4cGVjdCBpcyBtaXNzaW5nLCB0aGF0IGlzIHRoZSBwb2xpY3kgZW5naW5lIHdvcmtpbmcg4oCUIG5vdCBhIGJ1Zy4gUmVxdWVzdCBlbGV2YXRpb24gdmlhIHlvdXIgY2FzZSBtYW5hZ2VyLiIsICJjb2xvciI6ICLwn5KhIn0sIHsiaWQiOiAiYXctODciLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJSb3cgc2NvcGluZyJ9LCB7ImlkIjogImF3LTg4IiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIlJlZ2lvbiBmaXJzdCDigJQgQVBBQyBzdGFmZiBzZWUgQVBBQy10YWdnZWQgcm93cyBwbHVzIGdsb2JhbCByb3dzLiJ9LCB7ImlkIjogImF3LTg5IiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIkRlcGFydG1lbnQgc2Vjb25kIOKAlCBzdXJ2ZWlsbGFuY2Ugc2VlcyBgY29tbXVuaWNhdGlvbnNgIGFuZCBgdmVoaWNsZXNgLCBub3QgYHRyYW5zYWN0aW9uc2AuIn0sIHsiaWQiOiAiYXctOTAiLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiQXNzaWdubWVudCBsYXN0IOKAlCBjbGVhcmFuY2UgMiByZXF1aXJlcyBhbiBgYXNzaWdubWVudHNgIHJvdyBsaW5raW5nIHlvdSB0byB0aGUgY2FzZS4ifSwgeyJpZCI6ICJhdy05MSIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJTaGFyaW5nIGEgc2NyZWVuc2hvdCBvZiBkYXRhIHRoZSByZWNpcGllbnQgY291bGQgbm90IHF1ZXJ5IHRoZW1zZWx2ZXMgaXMgdHJlYXRlZCBhcyBhbiBhY2Nlc3MgdmlvbGF0aW9uLiBUaGUgbWFzayBmb2xsb3dzIHRoZSBkYXRhLCBub3QgdGhlIHBpeGVscy4iLCAiY29sb3IiOiAi4pqg77iPIn0sIHsiaWQiOiAiYXctOTIiLCAidHlwZSI6ICJkaXZpZGVyIiwgImNvbnRlbnQiOiAiIn0sIHsiaWQiOiAiYXctOTMiLCAidHlwZSI6ICJxdW90ZSIsICJjb250ZW50IjogIk5lZWQtdG8ta25vdyBpcyBub3QgZ2F0ZWtlZXBpbmc7IGl0IGlzIGhvdyB3ZSBwcm90ZWN0IHNvdXJjZXMuIOKAlCBNLiBSZWVkIn1d','base64'),'utf8')::jsonb, now() - interval '26 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('3167066d-e946-5535-9034-78615895a487', 'b1a0c1e5-0000-4000-a000-000000000001', '248cfe48-81c6-51f9-92ca-61a049a6ad0a', '9946155f-cfcb-4ad4-99a4-6b5ed49ad24c', 'Onboarding Checklist', '✅', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctOTQiLCAidHlwZSI6ICJoZWFkaW5nXzEiLCAiY29udGVudCI6ICJPbmJvYXJkaW5nIENoZWNrbGlzdCJ9LCB7ImlkIjogImF3LTk1IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIldlbGNvbWUgYWJvYXJkLiBXb3JrIHRocm91Z2ggdGhpcyB0b3AgdG8gYm90dG9tIGR1cmluZyB3ZWVrIG9uZS4gWW91ciBidWRkeSBpcyB5b3VyIGRlcGFydG1lbnQncyBzZW5pb3Ig4oCUIHBpbmcgdGhlbSBpbiAjZ2VuZXJhbCBpZiBhbnl0aGluZyBpcyB1bmNsZWFyLiIsICJjb2xvciI6ICLwn5GLIn0sIHsiaWQiOiAiYXctOTYiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJEYXkgMSDigJQgYWNjZXNzIn0sIHsiaWQiOiAiYXctOTciLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIkxvZyBpbiB0byB0aGUgd29ya3NwYWNlIGFuZCBzZXQgeW91ciBwcm9maWxlIChwaG90bywgcm9sZSwgcmVnaW9uKS4iLCAiY2hlY2tlZCI6IHRydWV9LCB7ImlkIjogImF3LTk4IiwgInR5cGUiOiAidG9fZG8iLCAiY29udGVudCI6ICJWZXJpZnkgeW91ciBjbGVhcmFuY2UgbGV2ZWwgcmVuZGVycyBjb3JyZWN0bHkgb24gdGhlIE1pc3Npb24gQ29udHJvbCBsaXZlIGJvYXJkLiIsICJjaGVja2VkIjogdHJ1ZX0sIHsiaWQiOiAiYXctOTkiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIkpvaW4geW91ciBjaGFubmVsczogI2dlbmVyYWwgcGx1cyB5b3VyIGRlcGFydG1lbnQgY2hhbm5lbC4iLCAiY2hlY2tlZCI6IHRydWV9LCB7ImlkIjogImF3LTEwMCIsICJ0eXBlIjogInRvX2RvIiwgImNvbnRlbnQiOiAiUmVhZCB0aGUgU3RhbmRhcmQgT3BlcmF0aW5nIFByb2NlZHVyZXMgZW5kIHRvIGVuZC4iLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0xMDEiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJXZWVrIDEg4oCUIGNvbnRleHQifSwgeyJpZCI6ICJhdy0xMDIiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIlJlYWQgdGhlIENsZWFyYW5jZSBMZXZlbHMgJiBBY2Nlc3MgUG9saWN5IGFuZCBjb25maXJtIHlvdXIgbWFza3Mgd2l0aCBJVCAoTWF5YSkuIiwgImNoZWNrZWQiOiBmYWxzZX0sIHsiaWQiOiAiYXctMTAzIiwgInR5cGUiOiAidG9fZG8iLCAiY29udGVudCI6ICJTaGFkb3cgb25lIGN1c3RvZHkgaGFuZC1vZmYgd2l0aCBmb3JlbnNpY3MgKEhhbm5haCBvciBPbWFyKS4iLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0xMDQiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIlJlYWQgdGhlIGNhc2Ugd2lraSBvZiBldmVyeSBvcGVyYXRpb24geW91IGFyZSBhc3NpZ25lZCB0by4iLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0xMDUiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIkFkZCB5b3Vyc2VsZiB0byB0aGUgb24tY2FsbCByb3RhIGlmIHlvdSBhcmUgZmllbGQgb3Igc3VydmVpbGxhbmNlLiIsICJjaGVja2VkIjogZmFsc2V9LCB7ImlkIjogImF3LTEwNiIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkJlZm9yZSB5b3VyIGZpcnN0IHNvbG8gdGFzayJ9LCB7ImlkIjogImF3LTEwNyIsICJ0eXBlIjogInRvX2RvIiwgImNvbnRlbnQiOiAiUGFzcyB0aGUgY3VzdG9keSBxdWl6IChmb3JlbnNpY3MgcnVucyBpdCBGcmlkYXlzKS4iLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0xMDgiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIkZpbGUgYSBwcmFjdGljZSBjb250YWN0IHJlcG9ydCBhbmQgaGF2ZSBpdCByZXZpZXdlZCBieSB5b3VyIGNhc2UgbWFuYWdlci4iLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0xMDkiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIkNvbmZpcm0geW91IGNhbiByZWFjaCB0aGUgV2FyIFJvb20gdmlkZW8gY2hhbm5lbCBmcm9tIHlvdXIga2l0LiIsICJjaGVja2VkIjogZmFsc2V9LCB7ImlkIjogImF3LTExMCIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJXaGVuIGV2ZXJ5IGJveCBhYm92ZSBpcyB0aWNrZWQsIHlvdXIgY2FzZSBtYW5hZ2VyIGZsaXBzIHlvdXIgc3RhdHVzIHRvIG9wZXJhdGlvbmFsLiIsICJjb2xvciI6ICLinIUifV0=','base64'),'utf8')::jsonb, now() - interval '25 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('a9620464-9708-5626-b72f-083f64a8ecde', 'b1a0c1e5-0000-4000-a000-000000000001', '248cfe48-81c6-51f9-92ca-61a049a6ad0a', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Field Operations Guide', '🕶️', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMTExIiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiRmllbGQgT3BlcmF0aW9ucyBHdWlkZSJ9LCB7ImlkIjogImF3LTExMiIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJHdWlkYW5jZSwgbm90IGxhdyDigJQgYnV0IGRldmlhdGluZyB3aXRob3V0IHRlbGxpbmcgeW91ciBjYXNlIG1hbmFnZXIgKmlzKiBhIHZpb2xhdGlvbiBvZiB0aGUgU09QLiBTdGF5IGJvcmluZywgc3RheSBpbnZpc2libGUuIiwgImNvbG9yIjogIvCflbbvuI8ifSwgeyJpZCI6ICJhdy0xMTMiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJTdXJ2ZWlsbGFuY2UgZGlzY2lwbGluZSJ9LCB7ImlkIjogImF3LTExNCIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJUd28tcGVyc29uIG1pbmltdW0gb24gc3RhdGljIG9ic2VydmF0aW9uOyByb3RhdGUgZXllcyBldmVyeSA0MCBtaW51dGVzLiJ9LCB7ImlkIjogImF3LTExNSIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJMb2cgcGxhdGUgc2lnaHRpbmdzIGltbWVkaWF0ZWx5IOKAlCB0aGUgc2Vjb25kLWxvY2F0aW9uIG1hdGNoIG9uIE9wZXJhdGlvbiBSZWQgTWVyaWRpYW4gY2FtZSBmcm9tIGEgMzAtc2Vjb25kIGxvZyBlbnRyeSAobGVhZCBMLTAwNzkpLiJ9LCB7ImlkIjogImF3LTExNiIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJSYWRpbyBjaGVja3Mgb24gdGhlIHF1YXJ0ZXIgaG91cjsgbWlzc2VkIGNoZWNrID0gYWJvcnQgY3JpdGVyaWEgYWZ0ZXIgMzAgbWludXRlcy4ifSwgeyJpZCI6ICJhdy0xMjAiLCAidHlwZSI6ICJ0b2dnbGUiLCAiY29udGVudCI6ICLilrggU2NlbmFyaW86IHN1YmplY3QgbWVldHMgdW5rbm93biBwYXJ0eSAobGlrZSB0aGUgbWFyaW5hIGNvbnRhY3QsIEwtMDAyMikiLCAiY2hpbGRyZW4iOiBbeyJpZCI6ICJhdy0xMTciLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiRG8gbm90IGFwcHJvYWNoLiBQaG90b2dyYXBoIGNvbnRleHQsIG5vdCBmYWNlcywgaWYgZXhwb3N1cmUgcmlzayBpcyBoaWdoLiJ9LCB7ImlkIjogImF3LTExOCIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICJMb2cgdGltZSwgbG9jYXRpb24gaWQsIGFuZCBhIG9uZS1saW5lIGRlc2NyaXB0aW9uIGluIGBsZWFkc2AuIn0sIHsiaWQiOiAiYXctMTE5IiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIlRhZyB0aGUgY2FzZSBhbmFseXN0IGluICNjYXNlLW9wcyB3aXRoaW4gdGhlIGhvdXIuIn1dLCAiY29sbGFwc2VkIjogdHJ1ZX0sIHsiaWQiOiAiYXctMTI0IiwgInR5cGUiOiAidG9nZ2xlIiwgImNvbnRlbnQiOiAi4pa4IFNjZW5hcmlvOiBjYXNoIGNvdXJpZXIgcGF0dGVybiAoRnJpZGF5IHJ1bnMsIEwtMDAzMikiLCAiY2hpbGRyZW4iOiBbeyJpZCI6ICJhdy0xMjEiLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiQ29uZmlybSB0aGUgcGF0dGVybiBhY3Jvc3MgdGhyZWUgY3ljbGVzIGJlZm9yZSByZXF1ZXN0aW5nIGFuIGludGVyY2VwdC4ifSwgeyJpZCI6ICJhdy0xMjIiLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiQ29vcmRpbmF0ZSB3aXRoIGZpbmFuY2UgKFJvYmVydCkgc28gdGhlIGFtb3VudHMgbGluZSB1cCB3aXRoIHRoZSB0eG4gcmVjb3JkLiJ9LCB7ImlkIjogImF3LTEyMyIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICJOZXZlciBzZWl6ZSBvbiBwYXR0ZXJuIGFsb25lIOKAlCB3YWl0IGZvciB0aGUgd2FycmFudCBwYWNrYWdlIGZyb20gbGVnYWwuIn1dLCAiY29sbGFwc2VkIjogdHJ1ZX0sIHsiaWQiOiAiYXctMTI1IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiQXBwcm9hY2ggJiBjb250YWN0In0sIHsiaWQiOiAiYXctMTI2IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkNvdmVycyBhcmUgaXNzdWVkIGJ5IG9wZXJhdGlvbnM7IG5ldmVyIGltcHJvdmlzZSBhbiBhZmZpbGlhdGlvbi4ifSwgeyJpZCI6ICJhdy0xMjciLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiSWYgYnVybmVkLCBicmVhayBjb250YWN0LCBzaWduYWwgaW4gI2ZpZWxkIHdpdGggdGhlIHdvcmQgKipTVU5TRVQqKiwgYW5kIGhlYWQgdG8gdGhlIGZhbGxiYWNrLiJ9LCB7ImlkIjogImF3LTEyOCIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJBIGJ1cm5lZCBjb3ZlciBpcyByZWNvdmVyYWJsZS4gQSBidXJuZWQgaW5mb3JtYW50IGlzIG5vdC4iLCAiY29sb3IiOiAi4pqg77iPIn0sIHsiaWQiOiAiYXctMTI5IiwgInR5cGUiOiAiZGl2aWRlciIsICJjb250ZW50IjogIiJ9LCB7ImlkIjogImF3LTEzMCIsICJ0eXBlIjogInF1b3RlIiwgImNvbnRlbnQiOiAiVGhlIGJlc3Qgc3VydmVpbGxhbmNlIGxvZyByZWFkcyBsaWtlIHRoZSBtb3N0IGJvcmluZyBub3ZlbCBldmVyIHdyaXR0ZW4uIOKAlCBKLiBTdWxsaXZhbiJ9XQ==','base64'),'utf8')::jsonb, now() - interval '24 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('aa16b202-afb1-56b1-936e-26ecafb1ec0a', 'b1a0c1e5-0000-4000-a000-000000000001', '248cfe48-81c6-51f9-92ca-61a049a6ad0a', 'e100fa34-6da5-4212-863b-55d041585e96', 'Report Writing Standards', '⚖️', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMTMxIiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiUmVwb3J0IFdyaXRpbmcgU3RhbmRhcmRzIn0sIHsiaWQiOiAiYXctMTMyIiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIlJlcG9ydHMgb3V0bGl2ZSBvcGVyYXRpb25zLiBXcml0ZSBldmVyeSBzZW50ZW5jZSBhcyBpZiBvcHBvc2luZyBjb3Vuc2VsIHdpbGwgcmVhZCBpdCBhbG91ZCwgc2xvd2x5LCB0byBhIGp1cnkuIiwgImNvbG9yIjogIuKalu+4jyJ9LCB7ImlkIjogImF3LTEzMyIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIlN0cnVjdHVyZSJ9LCB7ImlkIjogImF3LTEzNCIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICIqKkhlYWRlcioqIOKAlCBjYXNlIGNvZGUsIHJlcG9ydCBpZCwgYXV0aG9yLCBkYXRlLCBjbGFzc2lmaWNhdGlvbi4ifSwgeyJpZCI6ICJhdy0xMzUiLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiKipTdW1tYXJ5Kiog4oCUIHRocmVlIHNlbnRlbmNlcyBtYXg7IGZpbmRpbmdzLCBub3QgbmFycmF0aXZlLiJ9LCB7ImlkIjogImF3LTEzNiIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICIqKkZhY3RzKiog4oCUIG51bWJlcmVkIG9ic2VydmF0aW9ucywgZWFjaCBjaXRpbmcgaXRzIHNvdXJjZSByb3cgKEVWL1RYTi9DT01NIGlkKS4ifSwgeyJpZCI6ICJhdy0xMzciLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiKipBbmFseXNpcyoqIOKAlCBjbGVhcmx5IHNlcGFyYXRlZCBmcm9tIGZhY3RzOyBoZWRnZSB3aXRoIGNhbGlicmF0ZWQgbGFuZ3VhZ2UuIn0sIHsiaWQiOiAiYXctMTM4IiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIioqQW5uZXhlcyoqIOKAlCBleGhpYml0IGxpc3Qgd2l0aCBjdXN0b2R5IHJlZmVyZW5jZXMuIn0sIHsiaWQiOiAiYXctMTM5IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiTGFuZ3VhZ2UgcnVsZXMifSwgeyJpZCI6ICJhdy0xNDAiLCAidHlwZSI6ICJ0YWJsZV9ibG9jayIsICJjb250ZW50IjogIiIsICJ0YWJsZURhdGEiOiBbWyJXcml0ZSIsICJOb3QiLCAiV2h5Il0sIFsi4oCcY29uc2lzdGVudCB3aXRo4oCdIiwgIuKAnHByb3Zlc+KAnSIsICJhbmFseXNpcyBpcyBpbmZlcmVuY2UsIG5vdCB2ZXJkaWN0Il0sIFsi4oCcc3ViamVjdCBzdGF0ZWTigJ0iLCAi4oCcc3ViamVjdCBhZG1pdHRlZOKAnSIsICJjaGFyYWN0ZXJpc2F0aW9uIGlzIHRoZSBjb3VydCdzIGpvYiJdLCBbIuKAnEVWLTAwODQgKGxlZGdlcinigJ0iLCAi4oCcdGhlIGxlZGdlciB3ZSBmb3VuZOKAnSIsICJleGhpYml0cyBhcmUgY2l0ZWQgYnkgaWQiXSwgWyLigJxhc3Nlc3NlZCBhcyBsaWtlbHnigJ0iLCAi4oCcb2J2aW91c2x54oCdIiwgImNhbGlicmF0ZWQgY29uZmlkZW5jZSBzdXJ2aXZlcyBjcm9zcyJdXSwgInRhYmxlQ29uZmlnIjogeyJoZWFkZXJSb3ciOiB0cnVlLCAic2hvd0JvcmRlcnMiOiB0cnVlLCAic3RyaXBlZFJvd3MiOiB0cnVlfX0sIHsiaWQiOiAiYXctMTQxIiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIlByaXZpbGVnZWQgbWF0ZXJpYWwgKGxhd3llcuKAk2NsaWVudCBjb21tcykgaXMgbmV2ZXIgcXVvdGVkIGluIGEgcmVwb3J0IGJvZHkg4oCUIHJlZmVyZW5jZSB0aGUgcm93IGlkIGFuZCByb3V0ZSB0aHJvdWdoIGxlZ2FsIHJldmlldy4iLCAiY29sb3IiOiAi4p2XIn0sIHsiaWQiOiAiYXctMTQyIiwgInR5cGUiOiAicXVvdGUiLCAiY29udGVudCI6ICJJZiBpdCBpcyBub3QgaW4gdGhlIHJlY29yZCwgaXQgZGlkIG5vdCBoYXBwZW4uIn1d','base64'),'utf8')::jsonb, now() - interval '23 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('da038f81-9156-5d98-9398-9e5978ab1840', 'b1a0c1e5-0000-4000-a000-000000000001', NULL, '9878850d-3024-4012-b03a-d3d7ccef377c', '🗂️ Case Wikis', '🗂️', 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMTQzIiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiQ2FzZSBXaWtpcyJ9LCB7ImlkIjogImF3LTE0NCIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJPbmUgd2lraSBwZXIgb3BlcmF0aW9uLiBUaGUgd2lraSBpcyB0aGUgKm5hcnJhdGl2ZSogbGF5ZXIg4oCUIGNvbnRleHQsIGh5cG90aGVzZXMsIGRlY2lzaW9ucyDigJQgd2hpbGUgdGhlIGxpdmUgdGFibGVzIHN0YXkgdGhlIGZhY3R1YWwgcmVjb3JkLiBLZWVwIGlkcyBpbiBldmVyeSBjbGFpbS4iLCAiY29sb3IiOiAi8J+Xgu+4jyJ9LCB7ImlkIjogImF3LTE0NSIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkxpdmUgY2FzZSByZWdpc3RlciJ9LCB7ImlkIjogImF3LTE0NiIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkVtYmVkZGVkIHZpZXcgb2YgdGhlIGxpdmUgYGNhc2VzYCB0YWJsZS4gT3BlbiBhbiBvcGVyYXRpb24ncyB3aWtpIGJlbG93IGZvciB0aGUgd29ya2luZyBub3Rlcy4ifSwgeyJpZCI6ICJhdy0xNDciLCAidHlwZSI6ICJkYXRhYmFzZV9pbmxpbmUiLCAiY29udGVudCI6ICIiLCAiZGF0YWJhc2VJZCI6ICJiYWFzOmQzZWNiM2UxLTk5NDctNDFhNi1hMGQzLWZmMjA2M2I0YWRlZTpjYXNlcyJ9LCB7ImlkIjogImF3LTE0OCIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkZlYXR1cmVkIG9wZXJhdGlvbnMifSwgeyJpZCI6ICJhdy0xNDkiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiT3BlcmF0aW9uIE5pZ2h0ZmFsbCAoQklBLTIwMjYtMDAxKSDigJQgY3J5cHRvLW1peGVyIGxhdW5kZXJpbmcsIG91ciBuZXdlc3Qgb3BlbiBjYXNlLiJ9LCB7ImlkIjogImF3LTE1MCIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJPcGVyYXRpb24gQ29iYWx0IExlZGdlciAoQklBLTIwMjUtMDAyKSDigJQgY2xvc2VkOyBmcmVlcG9ydCB2YXVsdCBjb25jZWFsbWVudCwgZnVsbCByZXRyby4ifSwgeyJpZCI6ICJhdy0xNTEiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiT3BlcmF0aW9uIFJlZCBNZXJpZGlhbiAoQklBLTIwMjUtMDA4KSDigJQgaW4gcmV2aWV3OyBhcnQtbWFya2V0IGZyYXVkLCBjdXN0b2R5IGFsZXJ0LiJ9LCB7ImlkIjogImF3LTE1MiIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJPcGVyYXRpb24gUXVpZXQgU3Rvcm0gKEJJQS0yMDI1LTAxMikg4oCUIGFuYWx5c2lzOyBwcm9jdXJlbWVudCBmcmF1ZCB2aWEgZnJlZXBvcnQuIn0sIHsiaWQiOiAiYXctMTUzIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIk9wZXJhdGlvbiBBc2ggR2FyZGVuIChCSUEtMjAyNS0wMTQpIOKAlCBhY3RpdmUgc3VydmVpbGxhbmNlOyBzaGVsbC1jb21wYW55IGxhdW5kZXJpbmcuIn0sIHsiaWQiOiAiYXctMTU0IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIk9wZXJhdGlvbiBCcm9rZW4gQ29tcGFzcyAoQklBLTIwMjUtMDE2KSDigJQgb3BlbjsgYXNzZXQgY29uY2VhbG1lbnQgdGhyb3VnaCBtaXhlcnMuIn1d','base64'),'utf8')::jsonb, now() - interval '22 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('0123ae7a-c7c3-587e-a3c5-758e8a5a43b1', 'b1a0c1e5-0000-4000-a000-000000000001', 'da038f81-9156-5d98-9398-9e5978ab1840', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Operation Nightfall — BIA-2026-001', '🌒', 'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyIxNDY1NWJhYy1jN2EyLTUxNjQtOGFkZi00YWY3MjkzOGMwOTIiLCAiMjFjNmM1MDMtMzY0NS01MjRmLWExMjQtMTAzYjEwY2M5NjU1Il0sICJyZWxhdGlvblRhcmdldCI6ICJwYWdlIn1d','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMTU1IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiT3BlcmF0aW9uIE5pZ2h0ZmFsbCJ9LCB7ImlkIjogImF3LTE1NiIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICIqKkJJQS0yMDI2LTAwMSDCtyBPUEVOIMK3IEhJR0gqKiDigJQgc3VzcGVjdGVkIG1vbmV5IGxhdW5kZXJpbmcgdGhyb3VnaCBjcnlwdG8gbWl4ZXJzLiBMZWFkIGludmVzdGlnYXRvcjogTWFyY3VzIFJlZWQuIFByaW1hcnkgc3ViamVjdDogKipCZWF0cml6IEFsYnJlY2h0KiogKGxvZ2lzdGljcyBtYW5hZ2VyLCBJVCBuYXRpb25hbCwgcmlzazogY3JpdGljYWwpLiIsICJjb2xvciI6ICLwn4ySIiwgImhlYWRpbmdMZXZlbCI6IDN9LCB7ImlkIjogImF3LTE1NyIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIldvcmtpbmcgaHlwb3RoZXNpcyJ9LCB7ImlkIjogImF3LTE1OCIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkFsYnJlY2h0J3MgbG9naXN0aWNzIGZpcm0gYm9va3MgcGhhbnRvbSBmcmVpZ2h0LCBzZXR0bGVzIGluIFVTRFQgdGhyb3VnaCAqKkdvbGRxdWF5IEV4Y2hhbmdlKiosIGFuZCByZWN5Y2xlcyB0aGUgcHJvY2VlZHMgdmlhIG1peGVyIGhvcHMgYmVmb3JlIHRoZXkgcmVzdXJmYWNlIGFzICdjb25zdWx0aW5nIGluY29tZScgZnJvbSAqKldlc3Rwb3J0IENhcGl0YWwgU0EqKi4gVGhlIGxlZGdlciBzZWl6ZWQgaW4gTWFyY2ggKEVWLTAwODQpIHNob3dzIHBhaXJlZCBlbnRyaWVzIHRoYXQgbWF0Y2ggdGhlIGZsYWdnZWQgdHJhbnNhY3Rpb24gcGF0dGVybiBFcmlrIGRvY3VtZW50ZWQgaW4gaGlzIG5vdGVib29rLiJ9LCB7ImlkIjogImF3LTE1OSIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkV2aWRlbmNlIG9uIGhhbmQifSwgeyJpZCI6ICJhdy0xNjAiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipFVi0wMDg0Kiog4oCUIGxlZGdlciwgcmVjb3ZlcmVkIGR1cmluZyBzdXJ2ZWlsbGFuY2Ugb3AgKEFtYXJhIERpYWxsbyksIGN1c3RvZHkgbG9nIDQgZW50cmllcywgdmVyaWZpZWQuIn0sIHsiaWQiOiAiYXctMTYxIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIioqRVYtMDA4NioqIOKAlCBoYXJkIGRyaXZlLCBzdXJ2ZWlsbGFuY2Ugb3AgKEhhbm5haCBXZWlzcyksIGN1c3RvZHkgbG9nIDUgZW50cmllcywgdmVyaWZpZWQuIn0sIHsiaWQiOiAiYXctMTYyIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIioqRVYtMDA4NyoqIOKAlCBjb250cmFjdCwgc3VydmVpbGxhbmNlIG9wIChKYWNrIFN1bGxpdmFuKSwgY3VzdG9keSBsb2cgNCBlbnRyaWVzLCB2ZXJpZmllZC4ifSwgeyJpZCI6ICJhdy0xNjMiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipFVi0wMDk4Kiog4oCUIGhhcmQgZHJpdmUsIHNpdGUgdmlzaXQgKFZpa3RvciBBbnRvbm92KSwgY3VzdG9keSBsb2cgNCBlbnRyaWVzLCB2ZXJpZmllZC4ifSwgeyJpZCI6ICJhdy0xNjQiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJJbnRlcmNlcHQgb2Ygbm90ZSJ9LCB7ImlkIjogImF3LTE2NSIsICJ0eXBlIjogInF1b3RlIiwgImNvbnRlbnQiOiAiUGF5bWVudCBjb25maXJtYXRpb24g4oCUIGFtb3VudCBtYXRjaGVzIHR4biBwYXR0ZXJuLiAoQ09NTS0wMDg1LCBpbiBwZXJzb24sIHVucmVnaXN0ZXJlZCBTSU0sIGNsYXNzaWZpZWQ6IGluY3JpbWluYXRpbmcpIn0sIHsiaWQiOiAiYXctMTY2IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIkNPTU0tMDEzOSBtZW50aW9ucyBhIGZyZWVwb3J0IGNvbnRhY3QgKmJ5IGZpcnN0IG5hbWUqIGFuZCBpcyBjbGFzc2lmaWVkIHByaXZpbGVnZWQg4oCUIHJvdXRlIGFueSB1c2UgdGhyb3VnaCBJc2FiZWwgYmVmb3JlIGl0IGFwcGVhcnMgaW4gYSByZXBvcnQuIiwgImNvbG9yIjogIuKaoO+4jyJ9LCB7ImlkIjogImF3LTE2NyIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkxlYWQgdHJhY2tlciJ9LCB7ImlkIjogImF3LTE2OSIsICJ0eXBlIjogInRvZ2dsZSIsICJjb250ZW50IjogIuKWuCBMLTAwMDMg4oCUIGNhc2ggY291cmllciBvbiBGcmlkYXlzICh3aXJldGFwLCBtZWRpdW0pIOKAlCBERUFEIEVORCIsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTE2OCIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIlRocmVlIG9ic2VydmF0aW9uIGN5Y2xlcyBwcm9kdWNlZCBub3RoaW5nOyB0aGUgY291cmllciBwYXR0ZXJuIGJlbG9uZ3MgdG8gYSBsZWdpdGltYXRlIGNhc2gtaW50ZW5zaXZlIGJ1c2luZXNzIG5leHQgZG9vci4gQ2xvc2VkIDE0IE1heSwgZG8gbm90IHJlLW9wZW4gd2l0aG91dCBuZXcgc2lnbmFsLiJ9XSwgImNvbGxhcHNlZCI6IHRydWV9LCB7ImlkIjogImF3LTE3MyIsICJ0eXBlIjogInRvZ2dsZSIsICJjb250ZW50IjogIuKWuCBNaXhlciBleGl0IGNsdXN0ZXJpbmcgKGFuYWx5c2lzLCB3b3JraW5nKSIsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTE3MCIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkVyaWsncyBjbHVzdGVyaW5nIG9mIG1peGVyIGV4aXRzIHB1dHMgfjY0JSBvZiB0aGUgdHJhY2VkIHZhbHVlIHJlLWVudGVyaW5nIHRocm91Z2ggdHdvIEdvbGRxdWF5IHN1Yi1hY2NvdW50cy4gTmVlZCB0aGUgZXhjaGFuZ2UncyBLWUMgcGFja2V0IOKAlCBsZWdhbCBkcmFmdGluZyB0aGUgcmVxdWVzdC4ifSwgeyJpZCI6ICJhdy0xNzEiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIlN1YnBvZW5hIHBhY2thZ2UgZm9yIEdvbGRxdWF5IEtZQyAobGVnYWwpIiwgImNoZWNrZWQiOiBmYWxzZX0sIHsiaWQiOiAiYXctMTcyIiwgInR5cGUiOiAidG9fZG8iLCAiY29udGVudCI6ICJDcm9zcy1yZWZlcmVuY2UgRVYtMDA4NiB3YWxsZXQgYXJ0ZWZhY3RzIHdpdGggdGhlIGV4aXQgY2x1c3RlciIsICJjaGVja2VkIjogZmFsc2V9XSwgImNvbGxhcHNlZCI6IHRydWV9LCB7ImlkIjogImF3LTE3NCIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkxpbmtlZCBzdWJqZWN0cyAobGl2ZSkifSwgeyJpZCI6ICJhdy0xNzUiLCAidHlwZSI6ICJkYXRhYmFzZV9pbmxpbmUiLCAiY29udGVudCI6ICIiLCAiZGF0YWJhc2VJZCI6ICJiYWFzOmQzZWNiM2UxLTk5NDctNDFhNi1hMGQzLWZmMjA2M2I0YWRlZTpzdWJqZWN0cyJ9LCB7ImlkIjogImF3LTE3NiIsICJ0eXBlIjogImRpdmlkZXIiLCAiY29udGVudCI6ICIifSwgeyJpZCI6ICJhdy0xNzciLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiTmV4dCBzeW5jOiBXYXIgUm9vbSwgVGh1cnNkYXkgMDk6MDAg4oCUIGJyaW5nIHRoZSBHb2xkcXVheSB0aW1lbGluZS4iLCAiY29sb3IiOiAi4pyFIn1d','base64'),'utf8')::jsonb, now() - interval '21 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('d9f751ad-5813-523b-811c-2ffed905da54', 'b1a0c1e5-0000-4000-a000-000000000001', 'da038f81-9156-5d98-9398-9e5978ab1840', '7ba66e73-e7aa-4cba-a39c-8b0e43c06ec3', 'Operation Cobalt Ledger — BIA-2025-002', '🧊', 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyIwMTIzYWU3YS1jN2MzLTU4N2UtYTNjNS03NThlOGE1YTQzYjEiLCAiMjFjNmM1MDMtMzY0NS01MjRmLWExMjQtMTAzYjEwY2M5NjU1Il0sICJyZWxhdGlvblRhcmdldCI6ICJwYWdlIn1d','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMTc4IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiT3BlcmF0aW9uIENvYmFsdCBMZWRnZXIifSwgeyJpZCI6ICJhdy0xNzkiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiKipCSUEtMjAyNS0wMDIgwrcgQ0xPU0VEIMK3IENSSVRJQ0FMKiog4oCUIGFzc2V0IGNvbmNlYWxtZW50IHZpYSBhIGZyZWVwb3J0IHZhdWx0LiBMZWFkIGludmVzdGlnYXRvcjogTmFkaWEgUGV0cm92YS4gVGhpcyB3aWtpIGlzIHRoZSBjbG9zaW5nIHJldHJvc3BlY3RpdmUuIiwgImNvbG9yIjogIvCfp4oiLCAiaGVhZGluZ0xldmVsIjogM30sIHsiaWQiOiAiYXctMTgwIiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiV2hhdCBoYXBwZW5lZCJ9LCB7ImlkIjogImF3LTE4MSIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkFuIGltcG9ydC9leHBvcnQgYnJva2VyICgqKkxlbmEgRXNwb3NpdG8qKiwg4oCcRG9j4oCdKSBhbmQgYW4gYXJ0IGRlYWxlciAoKipNZWkgV2FnbmVyKiosIOKAnEJyaWNr4oCdKSBtb3ZlZCB1bmRlY2xhcmVkIGFzc2V0cyBpbnRvIGEgZnJlZXBvcnQgdmF1bHQgdXNpbmcgc2hpcHBpbmcgbWFuaWZlc3RzIHRoYXQgdW5kZXJzdGF0ZWQgY3JhdGUgY29udGVudHMuIFRoZWlyIGxhd3llciwgKipEbWl0cmkgSWdsZXNpYXMqKiwgc3RydWN0dXJlZCBvd25lcnNoaXAgdGhyb3VnaCBub21pbmVlIHBhcGVyLiBUaGUgbWFuaWZlc3Qgc2VpemVkIGF0IHRoZSBzb3VyY2UgbWVldGluZyAoRVYtMDAyNikgYnJva2UgdGhlIGNhc2Ugb3Blbi4ifSwgeyJpZCI6ICJhdy0xODIiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJIb3cgaXQgY2xvc2VkIn0sIHsiaWQiOiAiYXctMTgzIiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIkVWLTAwMjYgKHNoaXBwaW5nIG1hbmlmZXN0KSBzaG93ZWQgd2VpZ2h0L2luc3VyYW5jZSBtaXNtYXRjaGVzIGFjcm9zcyBuaW5lIHNoaXBtZW50cy4ifSwgeyJpZCI6ICJhdy0xODQiLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiVGhlIGNvbmZpcm1lZCB3aXJldGFwIGxlYWQgTC0wMDMyIOKAlCB0aGUgRnJpZGF5IGNhc2ggY291cmllciDigJQgdGllZCBFc3Bvc2l0byB0byB0aGUgdmF1bHQgY3VzdG9kaWFuLiJ9LCB7ImlkIjogImF3LTE4NSIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICJFVi0wMDk0IChwaG9uZSBkdW1wKSByZWNvdmVyZWQgZGVsZXRlZCBjb29yZGluYXRpb24gbWVzc2FnZXM7IEVWLTAxMDAgKGNvbnRyYWN0KSBlc3RhYmxpc2hlZCB0aGUgbm9taW5lZSBzdHJ1Y3R1cmUuIn0sIHsiaWQiOiAiYXctMTg2IiwgInR5cGUiOiAibnVtYmVyZWRfbGlzdCIsICJjb250ZW50IjogIlJlZmVycmFsIGFjY2VwdGVkOyBjb252aWN0aW9ucyBvbiA0IG9mIDUgY291bnRzLiBWYXVsdCBjb250ZW50cyByZXBhdHJpYXRlZC4ifSwgeyJpZCI6ICJhdy0xODciLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJJbnRlcmNlcHQgdGhhdCBtYXR0ZXJlZCJ9LCB7ImlkIjogImF3LTE4OCIsICJ0eXBlIjogInF1b3RlIiwgImNvbnRlbnQiOiAiQXJyYW5nZWQgbWVldGluZzsgbG9jYXRpb24gcmVmZXJlbmNlZCBvYmxpcXVlbHkuIChDT01NLTAxMTEsIGluIHBlcnNvbiwgV2VzdHBvcnQgQ2FwaXRhbCBTQSBjb3VudGVycGFydHkg4oCUIHRoZSAnb2JsaXF1ZScgbG9jYXRpb24gd2FzIHRoZSBmcmVlcG9ydCBtZXp6YW5pbmUuKSJ9LCB7ImlkIjogImF3LTE4OSIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkxlc3NvbnMgbGVhcm5lZCJ9LCB7ImlkIjogImF3LTIwMCIsICJ0eXBlIjogImNvbHVtbl9saXN0IiwgImNvbnRlbnQiOiAiIiwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMTk4IiwgInR5cGUiOiAiY29sdW1uIiwgImNvbnRlbnQiOiAiIiwgIndpZHRoUmF0aW8iOiAxLCAiY2hpbGRyZW4iOiBbeyJpZCI6ICJhdy0xOTAiLCAidHlwZSI6ICJoZWFkaW5nXzMiLCAiY29udGVudCI6ICLinIUgV2hhdCB3b3JrZWQifSwgeyJpZCI6ICJhdy0xOTEiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiTWFuaWZlc3QgZm9yZW5zaWNzIGJlZm9yZSBzdWJqZWN0IGludGVydmlld3Mg4oCUIHRoZXkgbmV2ZXIgc2F3IGl0IGNvbWluZy4ifSwgeyJpZCI6ICJhdy0xOTIiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiQ3VzdG9keSBkaXNjaXBsaW5lOiBhbGwgMyBleGhpYml0cyBzdXJ2aXZlZCBkZWZlbmNlIGNoYWxsZW5nZXMuIn0sIHsiaWQiOiAiYXctMTkzIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkpvaW50IGZpbmFuY2UvZmllbGQgcmV2aWV3IG9mIHRoZSBjb3VyaWVyIHBhdHRlcm4uIn1dfSwgeyJpZCI6ICJhdy0xOTkiLCAidHlwZSI6ICJjb2x1bW4iLCAiY29udGVudCI6ICIiLCAid2lkdGhSYXRpbyI6IDEsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTE5NCIsICJ0eXBlIjogImhlYWRpbmdfMyIsICJjb250ZW50IjogIuKaoO+4jyBXaGF0IHdlJ2QgY2hhbmdlIn0sIHsiaWQiOiAiYXctMTk1IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkVhcmxpZXIgbGVnYWwgcmV2aWV3IOKAlCB0aGUgbm9taW5lZSBwYXBlciB0b29rIHdlZWtzIHdlIGRpZCBub3QgaGF2ZS4ifSwgeyJpZCI6ICJhdy0xOTYiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiV2VzdHBvcnQgQ2FwaXRhbCBTQSBhcHBlYXJzIGFnYWluIGluIE5pZ2h0ZmFsbDogb3BlbiBhIHN0YW5kaW5nIHdhdGNobGlzdCBlbnRyeS4ifSwgeyJpZCI6ICJhdy0xOTciLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiVmF1bHQgQ0NUViByZXF1ZXN0IHdlbnQgb3V0IGxhdGU7IGZvb3RhZ2UgY3ljbGUgaGFkIHB1cmdlZCAxMSBkYXlzLiJ9XX1dfSwgeyJpZCI6ICJhdy0yMDEiLCAidHlwZSI6ICJkaXZpZGVyIiwgImNvbnRlbnQiOiAiIn0sIHsiaWQiOiAiYXctMjAyIiwgInR5cGUiOiAicXVvdGUiLCAiY29udGVudCI6ICJDbG9zZWQgY2FzZXMgdGVhY2ggbW9yZSB0aGFuIG9wZW4gb25lcywgaWYgeW91IGxldCB0aGVtLiDigJQgTi4gUGV0cm92YSJ9XQ==','base64'),'utf8')::jsonb, now() - interval '20 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('61b17d73-9a46-5f10-a4d3-0f9218814336', 'b1a0c1e5-0000-4000-a000-000000000001', 'da038f81-9156-5d98-9398-9e5978ab1840', '9878850d-3024-4012-b03a-d3d7ccef377c', 'Operation Red Meridian — BIA-2025-008', '🧭', 'https://images.unsplash.com/photo-1639322537228-f710d846310a?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyIyMWY1NzFkMy1hMDA2LTU1ZDgtYWM1ZS1lMjZjNWNkNWZkNmEiLCAiOGYxYWIzOGMtM2JhYy01ZjRmLTgzNTEtZDY3OWI5NWI4MDMwIl0sICJyZWxhdGlvblRhcmdldCI6ICJwYWdlIn1d','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMjAzIiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiT3BlcmF0aW9uIFJlZCBNZXJpZGlhbiJ9LCB7ImlkIjogImF3LTIwNCIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICIqKkJJQS0yMDI1LTAwOCDCtyBSRVZJRVcgwrcgQ1JJVElDQUwqKiDigJQgYXJ0LW1hcmtldCBmcmF1ZCB2aWEgdHJhZGUgbWlzLWludm9pY2luZy4gTGVhZCBpbnZlc3RpZ2F0b3I6IE1hcmN1cyBSZWVkLiBTdGF0dXM6IGV2aWRlbnRpYXJ5IHJldmlldyBiZWZvcmUgcmVmZXJyYWwuIiwgImNvbG9yIjogIvCfp60iLCAiaGVhZGluZ0xldmVsIjogM30sIHsiaWQiOiAiYXctMjA1IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiVGhlb3J5IG9mIHRoZSBjYXNlIn0sIHsiaWQiOiAiYXctMjA2IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiQ2FudmFzIHZhbHVhdGlvbnMgYXJlIGluZmxhdGVkIG9uIGV4cG9ydCBhbmQgZGVmbGF0ZWQgb24gaW1wb3J0LCB3aXRoIHRoZSBzcHJlYWQgc2V0dGxlZCBvZmZzaG9yZS4gVGhlIHN0cnVjdHVyaW5nIGxlYWRzIChMLTAwMzksIEwtMDA1OCkgc2hvdyByZXBlYXRlZCBtb3ZlbWVudHMganVzdCB1bmRlciB0aGUgcmVwb3J0aW5nIHRocmVzaG9sZCDigJQgdGV4dGJvb2sgc211cmZpbmcgYWdhaW5zdCB0aGUgYXJ0IGludm9pY2VzLiJ9LCB7ImlkIjogImF3LTIwNyIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkxlYWQgdHJhY2tlciJ9LCB7ImlkIjogImF3LTIwOSIsICJ0eXBlIjogInRvZ2dsZSIsICJjb250ZW50IjogIuKWuCBMLTAwMjIg4oCUIHN1YmplY3QgbWV0IHVua25vd24gbWFsZSBhdCB0aGUgbWFyaW5hIChvcGVuIHNvdXJjZSwgY29uZmlybWVkKSDigJQgRVNDQUxBVEVEIiwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMjA4IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiU3VydmVpbGxhbmNlIChHcmFjZSkgY29uZmlybWVkIGEgc2Vjb25kIG1lZXRpbmcgYXQgdGhlIHNhbWUgYmVydGguIENvdW50ZXJwYXJ0eSB0ZW50YXRpdmVseSBtYXRjaGVkIHRvIGEgS2VzdHJlbCBNYXJpbmUgTHRkIGNoYXJ0ZXIgbWFuYWdlci4ifV0sICJjb2xsYXBzZWQiOiB0cnVlfSwgeyJpZCI6ICJhdy0yMTEiLCAidHlwZSI6ICJ0b2dnbGUiLCAiY29udGVudCI6ICLilrggTC0wMDM5IOKAlCBzdHJ1Y3R1cmluZyB1bmRlciB0aGUgcmVwb3J0aW5nIHRocmVzaG9sZCAob3BlbiBzb3VyY2UsIGhpZ2gpIOKAlCBERUFEIEVORCIsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTIxMCIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIlRoZSBzdWItdGhyZXNob2xkIHBhdHRlcm4gdHJhY2VkIHRvIGFuIHVucmVsYXRlZCByZW1pdHRhbmNlIGNvcnJpZG9yLiBVc2VmdWwgbWV0aG9kb2xvZ3ksIHdyb25nIHN1YmplY3QuIEZpbGVkIGZvciB0aGUgYW5hbHlzdCBwbGF5Ym9vay4ifV0sICJjb2xsYXBzZWQiOiB0cnVlfSwgeyJpZCI6ICJhdy0yMTMiLCAidHlwZSI6ICJ0b2dnbGUiLCAiY29udGVudCI6ICLilrggTC0wMDQ4IOKAlCBjYXNoIGNvdXJpZXIgcnVuIG9uIEZyaWRheXMgKGJhbmsgYWxlcnQsIGNvbmZpcm1lZCkg4oCUIEVTQ0FMQVRFRCIsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTIxMiIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkJhbmsgYWxlcnQgY29ycm9ib3JhdGVkIGJ5IHR3byBmaWVsZCBjeWNsZXMuIFdhcnJhbnQgcGFja2FnZSBpbiBsZWdhbCByZXZpZXcuIn1dLCAiY29sbGFwc2VkIjogdHJ1ZX0sIHsiaWQiOiAiYXctMjE1IiwgInR5cGUiOiAidG9nZ2xlIiwgImNvbnRlbnQiOiAi4pa4IEwtMDA3OSDigJQgdmVoaWNsZSBwbGF0ZSBtYXRjaGVkIGF0IHNlY29uZCBsb2NhdGlvbiAoYW5vbnltb3VzIHRpcCkg4oCUIENPUlJPQk9SQVRFRCIsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTIxNCIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIlBsYXRlIGxvZ2dlZCBieSBUb20gYXQgbG9jYXRpb24gTC0yMSBtYXRjaGVkIFZpa3RvcidzIGVhcmxpZXIgc2lnaHRpbmcuIFRoZSB2ZWhpY2xlIGxpbmtzIG91ciBzdWJqZWN0IHRvIHRoZSBmcmVpZ2h0IGZvcndhcmRlcidzIHlhcmQuIn1dLCAiY29sbGFwc2VkIjogdHJ1ZX0sIHsiaWQiOiAiYXctMjE2IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiQ3VzdG9keSBhbGVydCJ9LCB7ImlkIjogImF3LTIxNyIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICIqKkVWLTAwNTUgKHN1cnZlaWxsYW5jZSB0YXBlKSBmYWlsZWQgaW50ZWdyaXR5IHZlcmlmaWNhdGlvbi4qKiBRdWFyYW50aW5lZDsgZm9yZW5zaWMgcmV2aWV3IHJ1bm5pbmcuIFRoZSByZWZlcnJhbCBwYWNrYWdlIG11c3QgTk9UIGNpdGUgRVYtMDA1NSB1bnRpbCBIYW5uYWggY2xlYXJzIGl0IOKAlCBzZWUgQ3VzdG9keSBBdWRpdCDigJQgUTIuIiwgImNvbG9yIjogIuKdjCJ9LCB7ImlkIjogImF3LTIxOCIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkV2aWRlbmNlIChjbGVhbikifSwgeyJpZCI6ICJhdy0yMTkiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipFVi0wMDE3Kiog4oCUIHNoaXBwaW5nIG1hbmlmZXN0IChQaWVycmUgTW9yZWF1KSwgdmVyaWZpZWQuIn0sIHsiaWQiOiAiYXctMjIwIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIioqRVYtMDAzOSoqIOKAlCBoYXJkIGRyaXZlIChHcmFjZSBMaXUpLCB2ZXJpZmllZC4ifSwgeyJpZCI6ICJhdy0yMjEiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJMaXZlIGxlYWRzIHJlZ2lzdGVyIn0sIHsiaWQiOiAiYXctMjIyIiwgInR5cGUiOiAiZGF0YWJhc2VfaW5saW5lIiwgImNvbnRlbnQiOiAiIiwgImRhdGFiYXNlSWQiOiAiYmFhczpkM2VjYjNlMS05OTQ3LTQxYTYtYTBkMy1mZjIwNjNiNGFkZWU6bGVhZHMifSwgeyJpZCI6ICJhdy0yMjMiLCAidHlwZSI6ICJkaXZpZGVyIiwgImNvbnRlbnQiOiAiIn0sIHsiaWQiOiAiYXctMjI0IiwgInR5cGUiOiAicXVvdGUiLCAiY29udGVudCI6ICJBcnJhbmdlZCBtZWV0aW5nOyBsb2NhdGlvbiByZWZlcmVuY2VkIG9ibGlxdWVseS4gKENPTU0tMDA0NCwgcGhvbmUsIHVucmVnaXN0ZXJlZCBTSU0pIn1d','base64'),'utf8')::jsonb, now() - interval '19 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('c50c3ce2-5071-532d-82fe-e44e7fe9118f', 'b1a0c1e5-0000-4000-a000-000000000001', 'da038f81-9156-5d98-9398-9e5978ab1840', '4fdd8889-ee0d-41c9-adf3-ddcc9c71955f', 'Operation Quiet Storm — BIA-2025-012', '⛈️', 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyI2YWU3MzE0OS1lOTI0LTUzODQtYmYyNi1jNGI5MjUwNzg3NTgiLCAiOGYxYWIzOGMtM2JhYy01ZjRmLTgzNTEtZDY3OWI5NWI4MDMwIl0sICJyZWxhdGlvblRhcmdldCI6ICJwYWdlIn1d','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMjI1IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiT3BlcmF0aW9uIFF1aWV0IFN0b3JtIn0sIHsiaWQiOiAiYXctMjI2IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIioqQklBLTIwMjUtMDEyIMK3IEFOQUxZU0lTIMK3IENSSVRJQ0FMKiog4oCUIHByb2N1cmVtZW50IGZyYXVkIHJvdXRlZCB0aHJvdWdoIGEgZnJlZXBvcnQgdmF1bHQuIExlYWQgaW52ZXN0aWdhdG9yOiBQaWVycmUgTW9yZWF1LiBTdWJqZWN0IG9mIHJlY29yZDogKipab2ZpYSBIYWRkYWQqKiAoY2FzaW5vIGhvc3QsIENIKS4iLCAiY29sb3IiOiAi4puI77iPIiwgImhlYWRpbmdMZXZlbCI6IDN9LCB7ImlkIjogImF3LTIyNyIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIldoZXJlIHRoZSBhbmFseXNpcyBzdGFuZHMifSwgeyJpZCI6ICJhdy0yMjgiLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJQcm9jdXJlbWVudCBhd2FyZHMgY2x1c3RlciBhcm91bmQgdGhyZWUgdmVuZG9ycyB0aGF0IHNoYXJlIGEgcmVnaXN0ZXJlZCBhZ2VudC4gU2V0dGxlbWVudCBmbG93cyBwYXNzICoqQ3lnbnVzIENvbnN1bHRpbmcgR21iSCoqIGFuZCAqKkF1cmVsaWEgVHJhZGUgRlpFKiogYmVmb3JlIGxhbmRpbmcgYXMgY2FzaW5vIGNyZWRpdCDigJQgd2hpY2ggaXMgd2hlcmUgSGFkZGFkJ3MgaG9zdCBsZWRnZXIgY29tZXMgaW4gKEVWLTAwNzEsIGJhbmsgc3RhdGVtZW50LCB2ZXJpZmllZCkuIn0sIHsiaWQiOiAiYXctMjI5IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiSW50ZXJjZXB0cyBvZiBub3RlIn0sIHsiaWQiOiAiYXctMjMwIiwgInR5cGUiOiAicXVvdGUiLCAiY29udGVudCI6ICJEaXNjdXNzZWQgJ3BhcGVyd29yaycgZm9yIHVwY29taW5nIHNoaXBtZW50LiAoQ09NTS0wMDc3LCBpbiBwZXJzb24sIEdvbGRxdWF5IEV4Y2hhbmdlIGNvdW50ZXJwYXJ0eSDigJQgcHJpdmlsZWdlZCwgbGVnYWwgaG9sZCkifSwgeyJpZCI6ICJhdy0yMzEiLCAidHlwZSI6ICJxdW90ZSIsICJjb250ZW50IjogIkFycmFuZ2VkIG1lZXRpbmc7IGxvY2F0aW9uIHJlZmVyZW5jZWQgb2JsaXF1ZWx5LiAoQ09NTS0wMTE4LCBwaG9uZSwgdW5rbm93biBudW1iZXIg4oCUIGNsYXNzaWZpZWQgaW5jcmltaW5hdGluZykifSwgeyJpZCI6ICJhdy0yMzIiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiVGhlICdwYXBlcndvcmsnIHBocmFzaW5nIHJlcGVhdHMgYWNyb3NzIFF1aWV0IFN0b3JtIGFuZCBBc2ggR2FyZGVuIGludGVyY2VwdHMuIFByaXlhIGlzIHRlc3Rpbmcgd2hldGhlciBpdCBpcyBjb3JyaWRvciBzbGFuZyBvciB0aGUgc2FtZSBmaXhlci4iLCAiY29sb3IiOiAi8J+SoSJ9LCB7ImlkIjogImF3LTIzMyIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkV2aWRlbmNlIG9uIGhhbmQifSwgeyJpZCI6ICJhdy0yMzQiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipFVi0wMDI5Kiog4oCUIHN1cnZlaWxsYW5jZSB0YXBlIChIYW5uYWggV2Vpc3MpLCB2ZXJpZmllZC4ifSwgeyJpZCI6ICJhdy0yMzUiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipFVi0wMDQxKiog4oCUIHN1cnZlaWxsYW5jZSB0YXBlIChMZWlsYSBIYWRkYWQpLCB2ZXJpZmllZC4ifSwgeyJpZCI6ICJhdy0yMzYiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipFVi0wMDcxKiog4oCUIGJhbmsgc3RhdGVtZW50IChWaWt0b3IgQW50b25vdiksIGN1c3RvZHkgbG9nIDYgZW50cmllcywgdmVyaWZpZWQuIn0sIHsiaWQiOiAiYXctMjM3IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiT3BlbiBhbmFseXNpcyB0YXNrcyJ9LCB7ImlkIjogImF3LTIzOCIsICJ0eXBlIjogInRvX2RvIiwgImNvbnRlbnQiOiAiTWFwIHRoZSByZWdpc3RlcmVkLWFnZW50IG92ZXJsYXAgYWNyb3NzIHRoZSB0aHJlZSB2ZW5kb3JzIChDYXJsb3MpIiwgImNoZWNrZWQiOiBmYWxzZX0sIHsiaWQiOiAiYXctMjM5IiwgInR5cGUiOiAidG9fZG8iLCAiY29udGVudCI6ICJSZWNvbmNpbGUgY2FzaW5vIGNyZWRpdCBpc3N1YW5jZSB2cy4gRVYtMDA3MSBzdGF0ZW1lbnQgbGluZXMgKFJvYmVydCArIEVyaWspIiwgImNoZWNrZWQiOiBmYWxzZX0sIHsiaWQiOiAiYXctMjQwIiwgInR5cGUiOiAidG9fZG8iLCAiY29udGVudCI6ICJSZXF1ZXN0IGZyZWVwb3J0IGFjY2VzcyBsb2dzIGZvciB0aGUgYXdhcmQgd2Vla3MgKGxlZ2FsKSIsICJjaGVja2VkIjogZmFsc2V9LCB7ImlkIjogImF3LTI0MSIsICJ0eXBlIjogImRpdmlkZXIiLCAiY29udGVudCI6ICIifSwgeyJpZCI6ICJhdy0yNDIiLCAidHlwZSI6ICJxdW90ZSIsICJjb250ZW50IjogIkZyYXVkIGF0IHRoaXMgbGF5ZXIgaXMganVzdCBsb2dpc3RpY3Mgd2l0aCBiZXR0ZXIgc3RhdGlvbmVyeS4g4oCUIFAuIE1vcmVhdSJ9XQ==','base64'),'utf8')::jsonb, now() - interval '18 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('b7307548-1e39-58b9-b3c3-e67f64700a35', 'b1a0c1e5-0000-4000-a000-000000000001', 'da038f81-9156-5d98-9398-9e5978ab1840', 'a3cefadb-2668-4f12-830c-14518e0a08f3', 'Operation Ash Garden — BIA-2025-014', '🌫️', 'https://images.unsplash.com/photo-1635070041078-e363dbe005cb?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyIwMTIzYWU3YS1jN2MzLTU4N2UtYTNjNS03NThlOGE1YTQzYjEiLCAiYzUwYzNjZTItNTA3MS01MzJkLTgyZmUtZTQ0ZTdmZTkxMThmIl0sICJyZWxhdGlvblRhcmdldCI6ICJwYWdlIn1d','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMjQzIiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiT3BlcmF0aW9uIEFzaCBHYXJkZW4ifSwgeyJpZCI6ICJhdy0yNDQiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiKipCSUEtMjAyNS0wMTQgwrcgQUNUSVZFIFNVUlZFSUxMQU5DRSDCtyBISUdIKiog4oCUIHN1c3BlY3RlZCBsYXVuZGVyaW5nIHRocm91Z2ggc2hlbGwgY29tcGFuaWVzLiBMZWFkIGludmVzdGlnYXRvcjogSmFjayBTdWxsaXZhbi4gU3ViamVjdCBvZiByZWNvcmQ6ICoqQmVhdHJpeiBQZXRyb3YqKiAobGF3eWVyLCBGUikuIiwgImNvbG9yIjogIvCfjKvvuI8iLCAiaGVhZGluZ0xldmVsIjogM30sIHsiaWQiOiAiYXctMjQ1IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIioqUHJpdmlsZWdlIHdhcm5pbmcqKiDigJQgdGhlIHN1YmplY3QgaXMgYSBwcmFjdGlzaW5nIGxhd3llci4gQ09NTS0wMDQxIGFuZCBDT01NLTAwNDUgYXJlIGNsYXNzaWZpZWQgcHJpdmlsZWdlZC4gU3VydmVpbGxhbmNlIHByb2R1Y3QgbXVzdCBiZSBzY3JlZW5lZCBieSBJc2FiZWwgQkVGT1JFIGl0IHJlYWNoZXMgdGhlIGFuYWx5c3RzLiBObyBleGNlcHRpb25zLiIsICJjb2xvciI6ICLimqDvuI8ifSwgeyJpZCI6ICJhdy0yNDYiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJTdXJ2ZWlsbGFuY2UgcG9zdHVyZSJ9LCB7ImlkIjogImF3LTI0NyIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJTdGF0aWMgb2JzZXJ2YXRpb24gb24gdGhlIG9mZmljZSBibG9jazogdHdvLXBlcnNvbiByb3RhdGlvbiAoR3JhY2UgLyBWaWt0b3IpLiJ9LCB7ImlkIjogImF3LTI0OCIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJNb2JpbGUgZm9sbG93IG9ubHkgb24gcHJlLWNsZWFyZWQgcm91dGVzOyBzdWJqZWN0IGlzIGNvdW50ZXItc3VydmVpbGxhbmNlIGF3YXJlLiJ9LCB7ImlkIjogImF3LTI0OSIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJGcmlkYXkgcGF0dGVybjogc3ViamVjdCB2aXNpdHMgdGhlIG5vdGFyeSwgdGhlbiB0aGUgR29sZHF1YXkgc3RvcmVmcm9udCAoQ09NTS0wMDQ1IHRyYXZlbC1wbGFuIGludGVyY2VwdCBtYXRjaGVzKS4ifSwgeyJpZCI6ICJhdy0yNTAiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJFdmlkZW5jZSBvbiBoYW5kIn0sIHsiaWQiOiAiYXctMjUxIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIioqRVYtMDAxMSoqIOKAlCBsZWRnZXIgKEphY2sgU3VsbGl2YW4pLCBjdXN0b2R5IGxvZyA1IGVudHJpZXMsIHZlcmlmaWVkLiJ9LCB7ImlkIjogImF3LTI1MiIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICIqKkVWLTAwNTEqKiDigJQgYnVybmVyIHBob25lIChNYXlhIEtvd2Fsc2tpKSwgZm9yZW5zaWMgaW1hZ2luZyBjb21wbGV0ZSwgdmVyaWZpZWQuIn0sIHsiaWQiOiAiYXctMjUzIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIioqRVYtMDA1OCoqIOKAlCBoYXJkIGRyaXZlIChNYXJjdXMgUmVlZCksIGZvcmVuc2ljIGltYWdpbmcgY29tcGxldGUsIHZlcmlmaWVkLiJ9LCB7ImlkIjogImF3LTI1NCIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICIqKkVWLTAwNzYqKiDigJQgc3VydmVpbGxhbmNlIHRhcGUgKFZpa3RvciBBbnRvbm92KSwgY3VzdG9keSBsb2cgNiBlbnRyaWVzLCB2ZXJpZmllZC4ifSwgeyJpZCI6ICJhdy0yNTUiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJXb3JraW5nIG5vdGVzIn0sIHsiaWQiOiAiYXctMjU3IiwgInR5cGUiOiAidG9nZ2xlIiwgImNvbnRlbnQiOiAi4pa4IFNoZWxsIGxheWVyaW5nIHBhdHRlcm4gKHdvcmtpbmcpIiwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMjU2IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiVGhyZWUgc2hlbGxzIHJvdGF0ZSB0aGUgc2FtZSBub21pbmVlIGRpcmVjdG9yLiBJbmNvcnBvcmF0aW9uIGRhdGVzIHByZWNlZGUgZWFjaCBwcm9jdXJlbWVudCB3aW5kb3cgYnkgMTjigJMyNCBkYXlzLiBDYXJsb3MgaXMgY2hlY2tpbmcgd2hldGhlciB0aGUgcGF0dGVybiBtYXRjaGVzIHRoZSBRdWlldCBTdG9ybSB2ZW5kb3JzLiJ9XSwgImNvbGxhcHNlZCI6IHRydWV9LCB7ImlkIjogImF3LTI2MSIsICJ0eXBlIjogInRvZ2dsZSIsICJjb250ZW50IjogIuKWuCBCdXJuZXIgcGhvbmUgKEVWLTAwNTEpIGV4dHJhY3Rpb24gaGlnaGxpZ2h0cyIsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTI1OCIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJDb250YWN0IGdyYXBoOiA5IG51bWJlcnMsIDcgc2luZ2xlLXVzZS4ifSwgeyJpZCI6ICJhdy0yNTkiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiUmVjdXJyaW5nIG51bWJlciByZXNvbHZlcyB0byBhbiB1bnJlZ2lzdGVyZWQgU0lNIOKAlCBzYW1lIGRldmljZSBmYW1pbHkgYXMgdGhlIE5pZ2h0ZmFsbCBDT01NLTAwODUgc291cmNlLiJ9LCB7ImlkIjogImF3LTI2MCIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJJZiB0aGUgU0lNIGxpbmsgaG9sZHMsIEFzaCBHYXJkZW4gYW5kIE5pZ2h0ZmFsbCBzaGFyZSBhIGZpeGVyLiIsICJjb2xvciI6ICLwn5SlIn1dLCAiY29sbGFwc2VkIjogdHJ1ZX0sIHsiaWQiOiAiYXctMjYyIiwgInR5cGUiOiAiZGl2aWRlciIsICJjb250ZW50IjogIiJ9LCB7ImlkIjogImF3LTI2MyIsICJ0eXBlIjogInF1b3RlIiwgImNvbnRlbnQiOiAiRGlzY3Vzc2VkICdwYXBlcndvcmsnIGZvciB1cGNvbWluZyBzaGlwbWVudC4gKENPTU0tMDA0MSwgcGhvbmUsIHVucmVnaXN0ZXJlZCBTSU0g4oCUIHByaXZpbGVnZWQpIn1d','base64'),'utf8')::jsonb, now() - interval '17 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('0a02865f-15a9-516c-a54d-11448621a04a', 'b1a0c1e5-0000-4000-a000-000000000001', 'da038f81-9156-5d98-9398-9e5978ab1840', '5540b1cb-b294-414d-a2c4-03087dd44a91', 'Operation Broken Compass — BIA-2025-016', '🧿', 'https://images.unsplash.com/photo-1564865878688-9a244444042a?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyI2MWIxN2Q3My05YTQ2LTVmMTAtYTRkMy0wZjkyMTg4MTQzMzYiLCAiMjFjNmM1MDMtMzY0NS01MjRmLWExMjQtMTAzYjEwY2M5NjU1Il0sICJyZWxhdGlvblRhcmdldCI6ICJwYWdlIn1d','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMjY0IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiT3BlcmF0aW9uIEJyb2tlbiBDb21wYXNzIn0sIHsiaWQiOiAiYXctMjY1IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIioqQklBLTIwMjUtMDE2IMK3IE9QRU4gwrcgSElHSCoqIOKAlCBhc3NldCBjb25jZWFsbWVudCB0aHJvdWdoIGNyeXB0byBtaXhlcnMuIExlYWQgaW52ZXN0aWdhdG9yOiBUb20gQmVja2VyLiIsICJjb2xvciI6ICLwn6e/IiwgImhlYWRpbmdMZXZlbCI6IDN9LCB7ImlkIjogImF3LTI2NiIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIlNpdHVhdGlvbiJ9LCB7ImlkIjogImF3LTI2NyIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIlByb2NlZWRzIGZyb20gYSBwcm9wZXJ0eSBmbGlwIHNjaGVtZSAoKipOb3Z1bSBFc3RhdGVzKiogaXMgdGhlIHJlY3VycmluZyBjb3VudGVycGFydHkg4oCUIHNlZSBDT01NLTAwNjgsIGNsYXNzaWZpZWQgaW5jcmltaW5hdGluZykgZGlzYXBwZWFyIGludG8gbWl4ZXIgZGVwb3NpdHMgd2l0aGluIDQ4IGhvdXJzIG9mIGVhY2ggY2xvc2luZy4gRXhpdCBsaXF1aWRpdHkgc3VyZmFjZXMgYXMgbWFyaW5lIGNoYXJ0ZXIgcGF5bWVudHMgdG8gKipLZXN0cmVsIE1hcmluZSBMdGQqKiDigJQgdGhlIHNhbWUgY2hhcnRlciBzaG9wIHRoYXQgYnJ1c2hlZCBhZ2FpbnN0IFJlZCBNZXJpZGlhbidzIG1hcmluYSBsZWFkLiJ9LCB7ImlkIjogImF3LTI2OCIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkV2aWRlbmNlIG9uIGhhbmQifSwgeyJpZCI6ICJhdy0yNjkiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipFVi0wMDc3Kiog4oCUIGNvbnRyYWN0IChHcmFjZSBMaXUpLCB2ZXJpZmllZC4ifSwgeyJpZCI6ICJhdy0yNzAiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipFVi0wMTAxKiog4oCUIGRvY3VtZW50IChKYWNrIFN1bGxpdmFuKSwgY3VzdG9keSBsb2cgNSBlbnRyaWVzLCB2ZXJpZmllZC4ifSwgeyJpZCI6ICJhdy0yNzEiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiKipFVi0wMTAzKiog4oCUIGJhbmsgc3RhdGVtZW50IChHcmFjZSBMaXUpLCB2ZXJpZmllZC4ifSwgeyJpZCI6ICJhdy0yNzIiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJJbnRlcmNlcHRzIG9mIG5vdGUifSwgeyJpZCI6ICJhdy0yNzMiLCAidHlwZSI6ICJxdW90ZSIsICJjb250ZW50IjogIkRpc2N1c3NlZCAncGFwZXJ3b3JrJyBmb3IgdXBjb21pbmcgc2hpcG1lbnQuIChDT01NLTAwNjgsIHBob25lLCBOb3Z1bSBFc3RhdGVzIOKAlCBpbmNyaW1pbmF0aW5nKSJ9LCB7ImlkIjogImF3LTI3NCIsICJ0eXBlIjogInF1b3RlIiwgImNvbnRlbnQiOiAiTWVudGlvbmVkIGNvbnRhY3QgYXQgdGhlIGZyZWVwb3J0IGJ5IGZpcnN0IG5hbWUuIChDT01NLTAxMzQsIHNtcywgS2VzdHJlbCBNYXJpbmUgTHRkKSJ9LCB7ImlkIjogImF3LTI3NSIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIk9wZW4gcXVlc3Rpb25zIn0sIHsiaWQiOiAiYXctMjc3IiwgInR5cGUiOiAidG9nZ2xlIiwgImNvbnRlbnQiOiAi4pa4IElzIEtlc3RyZWwgTWFyaW5lIGEgY29pbmNpZGVuY2Ugb3IgYSBodWI/IiwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMjc2IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiVHdvIG9wZXJhdGlvbnMsIG9uZSBjaGFydGVyIGNvbXBhbnkuIFByaXlhJ3MgY291bnRlcnBhcnR5IGdyYXBoIHJhbmtzIEtlc3RyZWwgaW4gdGhlIHRvcCBkZWNpbGUgYnkgY3Jvc3MtY2FzZSBkZWdyZWUuIFdhdGNobGlzdCBlbnRyeSBvcGVuZWQuIn1dLCAiY29sbGFwc2VkIjogdHJ1ZX0sIHsiaWQiOiAiYXctMjc5IiwgInR5cGUiOiAidG9nZ2xlIiwgImNvbnRlbnQiOiAi4pa4IE1peGVyIGV4aXQgdGltaW5nIiwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMjc4IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiRXhpdHMgY2x1c3RlciAwMzowMOKAkzA1OjAwIFVUQy4gRWl0aGVyIGF1dG9tYXRpb24gb3IgYW4gb3BlcmF0b3IgaW4gYW4gZWFzdGVybiB0aW1lem9uZS4gRXJpayBpcyBjb3JyZWxhdGluZyB3aXRoIEdvbGRxdWF5J3Mgc2V0dGxlbWVudCBiYXRjaGVzLiJ9XSwgImNvbGxhcHNlZCI6IHRydWV9LCB7ImlkIjogImF3LTI4MCIsICJ0eXBlIjogInRvX2RvIiwgImNvbnRlbnQiOiAiQ2hhcnRlciBtYW5pZmVzdHMgc3VicG9lbmEgKGxlZ2FsLCBkcmFmdGluZykiLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0yODEiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIkNyb3NzLWNhc2UgU0lNIGNvbXBhcmlzb24gd2l0aCBBc2ggR2FyZGVuIEVWLTAwNTEgZ3JhcGggKGZvcmVuc2ljcykiLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0yODIiLCAidHlwZSI6ICJkaXZpZGVyIiwgImNvbnRlbnQiOiAiIn0sIHsiaWQiOiAiYXctMjgzIiwgInR5cGUiOiAicXVvdGUiLCAiY29udGVudCI6ICJGb2xsb3cgdGhlIGJvYXRzLiBNb25leSBnZXRzIHNlYXNpY2sgdG9vLiDigJQgVC4gQmVja2VyIn1d','base64'),'utf8')::jsonb, now() - interval '16 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('e163d75d-2334-52eb-8f22-ca7b7d663608', 'b1a0c1e5-0000-4000-a000-000000000001', NULL, '920b7e0b-a87d-400d-8169-f2f521bfe5ad', '🖼️ Evidence Gallery', '🖼️', 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMjg0IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiRXZpZGVuY2UgR2FsbGVyeSJ9LCB7ImlkIjogImF3LTI4NSIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJWaXN1YWwgaW5kZXggb2YgdGhlIGV2aWRlbmNlIHZhdWx0LCBncm91cGVkIGJ5IGtpbmQuIENhcmRzIGFyZSBzYW5pdGlzZWQgc2NhbnMg4oCUIHRoZSBmYWN0dWFsIHJlY29yZCBzdGF5cyBpbiB0aGUgbGl2ZSBgZXZpZGVuY2VgIHRhYmxlIGJlbG93LiAxMjAgaXRlbXMgdW5kZXIgY3VzdG9keS4iLCAiY29sb3IiOiAi8J+WvO+4jyJ9LCB7ImlkIjogImF3LTI4NiIsICJ0eXBlIjogImhlYWRpbmdfMiIsICJjb250ZW50IjogIkNvbGxlY3Rpb25zIn0sIHsiaWQiOiAiYXctMjkzIiwgInR5cGUiOiAiY29sdW1uX2xpc3QiLCAiY29udGVudCI6ICIiLCAiY2hpbGRyZW4iOiBbeyJpZCI6ICJhdy0yOTEiLCAidHlwZSI6ICJjb2x1bW4iLCAiY29udGVudCI6ICIiLCAid2lkdGhSYXRpbyI6IDEsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTI4NyIsICJ0eXBlIjogImhlYWRpbmdfMyIsICJjb250ZW50IjogIvCfj6YgRmluYW5jaWFsIFJlY29yZHMifSwgeyJpZCI6ICJhdy0yODgiLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJCYW5rIHN0YXRlbWVudHMsIGxlZGdlcnMsIHdpcmUgcmVjZWlwdHMuIn1dfSwgeyJpZCI6ICJhdy0yOTIiLCAidHlwZSI6ICJjb2x1bW4iLCAiY29udGVudCI6ICIiLCAid2lkdGhSYXRpbyI6IDEsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTI4OSIsICJ0eXBlIjogImhlYWRpbmdfMyIsICJjb250ZW50IjogIvCfkr4gRGlnaXRhbCBGb3JlbnNpY3MifSwgeyJpZCI6ICJhdy0yOTAiLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJIYXJkIGRyaXZlcywgcGhvbmUgZHVtcHMsIGJ1cm5lciBwaG9uZXMuIn1dfV19LCB7ImlkIjogImF3LTMwMCIsICJ0eXBlIjogImNvbHVtbl9saXN0IiwgImNvbnRlbnQiOiAiIiwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMjk4IiwgInR5cGUiOiAiY29sdW1uIiwgImNvbnRlbnQiOiAiIiwgIndpZHRoUmF0aW8iOiAxLCAiY2hpbGRyZW4iOiBbeyJpZCI6ICJhdy0yOTQiLCAidHlwZSI6ICJoZWFkaW5nXzMiLCAiY29udGVudCI6ICLwn46e77iPIFN1cnZlaWxsYW5jZSBNYXRlcmlhbHMifSwgeyJpZCI6ICJhdy0yOTUiLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJUYXBlcyBhbmQgcGhvdG9ncmFwaHMgZnJvbSBvYnNlcnZhdGlvbiBwb3N0cy4ifV19LCB7ImlkIjogImF3LTI5OSIsICJ0eXBlIjogImNvbHVtbiIsICJjb250ZW50IjogIiIsICJ3aWR0aFJhdGlvIjogMSwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMjk2IiwgInR5cGUiOiAiaGVhZGluZ18zIiwgImNvbnRlbnQiOiAi8J+TnCBEb2N1bWVudHMgJiBDb250cmFjdHMifSwgeyJpZCI6ICJhdy0yOTciLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJDb250cmFjdHMsIG1hbmlmZXN0cywgY29ycG9yYXRlIHBhcGVyLiJ9XX1dfSwgeyJpZCI6ICJhdy0zMDEiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJMaXZlIGV2aWRlbmNlIHJlZ2lzdGVyIn0sIHsiaWQiOiAiYXctMzAyIiwgInR5cGUiOiAiZGF0YWJhc2VfaW5saW5lIiwgImNvbnRlbnQiOiAiIiwgImRhdGFiYXNlSWQiOiAiYmFhczpkM2VjYjNlMS05OTQ3LTQxYTYtYTBkMy1mZjIwNjNiNGFkZWU6ZXZpZGVuY2UifSwgeyJpZCI6ICJhdy0zMDMiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiT25lIGl0ZW0gaXMgY3VycmVudGx5IHF1YXJhbnRpbmVkIChFVi0wMDU1KS4gU2VlIEN1c3RvZHkgQXVkaXQg4oCUIFEyLiIsICJjb2xvciI6ICLimqDvuI8ifV0=','base64'),'utf8')::jsonb, now() - interval '15 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('ff43afdb-3ab0-5e31-8ffa-a89c5f55c5ff', 'b1a0c1e5-0000-4000-a000-000000000001', 'e163d75d-2334-52eb-8f22-ca7b7d663608', 'fc15e717-3cdf-4862-abce-8ea2e3be771f', 'Financial Records', '🏦', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzA0IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiRmluYW5jaWFsIFJlY29yZHMifSwgeyJpZCI6ICJhdy0zMDUiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiQmFuayBzdGF0ZW1lbnRzLCBsZWRnZXJzIGFuZCB3aXJlIHJlY2VpcHRzLiBBbW91bnRzIHJlbmRlciBtYXNrZWQgZm9yIGNsZWFyYW5jZSBiZWxvdyA0IOKAlCB0aGUgc2NhbnMgaGVyZSBhcmUgc2FuaXRpc2VkIHByZXZpZXdzLiIsICJjb2xvciI6ICLwn4+mIn0sIHsiaWQiOiAiYXctMzA2IiwgInR5cGUiOiAiaW1hZ2UiLCAiY29udGVudCI6ICJFVi0wMDg0IMK3IExFREdFUiDigJQgT3BlcmF0aW9uIE5pZ2h0ZmFsbCDigJQgcGFpcmVkIHBoYW50b20tZnJlaWdodCBlbnRyaWVzIiwgImFzc2V0IjogImRhdGE6aW1hZ2Uvc3ZnK3htbCwlM0NzdmcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJyB3aWR0aD0nNjQwJyBoZWlnaHQ9JzIyMCclM0UlM0NyZWN0IHdpZHRoPSc2NDAnIGhlaWdodD0nMjIwJyBmaWxsPSclMjMxZjNhNWYnLyUzRSUzQ3JlY3QgeD0nMTQnIHk9JzE0JyB3aWR0aD0nNjEyJyBoZWlnaHQ9JzE5MicgZmlsbD0nbm9uZScgc3Ryb2tlPSd3aGl0ZScgc3Ryb2tlLW9wYWNpdHk9JzAuNScgc3Ryb2tlLXdpZHRoPScyJyBzdHJva2UtZGFzaGFycmF5PSc4IDYnLyUzRSUzQ3RleHQgeD0nMzIwJyB5PSc5OCcgZm9udC1zaXplPSczMCcgZmlsbD0nd2hpdGUnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFRVYtMDA4NCAlQzIlQjcgTEVER0VSJTNDL3RleHQlM0UlM0N0ZXh0IHg9JzMyMCcgeT0nMTQwJyBmb250LXNpemU9JzE2JyBmaWxsPSd3aGl0ZScgZmlsbC1vcGFjaXR5PScwLjg1JyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRU9wZXJhdGlvbiBOaWdodGZhbGwgJUUyJTgwJTk0IHBhaXJlZCBwaGFudG9tLWZyZWlnaHQgZW50cmllcyUzQy90ZXh0JTNFJTNDdGV4dCB4PSczMjAnIHk9JzE5MCcgZm9udC1zaXplPScxMicgZmlsbD0nd2hpdGUnIGZpbGwtb3BhY2l0eT0nMC42JyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRUJJTk9DTEUgSU5URUxMSUdFTkNFIEFHRU5DWSAlRTIlODAlOTQgU0VDVVJFIFNDQU4lM0MvdGV4dCUzRSUzQy9zdmclM0UifSwgeyJpZCI6ICJhdy0zMDciLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJSZWNvdmVyZWQgYnkgQW1hcmEgRGlhbGxvIGR1cmluZyBhIHN1cnZlaWxsYW5jZSBvcC4gVGhlIHBhaXJlZCBlbnRyaWVzIG1hdGNoIEVyaWsncyBmbGFnZ2VkIEdvbGRxdWF5IHBhdHRlcm47IHJlZmVyZW5jZWQgdGhyb3VnaG91dCB0aGUgTmlnaHRmYWxsIHdpa2kuIn0sIHsiaWQiOiAiYXctMzA4IiwgInR5cGUiOiAiaW1hZ2UiLCAiY29udGVudCI6ICJFVi0wMDcxIMK3IEJBTksgU1RBVEVNRU5UIOKAlCBPcGVyYXRpb24gUXVpZXQgU3Rvcm0g4oCUIGNhc2lubyBjcmVkaXQgcmVjb25jaWxpYXRpb24iLCAiYXNzZXQiOiAiZGF0YTppbWFnZS9zdmcreG1sLCUzQ3N2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc2NDAnIGhlaWdodD0nMjIwJyUzRSUzQ3JlY3Qgd2lkdGg9JzY0MCcgaGVpZ2h0PScyMjAnIGZpbGw9JyUyMzI3NDA2MCcvJTNFJTNDcmVjdCB4PScxNCcgeT0nMTQnIHdpZHRoPSc2MTInIGhlaWdodD0nMTkyJyBmaWxsPSdub25lJyBzdHJva2U9J3doaXRlJyBzdHJva2Utb3BhY2l0eT0nMC41JyBzdHJva2Utd2lkdGg9JzInIHN0cm9rZS1kYXNoYXJyYXk9JzggNicvJTNFJTNDdGV4dCB4PSczMjAnIHk9Jzk4JyBmb250LXNpemU9JzMwJyBmaWxsPSd3aGl0ZScgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VFVi0wMDcxICVDMiVCNyBCQU5LIFNUQVRFTUVOVCUzQy90ZXh0JTNFJTNDdGV4dCB4PSczMjAnIHk9JzE0MCcgZm9udC1zaXplPScxNicgZmlsbD0nd2hpdGUnIGZpbGwtb3BhY2l0eT0nMC44NScgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VPcGVyYXRpb24gUXVpZXQgU3Rvcm0gJUUyJTgwJTk0IGNhc2lubyBjcmVkaXQgcmVjb25jaWxpYXRpb24lM0MvdGV4dCUzRSUzQ3RleHQgeD0nMzIwJyB5PScxOTAnIGZvbnQtc2l6ZT0nMTInIGZpbGw9J3doaXRlJyBmaWxsLW9wYWNpdHk9JzAuNicgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VCSU5PQ0xFIElOVEVMTElHRU5DRSBBR0VOQ1kgJUUyJTgwJTk0IFNFQ1VSRSBTQ0FOJTNDL3RleHQlM0UlM0Mvc3ZnJTNFIn0sIHsiaWQiOiAiYXctMzA5IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiU2l4IGN1c3RvZHkgZW50cmllcywgdmVyaWZpZWQuIFN0YXRlbWVudCBsaW5lcyByZWNvbmNpbGUgYWdhaW5zdCBjYXNpbm8gY3JlZGl0IGlzc3VhbmNlIHdpdGggYSByZXNpZHVhbCB0aGUgYW5hbHlzdHMgYXJlIHN0aWxsIGNoYXNpbmcuIn0sIHsiaWQiOiAiYXctMzEwIiwgInR5cGUiOiAiaW1hZ2UiLCAiY29udGVudCI6ICJFVi0wMTAzIMK3IEJBTksgU1RBVEVNRU5UIOKAlCBPcGVyYXRpb24gQnJva2VuIENvbXBhc3Mg4oCUIGNoYXJ0ZXIgcGF5bWVudCB0cmFpbCIsICJhc3NldCI6ICJkYXRhOmltYWdlL3N2Zyt4bWwsJTNDc3ZnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Zycgd2lkdGg9JzY0MCcgaGVpZ2h0PScyMjAnJTNFJTNDcmVjdCB3aWR0aD0nNjQwJyBoZWlnaHQ9JzIyMCcgZmlsbD0nJTIzMzA1MDZiJy8lM0UlM0NyZWN0IHg9JzE0JyB5PScxNCcgd2lkdGg9JzYxMicgaGVpZ2h0PScxOTInIGZpbGw9J25vbmUnIHN0cm9rZT0nd2hpdGUnIHN0cm9rZS1vcGFjaXR5PScwLjUnIHN0cm9rZS13aWR0aD0nMicgc3Ryb2tlLWRhc2hhcnJheT0nOCA2Jy8lM0UlM0N0ZXh0IHg9JzMyMCcgeT0nOTgnIGZvbnQtc2l6ZT0nMzAnIGZpbGw9J3doaXRlJyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRUVWLTAxMDMgJUMyJUI3IEJBTksgU1RBVEVNRU5UJTNDL3RleHQlM0UlM0N0ZXh0IHg9JzMyMCcgeT0nMTQwJyBmb250LXNpemU9JzE2JyBmaWxsPSd3aGl0ZScgZmlsbC1vcGFjaXR5PScwLjg1JyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRU9wZXJhdGlvbiBCcm9rZW4gQ29tcGFzcyAlRTIlODAlOTQgY2hhcnRlciBwYXltZW50IHRyYWlsJTNDL3RleHQlM0UlM0N0ZXh0IHg9JzMyMCcgeT0nMTkwJyBmb250LXNpemU9JzEyJyBmaWxsPSd3aGl0ZScgZmlsbC1vcGFjaXR5PScwLjYnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFQklOT0NMRSBJTlRFTExJR0VOQ0UgQUdFTkNZICVFMiU4MCU5NCBTRUNVUkUgU0NBTiUzQy90ZXh0JTNFJTNDL3N2ZyUzRSJ9LCB7ImlkIjogImF3LTMxMSIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkNvbGxlY3RlZCBieSBHcmFjZSBMaXUuIENoYXJ0ZXIgcGF5bWVudHMgdG8gS2VzdHJlbCBNYXJpbmUgTHRkIGxhbmQgd2l0aGluIDQ4aCBvZiBlYWNoIHByb3BlcnR5IGNsb3NpbmcuIn0sIHsiaWQiOiAiYXctMzEyIiwgInR5cGUiOiAiZGl2aWRlciIsICJjb250ZW50IjogIiJ9LCB7ImlkIjogImF3LTMxMyIsICJ0eXBlIjogInRhYmxlX2Jsb2NrIiwgImNvbnRlbnQiOiAiIiwgInRhYmxlRGF0YSI6IFtbIkl0ZW0iLCAiQ2FzZSIsICJTdGF0ZSJdLCBbIkVWLTAwODQgbGVkZ2VyIiwgIkJJQS0yMDI2LTAwMSIsICJ2ZXJpZmllZCDinIUiXSwgWyJFVi0wMDcxIGJhbmsgc3RhdGVtZW50IiwgIkJJQS0yMDI1LTAxMiIsICJ2ZXJpZmllZCDinIUiXSwgWyJFVi0wMTAzIGJhbmsgc3RhdGVtZW50IiwgIkJJQS0yMDI1LTAxNiIsICJ2ZXJpZmllZCDinIUiXV0sICJ0YWJsZUNvbmZpZyI6IHsiaGVhZGVyUm93IjogdHJ1ZSwgInNob3dCb3JkZXJzIjogdHJ1ZSwgInN0cmlwZWRSb3dzIjogdHJ1ZX19XQ==','base64'),'utf8')::jsonb, now() - interval '14 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('b7a26daf-fecb-5528-b58a-d7afae95ee8d', 'b1a0c1e5-0000-4000-a000-000000000001', 'e163d75d-2334-52eb-8f22-ca7b7d663608', '7dabcb4d-8c96-4968-bb4d-34b8e5364274', 'Digital Forensics', '💾', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzE0IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiRGlnaXRhbCBGb3JlbnNpY3MifSwgeyJpZCI6ICJhdy0zMTUiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiSW1hZ2VkIG1lZGlhIG9ubHkg4oCUIG9yaWdpbmFscyBhcmUgd3JpdGUtYmxvY2tlZCBhbmQgdmF1bHRlZC4gRXZlcnkgaW1hZ2UgY2FycmllcyBhIFNIQS0yNTYgcmVjb3JkZWQgYXQgY29sbGVjdGlvbiBhbmQgYXQgZWFjaCB0cmFuc2Zlci4iLCAiY29sb3IiOiAi8J+SviJ9LCB7ImlkIjogImF3LTMxNiIsICJ0eXBlIjogImltYWdlIiwgImNvbnRlbnQiOiAiRVYtMDA4NiDCtyBIQVJEIERSSVZFIOKAlCBPcGVyYXRpb24gTmlnaHRmYWxsIOKAlCB3YWxsZXQgYXJ0ZWZhY3RzIiwgImFzc2V0IjogImRhdGE6aW1hZ2Uvc3ZnK3htbCwlM0NzdmcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJyB3aWR0aD0nNjQwJyBoZWlnaHQ9JzIyMCclM0UlM0NyZWN0IHdpZHRoPSc2NDAnIGhlaWdodD0nMjIwJyBmaWxsPSclMjMzYjJmNWYnLyUzRSUzQ3JlY3QgeD0nMTQnIHk9JzE0JyB3aWR0aD0nNjEyJyBoZWlnaHQ9JzE5MicgZmlsbD0nbm9uZScgc3Ryb2tlPSd3aGl0ZScgc3Ryb2tlLW9wYWNpdHk9JzAuNScgc3Ryb2tlLXdpZHRoPScyJyBzdHJva2UtZGFzaGFycmF5PSc4IDYnLyUzRSUzQ3RleHQgeD0nMzIwJyB5PSc5OCcgZm9udC1zaXplPSczMCcgZmlsbD0nd2hpdGUnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFRVYtMDA4NiAlQzIlQjcgSEFSRCBEUklWRSUzQy90ZXh0JTNFJTNDdGV4dCB4PSczMjAnIHk9JzE0MCcgZm9udC1zaXplPScxNicgZmlsbD0nd2hpdGUnIGZpbGwtb3BhY2l0eT0nMC44NScgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VPcGVyYXRpb24gTmlnaHRmYWxsICVFMiU4MCU5NCB3YWxsZXQgYXJ0ZWZhY3RzJTNDL3RleHQlM0UlM0N0ZXh0IHg9JzMyMCcgeT0nMTkwJyBmb250LXNpemU9JzEyJyBmaWxsPSd3aGl0ZScgZmlsbC1vcGFjaXR5PScwLjYnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFQklOT0NMRSBJTlRFTExJR0VOQ0UgQUdFTkNZICVFMiU4MCU5NCBTRUNVUkUgU0NBTiUzQy90ZXh0JTNFJTNDL3N2ZyUzRSJ9LCB7ImlkIjogImF3LTMxNyIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkltYWdlZCBieSBIYW5uYWggV2Vpc3MuIFdhbGxldCBhcnRlZmFjdHMgZmVlZCB0aGUgbWl4ZXIgZXhpdCBjbHVzdGVyaW5nIHdvcmsuIn0sIHsiaWQiOiAiYXctMzE4IiwgInR5cGUiOiAiaW1hZ2UiLCAiY29udGVudCI6ICJFVi0wMDUxIMK3IEJVUk5FUiBQSE9ORSDigJQgT3BlcmF0aW9uIEFzaCBHYXJkZW4g4oCUIGNvbnRhY3QgZ3JhcGgsIDkgbnVtYmVycyIsICJhc3NldCI6ICJkYXRhOmltYWdlL3N2Zyt4bWwsJTNDc3ZnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Zycgd2lkdGg9JzY0MCcgaGVpZ2h0PScyMjAnJTNFJTNDcmVjdCB3aWR0aD0nNjQwJyBoZWlnaHQ9JzIyMCcgZmlsbD0nJTIzNDYzOTZiJy8lM0UlM0NyZWN0IHg9JzE0JyB5PScxNCcgd2lkdGg9JzYxMicgaGVpZ2h0PScxOTInIGZpbGw9J25vbmUnIHN0cm9rZT0nd2hpdGUnIHN0cm9rZS1vcGFjaXR5PScwLjUnIHN0cm9rZS13aWR0aD0nMicgc3Ryb2tlLWRhc2hhcnJheT0nOCA2Jy8lM0UlM0N0ZXh0IHg9JzMyMCcgeT0nOTgnIGZvbnQtc2l6ZT0nMzAnIGZpbGw9J3doaXRlJyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRUVWLTAwNTEgJUMyJUI3IEJVUk5FUiBQSE9ORSUzQy90ZXh0JTNFJTNDdGV4dCB4PSczMjAnIHk9JzE0MCcgZm9udC1zaXplPScxNicgZmlsbD0nd2hpdGUnIGZpbGwtb3BhY2l0eT0nMC44NScgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VPcGVyYXRpb24gQXNoIEdhcmRlbiAlRTIlODAlOTQgY29udGFjdCBncmFwaCwgOSBudW1iZXJzJTNDL3RleHQlM0UlM0N0ZXh0IHg9JzMyMCcgeT0nMTkwJyBmb250LXNpemU9JzEyJyBmaWxsPSd3aGl0ZScgZmlsbC1vcGFjaXR5PScwLjYnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFQklOT0NMRSBJTlRFTExJR0VOQ0UgQUdFTkNZICVFMiU4MCU5NCBTRUNVUkUgU0NBTiUzQy90ZXh0JTNFJTNDL3N2ZyUzRSJ9LCB7ImlkIjogImF3LTMxOSIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkZvcmVuc2ljIGltYWdpbmcgY29tcGxldGUgKE1heWEgS293YWxza2kpLiBUaGUgcmVjdXJyaW5nIHVucmVnaXN0ZXJlZCBTSU0gbWF5IGxpbmsgQXNoIEdhcmRlbiB0byBOaWdodGZhbGwncyBDT01NLTAwODUgc291cmNlLiJ9LCB7ImlkIjogImF3LTMyMCIsICJ0eXBlIjogImltYWdlIiwgImNvbnRlbnQiOiAiRVYtMDA5NCDCtyBQSE9ORSBEVU1QIOKAlCBPcGVyYXRpb24gQ29iYWx0IExlZGdlciDigJQgcmVjb3ZlcmVkIGRlbGV0aW9ucyIsICJhc3NldCI6ICJkYXRhOmltYWdlL3N2Zyt4bWwsJTNDc3ZnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Zycgd2lkdGg9JzY0MCcgaGVpZ2h0PScyMjAnJTNFJTNDcmVjdCB3aWR0aD0nNjQwJyBoZWlnaHQ9JzIyMCcgZmlsbD0nJTIzNTAzZjczJy8lM0UlM0NyZWN0IHg9JzE0JyB5PScxNCcgd2lkdGg9JzYxMicgaGVpZ2h0PScxOTInIGZpbGw9J25vbmUnIHN0cm9rZT0nd2hpdGUnIHN0cm9rZS1vcGFjaXR5PScwLjUnIHN0cm9rZS13aWR0aD0nMicgc3Ryb2tlLWRhc2hhcnJheT0nOCA2Jy8lM0UlM0N0ZXh0IHg9JzMyMCcgeT0nOTgnIGZvbnQtc2l6ZT0nMzAnIGZpbGw9J3doaXRlJyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRUVWLTAwOTQgJUMyJUI3IFBIT05FIERVTVAlM0MvdGV4dCUzRSUzQ3RleHQgeD0nMzIwJyB5PScxNDAnIGZvbnQtc2l6ZT0nMTYnIGZpbGw9J3doaXRlJyBmaWxsLW9wYWNpdHk9JzAuODUnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFT3BlcmF0aW9uIENvYmFsdCBMZWRnZXIgJUUyJTgwJTk0IHJlY292ZXJlZCBkZWxldGlvbnMlM0MvdGV4dCUzRSUzQ3RleHQgeD0nMzIwJyB5PScxOTAnIGZvbnQtc2l6ZT0nMTInIGZpbGw9J3doaXRlJyBmaWxsLW9wYWNpdHk9JzAuNicgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VCSU5PQ0xFIElOVEVMTElHRU5DRSBBR0VOQ1kgJUUyJTgwJTk0IFNFQ1VSRSBTQ0FOJTNDL3RleHQlM0UlM0Mvc3ZnJTNFIn0sIHsiaWQiOiAiYXctMzIxIiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiRGVsZXRlZCBjb29yZGluYXRpb24gbWVzc2FnZXMgcmVjb3ZlcmVkOyBhZG1pdHRlZCBhcyBleGhpYml0IGF0IHRyaWFsLiBDYXNlIGNsb3NlZC4ifSwgeyJpZCI6ICJhdy0zMjIiLCAidHlwZSI6ICJkaXZpZGVyIiwgImNvbnRlbnQiOiAiIn0sIHsiaWQiOiAiYXctMzIzIiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIkltYWdpbmcgcXVldWUgaXMgY2xlYXIuIE5ldyBtZWRpYSBnZXRzIGEgZm9yZW5zaWNzIHRpY2tldCBiZWZvcmUgYW55IGFuYWx5c3QgdG91Y2hlcyBpdC4iLCAiY29sb3IiOiAi4pyFIn1d','base64'),'utf8')::jsonb, now() - interval '13 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('46cc8d63-2738-5076-9688-1e303bdca5fb', 'b1a0c1e5-0000-4000-a000-000000000001', 'e163d75d-2334-52eb-8f22-ca7b7d663608', '99389d8f-5186-4bbb-a426-faf8fb91bc69', 'Surveillance Materials', '🎞️', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzI0IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiU3VydmVpbGxhbmNlIE1hdGVyaWFscyJ9LCB7ImlkIjogImF3LTMyNSIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJPYnNlcnZhdGlvbi1wb3N0IHByb2R1Y3Q6IHRhcGVzIGFuZCBwaG90b2dyYXBocy4gTG9jYXRpb24gaWRzIHJlZmVyZW5jZSB0aGUgbGl2ZSBgbG9jYXRpb25zYCB0YWJsZS4iLCAiY29sb3IiOiAi8J+Onu+4jyJ9LCB7ImlkIjogImF3LTMyNiIsICJ0eXBlIjogImltYWdlIiwgImNvbnRlbnQiOiAiRVYtMDA3NiDCtyBTVVJWRUlMTEFOQ0UgVEFQRSDigJQgT3BlcmF0aW9uIEFzaCBHYXJkZW4g4oCUIG9mZmljZSBibG9jayBzdGF0aWMiLCAiYXNzZXQiOiAiZGF0YTppbWFnZS9zdmcreG1sLCUzQ3N2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc2NDAnIGhlaWdodD0nMjIwJyUzRSUzQ3JlY3Qgd2lkdGg9JzY0MCcgaGVpZ2h0PScyMjAnIGZpbGw9JyUyMzVmMmYzYScvJTNFJTNDcmVjdCB4PScxNCcgeT0nMTQnIHdpZHRoPSc2MTInIGhlaWdodD0nMTkyJyBmaWxsPSdub25lJyBzdHJva2U9J3doaXRlJyBzdHJva2Utb3BhY2l0eT0nMC41JyBzdHJva2Utd2lkdGg9JzInIHN0cm9rZS1kYXNoYXJyYXk9JzggNicvJTNFJTNDdGV4dCB4PSczMjAnIHk9Jzk4JyBmb250LXNpemU9JzMwJyBmaWxsPSd3aGl0ZScgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VFVi0wMDc2ICVDMiVCNyBTVVJWRUlMTEFOQ0UgVEFQRSUzQy90ZXh0JTNFJTNDdGV4dCB4PSczMjAnIHk9JzE0MCcgZm9udC1zaXplPScxNicgZmlsbD0nd2hpdGUnIGZpbGwtb3BhY2l0eT0nMC44NScgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VPcGVyYXRpb24gQXNoIEdhcmRlbiAlRTIlODAlOTQgb2ZmaWNlIGJsb2NrIHN0YXRpYyUzQy90ZXh0JTNFJTNDdGV4dCB4PSczMjAnIHk9JzE5MCcgZm9udC1zaXplPScxMicgZmlsbD0nd2hpdGUnIGZpbGwtb3BhY2l0eT0nMC42JyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRUJJTk9DTEUgSU5URUxMSUdFTkNFIEFHRU5DWSAlRTIlODAlOTQgU0VDVVJFIFNDQU4lM0MvdGV4dCUzRSUzQy9zdmclM0UifSwgeyJpZCI6ICJhdy0zMjciLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJTaXggY3VzdG9keSBlbnRyaWVzLCB2ZXJpZmllZC4gQ292ZXJzIHRoZSBGcmlkYXkgbm90YXJ5LXRoZW4tR29sZHF1YXkgcGF0dGVybi4ifSwgeyJpZCI6ICJhdy0zMjgiLCAidHlwZSI6ICJpbWFnZSIsICJjb250ZW50IjogIkVWLTAwMjkgwrcgU1VSVkVJTExBTkNFIFRBUEUg4oCUIE9wZXJhdGlvbiBRdWlldCBTdG9ybSDigJQgcmVjb3JkcyByZXF1ZXN0IGNhcHR1cmUiLCAiYXNzZXQiOiAiZGF0YTppbWFnZS9zdmcreG1sLCUzQ3N2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc2NDAnIGhlaWdodD0nMjIwJyUzRSUzQ3JlY3Qgd2lkdGg9JzY0MCcgaGVpZ2h0PScyMjAnIGZpbGw9JyUyMzZiMzk0NicvJTNFJTNDcmVjdCB4PScxNCcgeT0nMTQnIHdpZHRoPSc2MTInIGhlaWdodD0nMTkyJyBmaWxsPSdub25lJyBzdHJva2U9J3doaXRlJyBzdHJva2Utb3BhY2l0eT0nMC41JyBzdHJva2Utd2lkdGg9JzInIHN0cm9rZS1kYXNoYXJyYXk9JzggNicvJTNFJTNDdGV4dCB4PSczMjAnIHk9Jzk4JyBmb250LXNpemU9JzMwJyBmaWxsPSd3aGl0ZScgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VFVi0wMDI5ICVDMiVCNyBTVVJWRUlMTEFOQ0UgVEFQRSUzQy90ZXh0JTNFJTNDdGV4dCB4PSczMjAnIHk9JzE0MCcgZm9udC1zaXplPScxNicgZmlsbD0nd2hpdGUnIGZpbGwtb3BhY2l0eT0nMC44NScgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VPcGVyYXRpb24gUXVpZXQgU3Rvcm0gJUUyJTgwJTk0IHJlY29yZHMgcmVxdWVzdCBjYXB0dXJlJTNDL3RleHQlM0UlM0N0ZXh0IHg9JzMyMCcgeT0nMTkwJyBmb250LXNpemU9JzEyJyBmaWxsPSd3aGl0ZScgZmlsbC1vcGFjaXR5PScwLjYnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFQklOT0NMRSBJTlRFTExJR0VOQ0UgQUdFTkNZICVFMiU4MCU5NCBTRUNVUkUgU0NBTiUzQy90ZXh0JTNFJTNDL3N2ZyUzRSJ9LCB7ImlkIjogImF3LTMyOSIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIlZlcmlmaWVkLiBQYWlyZWQgd2l0aCBFVi0wMDQxIGZvciB0aGUgdmVuZG9yLW1lZXRpbmcgdGltZWxpbmUuIn0sIHsiaWQiOiAiYXctMzMwIiwgInR5cGUiOiAiaW1hZ2UiLCAiY29udGVudCI6ICJFVi0wMDU1IMK3IFNVUlZFSUxMQU5DRSBUQVBFIOKAlCBPcGVyYXRpb24gUmVkIE1lcmlkaWFuIOKAlCBRVUFSQU5USU5FRCIsICJhc3NldCI6ICJkYXRhOmltYWdlL3N2Zyt4bWwsJTNDc3ZnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Zycgd2lkdGg9JzY0MCcgaGVpZ2h0PScyMjAnJTNFJTNDcmVjdCB3aWR0aD0nNjQwJyBoZWlnaHQ9JzIyMCcgZmlsbD0nJTIzN2EyZTJlJy8lM0UlM0NyZWN0IHg9JzE0JyB5PScxNCcgd2lkdGg9JzYxMicgaGVpZ2h0PScxOTInIGZpbGw9J25vbmUnIHN0cm9rZT0nd2hpdGUnIHN0cm9rZS1vcGFjaXR5PScwLjUnIHN0cm9rZS13aWR0aD0nMicgc3Ryb2tlLWRhc2hhcnJheT0nOCA2Jy8lM0UlM0N0ZXh0IHg9JzMyMCcgeT0nOTgnIGZvbnQtc2l6ZT0nMzAnIGZpbGw9J3doaXRlJyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRUVWLTAwNTUgJUMyJUI3IFNVUlZFSUxMQU5DRSBUQVBFJTNDL3RleHQlM0UlM0N0ZXh0IHg9JzMyMCcgeT0nMTQwJyBmb250LXNpemU9JzE2JyBmaWxsPSd3aGl0ZScgZmlsbC1vcGFjaXR5PScwLjg1JyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRU9wZXJhdGlvbiBSZWQgTWVyaWRpYW4gJUUyJTgwJTk0IFFVQVJBTlRJTkVEJTNDL3RleHQlM0UlM0N0ZXh0IHg9JzMyMCcgeT0nMTkwJyBmb250LXNpemU9JzEyJyBmaWxsPSd3aGl0ZScgZmlsbC1vcGFjaXR5PScwLjYnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFQklOT0NMRSBJTlRFTExJR0VOQ0UgQUdFTkNZICVFMiU4MCU5NCBTRUNVUkUgU0NBTiUzQy90ZXh0JTNFJTNDL3N2ZyUzRSJ9LCB7ImlkIjogImF3LTMzMSIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJFVi0wMDU1IGZhaWxlZCBpbnRlZ3JpdHkgdmVyaWZpY2F0aW9uIOKAlCBkbyBub3QgY2l0ZSB1bnRpbCBmb3JlbnNpY3MgY2xlYXJzIGl0LiIsICJjb2xvciI6ICLinYwifSwgeyJpZCI6ICJhdy0zMzIiLCAidHlwZSI6ICJkaXZpZGVyIiwgImNvbnRlbnQiOiAiIn0sIHsiaWQiOiAiYXctMzMzIiwgInR5cGUiOiAicXVvdGUiLCAiY29udGVudCI6ICJGaWxtIHdoYXQgaXMgdGhlcmUsIGxvZyB3aGF0IHlvdSBmaWxtZWQsIHRvdWNoIG5vdGhpbmcgZWxzZS4g4oCUIFYuIEFudG9ub3YifV0=','base64'),'utf8')::jsonb, now() - interval '12 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('b946751e-ca61-520f-aee7-ac68dd8d8b8f', 'b1a0c1e5-0000-4000-a000-000000000001', 'e163d75d-2334-52eb-8f22-ca7b7d663608', 'edf4ac74-3e63-49cc-8bbb-71d9aa3c09b0', 'Documents & Contracts', '📜', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzM0IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiRG9jdW1lbnRzICYgQ29udHJhY3RzIn0sIHsiaWQiOiAiYXctMzM1IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIkNvcnBvcmF0ZSBwYXBlcjogY29udHJhY3RzLCBtYW5pZmVzdHMsIGluY29ycG9yYXRpb24gZG9jdW1lbnRzLiBUaGUgbWlzLWludm9pY2luZyBjYXNlcyBsaXZlIGFuZCBkaWUgb24gdGhlc2UuIiwgImNvbG9yIjogIvCfk5wifSwgeyJpZCI6ICJhdy0zMzYiLCAidHlwZSI6ICJpbWFnZSIsICJjb250ZW50IjogIkVWLTAwMjYgwrcgU0hJUFBJTkcgTUFOSUZFU1Qg4oCUIE9wZXJhdGlvbiBDb2JhbHQgTGVkZ2VyIOKAlCB0aGUgY2FzZS1icmVha2VyIiwgImFzc2V0IjogImRhdGE6aW1hZ2Uvc3ZnK3htbCwlM0NzdmcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJyB3aWR0aD0nNjQwJyBoZWlnaHQ9JzIyMCclM0UlM0NyZWN0IHdpZHRoPSc2NDAnIGhlaWdodD0nMjIwJyBmaWxsPSclMjMyZjVmM2EnLyUzRSUzQ3JlY3QgeD0nMTQnIHk9JzE0JyB3aWR0aD0nNjEyJyBoZWlnaHQ9JzE5MicgZmlsbD0nbm9uZScgc3Ryb2tlPSd3aGl0ZScgc3Ryb2tlLW9wYWNpdHk9JzAuNScgc3Ryb2tlLXdpZHRoPScyJyBzdHJva2UtZGFzaGFycmF5PSc4IDYnLyUzRSUzQ3RleHQgeD0nMzIwJyB5PSc5OCcgZm9udC1zaXplPSczMCcgZmlsbD0nd2hpdGUnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFRVYtMDAyNiAlQzIlQjcgU0hJUFBJTkcgTUFOSUZFU1QlM0MvdGV4dCUzRSUzQ3RleHQgeD0nMzIwJyB5PScxNDAnIGZvbnQtc2l6ZT0nMTYnIGZpbGw9J3doaXRlJyBmaWxsLW9wYWNpdHk9JzAuODUnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFT3BlcmF0aW9uIENvYmFsdCBMZWRnZXIgJUUyJTgwJTk0IHRoZSBjYXNlLWJyZWFrZXIlM0MvdGV4dCUzRSUzQ3RleHQgeD0nMzIwJyB5PScxOTAnIGZvbnQtc2l6ZT0nMTInIGZpbGw9J3doaXRlJyBmaWxsLW9wYWNpdHk9JzAuNicgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VCSU5PQ0xFIElOVEVMTElHRU5DRSBBR0VOQ1kgJUUyJTgwJTk0IFNFQ1VSRSBTQ0FOJTNDL3RleHQlM0UlM0Mvc3ZnJTNFIn0sIHsiaWQiOiAiYXctMzM3IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiV2VpZ2h0L2luc3VyYW5jZSBtaXNtYXRjaGVzIGFjcm9zcyBuaW5lIHNoaXBtZW50cy4gVGhlIGV4aGliaXQgdGhhdCBjbG9zZWQgQ29iYWx0IExlZGdlci4ifSwgeyJpZCI6ICJhdy0zMzgiLCAidHlwZSI6ICJpbWFnZSIsICJjb250ZW50IjogIkVWLTAwMTcgwrcgU0hJUFBJTkcgTUFOSUZFU1Qg4oCUIE9wZXJhdGlvbiBSZWQgTWVyaWRpYW4g4oCUIGV4cG9ydCB2YWx1YXRpb24gc2V0IiwgImFzc2V0IjogImRhdGE6aW1hZ2Uvc3ZnK3htbCwlM0NzdmcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJyB3aWR0aD0nNjQwJyBoZWlnaHQ9JzIyMCclM0UlM0NyZWN0IHdpZHRoPSc2NDAnIGhlaWdodD0nMjIwJyBmaWxsPSclMjMzOTZiNDYnLyUzRSUzQ3JlY3QgeD0nMTQnIHk9JzE0JyB3aWR0aD0nNjEyJyBoZWlnaHQ9JzE5MicgZmlsbD0nbm9uZScgc3Ryb2tlPSd3aGl0ZScgc3Ryb2tlLW9wYWNpdHk9JzAuNScgc3Ryb2tlLXdpZHRoPScyJyBzdHJva2UtZGFzaGFycmF5PSc4IDYnLyUzRSUzQ3RleHQgeD0nMzIwJyB5PSc5OCcgZm9udC1zaXplPSczMCcgZmlsbD0nd2hpdGUnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFRVYtMDAxNyAlQzIlQjcgU0hJUFBJTkcgTUFOSUZFU1QlM0MvdGV4dCUzRSUzQ3RleHQgeD0nMzIwJyB5PScxNDAnIGZvbnQtc2l6ZT0nMTYnIGZpbGw9J3doaXRlJyBmaWxsLW9wYWNpdHk9JzAuODUnIHRleHQtYW5jaG9yPSdtaWRkbGUnIGZvbnQtZmFtaWx5PSdtb25vc3BhY2UnJTNFT3BlcmF0aW9uIFJlZCBNZXJpZGlhbiAlRTIlODAlOTQgZXhwb3J0IHZhbHVhdGlvbiBzZXQlM0MvdGV4dCUzRSUzQ3RleHQgeD0nMzIwJyB5PScxOTAnIGZvbnQtc2l6ZT0nMTInIGZpbGw9J3doaXRlJyBmaWxsLW9wYWNpdHk9JzAuNicgdGV4dC1hbmNob3I9J21pZGRsZScgZm9udC1mYW1pbHk9J21vbm9zcGFjZSclM0VCSU5PQ0xFIElOVEVMTElHRU5DRSBBR0VOQ1kgJUUyJTgwJTk0IFNFQ1VSRSBTQ0FOJTNDL3RleHQlM0UlM0Mvc3ZnJTNFIn0sIHsiaWQiOiAiYXctMzM5IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiQ29sbGVjdGVkIGJ5IFBpZXJyZSBNb3JlYXUuIEV4cG9ydCB2YWx1YXRpb25zIGZlZWQgdGhlIGluZmxhdGVkLXNwcmVhZCBtb2RlbC4ifSwgeyJpZCI6ICJhdy0zNDAiLCAidHlwZSI6ICJpbWFnZSIsICJjb250ZW50IjogIkVWLTAwODcgwrcgQ09OVFJBQ1Qg4oCUIE9wZXJhdGlvbiBOaWdodGZhbGwg4oCUIHBoYW50b20gZnJlaWdodCB0ZXJtcyIsICJhc3NldCI6ICJkYXRhOmltYWdlL3N2Zyt4bWwsJTNDc3ZnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Zycgd2lkdGg9JzY0MCcgaGVpZ2h0PScyMjAnJTNFJTNDcmVjdCB3aWR0aD0nNjQwJyBoZWlnaHQ9JzIyMCcgZmlsbD0nJTIzM2Y3MzUwJy8lM0UlM0NyZWN0IHg9JzE0JyB5PScxNCcgd2lkdGg9JzYxMicgaGVpZ2h0PScxOTInIGZpbGw9J25vbmUnIHN0cm9rZT0nd2hpdGUnIHN0cm9rZS1vcGFjaXR5PScwLjUnIHN0cm9rZS13aWR0aD0nMicgc3Ryb2tlLWRhc2hhcnJheT0nOCA2Jy8lM0UlM0N0ZXh0IHg9JzMyMCcgeT0nOTgnIGZvbnQtc2l6ZT0nMzAnIGZpbGw9J3doaXRlJyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRUVWLTAwODcgJUMyJUI3IENPTlRSQUNUJTNDL3RleHQlM0UlM0N0ZXh0IHg9JzMyMCcgeT0nMTQwJyBmb250LXNpemU9JzE2JyBmaWxsPSd3aGl0ZScgZmlsbC1vcGFjaXR5PScwLjg1JyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRU9wZXJhdGlvbiBOaWdodGZhbGwgJUUyJTgwJTk0IHBoYW50b20gZnJlaWdodCB0ZXJtcyUzQy90ZXh0JTNFJTNDdGV4dCB4PSczMjAnIHk9JzE5MCcgZm9udC1zaXplPScxMicgZmlsbD0nd2hpdGUnIGZpbGwtb3BhY2l0eT0nMC42JyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LWZhbWlseT0nbW9ub3NwYWNlJyUzRUJJTk9DTEUgSU5URUxMSUdFTkNFIEFHRU5DWSAlRTIlODAlOTQgU0VDVVJFIFNDQU4lM0MvdGV4dCUzRSUzQy9zdmclM0UifSwgeyJpZCI6ICJhdy0zNDEiLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJSZWNvdmVyZWQgYnkgSmFjayBTdWxsaXZhbi4gRnJlaWdodCB0ZXJtcyB3aXRoIG5vIGNvcnJlc3BvbmRpbmcgY2FyZ28gbW92ZW1lbnRzLiJ9LCB7ImlkIjogImF3LTM0MiIsICJ0eXBlIjogImRpdmlkZXIiLCAiY29udGVudCI6ICIifSwgeyJpZCI6ICJhdy0zNDMiLCAidHlwZSI6ICJ0YWJsZV9ibG9jayIsICJjb250ZW50IjogIiIsICJ0YWJsZURhdGEiOiBbWyJJdGVtIiwgIktpbmQiLCAiQ2FzZSIsICJTdGF0ZSJdLCBbIkVWLTAwMjYiLCAic2hpcHBpbmcgbWFuaWZlc3QiLCAiQklBLTIwMjUtMDAyIiwgInZlcmlmaWVkIOKchSJdLCBbIkVWLTAwMTciLCAic2hpcHBpbmcgbWFuaWZlc3QiLCAiQklBLTIwMjUtMDA4IiwgInZlcmlmaWVkIOKchSJdLCBbIkVWLTAwODciLCAiY29udHJhY3QiLCAiQklBLTIwMjYtMDAxIiwgInZlcmlmaWVkIOKchSJdLCBbIkVWLTAxMDAiLCAiY29udHJhY3QiLCAiQklBLTIwMjUtMDAyIiwgInZlcmlmaWVkIOKchSJdXSwgInRhYmxlQ29uZmlnIjogeyJoZWFkZXJSb3ciOiB0cnVlLCAic2hvd0JvcmRlcnMiOiB0cnVlLCAic3RyaXBlZFJvd3MiOiB0cnVlfX1d','base64'),'utf8')::jsonb, now() - interval '11 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('21f571d3-a006-55d8-ac5e-e26c5cd5fd6a', 'b1a0c1e5-0000-4000-a000-000000000001', 'e163d75d-2334-52eb-8f22-ca7b7d663608', 'ccbcc0cf-f94c-469c-8943-d9aeebf0140e', 'Custody Audit — Q2', '🧾', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzQ0IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiQ3VzdG9keSBBdWRpdCDigJQgUTIifSwgeyJpZCI6ICJhdy0zNDUiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiUXVhcnRlcmx5IHZlcmlmaWNhdGlvbiBzd2VlcCBhY3Jvc3MgdGhlIGV2aWRlbmNlIHZhdWx0LiAxMjAgaXRlbXMgYXVkaXRlZDsgb25lIGZhaWx1cmUsIHRocmVlIHBlbmRpbmcgcmUtY2hlY2tzLiIsICJjb2xvciI6ICLwn6e+In0sIHsiaWQiOiAiYXctMzQ2IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiRmluZGluZ3MifSwgeyJpZCI6ICJhdy0zNDciLCAidHlwZSI6ICJ0YWJsZV9ibG9jayIsICJjb250ZW50IjogIiIsICJ0YWJsZURhdGEiOiBbWyJFeGhpYml0IiwgIktpbmQiLCAiQ2FzZSIsICJSZXN1bHQiLCAiQWN0aW9uIl0sIFsiRVYtMDA1NSIsICJzdXJ2ZWlsbGFuY2UgdGFwZSIsICJCSUEtMjAyNS0wMDgiLCAiRkFJTEVEIOKdjCIsICJxdWFyYW50aW5lICsgZm9yZW5zaWMgcmV2aWV3Il0sIFsiRVYtMDA0MiIsICJ3aXJlIHJlY2VpcHQiLCAiQklBLTIwMjUtMDA5IiwgInJlLXZlcmlmeSDwn5WQIiwgImN1c3RvZHkgbG9nIHVwZGF0ZWQg4oCUIHNlY29uZCBjaGVjayBib29rZWQiXSwgWyJFVi0wMDExIiwgImxlZGdlciIsICJCSUEtMjAyNS0wMTQiLCAidmVyaWZpZWQg4pyFIiwgIm5vbmUiXSwgWyJFVi0wMDg0IiwgImxlZGdlciIsICJCSUEtMjAyNi0wMDEiLCAidmVyaWZpZWQg4pyFIiwgIm5vbmUiXSwgWyJFVi0wMDI2IiwgInNoaXBwaW5nIG1hbmlmZXN0IiwgIkJJQS0yMDI1LTAwMiIsICJ2ZXJpZmllZCDinIUiLCAiYXJjaGl2ZWQgKGNhc2UgY2xvc2VkKSJdXSwgInRhYmxlQ29uZmlnIjogeyJoZWFkZXJSb3ciOiB0cnVlLCAic2hvd0JvcmRlcnMiOiB0cnVlLCAic3RyaXBlZFJvd3MiOiB0cnVlfX0sIHsiaWQiOiAiYXctMzQ4IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiRVYtMDA1NSBpbmNpZGVudCJ9LCB7ImlkIjogImF3LTM0OSIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICJTZWFsIGF1ZGl0IGZvdW5kIHRoZSBvdXRlciBiYWcgaWQgcmUtbG9nZ2VkIHdpdGhvdXQgY3Jvc3MtcmVmZXJlbmNlIHRvIHRoZSBvcmlnaW5hbC4ifSwgeyJpZCI6ICJhdy0zNTAiLCAidHlwZSI6ICJudW1iZXJlZF9saXN0IiwgImNvbnRlbnQiOiAiVGFwZSBpdHNlbGYgc2hvd3Mgbm8gc3BsaWNlIGFydGVmYWN0cyBvbiBmaXJzdCBwYXNzOyBkZWVwIHZlcmlmaWNhdGlvbiBydW5uaW5nLiJ9LCB7ImlkIjogImF3LTM1MSIsICJ0eXBlIjogIm51bWJlcmVkX2xpc3QiLCAiY29udGVudCI6ICJVbnRpbCBjbGVhcmVkLCBSZWQgTWVyaWRpYW4ncyByZWZlcnJhbCBwYWNrYWdlIGV4Y2x1ZGVzIEVWLTAwNTUgKE1hcmN1cyBub3RpZmllZCkuIn0sIHsiaWQiOiAiYXctMzUyIiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIlJvb3QgY2F1c2Ugd2FzIGEgdHJhbnNwb3J0IGhhbmQtb2ZmIGxvZ2dlZCBhdCBvcmlnaW4gb25seSDigJQgZXhhY3RseSB0aGUgbWlzdGFrZSBjYWxsZWQgb3V0IGluIHRoZSBFdmlkZW5jZSBDdXN0b2R5IFByb3RvY29sLiBSZWZyZXNoZXIgc2Vzc2lvbiBzY2hlZHVsZWQuIiwgImNvbG9yIjogIuKaoO+4jyJ9LCB7ImlkIjogImF3LTM1MyIsICJ0eXBlIjogInRvX2RvIiwgImNvbnRlbnQiOiAiRGVlcCB2ZXJpZmljYXRpb24gb2YgRVYtMDA1NSAoZm9yZW5zaWNzKSIsICJjaGVja2VkIjogZmFsc2V9LCB7ImlkIjogImF3LTM1NCIsICJ0eXBlIjogInRvX2RvIiwgImNvbnRlbnQiOiAiUmUtdmVyaWZ5IEVWLTAwNDIgY3VzdG9keSBsb2ciLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0zNTUiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIkN1c3RvZHkgcmVmcmVzaGVyIGZvciB0cmFuc3BvcnQgY291cmllcnMiLCAiY2hlY2tlZCI6IGZhbHNlfV0=','base64'),'utf8')::jsonb, now() - interval '10 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('f969a3c7-6b42-5e98-967d-b9271cfc481c', 'b1a0c1e5-0000-4000-a000-000000000001', NULL, '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', '📓 Analyst Notebooks', '📓', 'https://images.unsplash.com/photo-1542831371-29b0f74f9713?auto=format&fit=crop&w=1200&q=80', NULL, 'shared', '[]'::jsonb, convert_from(decode('W10=','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzU2IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiQW5hbHlzdCBOb3RlYm9va3MifSwgeyJpZCI6ICJhdy0zNTciLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiV29ya2luZyBub3Rlcywgb25lIG5vdGVib29rIHBlciBhbmFseXN0LiBOb3RlYm9va3MgYXJlICp0aGlua2luZyBzcGFjZSog4oCUIGh5cG90aGVzZXMgbGl2ZSBoZXJlIHVudGlsIHRoZXkgZWFybiBhIHBsYWNlIG9uIGEgY2FzZSB3aWtpIHdpdGggYW4gaWQgYXR0YWNoZWQuIiwgImNvbG9yIjogIvCfk5MifSwgeyJpZCI6ICJhdy0zNTgiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiRXJpayBKb2hhbnNzb24g4oCUIHRyYW5zYWN0aW9uIHBhdHRlcm5zLCBzdHJ1Y3R1cmluZywgbWl4ZXIgZXhpdHMuIn0sIHsiaWQiOiAiYXctMzU5IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIlByaXlhIFNoYXJtYSDigJQgY291bnRlcnBhcnR5IGdyYXBoLCBjcm9zcy1jYXNlIGVudGl0eSByZXNvbHV0aW9uLiJ9LCB7ImlkIjogImF3LTM2MCIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJDYXJsb3MgTWVuZGV6IOKAlCBjb3JyaWRvcnMgKGhhd2FsYSwgZnJlaWdodCksIE5BIHJlZ2lvbiBmb2N1cy4ifSwgeyJpZCI6ICJhdy0zNjEiLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiV2F0Y2hsaXN0IOKAlCBDb3VudGVycGFydGllczogdGhlIHNoYXJlZCBtZW1vIHRoZSB0aHJlZSBrZWVwIGN1cnJlbnQuIn1d','base64'),'utf8')::jsonb, now() - interval '9 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('14655bac-c7a2-5164-8adf-4af72938c092', 'b1a0c1e5-0000-4000-a000-000000000001', 'f969a3c7-6b42-5e98-967d-b9271cfc481c', '4128e6d8-3a8a-48c7-bb96-cf8cf0ff6aa2', 'Erik Johansson — Working Notes', '🧮', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyIwMTIzYWU3YS1jN2MzLTU4N2UtYTNjNS03NThlOGE1YTQzYjEiLCAiMGEwMjg2NWYtMTVhOS01MTZjLWE1NGQtMTE0NDg2MjFhMDRhIl0sICJyZWxhdGlvblRhcmdldCI6ICJwYWdlIn1d','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzYyIiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiV29ya2luZyBOb3RlcyDigJQgRXJpayBKb2hhbnNzb24ifSwgeyJpZCI6ICJhdy0zNjMiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiQW5hbHlzaXMgZGVwdCDCtyBjbGVhcmFuY2UgMyDCtyBFVS4gQW1vdW50cyBhYm92ZSB0aHJlc2hvbGQgcmVuZGVyIG1hc2tlZCBvbiBteSB2aWV3OyBwYXR0ZXJucyBiZWxvdyBhcmUgY29tcHV0ZWQgb24gdGhlIG1hc2tlZCBzZXJpZXMgKHdoaWNoIGlzIHRoZSBwb2ludCkuIiwgImNvbG9yIjogIvCfp64ifSwgeyJpZCI6ICJhdy0zNjQiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJUWE4gcGF0dGVybjogc3RydWN0dXJpbmcgdW5kZXIgMTBrIHZpYSBHb2xkcXVheSJ9LCB7ImlkIjogImF3LTM2NSIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIlJlY3VycmluZyBtb3ZlbWVudHMgc2l0IGp1c3QgdW5kZXIgdGhlIHJlcG9ydGluZyB0aHJlc2hvbGQ6IFRYTi0wMDEwICg5LDc2OS41NyBFVVIsIHdpcmUsIFdlc3Rwb3J0IENhcGl0YWwgU0EpLCBUWE4tMDAwMiAoNSwxNDUuODggVVNEVCwgY2FzaCBkZXBvc2l0LCBHb2xkcXVheSBFeGNoYW5nZSksIFRYTi0wMDE0ICg2LDA2NC45NiBVU0RULCBjcnlwdG8sIEdvbGRxdWF5KS4gU3ViLXRocmVzaG9sZCBkZW5zaXR5IGFnYWluc3QgR29sZHF1YXkgaXMgMy4xw5cgdGhlIGNvcnJpZG9yIGJhc2VsaW5lIOKAlCB0aGF0IGlzIG5vdCBub2lzZS4ifSwgeyJpZCI6ICJhdy0zNjYiLCAidHlwZSI6ICJ0YWJsZV9ibG9jayIsICJjb250ZW50IjogIiIsICJ0YWJsZURhdGEiOiBbWyJUWE4iLCAiQW1vdW50IiwgIk1ldGhvZCIsICJDb3VudGVycGFydHkiLCAiRmxhZyJdLCBbIlRYTi0wMDEwIiwgIjksNzY5LjU3IEVVUiIsICJ3aXJlIiwgIldlc3Rwb3J0IENhcGl0YWwgU0EiLCAi8J+aqSJdLCBbIlRYTi0wMDAyIiwgIjUsMTQ1Ljg4IFVTRFQiLCAiY2FzaCBkZXBvc2l0IiwgIkdvbGRxdWF5IEV4Y2hhbmdlIiwgIvCfmqkiXSwgWyJUWE4tMDAxNCIsICI2LDA2NC45NiBVU0RUIiwgImNyeXB0byIsICJHb2xkcXVheSBFeGNoYW5nZSIsICLwn5qpIl0sIFsiVFhOLTAwMDkiLCAiNCw5NzQuODIgQUVEIiwgImNyeXB0byIsICJXZXN0cG9ydCBDYXBpdGFsIFNBIiwgIvCfmqkiXV0sICJ0YWJsZUNvbmZpZyI6IHsiaGVhZGVyUm93IjogdHJ1ZSwgInNob3dCb3JkZXJzIjogdHJ1ZSwgInN0cmlwZWRSb3dzIjogdHJ1ZX19LCB7ImlkIjogImF3LTM2NyIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJPdXRsaWVyOiAqKlRYTi0wMDE2IOKAlCAzMDYsNDU0LjU2IEVVUiB2aWEgaGF3YWxhIHRvIEdvbGRxdWF5IEV4Y2hhbmdlLioqIEEgc2l4LWZpZ3VyZSBoYXdhbGEgbW92ZW1lbnQgdG8gYW4gKmV4Y2hhbmdlKiBicmVha3MgdGhlIHN0cnVjdHVyaW5nIHByb2ZpbGUgZW50aXJlbHkuIEVpdGhlciBhIHNldHRsZW1lbnQgYmV0d2VlbiBvcGVyYXRvcnMgb3Igc29tZW9uZSBnb3Qgc2xvcHB5LiBFc2NhbGF0ZWQgdG8gTWFyY3VzLiIsICJjb2xvciI6ICLwn5SlIn0sIHsiaWQiOiAiYXctMzY4IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiTWl4ZXIgZXhpdCB0aW1pbmcgKEJyb2tlbiBDb21wYXNzIHN1cHBvcnQpIn0sIHsiaWQiOiAiYXctMzY5IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkV4aXQgY2x1c3RlciAwMzowMOKAkzA1OjAwIFVUQyBjb3JyZWxhdGVzIHdpdGggR29sZHF1YXkgc2V0dGxlbWVudCBiYXRjaGVzIGF0IDAuNzguIn0sIHsiaWQiOiAiYXctMzcwIiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIklmIGF1dG9tYXRpb24sIGV4cGVjdCB0aGUgc2FtZSBvZmZzZXRzIG5leHQgY3ljbGUg4oCUIHdhdGNoIGJvb2tlZCBmb3IgVGh1cnNkYXkuIn0sIHsiaWQiOiAiYXctMzcyIiwgInR5cGUiOiAidG9nZ2xlIiwgImNvbnRlbnQiOiAi4pa4IE1ldGhvZCBub3RlOiB3b3JraW5nIG9uIG1hc2tlZCBzZXJpZXMiLCAiY2hpbGRyZW4iOiBbeyJpZCI6ICJhdy0zNzEiLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJDbGVhcmFuY2UgMyBtYXNrcyBleGFjdCBhbW91bnRzIGFib3ZlIHRocmVzaG9sZCwgc28gSSBidWNrZXQgdG8gNTAwLXVuaXQgYmFuZHMgYW5kIHRlc3QgZGVuc2l0eSwgbm90IHZhbHVlcy4gUGF0dGVybiBjb25jbHVzaW9ucyBzdXJ2aXZlIHRoZSBtYXNrOyB0b3RhbHMgZ28gdG8gUm9iZXJ0IGZvciB0aGUgY2xlYXJhbmNlLTQgcmVjb25jaWxpYXRpb24uIn1dLCAiY29sbGFwc2VkIjogdHJ1ZX0sIHsiaWQiOiAiYXctMzczIiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiTGl2ZSB0cmFuc2FjdGlvbnMgKG15IG1hc2tlZCB2aWV3KSJ9LCB7ImlkIjogImF3LTM3NCIsICJ0eXBlIjogImRhdGFiYXNlX2lubGluZSIsICJjb250ZW50IjogIiIsICJkYXRhYmFzZUlkIjogImJhYXM6ZDNlY2IzZTEtOTk0Ny00MWE2LWEwZDMtZmYyMDYzYjRhZGVlOnRyYW5zYWN0aW9ucyJ9LCB7ImlkIjogImF3LTM3NSIsICJ0eXBlIjogInRvX2RvIiwgImNvbnRlbnQiOiAiVGh1cnNkYXk6IHJlLXJ1biBleGl0LWNsdXN0ZXIgY29ycmVsYXRpb24gYWZ0ZXIgR29sZHF1YXkgYmF0Y2giLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0zNzYiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIkhhbmQgVFhOLTAwMTYgbWVtbyB0byBNYXJjdXMgYmVmb3JlIFdhciBSb29tIiwgImNoZWNrZWQiOiB0cnVlfV0=','base64'),'utf8')::jsonb, now() - interval '8 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('8f1ab38c-3bac-5f4f-8351-d679b95b8030', 'b1a0c1e5-0000-4000-a000-000000000001', 'f969a3c7-6b42-5e98-967d-b9271cfc481c', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Priya Sharma — Working Notes', '🕸️', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyIyMWM2YzUwMy0zNjQ1LTUyNGYtYTEyNC0xMDNiMTBjYzk2NTUiLCAiYzUwYzNjZTItNTA3MS01MzJkLTgyZmUtZTQ0ZTdmZTkxMThmIl0sICJyZWxhdGlvblRhcmdldCI6ICJwYWdlIn1d','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzc3IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiV29ya2luZyBOb3RlcyDigJQgUHJpeWEgU2hhcm1hIn0sIHsiaWQiOiAiYXctMzc4IiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIkFuYWx5c2lzIGRlcHQgwrcgY2xlYXJhbmNlIDMgwrcgQVBBQy4gRW50aXR5IHJlc29sdXRpb24gYWNyb3NzIGNhc2VzIOKAlCB3aG8gaXMgYWN0dWFsbHkgdGhlIHNhbWUgbm9kZSB3ZWFyaW5nIGRpZmZlcmVudCBwYXBlcndvcmsuIiwgImNvbG9yIjogIvCflbjvuI8ifSwgeyJpZCI6ICJhdy0zNzkiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJDb3VudGVycGFydHkgZ3JhcGgg4oCUIGNyb3NzLWNhc2UgZGVncmVlIn0sIHsiaWQiOiAiYXctMzgwIiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiUmFua2luZyBjb3VudGVycGFydGllcyBieSBob3cgbWFueSBkaXN0aW5jdCBvcGVyYXRpb25zIHRoZXkgYnJ1c2ggYWdhaW5zdC4gQW55dGhpbmcg4omlMyBpcyBubyBsb25nZXIgYSBjb2luY2lkZW5jZTsgaXQgaXMgaW5mcmFzdHJ1Y3R1cmUuIn0sIHsiaWQiOiAiYXctMzgxIiwgInR5cGUiOiAidGFibGVfYmxvY2siLCAiY29udGVudCI6ICIiLCAidGFibGVEYXRhIjogW1siQ291bnRlcnBhcnR5IiwgIkNhc2VzIHRvdWNoZWQiLCAiRGVncmVlIiwgIk5vdGUiXSwgWyJHb2xkcXVheSBFeGNoYW5nZSIsICJOaWdodGZhbGwsIEFzaCBHYXJkZW4sIEJyb2tlbiBDb21wYXNzLCBRdWlldCBTdG9ybSIsICI0IiwgInNldHRsZW1lbnQgaHViIl0sIFsiV2VzdHBvcnQgQ2FwaXRhbCBTQSIsICJOaWdodGZhbGwsIENvYmFsdCBMZWRnZXIgKGNsb3NlZCkiLCAiMisiLCAicmUtb2ZmZW5kZXIg4oCUIHN0YW5kaW5nIHdhdGNoIl0sIFsiS2VzdHJlbCBNYXJpbmUgTHRkIiwgIlJlZCBNZXJpZGlhbiwgQnJva2VuIENvbXBhc3MiLCAiMiIsICJjaGFydGVyIHNob3AsIG1hcmluYSBsaW5rIl0sIFsiQXVyZWxpYSBUcmFkZSBGWkUiLCAiUXVpZXQgU3Rvcm0iLCAiMSIsICJyZWdpc3RlcmVkLWFnZW50IG92ZXJsYXAgcGVuZGluZyJdLCBbIk5vdnVtIEVzdGF0ZXMiLCAiQnJva2VuIENvbXBhc3MiLCAiMSIsICJwcm9wZXJ0eS1mbGlwIGNvdW50ZXJwYXJ0Il1dLCAidGFibGVDb25maWciOiB7ImhlYWRlclJvdyI6IHRydWUsICJzaG93Qm9yZGVycyI6IHRydWUsICJzdHJpcGVkUm93cyI6IHRydWV9fSwgeyJpZCI6ICJhdy0zODIiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiVGhlICdwYXBlcndvcmsnIHBocmFzaW5nIGluIENPTU0tMDA3NyAoUXVpZXQgU3Rvcm0pIGFuZCBDT01NLTAwNDEgKEFzaCBHYXJkZW4pIHBsdXMgdGhlIHNoYXJlZCB1bnJlZ2lzdGVyZWQtU0lNIGRldmljZSBmYW1pbHkgc3VnZ2VzdHMgb25lIGZpeGVyIHNlcnZpY2luZyBtdWx0aXBsZSByaW5ncy4gTmFtaW5nIHRoZSBub2RlIEZJWEVSLTAxIHVudGlsIHdlIGhhdmUgYmV0dGVyLiIsICJjb2xvciI6ICLwn5KhIn0sIHsiaWQiOiAiYXctMzgzIiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiRW50aXR5IHJlc29sdXRpb24gcXVldWUifSwgeyJpZCI6ICJhdy0zODQiLCAidHlwZSI6ICJ0b19kbyIsICJjb250ZW50IjogIk1lcmdlIGNhbmRpZGF0ZTogJ0hhbGN5b24nIGFsaWFzIGFwcGVhcnMgb24gdHdvIGRpZmZlcmVudCBzdWJqZWN0cyAoUy0wMDAzLCBTLTAwMDkpIOKAlCB0cnVlIGR1cGxpY2F0ZSBvciBhbGlhcyBjb2xsaXNpb24/IiwgImNoZWNrZWQiOiBmYWxzZX0sIHsiaWQiOiAiYXctMzg1IiwgInR5cGUiOiAidG9fZG8iLCAiY29udGVudCI6ICJDb25maXJtIEtlc3RyZWwgY2hhcnRlciBtYW5hZ2VyIGlkZW50aXR5IGZyb20gR3JhY2UncyBtYXJpbmEgbG9nIChMLTAwMjIpIiwgImNoZWNrZWQiOiBmYWxzZX0sIHsiaWQiOiAiYXctMzg2IiwgInR5cGUiOiAidG9fZG8iLCAiY29udGVudCI6ICJBdXJlbGlhIOKGlCBDeWdudXMgcmVnaXN0ZXJlZC1hZ2VudCBjb21wYXJpc29uICh3aXRoIENhcmxvcykiLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy0zODgiLCAidHlwZSI6ICJ0b2dnbGUiLCAiY29udGVudCI6ICLilrggV2h5IGFsaWFzIGNvbGxpc2lvbnMgbWF0dGVyIiwgImNoaWxkcmVuIjogW3siaWQiOiAiYXctMzg3IiwgInR5cGUiOiAicGFyYWdyYXBoIiwgImNvbnRlbnQiOiAiVHdvIHN1YmplY3RzIHNoYXJpbmcgdGhlIGFsaWFzIOKAnEhhbGN5b27igJ0gd2lsbCBzaWxlbnRseSBtZXJnZSBpbiBhbnkgbmFpdmUgZ3JhcGguIEtleXMgbXVzdCBiZSAoc3ViamVjdF9pZCksIG5ldmVyIChhbGlhcykg4oCUIHRoZSBkYXNoYm9hcmQgam9pbiB3YXMgcXVpZXRseSB3cm9uZyB1bnRpbCAwMiBKdW5lLiJ9XSwgImNvbGxhcHNlZCI6IHRydWV9LCB7ImlkIjogImF3LTM4OSIsICJ0eXBlIjogImRpdmlkZXIiLCAiY29udGVudCI6ICIifSwgeyJpZCI6ICJhdy0zOTAiLCAidHlwZSI6ICJxdW90ZSIsICJjb250ZW50IjogIkEgbmV0d29yayBpcyBqdXN0IGEgbGlzdCBvZiBwZW9wbGUgd2hvIGFuc3dlciB0aGUgc2FtZSBwaG9uZS4g4oCUIFAuIFNoYXJtYSJ9XQ==','base64'),'utf8')::jsonb, now() - interval '7 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('6ae73149-e924-5384-bf26-c4b925078758', 'b1a0c1e5-0000-4000-a000-000000000001', 'f969a3c7-6b42-5e98-967d-b9271cfc481c', 'b371611d-139f-454b-9195-2b655e1fe8a6', 'Carlos Mendez — Working Notes', '🚢', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyJjNTBjM2NlMi01MDcxLTUzMmQtODJmZS1lNDRlN2ZlOTExOGYiLCAiYjczMDc1NDgtMWUzOS01OGI5LWIzYzMtZTY3ZjY0NzAwYTM1Il0sICJyZWxhdGlvblRhcmdldCI6ICJwYWdlIn1d','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctMzkxIiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiV29ya2luZyBOb3RlcyDigJQgQ2FybG9zIE1lbmRleiJ9LCB7ImlkIjogImF3LTM5MiIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJBbmFseXNpcyBkZXB0IMK3IGNsZWFyYW5jZSAzIMK3IE5BLiBDb3JyaWRvcnM6IGhhd2FsYSwgZnJlaWdodCwgYW55dGhpbmcgdGhhdCBtb3ZlcyB2YWx1ZSB3aXRob3V0IG1vdmluZyBwYXBlcndvcmsuIiwgImNvbG9yIjogIvCfmqIifSwgeyJpZCI6ICJhdy0zOTMiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJIYXdhbGEgY29ycmlkb3IgYW5hbHlzaXMifSwgeyJpZCI6ICJhdy0zOTQiLCAidHlwZSI6ICJwYXJhZ3JhcGgiLCAiY29udGVudCI6ICJIYXdhbGEtbWV0aG9kIHRyYW5zYWN0aW9ucyBjbHVzdGVyIG9uIHR3byBjb3JyaWRvcnMuIFRoZSBOQSBsZWcgY29uc2lzdGVudGx5IHJvdXRlcyB2aWEgKipQZWxpY2FuIEZyZWlnaHQgTExDKiogKGUuZy4gVFhOLTAwMTMsIDYsMTc1LjA3IFVTRFQsIHdpcmUpIOKAlCBmcmVpZ2h0IGludm9pY2VzIGFjdCBhcyB0aGUgc2V0dGxlbWVudCBtZW1vIGJldHdlZW4gaGF3YWxhZGFycy4gVGhlIEVVIGxlZyBpcyB3aGVyZSBUWE4tMDAxNidzIHNpeC1maWd1cmUgbW92ZW1lbnQgKEVyaWsncyBvdXRsaWVyKSBjcm9zc2VkLiJ9LCB7ImlkIjogImF3LTM5NSIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJQYXR0ZXJuOiBmcmVpZ2h0IGludm9pY2UgaXNzdWVkIOKGkiBoYXdhbGEgc2V0dGxlbWVudCB3aXRoaW4gNzJoIOKGkiBpbnZvaWNlIGNhbmNlbGxlZCBvciBjcmVkaXRlZCB0aGUgZm9sbG93aW5nIHdlZWsuIn0sIHsiaWQiOiAiYXctMzk2IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkNhbmNlbGxhdGlvbiByYXRlIGF0IFBlbGljYW46IDMxJSBvZiBpbnZvaWNlcyB2cy4gNCUgaW5kdXN0cnkgbm9ybS4ifSwgeyJpZCI6ICJhdy0zOTciLCAidHlwZSI6ICJidWxsZXRlZF9saXN0IiwgImNvbnRlbnQiOiAiU2FibGUgVHJ1c3QgKEJWSSkgdGFrZXMgdGhlIHJlc2lkdWFsczogVFhOLTAwMjQgKDc2LDEwMi4zOCBFVVIsIGludm9pY2UgbWV0aG9kKSBmaXRzIHRoZSByZXNpZHVhbCBwcm9maWxlIGV4YWN0bHkuIn0sIHsiaWQiOiAiYXctMzk4IiwgInR5cGUiOiAiaGVhZGluZ18yIiwgImNvbnRlbnQiOiAiU2hlbGwgaW5jb3Jwb3JhdGlvbiB0aW1pbmcgKEFzaCBHYXJkZW4gYXNzaXN0KSJ9LCB7ImlkIjogImF3LTM5OSIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkphY2sncyB0aHJlZSBzaGVsbHMgaW5jb3Jwb3JhdGUgMTjigJMyNCBkYXlzIGJlZm9yZSBlYWNoIHByb2N1cmVtZW50IHdpbmRvdy4gUXVpZXQgU3Rvcm0ncyB2ZW5kb3JzIHNob3cgMTksIDIxIGFuZCAyMy1kYXkgb2Zmc2V0cy4gU2FtZSBwbGF5Ym9vaywgcG9zc2libHkgdGhlIHNhbWUgZm9ybWF0aW9uIGFnZW50IOKAlCBjaGVja2luZyB0aGUgcmVnaXN0ZXJlZC1hZ2VudCBvdmVybGFwIHdpdGggUHJpeWEuIn0sIHsiaWQiOiAiYXctNDAxIiwgInR5cGUiOiAidG9nZ2xlIiwgImNvbnRlbnQiOiAi4pa4IFdvcmtpbmcgcXVlcnkgKG1hc2tlZCB2aWV3KSIsICJjaGlsZHJlbiI6IFt7ImlkIjogImF3LTQwMCIsICJ0eXBlIjogInBhcmFncmFwaCIsICJjb250ZW50IjogIkdyb3VwIHRyYW5zYWN0aW9ucyBieSBtZXRob2Qgw5cgY291bnRlcnBhcnR5LCBmbGFnIHdoZXJlIGhhd2FsYSBzaGFyZSBleGNlZWRzIDIwJSBhbmQgdGhlIGNvdW50ZXJwYXJ0eSBhbHNvIGFwcGVhcnMgb24gYSBmcmVpZ2h0IG1hbmlmZXN0LiBUaHJlZSBoaXRzOiBQZWxpY2FuLCBLZXN0cmVsLCBhbmQgb25lIG5ldyBuYW1lIGhlbGQgYmFjayB1bnRpbCB2ZXJpZmllZC4ifV0sICJjb2xsYXBzZWQiOiB0cnVlfSwgeyJpZCI6ICJhdy00MDIiLCAidHlwZSI6ICJjYWxsb3V0IiwgImNvbnRlbnQiOiAiQ2xlYXJhbmNlIG5vdGU6IEkgY2Fubm90IHNlZSBpbmZvcm1hbnQgaWRlbnRpdGllcyBiZWhpbmQgdGhlIGJhbmsgYWxlcnRzIOKAlCBpZiBhIGxlYWQgbmVlZHMgc291cmNlIGNvbnRleHQsIGl0IGdvZXMgdGhyb3VnaCBTb2ZpYS4iLCAiY29sb3IiOiAi4pqg77iPIn0sIHsiaWQiOiAiYXctNDAzIiwgInR5cGUiOiAidG9fZG8iLCAiY29udGVudCI6ICJSZWdpc3RlcmVkLWFnZW50IG92ZXJsYXAgbWVtbyB3aXRoIFByaXlhIChkdWUgRnJpZGF5KSIsICJjaGVja2VkIjogZmFsc2V9LCB7ImlkIjogImF3LTQwNCIsICJ0eXBlIjogInRvX2RvIiwgImNvbnRlbnQiOiAiUHVsbCBQZWxpY2FuIGNhbmNlbGxhdGlvbiBsZWRnZXIgZm9yIFExIChyZWNvcmRzIHJlcXVlc3QgZHJhZnRlZCkiLCAiY2hlY2tlZCI6IGZhbHNlfSwgeyJpZCI6ICJhdy00MDUiLCAidHlwZSI6ICJkaXZpZGVyIiwgImNvbnRlbnQiOiAiIn0sIHsiaWQiOiAiYXctNDA2IiwgInR5cGUiOiAicXVvdGUiLCAiY29udGVudCI6ICJQYXBlciBkb2Vzbid0IG1vdmUgbW9uZXkuIFBlb3BsZSBtb3ZlIG1vbmV5OyBwYXBlciBhcG9sb2dpc2VzIGZvciBpdC4g4oCUIEMuIE1lbmRleiJ9XQ==','base64'),'utf8')::jsonb, now() - interval '6 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at) VALUES ('21c6c503-3645-524f-a124-103b10cc9655', 'b1a0c1e5-0000-4000-a000-000000000001', 'f969a3c7-6b42-5e98-967d-b9271cfc481c', 'fb1bbb39-0d85-4bc5-ae58-2ad5a6079d57', 'Watchlist — Counterparties (Joint Memo)', '👁️', NULL, NULL, 'shared', '[]'::jsonb, convert_from(decode('W3sia2V5IjogInJlbGF0ZWQiLCAibGFiZWwiOiAiUmVsYXRlZCIsICJ0eXBlIjogInJlbGF0aW9uIiwgInZhbHVlIjogWyIxNDY1NWJhYy1jN2EyLTUxNjQtOGFkZi00YWY3MjkzOGMwOTIiLCAiOGYxYWIzOGMtM2JhYy01ZjRmLTgzNTEtZDY3OWI5NWI4MDMwIiwgIjZhZTczMTQ5LWU5MjQtNTM4NC1iZjI2LWM0YjkyNTA3ODc1OCJdLCAicmVsYXRpb25UYXJnZXQiOiAicGFnZSJ9XQ==','base64'),'utf8')::jsonb, convert_from(decode('W3siaWQiOiAiYXctNDA3IiwgInR5cGUiOiAiaGVhZGluZ18xIiwgImNvbnRlbnQiOiAiV2F0Y2hsaXN0IOKAlCBDb3VudGVycGFydGllcyJ9LCB7ImlkIjogImF3LTQwOCIsICJ0eXBlIjogImNhbGxvdXQiLCAiY29udGVudCI6ICJKb2ludCBtZW1vIChFcmlrIMK3IFByaXlhIMK3IENhcmxvcykuIEEgY291bnRlcnBhcnR5IGVudGVycyB0aGUgd2F0Y2hsaXN0IG9uIHR3byBpbmRlcGVuZGVudCBzaWduYWxzOyBpdCBsZWF2ZXMgb25seSBieSBjYXNlIGNsb3N1cmUgb3IgcHJvdmVuIGlubm9jZW5jZS4iLCAiY29sb3IiOiAi8J+Rge+4jyJ9LCB7ImlkIjogImF3LTQwOSIsICJ0eXBlIjogInRhYmxlX2Jsb2NrIiwgImNvbnRlbnQiOiAiIiwgInRhYmxlRGF0YSI6IFtbIkVudGl0eSIsICJTaWduYWxzIiwgIldhdGNoIGxldmVsIiwgIk93bmVyIl0sIFsiR29sZHF1YXkgRXhjaGFuZ2UiLCAic3RydWN0dXJpbmcgZGVuc2l0eSwgbWl4ZXIgZXhpdCBjb3JyZWxhdGlvbiwgNC1jYXNlIGRlZ3JlZSIsICLwn5S0IGhpZ2giLCAiRXJpayJdLCBbIldlc3Rwb3J0IENhcGl0YWwgU0EiLCAiQ29iYWx0IExlZGdlciBjb252aWN0aW9uIHJlY29yZCwgTmlnaHRmYWxsIHJlY3VycmVuY2UiLCAi8J+UtCBoaWdoIiwgIlByaXlhIl0sIFsiS2VzdHJlbCBNYXJpbmUgTHRkIiwgIm1hcmluYSBsZWFkIEwtMDAyMiwgQnJva2VuIENvbXBhc3MgY2hhcnRlciBwYXltZW50cyIsICLwn5+gIGVsZXZhdGVkIiwgIkNhcmxvcyJdLCBbIlBlbGljYW4gRnJlaWdodCBMTEMiLCAiMzElIGludm9pY2UgY2FuY2VsbGF0aW9uLCBoYXdhbGEgc2V0dGxlbWVudCBtZW1vIHBhdHRlcm4iLCAi8J+foCBlbGV2YXRlZCIsICJDYXJsb3MiXSwgWyJTYWJsZSBUcnVzdCAoQlZJKSIsICJyZXNpZHVhbCBzaW5rLCBUWE4tMDAyNCBwcm9maWxlIiwgIvCfn6EgbW9uaXRvciIsICJFcmlrIl0sIFsiQXVyZWxpYSBUcmFkZSBGWkUiLCAiUXVpZXQgU3Rvcm0gc2V0dGxlbWVudCBob3AiLCAi8J+foSBtb25pdG9yIiwgIlByaXlhIl0sIFsiQ3lnbnVzIENvbnN1bHRpbmcgR21iSCIsICJUWE4tMDAxOCAoMzgxLDg4MS40OSBFVVIgY2FzaCBkZXBvc2l0KSwgdmVuZG9yIG92ZXJsYXAiLCAi8J+foCBlbGV2YXRlZCIsICJQcml5YSJdXSwgInRhYmxlQ29uZmlnIjogeyJoZWFkZXJSb3ciOiB0cnVlLCAic2hvd0JvcmRlcnMiOiB0cnVlLCAic3RyaXBlZFJvd3MiOiB0cnVlfX0sIHsiaWQiOiAiYXctNDEwIiwgInR5cGUiOiAiY2FsbG91dCIsICJjb250ZW50IjogIkFkZGluZyBhbiBlbnRpdHk/IFR3byBzaWduYWxzLCB3cml0dGVuIGRvd24sIHdpdGggaWRzLiBHdXQgZmVlbGluZ3MgZ28gaW4geW91ciBvd24gbm90ZWJvb2sgdW50aWwgdGhleSBncm93IHVwLiIsICJjb2xvciI6ICLinZcifSwgeyJpZCI6ICJhdy00MTEiLCAidHlwZSI6ICJoZWFkaW5nXzIiLCAiY29udGVudCI6ICJSZWNlbnQgY2hhbmdlcyJ9LCB7ImlkIjogImF3LTQxMiIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJLZXN0cmVsIE1hcmluZSBMdGQgcmFpc2VkIHRvIGVsZXZhdGVkIGFmdGVyIHRoZSBCcm9rZW4gQ29tcGFzcyBjaGFydGVyIHRyYWlsICgwOCBKdW4pLiJ9LCB7ImlkIjogImF3LTQxMyIsICJ0eXBlIjogImJ1bGxldGVkX2xpc3QiLCAiY29udGVudCI6ICJDeWdudXMgYWRkZWQgYWZ0ZXIgdGhlIFRYTi0wMDE4IGNhc2ggZGVwb3NpdCBhbm9tYWx5IGNsZWFyZWQgZGUtZHVwbGljYXRpb24gKDA1IEp1bikuIn0sIHsiaWQiOiAiYXctNDE0IiwgInR5cGUiOiAiYnVsbGV0ZWRfbGlzdCIsICJjb250ZW50IjogIkZJWEVSLTAxICh1bnJlc29sdmVkIGVudGl0eSkgdHJhY2tlZCBzZXBhcmF0ZWx5IHVudGlsIGlkZW50aXR5IHJlc29sdXRpb24gbGFuZHMuIn1d','base64'),'utf8')::jsonb, now() - interval '5 days', now()) ON CONFLICT (id) DO UPDATE SET parent_page_id=EXCLUDED.parent_page_id, owner_id=EXCLUDED.owner_id, title=EXCLUDED.title, icon=EXCLUDED.icon, cover=EXCLUDED.cover, visibility=EXCLUDED.visibility, properties=EXCLUDED.properties, content=EXCLUDED.content, archived_at=NULL, updated_at=now(); +COMMIT; diff --git a/tools/seeds/seed_delivery_wiki.py b/tools/seeds/seed_delivery_wiki.py new file mode 100644 index 00000000..af3efe63 --- /dev/null +++ b/tools/seeds/seed_delivery_wiki.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Emit SQL for the SHARED 'Delivery Wiki' pages (stdout -> psql on the root +stack postgres). + +The wiki showcases the notion-database-sys system end to end: the Milestones +and Files databases (relations both ways, formulas, rollups, buttons) live in +the app seed (wikiSeed.ts); these pages embed their views as database_inline / +database_full_page blocks — boards, a timeline, a gallery collection, a +calendar, and the relation-filtered "notes of milestone X" tables. + +Usage: seed_delivery_wiki.py +Pages are uuid5-stable and upserted (ON CONFLICT DO UPDATE), so re-runs +refresh content in place. visibility='shared' puts them in the Shared bucket. +""" +import base64 +import json +import sys +import uuid + +WS, OWNER = sys.argv[1:3] +NS = uuid.UUID("6ba7b811-9dad-11d1-80b4-00c04fd430c8") + + +def b64(payload) -> str: + return base64.b64encode(json.dumps(payload).encode()).decode() + + +def q(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def pid(key: str) -> str: + return str(uuid.uuid5(NS, f"delivery-wiki:{key}")) + + +def page_sql(key, parent_key, title, icon, surface, content, cover=None) -> str: + cover_sql = q(cover) if cover else "NULL" + parent = "NULL" if parent_key is None else q(pid(parent_key)) + return ( + "INSERT INTO public.osionos_pages" + " (id, workspace_id, parent_page_id, owner_id, title, icon, cover, surface," + " visibility, collaborators, properties, content, created_at, updated_at) VALUES (" + f"{q(pid(key))}, {q(WS)}, {parent}, {q(OWNER)}," + f" {q(title)}, {q(icon)}, {cover_sql}, {q(surface)}, 'shared', '[]'::jsonb," + f" convert_from(decode('{b64([])}','base64'),'utf8')::jsonb," + f" convert_from(decode('{b64(content)}','base64'),'utf8')::jsonb, now(), now())" + " ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, icon = EXCLUDED.icon," + " cover = EXCLUDED.cover, content = EXCLUDED.content," + " parent_page_id = EXCLUDED.parent_page_id, visibility = EXCLUDED.visibility," + " updated_at = now();" + ) + + +B = 0 + + +def blk(type_, content="", **extra): + global B + B += 1 + return {"id": f"dw-{B:03d}", "type": type_, "content": content, **extra} + + +def db(view_id, database_id, full=False): + return blk("database_full_page" if full else "database_inline", "", + databaseId=database_id, viewId=view_id) + + +H1, H2, H3, P, C, D = "heading_1", "heading_2", "heading_3", "paragraph", "callout", "divider" + +home = [ + blk(H1, "Delivery Wiki"), + blk(P, "The **single shared source of truth** for what we ship and every artifact " + "produced on the way. Two databases power it: **🎯 Milestones** and the " + "**🗂️ Files** table — a filesystem where every entry has a *Source* " + "(notes, documents, images, datasets) and a *relation* to its milestone."), + blk(C, "**How it works** — the two databases are linked by a two-way relation. " + "Rollups count each milestone's files and sum their size, formulas compute " + "health and ages, and every view below is just a different lens over the " + "same rows. Edit anywhere, it updates everywhere."), + blk(D), + blk(H2, "🗺️ Roadmap"), + blk(P, "Milestones from start to due date — the timeline reads left to right, the " + "*Health* formula flags anything overdue or within two weeks."), + db("v-wiki-ms-timeline", "db-milestones"), + blk(H2, "📌 Status Board"), + db("v-wiki-ms-board", "db-milestones"), + blk(H2, "📋 Every Milestone"), + blk(P, "The full table: rollup columns (*File Count*, *Total KB*) aggregate the " + "related Files rows; *Days Left* and *Health* are live formulas; *Brief* " + "is a button property."), + db("v-wiki-ms-table", "db-milestones"), + blk(D), + blk(H2, "🔬 Milestone Deep Dives"), + blk(P, "The wiki's signature trick: views **filtered by relation**. Each table " + "below shows ONLY the notes (Source = Note) whose Milestone relation " + "points at one specific milestone."), + blk(H3, "📝 Notes — Venus (Realtime Sync)"), + db("v-wiki-notes-venus", "db-wikifiles"), + blk(H3, "📝 Notes — Mars (Plugin SDK)"), + db("v-wiki-notes-mars", "db-wikifiles"), + blk(C, "Want the same lens on another milestone? Duplicate one of these views and " + "change the relation filter — two clicks in the view menu."), +] + +files_page = [ + blk(H1, "The Filesystem"), + blk(P, "Every artifact the team produces, in one table. *Source* says what kind " + "of thing it is, *Weight* is a size formula, *Age* counts days since the " + "last edit, and *Pinned* keeps the load-bearing few on top."), + blk(H2, "🖼️ Collection"), + blk(P, "The gallery view — browse files as cards, heaviest first."), + db("v-wiki-files-gallery", "db-wikifiles"), + blk(H2, "🧭 By Source"), + db("v-wiki-files-board", "db-wikifiles"), + blk(H2, "🗓️ Activity Calendar"), + blk(P, "Files plotted on their last-modified date — the team's pulse."), + db("v-wiki-files-calendar", "db-wikifiles"), + blk(H2, "📌 Pinned"), + db("v-wiki-files-pinned", "db-wikifiles"), + blk(D), + blk(H2, "🗃️ Everything"), + db("v-wiki-files-table", "db-wikifiles"), +] + +milestones_full = [ + blk(P, "Full-page milestone tracker — switch tabs for the board, roadmap and table."), + db("v-wiki-ms-table", "db-milestones", full=True), +] + +COVER = ("https://images.unsplash.com/photo-1454165804606-c3d57bc86b40" + "?auto=format&fit=crop&w=1600&q=80") + +statements = ["BEGIN;", + page_sql("home", None, "Delivery Wiki", "📖", "page", home, cover=COVER), + page_sql("files", "home", "The Filesystem", "🗂️", "page", files_page), + page_sql("milestones", "home", "Milestone Tracker", "🎯", "page", milestones_full), + "COMMIT;"] +print("\n".join(statements)) diff --git a/tools/seeds/seed_wiki.py b/tools/seeds/seed_wiki.py new file mode 100644 index 00000000..a54a9b4d --- /dev/null +++ b/tools/seeds/seed_wiki.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""Company Wiki seeder — a governed knowledge base (Notion-style wiki) for +dylan's workspace, exercising every editor component against real content. + +The wiki root uses surface='wiki' (opens onto its index AND groups children — +see models/osionos-wiki-surface-migration.sql). Sections are folders; the ~50 +articles are pages whose `properties` carry the wiki governance schema +(owner / status / last-verified / domain / related). Content embeds known +database views (v-*), LIVE baas mounts, layout dashboards, mermaid diagrams, +equations, tables, code, toggles, columns — the whole block vocabulary. + +Idempotent: page ids are uuid5 of their slug; INSERT ... ON CONFLICT DO UPDATE. +Emits SQL to temp/seed_wiki.sql (apply with: make wiki-seed). +""" +import base64 +import json +import re +import sys +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1].parent +WS = "3f009d03-d954-5e35-85b8-db5c37aa859f" +OWNER = "ff284cf3-ab7d-4756-ade3-369257e36b2a" # dylan@gmail.com +NS = uuid.UUID("6ba7b811-9dad-11d1-80b4-00c04fd430c8") +ROOT_SLUG = "company-wiki" +VERIFIED_DEFAULT = "2026-06-02" + +# ---- live mounts (re-resolved from the app .env each run) ------------------- +LIVE_FALLBACK = { + "pg-commerce": "472138fd-83f5-497c-a8c2-2cd343285b65", + "mysql-ops": "3569d015-da4f-4d05-87c1-cdcfe19597d6", + "mongo-activity": "781b70bd-6598-4076-88c6-e1d441492c84", +} + + +def live_mounts() -> dict: + env = ROOT / "apps/osionos/app/.env" + try: + m = re.search(r"^VITE_BAAS_LIVE_MOUNTS=(.+)$", env.read_text(), re.M) + return {e["name"]: e["dbId"] for e in json.loads(m.group(1))} + except Exception: + return dict(LIVE_FALLBACK) + + +LIVE = live_mounts() + +# known database views (databaseViewCatalog.meta.ts) — prefix → databaseId +VIEW_DB_PREFIX = {"v-tasks": "db-tasks", "v-crm": "db-crm", "v-content": "db-content", + "v-inv": "db-inventory", "v-prod": "db-products", "v-proj": "db-projects"} + + +def view_db(view_id: str) -> str: + for prefix, db in VIEW_DB_PREFIX.items(): + if view_id.startswith(prefix): + return db + raise KeyError(view_id) + + +# ---- block toolkit (matches entities/block ReadOnlyBlock exactly) ----------- +_bid = 0 + + +def bid() -> str: + global _bid + _bid += 1 + return f"w-{_bid}" + + +def B(t, content="", **x): + return {"id": bid(), "type": t, "content": content, **x} + + +def h1(t): return B("heading_1", t) +def h2(t): return B("heading_2", t) +def h3(t): return B("heading_3", t) +def p(t): return B("paragraph", t) +def bullet(t, kids=None): return B("bulleted_list", t, **({"children": kids} if kids else {})) +def numbered(t, kids=None): return B("numbered_list", t, **({"children": kids} if kids else {})) +def todo(t, done=False): return B("to_do", t, checked=done) +def quote(t): return B("quote", t) +def divider(): return B("divider") +def equation(latex): return B("equation", latex) + + +def callout(icon, t, kids=None, level=None): + b = B("callout", t, color=icon) + if kids: + b["children"] = kids + if level: + b["headingLevel"] = level + return b + + +def code(lang, src, fname=None, theme="dark"): + b = B("code", src, language=lang, lineNumbers=True, codeTheme=theme, codeView="source") + if fname: + b["fileName"] = fname + return b + + +def mermaid(src): + return B("code", src, language="mermaid", codeView="preview", lineNumbers=False) + + +def toggle(t, kids, collapsed=True): + return B("toggle", t, children=kids, collapsed=collapsed) + + +def columns(*cols): + return B("column_list", children=[B("column", widthRatio=r, children=bl) for r, bl in cols]) + + +def table(headers, rows, aligns=None): + cfg = {"headerRow": True, "showBorders": True, "stripedRows": True} + if aligns: + cfg["columnAlignments"] = aligns + return B("table_block", tableData=[list(map(str, headers))] + [list(map(str, r)) for r in rows], + tableConfig=cfg) + + +def image(url, caption=""): + return B("image", caption, asset=url) + + +def view_block(view_id): + return B("database_inline", "", databaseId=view_db(view_id), viewId=view_id) + + +def live_block(mount, tbl, full=False): + db_id = LIVE.get(mount, LIVE_FALLBACK[mount]) + return B("database_full_page" if full else "database_inline", "", + databaseId=f"baas:{db_id}:{tbl}") + + +# ---- DSL: compact item tuples → blocks -------------------------------------- +def render_items(items): + out = [] + for it in items: + tag = it[0] + if tag == "p": + out.append(p(it[1])) + elif tag == "b": + out.extend(bullet(x) for x in it[1:]) + elif tag == "n": + out.extend(numbered(x) for x in it[1:]) + elif tag == "c": + out.append(callout(it[1], it[2], kids=render_items(it[3]) if len(it) > 3 else None)) + elif tag == "t": + out.append(table(it[1], it[2], it[3] if len(it) > 3 else None)) + elif tag == "code": + out.append(code(it[1], it[2], it[3] if len(it) > 3 else None)) + elif tag == "mm": + out.append(mermaid(it[1])) + elif tag == "eq": + out.append(callout("🧮", f"**{it[2]}**")) + out.append(equation(it[1])) + elif tag == "todo": + out.extend(todo(t, d) for t, d in it[1]) + elif tag == "tg": + out.append(toggle(it[1], render_items(it[2]))) + elif tag == "cols": + out.append(columns(*[(r, render_items(blk)) for r, blk in it[1]])) + elif tag == "q": + out.append(quote(it[1])) + elif tag == "view": + out.append(callout("◈", it[2])) + out.append(view_block(it[1])) + elif tag == "live": + out.append(callout("🔌", f"**LIVE data** — {it[3]} (mount `{it[1]}`, table `{it[2]}`; edits write back to the engine)")) + out.append(live_block(it[1], it[2])) + elif tag == "livefull": + out.append(callout("🔌", f"**LIVE data** — {it[3]} (mount `{it[1]}`, table `{it[2]}`)")) + out.append(live_block(it[1], it[2], full=True)) + elif tag == "img": + out.append(image(it[1], it[2] if len(it) > 2 else "")) + elif tag == "d": + out.append(divider()) + else: + raise ValueError(f"unknown item tag {tag!r}") + return out + + +# ---- article scaffold -------------------------------------------------------- +STATUS_LEGEND = ("✅ Verified — the owner re-checked every claim within the review window. " + "🔍 In review — content is being re-verified; treat with care. " + "📝 Draft — not yet governed; do not rely on it for decisions.") + + +def meta_bar(a): + return columns( + (1, [callout("👤", f"**Owner**\n{a['owner']}")]), + (1, [callout("🏷️", f"**Status**\n{a.get('status', '✅ Verified')}")]), + (1, [callout("📅", f"**Last verified**\n{a.get('verified', VERIFIED_DEFAULT)}")]), + (1, [callout("🗂️", f"**Domain**\n{a['domain']}")]), + ) + + +def kpi_strip(kpis): + return columns(*[(1, [callout(icon, f"**{label}**\n{value}")]) for icon, label, value in kpis]) + + +def toc_block(a): + rows = [bullet(f"{icon} {heading}") for icon, heading, _ in a.get("secs", ())] + rows += [bullet("✅ Operating checklist"), bullet("❓ FAQ"), + bullet("🧾 Decision log"), bullet("🕑 Changelog & review")] + return toggle("📑 On this page", rows, collapsed=False) + + +def article_blocks(a, related_titles): + body = [ + h1(a["title"]), + callout("🎯", f"**TL;DR** — {a['tldr']}", level=3), + meta_bar(a), + callout("📖", "**Wiki contract** — this article is part of the Company Wiki: it has one " + "accountable owner, a verification date, and a review cadence. If you ship " + "something that contradicts it, the wiki wins until the owner updates it. " + "Propose changes via a comment or the #wiki channel; the owner must re-verify " + "within 5 working days."), + toc_block(a), + divider(), + ] + if a.get("kpis"): + body.append(kpi_strip(a["kpis"])) + for text in a.get("ctx", ()): + body.append(p(text)) + for icon, heading, items in a.get("secs", ()): + body.append(h2(f"{icon} {heading}")) + body.extend(render_items(items)) + if a.get("check"): + body += [divider(), h2("✅ Operating checklist")] + body += [todo(t, d) for t, d in a["check"]] + if a.get("faq"): + body += [divider(), h2("❓ FAQ")] + body += [toggle(f"▸ {q}", [callout("💬", ans)]) for q, ans in a["faq"]] + if a.get("decisions"): + body += [divider(), h2("🧾 Decision log"), + table(["Date", "Decision", "Why", "Owner"], a["decisions"])] + body += [divider(), h2("🕑 Changelog & review")] + body.append(table(["Date", "Change", "By"], a.get( + "changelog", [[a.get("verified", VERIFIED_DEFAULT), "Verified against current practice", a["owner"]]]))) + body.append(callout("⏰", f"**Review cadence** — quarterly. Next review due **2026-09-01**. " + f"Owner: {a['owner']}. Status legend: {STATUS_LEGEND}")) + if related_titles: + body += [divider(), h3("🔗 Related articles")] + body += [bullet(f"→ {t}") for t in related_titles] + return body + + +def governance_props(a, related_ids): + props = [ + {"key": "owner", "label": "Owner", "type": "text", "value": a["owner"]}, + {"key": "status", "label": "Status", "type": "select", + "value": a.get("status", "✅ Verified"), + "options": ["✅ Verified", "🔍 In review", "📝 Draft"]}, + {"key": "verified", "label": "Last verified", "type": "date", + "value": a.get("verified", VERIFIED_DEFAULT)}, + {"key": "domain", "label": "Domain", "type": "select", "value": a["domain"], + "options": ["Company", "Engineering", "Security", "Product", "Data", + "Go-to-market", "People", "Finance"]}, + ] + if related_ids: + props.append({"key": "related", "label": "Related", "type": "relation", + "value": related_ids, "relationTarget": "page"}) + return props + + +# ---- wiki root index ---------------------------------------------------------- +DASH_PLACEMENTS = [ + (1, 6, 1, 2), (7, 6, 1, 2), + (1, 6, 3, 4), (7, 6, 3, 4), + (1, 6, 7, 4), (7, 6, 7, 4), +] + + +def dash_cell(placement, label, blocks, tint): + col, span, row, rspan = placement + return { + "id": bid(), "colStart": col, "colSpan": span, "rowStart": row, "rowSpan": rspan, + "label": label, "type": "text", "content": "", "blocks": blocks, + "sizing": "fixed", "horizontalConstraint": "stretch", "verticalConstraint": "top", + "wrap": True, "padding": "comfortable", "fontSize": "base", + "backgroundColor": f"color-mix(in srgb, {tint} 9%, var(--osio-bg-surface))", + "textColor": "var(--osio-fg-default)", + } + + +def root_dashboard(): + cells = [ + dash_cell(DASH_PLACEMENTS[0], "Pulse — delivery", [ + h3("🗂️ Delivery pulse"), + p("Tasks across the org, grouped by status. Blocked items surface here before they surface in a meeting."), + view_block("v-tasks-board")], "#2563eb"), + dash_cell(DASH_PLACEMENTS[1], "Pulse — budget", [ + h3("◔ Budget exposure"), + p("Committed project budget by project — the number every quarterly review starts from."), + view_block("v-proj-chart")], "#b45309"), + dash_cell(DASH_PLACEMENTS[2], "Catalog analytics", [ + h3("◈ Product analytics"), + p("Catalog health: category balance, ratings, price distribution."), + view_block("v-prod-analytics")], "#0f766e"), + dash_cell(DASH_PLACEMENTS[3], "Editorial", [ + h3("◫ Editorial calendar"), + p("What we publish and when — the publishing rhythm the content team commits to."), + view_block("v-content-calendar")], "#7c3aed"), + dash_cell(DASH_PLACEMENTS[4], "Live commerce", [ + h3("🐘 LIVE — storefront orders"), + p("Real PostgreSQL rows through the mini-baas gateway. Edits write back to the engine."), + live_block("pg-commerce", "orders")], "#be123c"), + dash_cell(DASH_PLACEMENTS[5], "Live support", [ + h3("🐬 LIVE — support tickets"), + p("Real MySQL rows: severity enums make this an instant triage board."), + live_block("mysql-ops", "tickets")], "#0891b2"), + ] + return { + "id": bid(), "type": "layout", "content": "", "layoutMode": "inline", + "layoutConfig": {"columns": 12, "rows": 11, "gap": 16, "rowHeight": 132, + "wrap": True, "autoArrange": False, "snapToGrid": True, + "guideVisibility": "auto", "preview": False, "theme": "spacious"}, + "layoutCells": cells, + } + + +def root_blocks(sections, article_count): + section_rows = [[f"{icon} {name}", desc, str(count)] for name, icon, desc, count in sections] + return [ + h1("Company Wiki"), + callout("🎯", "**The single source of truth for how this company runs.** " + f"{article_count} governed articles across {len(sections)} domains — every one " + "has an accountable owner, a verification date, and a quarterly review. " + "If a doc matters, it lives here; if it lives here, you can trust it.", level=3), + kpi_strip([("📚", "Articles", str(article_count)), + ("🗂️", "Sections", str(len(sections))), + ("👤", "Owners", "8 accountable leads"), + ("⏰", "Review cadence", "Quarterly")]), + divider(), + h2("📜 The rules of this wiki"), + numbered("**A wiki is not a folder.** This root opens onto the index you are reading, and it" + " groups every article beneath it in the sidebar — both at once (surface = `wiki`)."), + numbered("**Every article has exactly one owner.** Names, not teams. The owner answers for" + " accuracy and re-verifies quarterly."), + numbered("**Status is explicit.** ✅ Verified / 🔍 In review / 📝 Draft — shown in each" + " article's meta bar and its page properties."), + numbered("**The wiki wins conflicts.** If practice and wiki disagree, follow the wiki and" + " flag the owner — fixing the doc IS the fix."), + numbered("**Articles cross-link.** Every page lists Related articles (relation properties)," + " so the knowledge graph stays connected — no orphan pages."), + numbered("**Live data over screenshots.** Operational articles embed the real databases" + " (live mounts) and shared /views — numbers in the wiki are never stale copies."), + toggle("📖 How verification works", [ + p("Owners re-verify their articles every quarter (next sweep: 2026-09-01). Verification " + "means re-reading the article, re-running embedded queries/views, and bumping the " + "Last-verified date."), + bullet("✅ Verified — safe to act on without double-checking."), + bullet("🔍 In review — owner is re-verifying; confirm critical claims before acting."), + bullet("📝 Draft — structure may be right, numbers may not be. Never cite a draft."), + p("Changing an article you don't own: propose the edit in a comment or #wiki; the owner " + "must accept (or counter) within 5 working days."), + ], collapsed=False), + divider(), + h2("🗺️ Wiki map"), + mermaid("mindmap\n root((Company Wiki))\n Company Handbook\n Mission & principles\n Org & teams\n Decisions\n Engineering\n Architecture\n Quality gates\n On-call\n Security\n Vault & secrets\n Incident response\n Product & Design\n Editor spec\n Views & live mounts\n Data & Analytics\n KPI dictionary\n Dashboards\n Sales & Support\n CRM playbook\n SLAs\n People Ops\n Onboarding\n Hiring\n Finance\n Unit economics\n Runway"), + h2("🗂️ Section directory"), + table(["Section", "What lives here", "Articles"], section_rows), + divider(), + h2("📊 Company pulse — live"), + p("This dashboard is built from the same shared /views and LIVE database mounts the rest of " + "the company uses — it is never a stale screenshot. Edit a cell and you are editing the " + "source engine."), + root_dashboard(), + divider(), + callout("🧭", "**Start here if you are new:** People Ops → *Onboarding: your first two weeks*, " + "then Company Handbook → *Mission & operating principles*, then your team's " + "section. Everything else you can pull when you need it."), + ] + + +# ---- emission ----------------------------------------------------------------- +def b64(o) -> str: + return base64.b64encode(json.dumps(o).encode()).decode() + + +def jcol(o) -> str: + return f"convert_from(decode('{b64(o)}','base64'),'utf8')::jsonb" + + +def sqlstr(s) -> str: + return "'" + s.replace("'", "''") + "'" + + +def page_sql(pid, parent, title, icon, surface, props, content, cover=None): + return ( + "INSERT INTO public.osionos_pages (id, workspace_id, parent_page_id, owner_id, title," + " icon, cover, surface, visibility, collaborators, properties, content, created_at, updated_at)" + f" VALUES ({sqlstr(pid)}, {sqlstr(WS)}, {sqlstr(parent) if parent else 'NULL'}, {sqlstr(OWNER)}," + f" {sqlstr(title)}, {sqlstr(icon) if icon else 'NULL'}, {sqlstr(cover) if cover else 'NULL'}," + f" {sqlstr(surface) if surface else 'NULL'}, 'private', '[]'::jsonb," + f" {jcol(props)}, {jcol(content)}, now(), now())" + " ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, icon = EXCLUDED.icon," + " cover = EXCLUDED.cover, surface = EXCLUDED.surface, parent_page_id = EXCLUDED.parent_page_id," + " properties = EXCLUDED.properties, content = EXCLUDED.content, updated_at = now();" + ) + + +def est_lines(blocks) -> int: + """Rough rendered-line estimate: each block ≥1 line; lists/tables/code expand.""" + n = 0 + for blk in blocks: + n += 1 + n += max(0, len(blk.get("content", "")) // 90) + if blk.get("type") == "table_block": + n += len(blk.get("tableData", [])) + if blk.get("type") == "code": + n += blk.get("content", "").count("\n") + for cell in blk.get("layoutCells", []) or []: + n += est_lines(cell.get("blocks", [])) + n += est_lines(blk.get("children", []) or []) + return n + + +def main() -> None: + from seed_wiki_content import SECTIONS as S1, ARTICLES as A1 + from seed_wiki_content2 import SECTIONS as S2, ARTICLES as A2 + sections = S1 + S2 + articles = A1 + A2 + + pid = lambda slug: str(uuid.uuid5(NS, f"{ROOT_SLUG}:{slug}")) + by_slug = {a["slug"]: a for a in articles} + title_of = {a["slug"]: a["title"] for a in articles} + + root_id = pid("root") + section_meta = [] + for key, name, icon, desc, cover in sections: + count = sum(1 for a in articles if a["sec"] == key) + section_meta.append((name, icon, desc, count)) + + stmts = ["BEGIN;"] + stmts.append(page_sql(root_id, None, "Company Wiki", "📚", "wiki", [], + root_blocks(section_meta, len(articles)), + cover="https://images.unsplash.com/photo-1481627834876-b7833e8f5570?auto=format&fit=crop&w=1600&q=80")) + for key, name, icon, desc, cover in sections: + stmts.append(page_sql(pid(f"sec:{key}"), root_id, name, icon, "folder", [], [], cover=cover)) + + total_blocks, low = 0, [] + for a in articles: + rel_slugs = [s for s in a.get("related", ()) if s in by_slug] + rel_ids = [pid(s) for s in rel_slugs] + rel_titles = [title_of[s] for s in rel_slugs] + content = article_blocks(a, rel_titles) + lines = est_lines(content) + total_blocks += len(content) + if lines < 300: + low.append((a["slug"], lines)) + stmts.append(page_sql(pid(a["slug"]), pid(f"sec:{a['sec']}"), a["title"], a["icon"], + None, governance_props(a, rel_ids), content, cover=a.get("cover"))) + print(f" {a['slug']:<28} blocks={len(content):>4} est-lines={lines:>4}", file=sys.stderr) + stmts.append("COMMIT;") + + out = ROOT / "temp/seed_wiki.sql" + out.parent.mkdir(exist_ok=True) + out.write_text("\n".join(stmts) + "\n") + print(f"wiki: 1 root + {len(sections)} sections + {len(articles)} articles | " + f"top-level blocks total={total_blocks}", file=sys.stderr) + if low: + print(f"NOTE thin articles (<300 est lines): {low}", file=sys.stderr) + print(out) + + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).resolve().parent)) + main() diff --git a/tools/seeds/seed_wiki_content.py b/tools/seeds/seed_wiki_content.py new file mode 100644 index 00000000..28cb5e19 --- /dev/null +++ b/tools/seeds/seed_wiki_content.py @@ -0,0 +1,810 @@ +"""Company Wiki content pack 1: Handbook, Engineering, Security, Product (26 articles). +Specs are rendered by seed_wiki.py (see its DSL: p/b/n/c/t/code/mm/eq/todo/tg/cols/q/view/live/img/d).""" + +SECTIONS = [ + ("handbook", "Company Handbook", "🧭", + "Mission, principles, org, communication, decisions, rituals, vocabulary.", + "https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1600&q=80"), + ("engineering", "Engineering", "🛠️", + "Architecture, dev environment, review/release/testing standards, migrations, on-call.", + "https://images.unsplash.com/photo-1555066931-4365d14bab8c?auto=format&fit=crop&w=1600&q=80"), + ("security", "Security & Compliance", "🔐", + "Security model, secrets, access control, incidents, privacy, backup/DR.", + "https://images.unsplash.com/photo-1563013544-824ae1b704d3?auto=format&fit=crop&w=1600&q=80"), + ("product", "Product & Design", "📦", + "Product principles, editor & database specs, design system, research.", + "https://images.unsplash.com/photo-1581291518857-4e27b48ff24e?auto=format&fit=crop&w=1600&q=80"), +] + +ARTICLES = [ +# ============================ COMPANY HANDBOOK ================================ +dict(slug="mission-principles", sec="handbook", icon="🧭", + title="Mission & operating principles", owner="Dylan Lesieur", domain="Company", + related=("decision-framework", "communication-charter", "product-principles", "metrics-reviews"), + tldr="We build the collaborative workspace where a company's knowledge, data and work live in one place — and we run the company on our own product. Seven principles govern every decision.", + kpis=[("🎯", "Mission age", "Founded 2024"), ("🧪", "Dogfooding", "100% — this wiki IS the product"), + ("🧭", "Principles", "7"), ("🔁", "Revisited", "Yearly offsite")], + ctx=("Every company says it has principles; few can point to where a principle changed a decision. " + "This page lists ours together with the decisions they have already settled, so new people can " + "calibrate fast and veterans can cite them in reviews instead of re-arguing first principles.", + "The mission: **make a company's working memory live in one governed place** — documents, " + "databases, dashboards and decisions — instead of being scattered across ten tools that don't " + "talk to each other. We compete by being the tool we ourselves cannot live without."), + secs=[ + ("🏔️", "The seven principles", [ + ("n", "**Dogfood or die.** We run the entire company on osionos — this wiki, our dashboards, our CRM views. If a workflow hurts, we feel it before customers do.", + "**The wiki wins.** Written, verified knowledge beats memory and meetings. If it matters, it gets a page and an owner.", + "**Live data over screenshots.** Numbers embedded in documents come from live views, never pasted images that rot.", + "**Small files, sharp edges.** Code stays under hard size caps; systems stay decomposable. Constraints are features.", + "**Shadow before cutover.** New systems run in parallel and prove parity before old ones die. Deletion is the last step, never the first.", + "**One owner per thing.** Every article, service, metric and decision names exactly one accountable human.", + "**Spend error budget deliberately.** Reliability targets are explicit; we ship aggressively while the budget lasts and freeze when it's gone."), + ("c", "💡", "Principles are tested at the yearly offsite against the decision log: a principle that settled zero decisions gets rewritten or deleted."), + ]), + ("⚖️", "Decisions these principles already settled", [ + ("t", ["Decision", "Principle applied", "Outcome"], + [["Rust data plane rollout", "Shadow before cutover", "6 weeks of parity gates before any TS deletion"], + ["Company KPIs in the wiki", "Live data over screenshots", "Exec dashboard embeds live views (see Data section)"], + ["200-line file cap in the editor", "Small files, sharp edges", "Enforced in review; refactors split files first"], + ["Support runbook ownership", "One owner per thing", "Marco owns it; escalations stopped bouncing"], + ["Q1 feature freeze", "Spend error budget deliberately", "Two-week freeze after SLO burn, then resumed"]]), + ]), + ("🧬", "How the mission cascades", [ + ("mm", "flowchart TD\n M[Mission: one governed working memory] --> P1[Product: blocks + databases + views]\n M --> P2[Company: run on our own product]\n P1 --> E1[Editor & live mounts]\n P1 --> E2[Wiki governance surfaces]\n P2 --> O1[This wiki]\n P2 --> O2[Live dashboards in Data section]\n O1 --> F[Feedback loop: what hurts us first]\n O2 --> F\n F --> P1"), + ("p", "The loop at the bottom is the moat: because the company runs on the product, every internal " + "pain is a roadmap signal that arrives months before a customer churn signal would."), + ]), + ("🗣️", "Using principles in arguments", [ + ("b", "Cite the principle by name in the review or RFC — \"this violates *Shadow before cutover*\" ends debates faster than taste does.", + "If two principles conflict, escalate to the decision framework (DACI) rather than picking silently.", + "Proposing a principle change requires: the principle, three decisions it would have changed, and the offsite slot."), + ("q", "A principle that never costs you anything is a slogan, not a principle."), + ]), + ], + faq=[("Do principles apply to contractors and vendors?", "Yes — anyone shipping changes follows them; vendor contracts reference the security and review principles explicitly."), + ("Who can change the mission?", "Only the yearly offsite, with board sign-off. Principles can change quarterly via DACI with Dylan as approver."), + ("What if a principle blocks an urgent fix?", "Incident response (Security section) overrides everything for the duration of a declared incident — then the postmortem reconciles."), + ("Where do I see principles in tooling?", "Review checklists, the RFC template, and the wiki contract banner on every article all cite them.")], + check=[("Read the seven principles and the settled-decisions table", False), + ("Find one current debate and identify which principle applies", False), + ("Read the decision framework article next", False), + ("Bookmark the wiki root — it is the company home page", False), + ("Challenge one stale practice citing a principle this quarter", False)], + decisions=[["2026-01-12", "Adopted 'Spend error budget deliberately' as principle #7", "Two outages traced to silent budget burn", "Dylan Lesieur"], + ["2025-09-02", "Rewrote principle #3 to mandate live views in docs", "Stale screenshots caused a mispriced renewal", "Dylan Lesieur"]], + changelog=[["2026-06-02", "Verified; added Q1 freeze to settled decisions", "Dylan Lesieur"], + ["2026-03-15", "Added cascade diagram", "Dylan Lesieur"]]), + +dict(slug="org-teams", sec="handbook", icon="🏢", + title="Org & teams: who owns what", owner="Dylan Lesieur", domain="Company", + related=("mission-principles", "communication-charter", "oncall-escalation", "hiring-process"), + tldr="Eight leads, five teams, one rule: every system, metric and customer touchpoint has exactly one named owner. This page is the routing table for 'who do I ask?'.", + kpis=[("👥", "Headcount", "34"), ("🧑‍✈️", "Leads", "8"), ("🏟️", "Teams", "5"), ("🧭", "Re-orgs", "≤1/year, announced 30d ahead")], + ctx=("Org charts usually rot because they live in slides. This one is wiki-governed: when ownership " + "moves, this page moves first and the change is announced from here — the chart is the act, " + "not the record of it.", + "Teams are sized to own their systems end-to-end (build, operate, on-call). Nobody throws " + "code over a wall; the team that ships a service answers its pages."), + secs=[ + ("🗺️", "The org at a glance", [ + ("mm", "flowchart TD\n CEO[Dylan Lesieur - CEO] --> ENG[Amara Okonkwo - VP Engineering]\n CEO --> FIN[Tom Nguyen - Finance Lead]\n CEO --> PPL[Priya Sharma - People Ops Lead]\n ENG --> PLAT[Yuki Tanaka - Platform Lead]\n ENG --> SEC[Lena Fischer - Security Lead]\n ENG --> DATA[David Okafor - Data Lead]\n CEO --> GTM[Marco Ruiz - Support & GTM Lead]\n CEO --> DES[Sofia Marchetti - Design Lead]\n PLAT --> T1[Editor team - 6]\n PLAT --> T2[BaaS team - 7]\n DATA --> T3[Analytics team - 4]\n GTM --> T4[Support team - 5]\n DES --> T5[Product design - 3]"), + ]), + ("🧑‍✈️", "Lead routing table", [ + ("t", ["Area", "Lead", "Escalation path", "Wiki section owned"], + [["Company direction, principles", "Dylan Lesieur", "Board", "Company Handbook"], + ["Engineering org & standards", "Amara Okonkwo", "Dylan", "Engineering"], + ["Platform, infra, releases", "Yuki Tanaka", "Amara", "Engineering (infra pages)"], + ["Security, privacy, compliance", "Lena Fischer", "Amara → Dylan", "Security & Compliance"], + ["Data, KPIs, dashboards", "David Okafor", "Amara", "Data & Analytics"], + ["Support, CRM, GTM", "Marco Ruiz", "Dylan", "Sales & Support"], + ["People, hiring, onboarding", "Priya Sharma", "Dylan", "People Ops"], + ["Finance, vendors, budget", "Tom Nguyen", "Dylan", "Finance & Ops"], + ["Design system, research", "Sofia Marchetti", "Dylan", "Product & Design (design pages)"]]), + ("c", "📌", "If you cannot find an owner in this table, the question goes to #ask-anything — and the answer becomes a wiki edit so the next person finds it here."), + ]), + ("🏟️", "Team charters", [ + ("cols", [(1, [("c", "⚡", "**Editor team** — owns the block editor, canvas model, slash commands, rendering performance. On-call for the app."), + ("c", "🧱", "**BaaS team** — owns the mini-baas gateway, engines, live mounts, realtime plane. On-call for the data path.")]), + (1, [("c", "📈", "**Analytics team** — owns KPI definitions, the exec dashboard, experiment tooling and the live demo datasets."), + ("c", "🎧", "**Support team** — owns ticket triage, the CRM, SLAs and the support runbook.")])]), + ("p", "Product design pairs with all four; designers embed with a team per quarter rather than reviewing from outside."), + ]), + ("🔁", "How ownership changes", [ + ("n", "Propose the move in a decision record (DACI) naming old and new owner.", + "Update this page and the owned wiki articles' Owner property in the same change.", + "Announce in #announcements with a link HERE — the wiki page is the announcement source.", + "New owner re-verifies inherited articles within 30 days."), + ]), + ], + faq=[("Who is on-call right now?", "The on-call article in Engineering embeds the live rotation — this page only routes ownership, not shifts."), + ("Can a system have two owners?", "No. It can have many maintainers, but exactly one accountable owner — principle #6."), + ("Where are team OKRs?", "Metrics reviews article in Finance & Ops; team goals live as project rows in the live projects table."), + ("How do re-orgs happen?", "At most one structural change per year, announced 30 days ahead with a migration plan for owned pages.")], + check=[("Find your team's charter above", False), + ("Check the routing table for the three areas you touch most", False), + ("Verify your own wiki pages list you as Owner", False), + ("Meet your escalation contact once this quarter", False)], + decisions=[["2026-04-20", "Design embeds with product teams quarterly", "Review-from-outside caused late redesigns", "Dylan Lesieur"], + ["2026-02-10", "Support moved under GTM with Marco as single lead", "Ticket escalations had two competing paths", "Dylan Lesieur"]], + changelog=[["2026-06-02", "Verified; headcount updated to 34", "Dylan Lesieur"]]), + +dict(slug="communication-charter", sec="handbook", icon="📨", + title="Communication charter: async-first", owner="Priya Sharma", domain="Company", + related=("rituals-calendar", "decision-framework", "org-teams", "remote-policy"), + tldr="Async and written by default; meetings are the escalation path, not the default. Channels have contracts, response-time expectations are explicit, and anything decided out-of-band gets written back into the wiki.", + kpis=[("✍️", "Default", "Written, async"), ("⏱️", "Non-urgent SLA", "24h replies"), + ("🚨", "Urgent path", "Page, not ping"), ("📵", "Focus blocks", "Protected 4h/day")], + ctx=("A remote-friendly company runs on its written layer. This charter defines where each kind of " + "message belongs, how fast a reply is owed, and the one unbreakable rule: decisions made in " + "calls or DMs do not exist until they are written into the wiki or a decision record.", + "The goal is not fewer conversations — it is that no conversation needs to be repeated because " + "its outcome was captured."), + secs=[ + ("📮", "Channel contracts", [ + ("t", ["Medium", "Use for", "Reply expectation", "Never use for"], + [["Wiki article", "Anything durable: policy, spec, runbook", "n/a — it IS the answer", "Quick questions"], + ["Decision record", "Cross-team choices (DACI)", "Approver: 5 working days", "Status updates"], + ["#team channels", "Coordination, blockers, FYIs", "Same working day", "Decisions"], + ["#ask-anything", "Questions with no obvious owner", "24h; answer becomes a wiki edit", "Urgent incidents"], + ["DMs", "Personal, sensitive, scheduling", "24h", "Anything others will need later"], + ["Pager", "SEV1/SEV2 incidents only", "5 min ack", "Anything that can wait a day"], + ["Meetings", "Conflict, ambiguity, bonding", "n/a", "Information transfer"]]), + ("c", "🚨", "**The unbreakable rule:** a decision that is not written down within 24 hours did not happen. The writer of the summary owns its accuracy."), + ]), + ("⏱️", "Response-time expectations", [ + ("b", "Non-urgent written messages: 24 hours, working days. Silence past that is a process bug — escalate politely.", + "Mentions in a decision record: 5 working days (the DACI clock).", + "Incident pages: 5-minute acknowledgement for on-call, see the incident response runbook.", + "Nobody is expected to read anything outside their working hours — urgency is the pager's job, not the inbox's."), + ("tg", "Why we protect 4-hour focus blocks", [ + ("p", "Maker schedules collapse under random pings. Every engineer and designer keeps a " + "protected half-day daily; channels carry an explicit 'slow until 14:00' status. " + "Throughput went up, not down, when we introduced this — see the decision log."), + ]), + ]), + ("🌍", "Timezone etiquette", [ + ("b", "Default writing style: full context, links, no 'quick question' bait — assume the reader wakes up 8 timezones away.", + "Recordings + written summaries for any meeting with an absent timezone.", + "Handoffs (support, on-call) follow the sun and are written in the shift log, not spoken."), + ("mm", "sequenceDiagram\n participant EU as EU morning\n participant US as US morning\n EU->>US: Written brief with full context (end of EU day)\n US->>US: Executes during US day\n US->>EU: Written status + blockers (end of US day)\n EU->>EU: Unblocks during EU morning\n Note over EU,US: 24h cycle, zero meetings required"), + ]), + ("✍️", "Writing standards", [ + ("n", "Lead with the ask or the answer; context after.", + "One topic per message or page; split threads that fork.", + "Link the wiki instead of re-explaining — and if the wiki is wrong, fix it or flag the owner.", + "Use the article templates (TL;DR, owner, status) for anything that outlives the week."), + ]), + ], + faq=[("Are cameras required on calls?", "No. Audio and a written summary are required; the summary is what counts."), + ("What about urgent product questions from customers?", "Support escalation path in Sales & Support — it has its own SLA table, not this charter's."), + ("Can teams add their own channel rules?", "Yes, stricter but not looser, and they document them in the team charter."), + ("Do execs get faster reply SLAs?", "No. Urgency comes from the issue, not the sender."), + ("Where do watercooler chats live?", "#random and optional coffee pairings — explicitly exempt from all rules above.")], + check=[("Set your working hours in your profile and calendar", False), + ("Adopt the 'answer becomes a wiki edit' habit in #ask-anything", False), + ("Schedule your daily focus block", False), + ("Re-read the unbreakable rule before your next sync call", False), + ("Audit one of your recent DM threads: should it have been a channel post?", False)], + decisions=[["2026-03-08", "Protected 4h daily focus blocks org-wide", "Ping-driven days crushed maker output", "Priya Sharma"], + ["2025-11-19", "Decisions must be written within 24h to be valid", "Two conflicting 'verbal decisions' shipped twice", "Dylan Lesieur"]], + changelog=[["2026-06-01", "Verified; added timezone handoff diagram", "Priya Sharma"]]), + +dict(slug="decision-framework", sec="handbook", icon="⚖️", + title="How we decide: DACI + decision records", owner="Dylan Lesieur", domain="Company", + related=("mission-principles", "communication-charter", "migration-discipline", "metrics-reviews"), + tldr="Every cross-team decision gets a record: one Driver, one Approver, named Contributors, Informed list. Five working days max from proposal to verdict; reversals require a new record, not a hallway chat.", + kpis=[("📄", "Records to date", "61"), ("⏱️", "Median time-to-verdict", "3.2 days"), + ("🔁", "Reversed", "4 (all via new records)"), ("🧊", "Expired unanswered", "0 tolerated")], + ctx=("Slow decisions are usually undecided owners, not missing information. DACI fixes the owner " + "problem: the Driver pushes, exactly one Approver decides, everyone else is explicitly a " + "Contributor (consulted) or Informed (told). The record makes the verdict citable forever.", + "Records live as pages under this handbook; their decision tables are mirrored into the " + "articles they affect — that is why every wiki article carries a Decision log section."), + secs=[ + ("🧩", "The DACI roles", [ + ("t", ["Role", "Count", "Does", "Does NOT"], + [["Driver", "exactly 1", "Writes the record, gathers input, pushes to verdict", "Decide"], + ["Approver", "exactly 1", "Says yes/no/counter within 5 working days", "Delegate silently"], + ["Contributors", "2–6", "Give input they can defend", "Block by silence"], + ["Informed", "any", "Read the verdict", "Re-litigate it"]]), + ("c", "⚠️", "If you cannot name the single Approver, you have found the real problem — fix ownership first (see Org & teams)."), + ]), + ("📄", "Anatomy of a decision record", [ + ("n", "**Context** — what forces the choice now (one paragraph).", + "**Options** — 2–4, each with the strongest honest case for it.", + "**Recommendation** — the Driver's pick and the principle it leans on.", + "**Verdict** — Approver's call, date, and conditions.", + "**Expiry/Review** — when this decision should be re-examined."), + ("code", "markdown", "# DR-061: Adopt wiki surface for governed knowledge\nDriver: Amara · Approver: Dylan · Contributors: Yuki, Lena, Sofia\nContext: docs scattered; no verification trail; folders can't open as pages.\nOptions: (A) folder convention (B) new 'wiki' surface (C) external tool\nRecommendation: B — keeps knowledge in-product (principle #1, #3)\nVerdict: B approved 2026-06-02, ship behind additive DB migration\nReview: 2026-09-01 with adoption metrics", "decision-record-template.md"), + ]), + ("🚦", "The five-day clock", [ + ("mm", "stateDiagram-v2\n [*] --> Proposed: Driver publishes record\n Proposed --> InReview: Approver acknowledges (24h)\n InReview --> Approved: verdict\n InReview --> Countered: approver proposes option D\n Countered --> InReview: driver updates record\n InReview --> Escalated: clock exceeds 5 working days\n Escalated --> Approved: next-level approver decides in 48h\n Approved --> [*]"), + ("b", "The clock starts at publication, not at the meeting where it is mentioned.", + "Escalation is automatic and blameless — a busy approver is a routing fact, not an offense.", + "Verdicts are copied into affected wiki articles' Decision logs by the Driver."), + ]), + ("🔁", "Reversals and expiry", [ + ("p", "Decisions are not sacred — but reversals must pay the same toll: a new record citing the " + "old one, what changed, and the same five-day clock. This kills relitigating-by-attrition " + "while keeping the org honest when facts change."), + ("b", "Records carry an expiry/review date; expired records show up in the quarterly wiki sweep.", + "Four reversals to date; each cost one page and zero meetings."), + ]), + ], + faq=[("What needs a record vs a team call?", "Crosses team boundaries, costs >1 engineer-week, touches security/money/customers, or changes a principle → record. Otherwise the team lead just decides."), + ("Can the Driver and Approver be the same person?", "No — the separation is the point. Single-team decisions don't need DACI at all."), + ("What about emergencies?", "Incident command (Security section) decides during incidents; a retroactive record captures anything durable."), + ("Who can read records?", "Everyone. Records are private to the company, never to a subset of it.")], + check=[("Read one approved and one countered record end-to-end", False), + ("Identify the Approver for your current biggest open question", False), + ("Add a Review date to your team's last big decision", False), + ("Mirror your latest verdict into the affected wiki articles", False)], + decisions=[["2026-06-02", "DR-061: adopt the wiki surface for governed knowledge", "Folders can't open as governed indexes", "Dylan Lesieur"], + ["2026-01-30", "Escalation auto-triggers at day 5", "Records were rotting in inboxes", "Dylan Lesieur"]], + changelog=[["2026-06-02", "Verified; linked DR-061", "Dylan Lesieur"]]), + +dict(slug="rituals-calendar", sec="handbook", icon="🗓️", + title="Rituals & company calendar", owner="Priya Sharma", domain="Company", + related=("communication-charter", "metrics-reviews", "onboarding-first-two-weeks", "remote-policy"), + tldr="Five recurring rituals carry the company: weekly written pulse, biweekly demo, monthly metrics review, quarterly wiki sweep, yearly offsite. Everything else is optional or async.", + kpis=[("🔁", "Recurring rituals", "5"), ("🕐", "Weekly synchronous", "≤2h total"), + ("🧹", "Wiki sweep", "Quarterly"), ("🏔️", "Offsite", "Yearly, principles reviewed")], + ctx=("Rituals are the heartbeat that lets everything else be async. Each one has an owner, an " + "artifact (what it produces in the wiki), and a strict time-box — a ritual that stops " + "producing its artifact gets deleted at the next quarterly review.", + "The calendar is deliberately sparse: two synchronous hours a week company-wide. Teams add " + "their own standups if they want them; none are mandated."), + secs=[ + ("💓", "The five rituals", [ + ("t", ["Ritual", "Cadence", "Owner", "Artifact (in wiki)", "Time-box"], + [["Written pulse", "Weekly, Mon", "Every lead", "Pulse page per team (async)", "0 min sync"], + ["Demo day", "Biweekly, Thu", "Amara", "Demo notes + recordings", "45 min"], + ["Metrics review", "Monthly, 1st Wed", "David", "Annotated exec dashboard", "60 min"], + ["Wiki sweep", "Quarterly", "Priya", "Re-verified articles, pruned drafts", "async week"], + ["Offsite", "Yearly, Oct", "Dylan", "Principles review + next-year bets", "3 days"]]), + ("c", "📌", "The artifact column is the contract: no artifact, no ritual. Demo day without notes in the wiki counts as not having happened."), + ]), + ("📝", "Written pulse format", [ + ("code", "markdown", "## Pulse — Editor team — 2026-W23\n**Shipped:** wiki surface end-to-end (DB→bridge→sidebar)\n**Slipped:** mermaid lazy-load perf fix → W24\n**Blocked:** none\n**Risk:** seed volume may slow first hydrate; measuring\n**Mood:** 🟢 — first dogfood week of wiki governance", "pulse-template.md"), + ("b", "Five lines max; links carry the detail.", + "Pulses roll up into the monthly metrics review automatically.", + "Red mood two weeks running triggers a lead 1:1 — that is the alerting path for humans."), + ]), + ("🧹", "The quarterly wiki sweep", [ + ("n", "Every owner gets their article list with verification dates two weeks before quarter end.", + "Re-verify (re-read, re-run embedded views, bump date) or demote to 🔍 In review honestly.", + "Drafts older than two quarters are archived — the sweep is allowed to delete.", + "Sweep results (verified %, demoted, archived) land in the metrics review."), + ("mm", "gantt\n title Quarterly sweep (Q3 2026)\n dateFormat YYYY-MM-DD\n section Sweep\n Owner lists sent :a1, 2026-08-18, 3d\n Re-verification week :a2, 2026-08-24, 7d\n Archive & report :a3, 2026-09-01, 2d"), + ]), + ("🏖️", "Calendar hygiene", [ + ("b", "Company holidays + focus blocks are pre-seeded on everyone's calendar; meetings cannot be booked over them.", + "Any new recurring meeting needs an owner, artifact, and end date — same contract as rituals.", + "October offsite week is meeting-free except the offsite itself."), + ]), + ], + faq=[("Are team standups required?", "No. Teams choose; the written pulse is the only mandatory cadence."), + ("What happens to demo recordings?", "Linked from demo notes pages; pruned after a year."), + ("Can I skip the metrics review?", "Leads cannot; everyone else is Informed and reads the annotated dashboard async."), + ("Who schedules the offsite?", "Priya logistics, Dylan content. Families invited to day 3.")], + check=[("Subscribe to the company calendar layer", False), + ("Write your first pulse using the template", False), + ("Block your focus hours before someone books them", False), + ("Check when your articles are due in the next sweep", False)], + decisions=[["2026-02-14", "Killed the weekly all-hands", "Pulse + demo day covered it in 0 sync minutes", "Priya Sharma"], + ["2025-10-12", "Wiki sweep became a ritual with archive powers", "Dead drafts were eroding trust in search", "Dylan Lesieur"]], + changelog=[["2026-06-01", "Verified; Q3 sweep dates added", "Priya Sharma"]]), + +dict(slug="glossary", sec="handbook", icon="📖", + title="Glossary: the words we use precisely", owner="Amara Okonkwo", domain="Company", + related=("architecture-overview", "kpi-dictionary", "database-views-spec", "mission-principles"), + tldr="Shared vocabulary, alphabetized. If two people use one of these words differently, this page settles it — edit it via the owner, not in place.", + kpis=[("🔤", "Terms", "38"), ("🧭", "Rule", "One meaning per word"), + ("🔁", "Updated", "On every naming decision"), ("⚠️", "Conflicts settled", "12")], + ctx=("Most cross-team confusion is two teams using one word for two things (or two words for one " + "thing). This glossary is the tiebreaker. Terms get added when a naming confusion costs an " + "hour — the entry is cheaper than the second hour.",), + secs=[ + ("🔤", "A–E", [ + ("t", ["Term", "Means here", "Not to be confused with"], + [["ABAC", "Attribute-based access control in the permission engine", "Simple role checks (RBAC)"], + ["Article", "A governed wiki page with owner/status/verified properties", "Any random page"], + ["BaaS", "Our mini-baas: gateway + engines + control/data planes", "Third-party Firebase-likes"], + ["Block", "One editor content unit (paragraph, table, view, …)", "A database row"], + ["Bridge", "The workspace persistence API between editor and BaaS", "The auth gateway"], + ["Cutover", "Final traffic switch after parity gates pass", "Any deploy"], + ["DACI", "Driver/Approver/Contributors/Informed decision protocol", "Consensus"], + ["Edition", "A named, known-good compose shape of the BaaS stack", "An environment"], + ["Error budget", "1 − SLO: the unreliability we may spend", "Bug backlog"]]), + ]), + ("🔤", "F–O", [ + ("t", ["Term", "Means here", "Not to be confused with"], + [["Folder", "Sidebar container that groups and never opens", "Wiki (which opens)"], + ["Gate", "A pass/fail check that blocks promotion (CI, parity, SLO)", "A dashboard"], + ["Known database", "The in-app seeded demo databases (db-tasks, …)", "Live mounts"], + ["Live mount", "A real engine table mounted via baas::
", "Known databases"], + ["Mount", "Registered external database in the BaaS catalog", "A Docker volume"], + ["On-call", "The person whose pager answers for a system this week", "The owner"], + ["Outbox", "Client-side pending-writes ledger for offline-safe sync", "A message queue"], + ["Owner", "The single accountable human for a thing", "The on-call"]]), + ]), + ("🔤", "P–Z", [ + ("t", ["Term", "Means here", "Not to be confused with"], + [["Parity", "Shadow and legacy paths returning identical results", "Feature parity with competitors"], + ["Plane", "TS app / Go control / Rust data split of the BaaS", "A deployment tier"], + ["Pulse", "The weekly written team status (5 lines)", "A standup"], + ["Shadow", "Running new code in parallel, comparing, not serving", "A canary"], + ["SLI/SLO/SLA", "Signal / internal target / external contract", "Each other"], + ["Surface", "A page's behavioral class: page/agent/home/folder/wiki", "Visual theme"], + ["Sweep", "The quarterly wiki re-verification", "A migration"], + ["View", "A saved lens over a database (board, calendar, chart…)", "A SQL view"], + ["Wiki", "Governed knowledge root: opens AND groups (surface=wiki)", "Folder"]]), + ("c", "💡", "The folder-vs-wiki distinction is new (DR-061): a folder organizes and never opens; a wiki opens onto its governed index *and* organizes its children."), + ]), + ("➕", "Adding a term", [ + ("n", "Confirm the confusion actually cost time (cite the thread).", + "Propose the one-line meaning + the 'not to be confused with' column.", + "Amara merges it; conflicting usage elsewhere gets fixed in the same PR/edit."), + ]), + ], + faq=[("Why one meaning per word instead of context?", "Context disambiguation works for readers and fails in grep, search, and onboarding. One meaning wins."), + ("Can teams keep private jargon?", "Inside the team, sure. The moment it crosses a team boundary it belongs here."), + ("Why is 'owner ≠ on-call' called out?", "Because conflating them caused two escalation loops — the pager answers, the owner decides.")], + check=[("Skim all three tables once", False), + ("Fix one doc of yours that misuses a glossary term", False), + ("Propose the term that confused you most when you joined", False)], + decisions=[["2026-06-02", "Added 'wiki' and 'surface' entries", "DR-061 introduced the new surface", "Amara Okonkwo"], + ["2026-03-22", "Settled 'mount' vs 'volume'", "Infra and BaaS teams collided weekly on it", "Amara Okonkwo"]], + changelog=[["2026-06-02", "Verified; 38 terms", "Amara Okonkwo"]]), + +# ============================== ENGINEERING ================================== +dict(slug="architecture-overview", sec="engineering", icon="🏗️", + title="Platform architecture overview", owner="Amara Okonkwo", domain="Engineering", + related=("three-plane-pattern", "dev-environment", "live-mounts-spec", "security-model"), + tldr="One product (the editor), one gateway (the bridge), one BaaS (three language planes), many engines. Requests flow frontend → bridge/Kong → control plane → data plane → engines; everything ships as containers.", + kpis=[("📦", "Services in compose", "~40"), ("🗄️", "Engines", "Postgres, MySQL, MongoDB, Redis"), + ("🌐", "Public surfaces", "app, website, gateway"), ("🔁", "Deploy unit", "Image, never host")], + ctx=("This is the map you need before touching anything: which box you are in, who you may call, " + "and where the trust boundaries sit. Detail lives in per-system articles; this page is the " + "wiring diagram and the contracts between boxes.", + "Two iron rules shape everything: containers are the only runtime (no host installs), and " + "planes call downward only — the data plane never calls the control plane, engines never " + "call anyone."), + secs=[ + ("🗺️", "The big picture", [ + ("mm", "flowchart LR\n subgraph Client\n APP[osionos editor SPA]\n WEB[website]\n end\n subgraph Edge\n BR[bridge :4000]\n KONG[Kong gateway :8000]\n AUTH[auth-gateway Go]\n end\n subgraph BaaS\n CP[Control plane Go]\n QR[Query router TS]\n DP[Data plane Rust]\n end\n subgraph Engines\n PG[(PostgreSQL)]\n MY[(MySQL)]\n MG[(MongoDB)]\n RD[(Redis)]\n end\n APP --> BR\n WEB --> AUTH\n APP --> KONG\n KONG --> QR\n QR --> DP\n AUTH --> CP\n CP --> DP\n DP --> PG\n DP --> MY\n DP --> MG\n DP --> RD\n BR --> PG"), + ("p", "The bridge owns workspace persistence (pages, workspaces) directly in Postgres; the BaaS " + "owns everything behind a mount id. The editor talks to both, never to engines."), + ]), + ("📦", "Box contracts", [ + ("t", ["Box", "Owns", "May call", "Never"], + [["Editor SPA", "Rendering, canvas model, outbox", "bridge, Kong", "Engines, planes directly"], + ["Bridge", "Pages/workspaces persistence, sessions", "Postgres, gotrue", "Other engines"], + ["Kong", "Routing, keys, rate limits", "Query router", "Engines"], + ["Control plane (Go)", "Tenancy, secrets, provisioning", "Data plane, Vault", "Serving queries"], + ["Query router (TS)", "Validation, capability checks", "Data plane", "Engines directly (post-cutover)"], + ["Data plane (Rust)", "Query execution, pools, transactions", "Engines", "Upward calls"], + ["Engines", "Storage", "—", "Calling anything"]]), + ("c", "⚠️", "A PR that makes an arrow point upward is wrong by construction — cite this page in review and reject."), + ]), + ("🔌", "The request path, concretely", [ + ("n", "Editor issues a query against `baas::
` with the workspace API key.", + "Kong authenticates the key, applies rate limits, forwards to the query router.", + "Router validates shape + capabilities (can this engine UPDATE? does owner-scope apply?).", + "Rust data plane executes on the right engine pool, stamps owner scoping.", + "Result flows back; realtime plane publishes change events to subscribers."), + ("code", "bash", "# trace one hop yourself (inside the compose network)\ncurl -s http://kong:8000/query/v1/472138fd/orders \\\n -H \"apikey: $VITE_BAAS_API_KEY\" \\\n -H 'content-type: application/json' \\\n -d '{\"op\":\"select\",\"limit\":3}' | jq '.rows | length'", "trace-a-query.sh"), + ]), + ("🧱", "Why three planes (the short version)", [ + ("b", "TypeScript plane: product velocity — where shapes change weekly.", + "Go plane: control logic — tenancy, secrets, the things that must be boring.", + "Rust plane: the hot path — pooling, transactions, per-row scoping at speed.", + "Full rationale + the cutover story: see *Three-plane pattern* and *Migration discipline*."), + ]), + ("🔭", "Where to look when it breaks", [ + ("t", ["Symptom", "First look", "Owning article"], + [["Editor loads, pages missing", "bridge logs, osionos_pages", "Dev environment"], + ["Live view empty", "Kong key, mount catalog env", "Live mounts spec"], + ["Writes 409 then snap back", "outbox conflict path (expected)", "Block editor spec"], + ["Query slow only on MySQL", "data plane pool metrics", "On-call & escalation"], + ["Auth loops", "gotrue + auth-gateway", "Security model"]]), + ]), + ], + faq=[("Why does the bridge bypass the BaaS for pages?", "Pages predate the BaaS and need transactional semantics with sessions; migrating them behind a mount is a parked decision record."), + ("Can a new service talk to an engine directly?", "Only via the data plane. The single chokepoint is what makes owner-scoping and pooling enforceable."), + ("Where are the TLS boundaries?", "Local HTTPS proxy terminates for the web surfaces; intra-compose traffic is plain HTTP inside the network namespace — see Security model."), + ("Is Redis an engine or a cache?", "Both: engine behind a mount for demo data, cache for sessions. The glossary entry disambiguates per context.")], + check=[("Find your service in the big-picture diagram", False), + ("Run the trace-a-query snippet against pg-commerce", False), + ("Read the box contract for every box you call", False), + ("Bookmark the symptom table for your next incident", False)], + decisions=[["2026-04-11", "All engine access funnels through the Rust data plane", "Owner-scoping had three implementations", "Amara Okonkwo"], + ["2025-12-03", "Bridge keeps direct Postgres for pages", "Transactional session semantics", "Yuki Tanaka"]], + changelog=[["2026-06-02", "Verified; symptom table extended", "Amara Okonkwo"]]), + +dict(slug="three-plane-pattern", sec="engineering", icon="🛰️", + title="The three-plane pattern (TS / Go / Rust)", owner="Yuki Tanaka", domain="Engineering", + related=("architecture-overview", "migration-discipline", "testing-quality-gates", "glossary"), + tldr="Application plane in TypeScript for product velocity, control plane in Go for boring-by-design tenancy/secrets, data plane in Rust for the hot query path. Planes call downward only; each is independently testable, shadowable, replaceable.", + kpis=[("🗼", "Planes", "3 languages, 3 jobs"), ("⬇️", "Call direction", "Down only"), + ("🧪", "Parity gates", "Required for replacement"), ("🚀", "Hot-path p99", "Rust-owned")], + ctx=("Polyglot by accident is a mess; polyglot by contract is leverage. Each plane uses the " + "language whose failure modes match the job: TS where iteration speed dominates, Go where " + "predictability dominates, Rust where the CPU and the connection pool dominate.", + "The pattern is enforced socially (review) and mechanically (network policies + the parity " + "harness). The boundaries are the API schemas in the repo, not tribal knowledge."), + secs=[ + ("📜", "Plane contracts", [ + ("t", ["Plane", "Language", "Owns", "Failure budget"], + [["Application", "TypeScript", "Routing, validation, product shapes, orchestration", "Fails loud, retried by client"], + ["Control", "Go", "Tenancy, provisioning, secrets, webhooks", "Must not fail: idempotent + audited"], + ["Data", "Rust", "Query execution, pools, transactions, owner-scoping", "Fails fast, never corrupts"]]), + ("c", "📌", "If a feature needs a new capability, decide which plane owns it BEFORE writing code — the decision record template has a plane checkbox for this."), + ]), + ("🔁", "A write, across all three", [ + ("mm", "sequenceDiagram\n participant E as Editor (TS client)\n participant Q as Query router (TS)\n participant C as Control plane (Go)\n participant D as Data plane (Rust)\n participant P as PostgreSQL\n E->>Q: PATCH baas:db:orders row 42\n Q->>Q: validate shape + capability\n Q->>D: execute(update, scope=owner)\n D->>P: UPDATE ... WHERE id=42 AND owner=$1\n P-->>D: 1 row\n D-->>Q: ok + new version\n Q-->>E: 200 (outbox clears)\n Note over C: not on the write path —\n Note over C: provisioning/secrets only"), + ("p", "Note what is absent: the control plane. Keeping Go off the hot path is why its " + "simplicity budget holds — it changes monthly, not daily."), + ]), + ("🧠", "Why these languages, honestly", [ + ("cols", [(1, [("c", "🟦", "**TypeScript** — the product changes shape weekly; structural typing and the shared types with the editor make schema drift a compile error, not a runtime surprise.")]), + (1, [("c", "🟩", "**Go** — provisioning and secrets must be readable by everyone and deadlock-free; goroutines + context cancellation + boring stdlib fit exactly.")]), + (1, [("c", "🟧", "**Rust** — per-request allocation and pool contention showed up directly in p99; ownership semantics also make 'never corrupt a transaction' a type-level property.")])]), + ("q", "Pick the language whose worst day matches the component's worst day."), + ]), + ("🧪", "Testing seams", [ + ("b", "Each plane has a standalone harness: TS contract tests, Go golden-file tests, Rust proptests on the executor.", + "Cross-plane: the parity harness replays recorded traffic through old and new paths and diffs results (see Migration discipline).", + "A plane may be swapped wholesale if its replacement passes the same harness — that is how the Rust data plane replaced the TS executor."), + ("code", "rust", "#[tokio::test]\nasync fn owner_scope_is_always_applied() {\n let plan = plan_update(\"orders\", row(42), Scope::Owner(\"api-key:abc\"));\n assert!(plan.sql.contains(\"owner_id = $\"));\n // property: no plan without a scope predicate may compile-time exist\n}", "data-plane/tests/scope.rs"), + ]), + ], + faq=[("Can the control plane ever serve a query?", "No. If a 'just this once' appears, it becomes a data-plane capability instead."), + ("Why not one language with three modules?", "We had that; pool tuning and product velocity fought in the same runtime. The split made both budgets explicit."), + ("What about a fourth plane?", "The realtime router is arguably it (Rust, eventing) — its article lives with the BaaS docs; same downward-only rule."), + ("How do shared types cross planes?", "Schemas are the contract (JSON schema / SQL), generated types per language; no runtime coupling.")], + check=[("Name which plane owns your current change", False), + ("Run one plane's harness locally via its compose profile", False), + ("Read the parity harness README before touching the data plane", False), + ("Check your PR doesn't add an upward call", False)], + decisions=[["2026-03-29", "Realtime eventing stays a Rust workspace beside the data plane", "Same failure budget as the hot path", "Yuki Tanaka"], + ["2025-11-08", "TS query executor frozen; Rust shadows it", "p99 and pool exhaustion under load", "Amara Okonkwo"]], + changelog=[["2026-06-01", "Verified; added write sequence diagram", "Yuki Tanaka"]]), + +dict(slug="dev-environment", sec="engineering", icon="🐳", + title="Dev environment: Docker-first, no exceptions", owner="Yuki Tanaka", domain="Engineering", + related=("architecture-overview", "git-release-flow", "testing-quality-gates", "secrets-vault"), + tldr="The entire stack runs in containers; the host needs Docker, make, git and nothing else. `make all` is the authority; editions give you smaller known-good shapes. Host node_modules are forbidden because they break container volumes.", + kpis=[("🐳", "Host deps", "Docker + make + git only"), ("🚀", "Bootstrap", "make all"), + ("📦", "Lean edition", "~12 containers"), ("🔁", "Reset", "compose down -v && make all")], + ctx=("Every 'works on my machine' is a host dependency that escaped. The rule is absolute: no " + "local Node, no local package managers, no host installs — containers are the only runtime, " + "and the Makefile is the only lifecycle entry point.", + "This page is the happy path plus the five failure modes that cover 95% of broken setups. " + "Anything deeper goes to #platform with the doctor output attached."), + secs=[ + ("🚀", "Bootstrap, exactly", [ + ("code", "bash", "git clone && cd ft_transcendence\nmake pulls # submodules with upstream tracking\nmake all # certs, secrets, build, start, healthcheck\n# variants:\nmake all-local # offline path, locally generated secrets\nmake local # lean ~12-container HTTP edition (desktop backend)\nmake healthcheck # re-validate anytime", "bootstrap.sh"), + ("b", "First run may prompt sudo once — that is the local CA being trusted, nothing else.", + "`make all` fetches shared secrets from Vault when a credential exists and falls back to generated ones when it doesn't.", + "URLs print at the end; nothing is 'started by hand'."), + ]), + ("⚖️", "The laws", [ + ("n", "**No host installs.** Scripts detect host execution and proxy into containers; don't fight them.", + "**No host node_modules.** They shadow container volumes and produce unreproducible builds — delete them on sight.", + "**Makefile only.** Raw `docker compose` is fine for logs/exec; lifecycle (up/build/seed) goes through make targets.", + "**Frozen lockfiles.** Installs use `--frozen-lockfile` / `npm ci --ignore-scripts`; adding install-time hooks is a security review, not a convenience."), + ("c", "⚠️", "The supply-chain rules are not style: pnpm runs with a minimum release age and an allowlist for build scripts. A red install gate means wait or allowlist via security review."), + ]), + ("🧭", "Working on one service", [ + ("code", "bash", "docker compose up -d --build osionos-app # rebuild one service\ndocker compose logs -f osionos-bridge # follow logs\ndocker compose exec osionos-app sh # shell inside\nmake -C apps/baas/mini-baas-infra up EDITION=query # BaaS shape\nmake osionos-app-live # rebuild app with live BaaS env baked", "daily-driver.sh"), + ("p", "Editions are named, known-good shapes of the BaaS stack (query, realtime, prod, full). " + "Start the smallest one that exercises your change; CI runs the full one."), + ]), + ("🚑", "The five classic breakages", [ + ("t", ["Symptom", "Cause", "Fix"], + [["Port already in use", "Stale container or host process", "lsof -i :PORT, then compose down"], + ["Browser distrusts localhost", "CA not trusted / rotated", "make certs-trust-local"], + ["DB auth failures after pull", "Password drift vs .env.local", "make db-password-check && make db-password-apply"], + ["Submodule at wrong SHA", "Detached or unfetched submodule", "make pulls && git submodule update --init --recursive"], + ["Vite env not taking effect", "VITE_* inlined at build time", "rebuild image (make osionos-app-live), not restart"]]), + ("tg", "Nuclear option (loses local data)", [ + ("code", "bash", "docker compose down -v # deletes volumes!\nmake all # full re-bootstrap\n# live-demo data: make seed-live-demo ; wiki: make wiki-seed", "reset.sh"), + ("c", "🚨", "down -v deletes seeded demo databases and local workspace state. Re-seed afterwards; never run it to fix a port conflict."), + ]), + ]), + ("🔍", "Self-diagnosis before asking", [ + ("code", "bash", "make doctor # env sanity: versions, ports, certs, vault\nmake healthcheck # service-by-service probes\ndocker compose ps # who is actually up\n# attach all three outputs to any #platform question", "doctor.sh"), + ]), + ], + faq=[("Can I use my IDE's debugger?", "Yes — services expose debug ports in the dev compose profile; attach to the container, not a host process."), + ("Why does first build take so long?", "Cold pnpm store + image layers. Subsequent builds hit the cache mounts; the weekly docker cleanup keeps the cache under control without nuking it."), + ("Is M1/ARM supported?", "Yes via multi-arch base images; report arch-specific failures with `make doctor` output."), + ("Where do I put a new service?", "docker-compose.yml + an infrastructure/docker/ Dockerfile + a make fragment — copy the pattern of an existing sibling and read the architecture box contracts first.")], + check=[("Run make doctor and read every line once", False), + ("Bootstrap the lean edition (make local)", False), + ("Break and fix the cert trust on purpose (make certs-trust-local)", False), + ("Delete any host node_modules you find", True), + ("Learn the five classic breakages table by heart", False)], + decisions=[["2026-05-05", "Weekly automated docker cleanup with 7-day filters", "Build caches ate the disk repeatedly", "Yuki Tanaka"], + ["2025-10-20", "Banned host package managers entirely", "Volume shadowing caused phantom bugs", "Amara Okonkwo"]], + changelog=[["2026-06-02", "Verified; added live-env rebuild note", "Yuki Tanaka"]]), + +dict(slug="code-review-standard", sec="engineering", icon="🔍", + title="Code review standard", owner="Amara Okonkwo", domain="Engineering", + related=("testing-quality-gates", "git-release-flow", "dev-environment", "decision-framework"), + tldr="Reviews exist to catch correctness bugs and keep the codebase shaped (small files, clear ownership). One approving reviewer who actually ran the code; comments cite principles or contracts, not taste; 24h first-response SLA.", + kpis=[("👀", "Approvals required", "1 who ran it"), ("⏱️", "First response", "≤24h"), + ("📏", "Editor file cap", "200 lines / 5 functions"), ("🚫", "Taste debates", "Cite a rule or let go")], + ctx=("A review is a second pair of eyes on correctness and a guardrail on shape — it is not a " + "style tribunal. Anything a linter can say, a linter says; humans spend their attention on " + "logic, contracts, and the question 'will the next reader understand this?'.", + "The reviewer contract: you ran the change (or its tests) before approving. Rubber stamps " + "are worse than no review because they transfer false confidence."), + secs=[ + ("📐", "What reviewers check, in order", [ + ("n", "**Correctness** — does it do what the description says? Edge cases? Failure paths?", + "**Contracts** — box contracts (architecture), plane direction, API schemas, the file caps.", + "**Blast radius** — what breaks if this is wrong? Is it shadowed/gated/reversible?", + "**Tests** — do the gates cover the risky part, not just the happy line?", + "**Readability** — will the next person (not the author) follow it cold?"), + ("c", "💡", "Style is the linter's job. If you are about to write a comment the linter could have written, configure the linter instead."), + ]), + ("🧱", "The editor's hard caps", [ + ("p", "The osionos app enforces ≤200 lines and ≤5 functions per file — not aspirationally, " + "in review. A file at the cap gets split BEFORE the feature lands, as its own commit."), + ("b", "Caps force decomposition while changes are cheap.", + "Submodule rules apply: work on the designated branch, commit message discipline, no auto-push.", + "Caps do not apply to generated files or vendored code — they apply to everything we author."), + ]), + ("✍️", "Comment etiquette", [ + ("t", ["Prefix", "Means", "Author must"], + [["blocking:", "Correctness/contract violation", "Fix or refute with evidence"], + ["question:", "Reviewer needs context", "Answer; consider a code comment"], + ["nit:", "Take it or leave it", "Nothing"], + ["followup:", "Real but out of scope", "File it before merge"]]), + ("b", "Every blocking comment cites the rule it enforces (article, contract, principle).", + "Unresolved disagreement after one round-trip → 15-minute call → outcome written on the PR.", + "Reviewers praise good moves explicitly; review tone is a hiring asset."), + ]), + ("🔁", "The flow", [ + ("mm", "flowchart LR\n A[PR opened with description + test evidence] --> B{CI green?}\n B -- no --> A\n B -- yes --> C[Reviewer runs it]\n C --> D{blocking comments?}\n D -- yes --> E[Author fixes / refutes]\n E --> C\n D -- no --> F[Approve]\n F --> G[Author merges - author owns prod]"), + ("p", "The author merges, not the reviewer: whoever shipped it watches it land and owns the " + "first hour in production (see on-call article for the handoff after that)."), + ]), + ("📦", "PR hygiene", [ + ("b", "One concern per PR; refactors separate from behavior changes.", + "Description states what changed, why now, how verified — reviewers should not reverse-engineer intent.", + "Draft PRs early for direction checks; review effort scales with risk, not line count.", + "Large mechanical diffs (rename, codemod) get a 'generated by' note and a spot-check protocol."), + ]), + ], + faq=[("Who reviews cross-team changes?", "One reviewer from each affected owner team; the box contract table tells you whose contract you touched."), + ("Can I self-merge a one-liner?", "Only revert/rollback commits during incidents; everything else waits for its one real review."), + ("What about AI-generated code?", "Same standard, stricter scrutiny on tests: the author certifies they understand every line they submit."), + ("Reviewer is on holiday?", "Each area lists a backup in the org routing table; 24h SLA falls to the backup automatically.")], + check=[("Run the change before your next approval", False), + ("Use the comment prefixes in your next review", False), + ("Split any file you own that is over the cap", False), + ("Write test evidence into your next PR description", False)], + decisions=[["2026-02-27", "Author merges; reviewer never merges", "Ownership of the prod hour was ambiguous", "Amara Okonkwo"], + ["2025-09-30", "Blocking comments must cite a rule", "Taste wars were burning days", "Amara Okonkwo"]], + changelog=[["2026-06-01", "Verified; AI-code note added", "Amara Okonkwo"]]), + +dict(slug="git-release-flow", sec="engineering", icon="🔀", + title="Git & release flow", owner="Yuki Tanaka", domain="Engineering", + related=("code-review-standard", "testing-quality-gates", "migration-discipline", "dev-environment"), + tldr="Trunk-based with short-lived branches; the monorepo pins submodule SHAs; releases are images tagged from green main. Rollback is re-tagging the previous image — under five minutes, no git surgery.", + kpis=[("🌳", "Model", "Trunk + short branches"), ("📦", "Release unit", "Container image"), + ("⏪", "Rollback", "<5 min, re-tag"), ("🧷", "Submodules", "SHA-pinned by root")], + ctx=("The repo is a monorepo with submodules for the apps that ship independently. The root pins " + "exact SHAs: 'what was running' is always answerable from one commit. Branches live days, " + "not weeks — anything bigger hides behind a flag or a shadow path instead of a long branch.",), + secs=[ + ("🌳", "Branch discipline", [ + ("b", "main is releasable at every commit — gates enforce it (see Testing & quality gates).", + "Feature branches: branch from main, merge within days; rebase before merge for linear history.", + "Submodules first: commit inside the submodule, then bump the SHA in root — never the reverse.", + "The editor submodule has special rules (designated branch + commit message discipline); they're in its README and they are not optional."), + ("mm", "gitGraph\n commit id: \"main\"\n branch wiki-surface\n commit id: \"bridge whitelist\"\n commit id: \"sidebar + menu\"\n checkout main\n merge wiki-surface tag: \"v2026.06.02\"\n commit id: \"hotfix: csp\" tag: \"v2026.06.02-1\""), + ]), + ("🏷️", "Releases are images", [ + ("n", "Green main → CI builds images → tagged with calendar version + git SHA.", + "Deploy = pointing an environment at a tag (compose env var), never building on the target.", + "Rollback = pointing back at the previous tag; data migrations are forward-only and additive, which is what makes this safe.", + "Local 'release' equivalents: make osionos-app-live bakes env and restarts — same image-first idea."), + ("code", "bash", "# what was running last Tuesday?\ngit log --until='last tuesday' -1 --format=%H # root SHA\ngit show :.gitmodules # submodule pins\n# images carry the SHA as a label:\ndocker inspect dlesieur/osionos-app:latest | jq '.[0].Config.Labels'", "archaeology.sh"), + ]), + ("🧷", "Migrations & releases", [ + ("b", "Schema changes are additive (widen, add, backfill) and ship BEFORE the code that needs them — see the wiki surface migration as the canonical example.", + "Destructive cleanup ships at least one release after nothing references the old shape.", + "Every migration is a file in models/ with a header explaining reversal."), + ("c", "📌", "Additive-first is why rollback is a re-tag: old code must always run against the new schema."), + ]), + ("🚑", "Hotfix path", [ + ("n", "Branch from the deployed tag, not main, if main has drifted.", + "Fix + test + the one-real-review rule still applies (revert commits excepted).", + "Tag with a -N suffix; cherry-pick back to main the same day.", + "Postmortem if a hotfix recurs in the same area twice a quarter."), + ]), + ], + faq=[("Why calendar versions?", "Nothing here has marketing-driven semver semantics; 'when' beats 'how big' for archaeology."), + ("Squash or merge commits?", "Rebase + merge for linear history; submodule bumps are their own commits with the submodule's short log in the message."), + ("Who may push tags?", "CI only. Humans pushing tags was the root cause of release confusion twice — see decision log."), + ("Long-running feature?", "Flag it or shadow it (Migration discipline). A three-week branch is a process smell we treat as an incident.")], + check=[("Confirm your local main is releasable (run the gates)", False), + ("Practice the rollback: re-tag a local image and restart", False), + ("Find last week's deployed SHA via the archaeology snippet", False), + ("Check your migration is additive before review", False)], + decisions=[["2026-04-02", "CI is the only tag pusher", "Manual tags shipped an unreviewed build", "Yuki Tanaka"], + ["2026-01-21", "Forward-only additive migrations", "A down-migration ate seeded data", "Amara Okonkwo"]], + changelog=[["2026-06-02", "Verified; wiki migration cited as example", "Yuki Tanaka"]]), + +dict(slug="testing-quality-gates", sec="engineering", icon="🧪", + title="Testing strategy & quality gates", owner="Amara Okonkwo", domain="Engineering", + related=("code-review-standard", "git-release-flow", "migration-discipline", "block-editor-spec"), + tldr="Five gate layers — unit/canvas, e2e, bridge, quality (lint/a11y/perf/CSP), parity — and a PR merges only when the layers it touches are green. Gates are blocking by definition; a flaky gate is an incident, not an annoyance.", + kpis=[("🚦", "Gate layers", "5"), ("🎭", "E2E runner", "Playwright"), + ("♿", "A11y floor", "pa11y/WCAG enforced"), ("⚡", "Perf floor", "Lighthouse thresholds")], + ctx=("Tests are graded by the decision they protect: unit tests protect logic, e2e protects user " + "flows, bridge tests protect the auth handoff, quality gates protect the experience floor, " + "parity gates protect migrations. Coverage percent is not a goal; gate greenness is.", + "Everything runs in containers, same as dev — a test that needs host state is a bug by " + "definition."), + secs=[ + ("🚦", "The five layers", [ + ("t", ["Layer", "Protects", "Runs", "Blocking for"], + [["Canvas/unit (Vitest)", "Block model, serialization, transactions", "every PR", "all merges"], + ["E2E (Playwright)", "Signup→login→edit→persist flows", "every PR + nightly", "all merges"], + ["Bridge tests", "Website→editor token handoff, sessions", "every PR touching auth/bridge", "those PRs"], + ["Quality (eslint, stylelint, Lighthouse, pa11y, CSP)", "The experience floor", "every PR + nightly", "all merges"], + ["Parity harness", "Old vs new path identity", "continuously while shadowing", "cutover decisions"]]), + ("code", "bash", "docker compose exec osionos-app npm run test:canvas\ndocker compose exec osionos-app npm run test:e2e\ndocker compose exec osionos-app npm run test:bridge\ndocker compose exec osionos-app npm run test:quality\nmake baas-verify VERIFY_EDITION=query # parity gates", "run-the-gates.sh"), + ]), + ("🎭", "What e2e actually covers", [ + ("b", "The playground flow: create account, login, open editor, type, reload, assert persistence.", + "Database surfaces: open a known view, edit a cell, assert optimistic update + server truth.", + "Live mounts: mount catalog loads, a live table renders rows, a write lands in the engine.", + "Wiki governance (new): wiki root opens with index, articles show properties, sidebar nests."), + ("c", "💡", "E2E tests assert user-visible truth, not implementation: 'the row shows the new value after reload' — never 'the store contains X'."), + ]), + ("♿", "The experience floor", [ + ("b", "Lighthouse: performance and best-practice scores have hard thresholds per surface; the warm-path budget is tracked per route.", + "pa11y: WCAG violations are build failures — accessibility regressions do not merge.", + "CSP audit: strict policy, hashed inline, no unsafe-inline anywhere; the audit diffs the actual headers.", + "Style/lint: zero-warning policy; warnings rot into noise that hides errors."), + ]), + ("🧯", "Flake policy", [ + ("n", "A flaky test is quarantined the day it flakes (skipped with a ticket, never deleted).", + "Quarantine has a 2-week TTL: fix it or the owning team's merge rights to that area pause.", + "Flake rate is reviewed monthly in the metrics review — it is an SLO on the gates themselves."), + ("q", "A gate you sometimes ignore is not a gate; it is decoration."), + ]), + ("🌱", "Seeded data as fixtures", [ + ("p", "The live-demo seeders (commerce/ops/activity) and this wiki's seeder are deterministic — " + "PRNG seeds and stable uuid5 ids — so e2e tests assert against known rows. Re-running a " + "seeder converges instead of duplicating; tests may depend on that."), + ]), + ], + faq=[("Do I need all five layers locally?", "Run the layers your change touches; CI runs everything. The table's 'blocking for' column is the contract."), + ("Why is a11y blocking?", "Retrofits cost 10x and the floor only stays a floor if it cannot regress silently."), + ("Can I merge with a quarantined test in my area?", "Within the 2-week TTL yes; after that the area freezes until it's fixed."), + ("Where do load tests live?", "Data-plane benches in the Rust workspace; they gate cutovers, not PRs.")], + check=[("Run the full gate set once end-to-end", False), + ("Find which layers your current change touches", False), + ("Check the quarantine list for your team's area", False), + ("Add one e2e assertion for your last shipped feature", False)], + decisions=[["2026-03-17", "Quarantine TTL with merge-rights pause", "Quarantine had become a graveyard", "Amara Okonkwo"], + ["2025-12-10", "pa11y violations became hard failures", "A11y debt was compounding", "Sofia Marchetti"]], + changelog=[["2026-06-02", "Verified; wiki flows added to e2e scope", "Amara Okonkwo"]]), + +dict(slug="migration-discipline", sec="engineering", icon="🦺", + title="Migration discipline: shadow → parity → cutover", owner="Yuki Tanaka", domain="Engineering", + related=("three-plane-pattern", "testing-quality-gates", "git-release-flow", "decision-framework"), + tldr="Replacements run in parallel (shadow), must prove identical results (parity), then take traffic (cutover) — and deletion of the old path is a separate, gated, final step. If gates are UNKNOWN, you keep shadowing; you never delete on faith.", + kpis=[("👥", "Paths during shadow", "2, compared per request"), ("✅", "Gates to delete", "3, all PASS"), + ("🗑️", "Deletes on faith", "0 ever"), ("🏆", "Proven on", "TS→Rust data plane")], + ctx=("Big-bang rewrites fail at the edges nobody documented. This discipline turns a rewrite into " + "evidence collection: the new path watches real traffic, differences surface as diffs (not " + "outages), and the old path stays warm until three explicit gates pass.", + "It is slower per step and dramatically faster to 'actually done' — the Rust data plane " + "cutover shipped with zero user-facing incidents."), + secs=[ + ("🪜", "The ladder", [ + ("mm", "stateDiagram-v2\n [*] --> Shadow: new path deployed, 0% serving\n Shadow --> Shadow: diffs found → fix → keep shadowing\n Shadow --> Parity: replay + live diffs clean over window\n Parity --> Cutover: gates reviewed, traffic switched\n Cutover --> Soak: old path warm, instant revert\n Soak --> Deletion: 3 gates PASS\n Deletion --> [*]\n Cutover --> Shadow: regression → revert + diagnose"), + ("p", "Each promotion is a decision-record entry with the evidence attached — the ladder " + "leaves an audit trail, not a vibe."), + ]), + ("🚧", "The three deletion gates", [ + ("n", "**Live traffic**: the new path serves real production traffic in its final mode.", + "**Shadow parity**: identical requests return identical results through both paths across the comparison window.", + "**CI green with forward routing**: the full gate set passes with the new path as default."), + ("c", "🚨", "All three must be explicitly PASS. UNKNOWN is not PASS. The person deleting cites the evidence in the PR — 'it's been fine' is not evidence."), + ]), + ("🔬", "What the parity harness does", [ + ("b", "Duplicates requests to old and new paths; normalizes (timestamps, ids) and diffs results.", + "Diffs are triaged: new-path bug (fix), old-path bug (document — the rewrite found treasure), or acceptable divergence (recorded with rationale).", + "Divergence budget is zero for writes, explicit-and-listed for reads.", + "Artifacts persist (the .parity/ trail) so the cutover record can point at them."), + ("code", "bash", "make baas-verify VERIFY_EDITION=query # run parity gates\n# inspect the trail:\nls apps/baas/mini-baas-infra/.parity/ # diffs, windows, verdicts", "parity.sh"), + ]), + ("🧯", "When shadow finds a difference", [ + ("n", "Reproduce as a recorded request in the harness (it becomes a permanent regression test).", + "Classify: which path is wrong, against what spec?", + "Fix forward in the new path OR document the old path's bug in the spec article.", + "Re-run the window. The clock restarts; that is the price and the point."), + ]), + ("📦", "What qualifies for the ladder", [ + ("b", "Plane replacements, storage engine changes, auth/session flows, anything where 'subtle difference' = corruption.", + "NOT every refactor: in-plane changes with full gate coverage ship normally.", + "Rule of thumb: if rollback is harder than re-tag, you need the ladder."), + ]), + ], + faq=[("How long is a parity window?", "Long enough to cover periodics (cron, weekly jobs) — minimum one week, typically two, per the cutover record."), + ("Costs of running both paths?", "Real (compute, latency on shadow). Budgeted in the decision record; far cheaper than one corruption incident."), + ("Can shadow mode mutate?", "Never. Shadow writes go to a sink or a shadow store; only the serving path mutates truth."), + ("Who approves cutover?", "The owning lead via DACI; deletion needs the same approver again, with gates attached.")], + check=[("Read the Rust cutover decision record end-to-end", False), + ("Run parity gates against the query edition", False), + ("Classify your next big change: ladder or normal ship?", False), + ("Find one 'old path bug' the harness documented", False)], + decisions=[["2026-04-11", "Data plane cutover approved; TS executor frozen", "Three gates PASS with attached evidence", "Amara Okonkwo"], + ["2026-05-19", "TS executor deletion deferred", "Forward-routing CI gate was UNKNOWN", "Yuki Tanaka"]], + changelog=[["2026-06-01", "Verified; deletion still gated (correctly)", "Yuki Tanaka"]]), + +dict(slug="oncall-escalation", sec="engineering", icon="📟", + title="On-call & escalation", owner="Amara Okonkwo", domain="Engineering", + related=("incident-response", "org-teams", "support-runbook", "testing-quality-gates"), + tldr="Weekly rotations per team, 5-minute ack SLA for pages, mitigate-first doctrine, and a hard rule: the pager escalates to humans by policy, never by guilt. Support tickets and pages are different pipes that meet only at SEV declaration.", + kpis=[("📟", "Rotations", "2 (editor, data path)"), ("⏱️", "Ack SLA", "5 min"), + ("🛌", "Quiet hours pages", "SEV1/2 only"), ("🔁", "Handoff", "Written, Mondays")], + ctx=("On-call answers 'who acts right now'; ownership answers 'who decides' — keep them separate " + "(glossary: owner ≠ on-call). This article covers rotations, the escalation tree, and the " + "handoff ritual; what to actually DO during an incident lives in the incident response " + "runbook in Security.", + "The support team's live ticket queue is embedded below because most 'is this an incident?' " + "questions start as tickets — the bridge between the two pipes is explicit."), + secs=[ + ("🗓️", "Rotations & expectations", [ + ("t", ["Rotation", "Covers", "Pageable for", "Backup"], + [["Editor", "app, bridge, sessions", "SEV1-3 user-facing", "Yuki Tanaka"], + ["Data path", "BaaS, engines, realtime", "SEV1-3 data/queries", "David Okafor"]]), + ("b", "Shifts are Monday-to-Monday; trades are fine if the schedule page is updated first.", + "On-call weeks have no feature commitments — interrupt capacity IS the commitment.", + "Comp: a recovery day after any week with a night page (People Ops policy, automatic)."), + ]), + ("🌲", "The escalation tree", [ + ("mm", "flowchart TD\n P[Page fires] --> A{Ack in 5 min?}\n A -- no --> B[Backup paged automatically]\n A -- yes --> C{Mitigable alone in 30 min?}\n C -- yes --> M[Mitigate, then diagnose]\n C -- no --> D[Declare SEV - incident response takes over]\n D --> IC[Incident commander assigned]\n B --> C\n M --> R[Write handoff note + ticket]"), + ("c", "📌", "Escalating early is always right: paging the backup or declaring a SEV costs minutes; hero-debugging alone at 3am costs hours and trust."), + ]), + ("🎫", "Tickets vs pages", [ + ("p", "Support tickets (below, LIVE) and pages are different pipes: tickets carry customer " + "impact reports on support SLAs; pages carry system signals on the 5-minute SLA. A " + "ticket becomes a page only when support invokes the severity matrix — see the SLA " + "article in Sales & Support."), + ("live", "mysql-ops", "tickets", + "the real support queue — severity + status straight from MySQL; this is the bridge view on-call checks when a page mentions customer reports"), + ]), + ("📝", "The Monday handoff", [ + ("n", "Outgoing writes the handoff note: open mitigations, fragile areas, quarantined tests, anything 'watch this'.", + "Incoming reads it BEFORE taking the pager and asks questions in-thread.", + "Unacknowledged handoff = rotation has not transferred; the schedule page enforces this.", + "Notes accumulate into the monthly reliability review (metrics review input)."), + ("code", "markdown", "## Handoff — data path — 2026-W23\n**Open:** mongo-activity events index rebuild running (ETA Tue)\n**Fragile:** kong rate limits at 80% on exec dashboard refresh spikes\n**Watch:** parity harness re-window after Friday's diff fix\n**Pages last week:** 1 (SEV3, pool exhaustion, mitigated by restart + PR #1247)", "handoff-template.md"), + ]), + ], + faq=[("Can on-call deploy a fix directly?", "Mitigation-by-rollback (re-tag) yes, immediately. Forward fixes still need the one real review — find a reviewer, or mitigate and wait."), + ("What pages during quiet hours?", "SEV1/SEV2 only; SEV3 waits for morning. The severity matrix in incident response defines them."), + ("Do leads take rotation?", "Yes, same as everyone — leads exempt from the pager lose touch with what it costs."), + ("Who fixes alert noise?", "On-call files alert-quality tickets with the same priority as code bugs; a page with no action twice is deleted or rewritten.")], + check=[("Find your next rotation week on the schedule page", False), + ("Read last week's handoff note for your rotation", False), + ("Walk the escalation tree once from memory", False), + ("Verify your pager actually fires (test page)", False), + ("Read the severity matrix before your first shift", False)], + decisions=[["2026-05-12", "Automatic backup-page at 5 min", "A missed 2am page extended SEV2 by an hour", "Amara Okonkwo"], + ["2026-02-08", "Recovery day after night-page weeks", "Burnout signals in pulse moods", "Priya Sharma"]], + changelog=[["2026-06-02", "Verified; live ticket queue embedded", "Amara Okonkwo"]]), + +# __MORE_PACK1__ +] diff --git a/vendor/born2root b/vendor/born2root index b3eab2e7..c909d0b5 160000 --- a/vendor/born2root +++ b/vendor/born2root @@ -1 +1 @@ -Subproject commit b3eab2e733e74872754ef3dd7a31f14ffb41ffab +Subproject commit c909d0b5cc72a10ee656e9b652ca3b757f959b74