Skip to content

OnePlatform User Story Analysis v4

Date: 2026-06-15 Method: 10-persona exhaustive analysis with source code verification and deduplication against v2+v3 Previous analyses: v1 (108 points), v2 (85 unique, 13 CRITICAL), v3 (53 unique, 5 CRITICAL) This analysis: NET-NEW findings only -- every item verified as absent from v2 and v3


Summary Table

Severity Count
CRITICAL 18
HIGH 45
MEDIUM 61
LOW 24
Total net-new findings 148

Methodology

This v4 analysis applies 10 distinct user personas to the entire OnePlatform codebase. Every finding has been:

  1. Verified in source code -- file paths, line numbers, and exact code patterns are cited
  2. Cross-referenced against v2 and v3 -- only findings absent from v2's 85 items and v3's 53 items are included
  3. Deduplicated -- overlapping findings from different personas are merged into a single entry
  4. Classified by severity using the same rubric as v2/v3

Severity Rubric

Level Criteria
CRITICAL Blocks a core workflow entirely, causes data loss, or creates an exploitable security vulnerability
HIGH Significant functionality gap, performance degradation, or security weakness that affects many users
MEDIUM Noticeable quality issue, inconsistency, or missing feature that has workarounds
LOW Cosmetic issue, minor inconvenience, or improvement opportunity

Personas Applied

Persona Focus Area Findings
First-Time Self-Hoster Docker Compose experience, bootstrap flow, first-run errors 29
Data Engineer Connector pipelines, sync reliability, mapping correctness, batch processing 17
App Developer SDK, BFF, build/deploy lifecycle, type safety 17
Plugin Developer Plugin SDK, hook system, sandbox interaction, bundle lifecycle 30
Platform Admin User management, RBAC configuration, tenant operations, monitoring (included in Plugin Dev)
DevOps/SRE Observability, scaling, backup, health checks, container lifecycle 15
Security Auditor Auth flows, token handling, SSRF, injection, secrets management (included in Enterprise)
Enterprise Evaluator Multi-tenancy, compliance, audit trail, SSO integration 9
Power User / Data Scientist Query performance, API ergonomics, large dataset handling 17
Casual / Non-Technical User UI clarity, error messages, onboarding guidance 14

Top 10 Priorities

# Finding Severity Why It Matters Effort
1 V4-C-01: Quick Start docker compose up fails -- no compose file at repo root CRITICAL The literal first step in onboarding is broken; every new user hits a wall immediately Small
2 V4-C-02: Redis URL missing username -- all services fail Redis auth with ACL mode CRITICAL All 9 services fail to connect to Redis at startup; the platform is completely non-functional in Docker Small
3 V4-C-14: BullMQ key prefix bull: not covered by any service ACL patterns CRITICAL All BullMQ-based job processing (sync, pipeline, audit, retention) fails with NOPERM errors Small
4 V4-C-16: Log events table lacks tenant_id column -- cross-tenant log leakage CRITICAL Any user can read all tenants' log data; complete multi-tenancy failure for logs Large
5 V4-C-04: useUser() returns wrong shape -- BffClient does not unwrap /bff/me envelope CRITICAL User identity is broken for all hosted apps; user.id, user.displayName are always undefined Medium
6 V4-C-10: Audit log frontend calls wrong API endpoint path (/v1/audit vs /v1/audit-events) CRITICAL The entire Audit Log page is non-functional; admins cannot view any audit events Small
7 V4-C-18: listRuns ignores pipelineId filter -- returns all tenant runs CRITICAL Users querying runs for a specific pipeline get ALL runs from ALL pipelines in the tenant Small
8 V4-C-03: No op_execution user in Redis ACL -- execution service cannot start CRITICAL The execution-service container fails to start because the Redis password file does not exist Small
9 V4-C-05: SDK deploy() sends PATCH with wrong field, should be POST to /deploy CRITICAL Every client.apps.deploy() call fails with a 400 validation error Small
10 V4-H-03: NewConnectorPage calls non-existent /v1/connectors/types endpoint HIGH The connector creation wizard is broken at step 1; users cannot create any connectors via UI Medium

All Findings

CRITICAL (18)

V4-C-01: Quick Start docker compose up fails -- no docker-compose.yml at repo root

Severity: CRITICAL Personas: First-Time Self-Hoster, DevOps/SRE Component: README.md (line 38-43)

Problem: The README instructs users to run docker compose up from the repo root, but the compose file is at docker/docker-compose.yml, not at the root. There is no symlink or compose.yml/docker-compose.yml anywhere in the project root:

git clone https://github.com/aaron777collins/oneplatform.git
cd oneplatform
docker compose up

Running this produces: no configuration file provided: not found

Impact: Every first-time self-hoster hits a wall at the very first step. The Quick Start is the #1 entry point and it is broken. Users must discover the correct path themselves by exploring the repo structure.

Fix: Either add a symlink or root-level docker-compose.yml that includes the docker/ path, or update the README to use docker compose -f docker/docker-compose.yml up. Also add the missing cp .env.example .env step before docker compose up.

Effort: Small


V4-C-02: Redis URL missing username -- all services fail Redis auth with ACL mode

Severity: CRITICAL Personas: First-Time Self-Hoster, DevOps/SRE Component: docker/service-entrypoint.sh (line 77)

Problem: The service-entrypoint.sh constructs the Redis URL without a username:

export OP_REDIS_URL="redis://:${REDIS_PASSWORD}@redis:6379"

But redis.conf uses ACL mode with protected-mode yes and the default user is disabled in users.acl.template:

user default off nopass ~* &* -@all

With no username in the URL, ioredis authenticates as the default user, which is disabled. The Redis unit test at packages/core/src/__tests__/redis.test.ts:12 shows the correct format: redis://op_auth:secret@redis:6379.

Impact: Every service (gateway, auth, ingestion, ontology, pipeline, logging, app, plugin) fails to connect to Redis at startup. The platform is completely non-functional. Health checks report Redis as unhealthy and services enter restart loops.

Fix: Change line 77 of service-entrypoint.sh to include the ACL username:

export OP_REDIS_URL="redis://op_${SERVICE_SHORT}:${REDIS_PASSWORD}@redis:6379"

Effort: Small


V4-C-03: No op_execution user in Redis ACL -- execution service has no Redis credentials

Severity: CRITICAL Personas: First-Time Self-Hoster, DevOps/SRE Component: docker/redis/users.acl.template (line 1-10)

Problem: The Redis ACL template defines users for 8 of 9 services (op_admin, op_auth, op_pipeline, op_logging, op_gateway, op_ingestion, op_ontology, op_app, op_plugin) but has NO op_execution user. The init.sh generates redis_password_execution.txt at line 160:

for REDIS_USER in admin auth pipeline logging gateway ingestion ontology app plugin; do

But execution is missing from this list. The redis-entrypoint.sh substitution loop also lacks execution. While the execution service currently uses a noop Redis stub (line 72 of services/execution/src/index.ts), the service-entrypoint.sh still tries to read redis_password_execution.txt and would FATAL because the file does not exist.

Impact: The execution-service container fails to start because service-entrypoint.sh cannot find /data/init/redis_password_execution.txt. The read_secret function exits with a fatal error.

Fix: Either (a) add execution to the Redis password generation loop in init.sh and add an op_execution ACL entry in the template, and add execution to the redis-entrypoint.sh substitution loop, or (b) add special-case handling in service-entrypoint.sh to skip Redis URL construction for the execution service.

Effort: Small


V4-C-04: useUser() returns wrong shape -- BffClient does not unwrap data envelope from /bff/me

Severity: CRITICAL Personas: App Developer, Plugin Developer Component: packages/app-sdk/src/provider/AppProvider.tsx (line 200, 207)

Problem: AppProvider fetches bffClient.request<UserContext>("/bff/me") at line 200 and stores the result via setUser(meResult) at line 207. However, BffClient.request() (BffClient.ts line 215) returns response.json() without unwrapping the { data: ... } envelope. The server returns { data: { userId, tenantId, appId, roles, isGuest } } (bff.ts lines 77-86). So meResult is the full envelope { data: { userId, ... } }, not a UserContext. Additionally, the server returns userId but UserContext expects id, and the server omits email and displayName entirely. The default template App.tsx generated on app creation uses user.displayName which will always be undefined.

Server response shape (bff.ts line 77-86):

{ data: { userId, tenantId, appId, roles, isGuest } }
Expected UserContext shape (entities.ts line 116-124):
{ id, email, displayName, tenantId, roles, isGuest }

Impact: Every app developer using useUser() gets an object with all fields undefined. The default scaffold template's {user.displayName} renders nothing. User identity is effectively broken for all hosted apps.

Fix: Either: (1) BffClient.request() should unwrap the { data: T } envelope like the SDK Transport does, or (2) AppProvider should access meResult.data before calling setUser(). Additionally, the /bff/me endpoint must return id (not userId) and include email and displayName from the auth token to match the UserContext interface.

Effort: Medium


V4-C-05: SDK deploy() sends currentBuildId via PATCH which is rejected by strict schema validation

Severity: CRITICAL Personas: App Developer, Plugin Developer Component: packages/sdk/src/resources/apps.ts (line 220-226)

Problem: The SDK's deploy() method (line 220-226) calls:

async deploy(id: string, buildId: string): Promise<App> {
  return transport.request<App>({
    method: 'PATCH',
    path: `${BASE}/${encodeURIComponent(id)}`,
    body: { currentBuildId: buildId },
  });
}
But the server's PatchAppSchema (schemas/index.ts line 19-25) uses .strict() which rejects any fields not explicitly defined. currentBuildId is not in the schema -- only name, slug, description, accessMode, and allowedModules are allowed. The actual deploy endpoint is POST /api/v1/apps/:appId/deploy handled by deployments.ts.

Impact: Every client.apps.deploy(id, buildId) call fails with a 400 validation error. Developers cannot deploy apps through the SDK.

Fix: Change SDK deploy() to use POST ${BASE}/${encodeURIComponent(id)}/deploy with body { buildId } matching the DeploySchema, and return a Deployment (not App) to match the server response shape.

Effort: Small


V4-C-06: SDK AppBuild status enum uses 'queued' but the server only ever returns 'pending'

Severity: CRITICAL Personas: App Developer, Plugin Developer Component: packages/sdk/src/resources/apps.ts (line 38)

Problem: The SDK defines the build status type as:

readonly status: 'queued' | 'building' | 'success' | 'failed';
(apps.ts line 38)

But the server database constraint (001_initial_schema.sql line 72) and all service code only use 'pending', never 'queued':

CHECK (status IN ('pending','building','success','failed'))
The build-service.ts explicitly returns status: "pending" at line 361.

Impact: TypeScript consumers comparing build.status === 'queued' will never match. Type narrowing based on the SDK type is incorrect -- the actual 'pending' status value is not representable in the SDK's union type.

Fix: Change AppBuild.status from 'queued' | 'building' | 'success' | 'failed' to 'pending' | 'building' | 'success' | 'failed' to match the server.

Effort: Small


V4-C-07: HookPayloadDataMap uses non-existent pipeline stage names, breaking typed narrowing

Severity: CRITICAL Personas: Platform Admin, Security Auditor, Plugin Developer Component: packages/plugin-sdk/src/types/hooks.ts (line 173-174)

Problem: The HookPayloadDataMap maps "before:pipeline.execute" and "after:pipeline.execute" to PipelineExecuteData, but these are NOT valid HookStage values. The HookStage union defines pipeline.trigger, pipeline.step, and pipeline.complete -- there is no pipeline.execute stage.

export interface HookPayloadDataMap {
  // ...
  "before:pipeline.execute":   PipelineExecuteData;  // NOT a valid HookStage
  "after:pipeline.execute":    PipelineExecuteData;   // NOT a valid HookStage
  // ...
}

The HookStage union defines:

| "before:pipeline.trigger"
| "after:pipeline.trigger"
| "before:pipeline.step"
| "after:pipeline.step"
| "before:pipeline.complete"
| "after:pipeline.complete"

Since "before:pipeline.execute" is not in HookStage, the conditional type S extends keyof HookPayloadDataMap ? HookPayloadDataMap[S] : Record<string, unknown> always falls through to Record<string, unknown> for all pipeline stages.

Impact: Plugin developers writing pipeline hooks get zero type safety. HookPayload<"before:pipeline.step">.data resolves to Record<string, unknown> instead of PipelineExecuteData. The typed hook system is advertised but non-functional for the entire pipeline domain -- the most common hook target.

Fix: Change the HookPayloadDataMap keys to match actual HookStage values. Map before:pipeline.step and after:pipeline.step (and optionally trigger/complete) to PipelineExecuteData instead of the non-existent pipeline.execute stages.

Effort: Small


V4-C-08: cache.lock() not implemented in sandbox ContextCallHandler -- runtime crash

Severity: CRITICAL Personas: Platform Admin, Security Auditor, Plugin Developer Component: services/execution/src/services/context-call-handler.ts (line 24-32)

Problem: The PluginContext interface exposes cache.lock() as part of CacheAccessor, documented for distributed mutex operations like token refresh deduplication. The mock context implements it. But the ContextCallRequest method union in the execution sandbox does NOT include cache.lock:

method:
  | "fetch"
  | "credentials.get"
  | "credentials.list"
  | "cache.get"
  | "cache.set"
  | "cache.delete"
  | "pipeline.trigger"
  | "ontology.getEntity";

The default branch in the switch throws an UNSUPPORTED_METHOD error. Any plugin calling context.cache.lock() will crash at runtime in production with an unhelpful "Unknown contextCall method" error, despite the call compiling successfully against the SDK types and working in local tests with the mock context.

Impact: Plugin developers following the SDK documentation to use distributed locks for token refresh or singleton operations will have code that passes local tests but fails at runtime in production. The AuthProvider documentation specifically says to use context.cache.lock() to prevent concurrent refreshes.

Fix: Add "cache.lock" and "cache.lock.release" to the ContextCallRequest method union and implement the handler, likely delegating to a Redis SETNX + TTL pattern via the Plugin Service's cache endpoint.

Effort: Medium


V4-C-09: ontology.getSchema() not implemented in sandbox -- only getEntity exists

Severity: CRITICAL Personas: Platform Admin, Security Auditor, Plugin Developer Component: services/execution/src/services/context-call-handler.ts (line 24-32)

Problem: The OntologyAccessor interface exposes two methods: getSchema() and getEntitySchema(). The ContextCallHandler only implements ontology.getEntity (singular):

method:
  | "fetch"
  | ...
  | "ontology.getEntity";  // only this one

The getSchema() method -- which returns the full ontology with all entity types -- has no corresponding context call method. When a plugin calls context.ontology.getSchema() in production, it will fail with UNSUPPORTED_METHOD. The mock context returns the full schema, so tests pass.

Impact: Any plugin that inspects the full ontology schema (common for transformer plugins that need to discover entity types dynamically) will fail at runtime. This is especially bad for transformer and destination plugins that need to handle multiple entity types.

Fix: Add "ontology.getSchema" to the ContextCallRequest method union and implement it by returning executionCtx.ontologySnapshot directly (the full snapshot is already injected at execution start).

Effort: Small


V4-C-10: Audit log frontend calls wrong API endpoint path

Severity: CRITICAL Personas: Platform Admin, Security Auditor, Plugin Developer Component: packages/frontend/src/components/logs/AuditLogTable.tsx (line 64)

Problem: The AuditLogTable component requests /v1/audit (line 64) which becomes /api/v1/audit at the gateway. However, the backend registers the route at /api/v1/audit-events (services/logging/src/routes/audit.ts line 39), and the gateway SERVICE_MAP only has an audit-events key (services/gateway/src/services/proxy-service.ts line 31-32). There is no audit key, so the gateway cannot route this request to any upstream service.

Frontend code:

client.get<PaginatedResponse<AuditEvent>>("/v1/audit", ...)
Backend route:
routes.get("/api/v1/audit-events", async (c) => { ... })
Gateway map:
"audit-events": process.env["LOGGING_SERVICE_URL"] ?? "http://logging-service:3000"

Impact: The entire Audit Log page at /logs/audit is completely broken. Platform admins cannot view any audit events through the UI. The page will always show 'No audit events found' or an error, defeating the purpose of having an audit trail.

Fix: Change the frontend endpoint from /v1/audit to /v1/audit-events in AuditLogTable.tsx line 64. Additionally, update the query parameter names to match the backend's expected format (the backend expects actorId, from, to, etc. as query params, not the filter DSL syntax filter[timestamp][gte] the frontend is sending).

Effort: Small


V4-C-11: Audit log frontend/backend response shape mismatch

Severity: CRITICAL Personas: Platform Admin, Security Auditor, Plugin Developer Component: packages/frontend/src/components/logs/AuditLogTable.tsx (line 30-43)

Problem: Even if the endpoint path were correct, the frontend AuditEvent interface expects fields that don't match the backend response. The frontend expects:

interface AuditEvent {
  id: string;
  timestamp: string;   // backend sends 'createdAt'
  actor: string;       // backend sends 'actorId' + 'actorType'
  action: string;
  resource: string;    // backend sends 'resourceType' + 'resourceId'
  outcome: string;     // backend sends 'result'
  traceId?: string;
}
But the backend mapAuditRow (services/logging/src/routes/audit.ts lines 9-23) returns:
{ id, traceId, actorId, actorType, tenantId, action, resourceType, resourceId, result, metadata, createdAt }
Fields timestamp, actor, resource, and outcome do not exist in the API response.

Impact: Even with a corrected endpoint path, the audit table would display empty/undefined values for timestamp, actor, resource, and outcome columns. All audit data would be present but invisible to the admin.

Fix: Either update the frontend AuditEvent interface and table rendering to use the actual backend field names (actorId, actorType, resourceType, resourceId, result, createdAt), or add a mapping layer in the API client that transforms the response into the expected shape.

Effort: Small


V4-C-12: Redis URL missing username -- services cannot authenticate with ACL

Severity: CRITICAL Personas: DevOps/SRE, First-Time Self-Hoster Component: docker/service-entrypoint.sh (line 77)

Problem: The service-entrypoint.sh constructs the Redis URL without a username:

export OP_REDIS_URL="redis://:${REDIS_PASSWORD}@redis:6379"

The redis://:password@host format sends AUTH password which authenticates as the Redis "default" user. However, docker/redis/users.acl.template line 1 disables the default user:

user default off nopass ~* &* -@all

With the default user disabled, every service that connects through this entrypoint will receive a NOAUTH or WRONGPASS error. Named users like op_auth, op_gateway, etc. exist in the ACL but the URL never references them. The correct URL format for Redis 6+ ACL is redis://op_<service>:<password>@redis:6379.

Impact: Every service that uses Redis (8 of 9 services) will fail to connect in a Docker Compose deployment. BullMQ workers, rate limiting, pub/sub, caching, and JWT revocation checks will all fail. The platform is completely non-functional in production.

Fix: Change service-entrypoint.sh line 77 to include the per-service username:

export OP_REDIS_URL="redis://op_${SERVICE_SHORT}:${REDIS_PASSWORD}@redis:6379"

Effort: Small


V4-C-13: App service Redis ACL missing key patterns for build logs, BFF cache, rate limiting, and BullMQ

Severity: CRITICAL Personas: DevOps/SRE, First-Time Self-Hoster Component: docker/redis/users.acl.template (line 9)

Problem: The op_app ACL user only allows ~guest-session:*:

user op_app on >REDIS_PASSWORD_app ~guest-session:* &events:* ...

But the app service uses many additional key patterns: - rate:guest-session:* (index.ts line 167) -- rate limiting keys - app:build-logs:* (build-service.ts line 374) -- build log lists - app:build:*:log (build-service.ts line 375) -- build log pub/sub channels - bff:runtime-config:* (bff.ts line 572) -- BFF config cache - app:preview-reload:* (index.ts line 572) -- preview SSE channel - bull:queue:app:retention:* (BullMQ default prefix) -- retention worker

None of these key patterns match ~guest-session:*.

Impact: The app service will receive NOPERM errors for build logs, BFF runtime config caching, rate limiting, preview SSE, and retention cleanup. App building, deployment, and BFF functionality are broken in Docker Compose.

Fix: Expand the op_app ACL to include all needed key patterns:

user op_app on >REDIS_PASSWORD_app ~guest-session:* ~rate:guest-session:* ~app:* ~bff:* ~bull:queue:app:* &events:* &app:* ...

Effort: Small


V4-C-14: BullMQ default key prefix 'bull:' not covered by any service ACL patterns

Severity: CRITICAL Personas: DevOps/SRE, First-Time Self-Hoster Component: docker/redis/users.acl.template (line 4-8)

Problem: BullMQ uses a default key prefix of bull: (confirmed in bullmq@5.78.0/dist/esm/classes/queue-keys.js line 2: constructor(prefix = 'bull')).

The ingestion service creates queue ingestion:sync which produces Redis keys like bull:ingestion:sync:id. The ACL for op_ingestion allows ~queue:ingestion:* ~ingestion:sync:* -- neither matches bull:ingestion:sync:*.

The pipeline service creates queues queue:pipeline:run and queue:pipeline:cron which produce keys like bull:queue:pipeline:run:*. The ACL for op_pipeline allows ~queue:pipeline:* ~queue:execution:* -- neither matches bull:queue:pipeline:run:*.

The logging audit worker uses BullMQ similarly -- bull:audit.event:* doesn't match ~audit:*.

Impact: All BullMQ-based job processing across ingestion (sync/batch/file-parse workers), pipeline (run workers), logging (audit worker), and app (retention worker) services will fail with NOPERM errors. Data sync, pipeline execution, audit logging, and build retention are all broken.

Fix: Either add bull: prefix variants to each ACL (e.g., ~bull:ingestion:sync:* for op_ingestion), or configure BullMQ queues with a custom prefix matching the existing ACL patterns. The simplest fix is to add the bull: prefix to each service's ACL key patterns.

Effort: Small


V4-C-15: SQL injection in countDataRows via unquoted identifier interpolation

Severity: CRITICAL Personas: First-Time Self-Hoster, DevOps/SRE Component: services/ontology/src/repositories/entity-repository.ts (line 131-134)

Problem: The countDataRows method directly interpolates schemaName and entitySlug into a SQL query without using the quotePgIdentifier() function that exists in the same codebase. The query is:

async countDataRows(schemaName, entitySlug) {
  const result = await db.query<{ count: string }>(
    `SELECT COUNT(*)::text AS count FROM "${schemaName}"."${entitySlug}"`,
  );

While other DDL operations in entity-service.ts (line 264, 300, 303, 560) correctly use quotePgIdentifier() to validate and escape identifiers, countDataRows bypasses this protection entirely. Although tenantSchemaName() and deriveSlug() produce restricted character sets, the method itself performs no validation, meaning any caller that passes unsanitized values could achieve SQL injection. A double-quote in entitySlug would break out of the quoted identifier context.

Impact: If any code path passes an unvalidated entity slug to countDataRows, an attacker could execute arbitrary SQL. Even with current callers being safe, this is a defense-in-depth failure that could be exploited by future code changes.

Fix: Use quotePgIdentifier() for both parameters: SELECT COUNT(*)::text AS count FROM ${quotePgIdentifier(schemaName)}.${quotePgIdentifier(entitySlug)}. This matches the pattern used everywhere else in the ontology service.

Effort: Small


V4-C-16: Log events table lacks tenant_id column -- complete absence of tenant isolation for logs

Severity: CRITICAL Personas: Enterprise Evaluator, Security Auditor, Platform Admin Component: services/logging/src/db/migrations/001_initial_schema.sql (line 29-40)

Problem: The logging.events table schema has no tenant_id column:

CREATE TABLE IF NOT EXISTS logging.events (
  id          UUID        NOT NULL DEFAULT gen_random_uuid(),
  trace_id    TEXT        NOT NULL DEFAULT '',
  service     TEXT        NOT NULL,
  level       TEXT        NOT NULL CHECK (level IN ('debug','info','warn','error')),
  message     TEXT        NOT NULL,
  metadata    JSONB       NOT NULL DEFAULT '{}',
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  search_vec  TSVECTOR    GENERATED ALWAYS AS (...) STORED
) PARTITION BY RANGE (created_at);
Compare with logging.audit_events which correctly has tenant_id TEXT NOT NULL. The log query route at services/logging/src/routes/logs.ts:37-74 performs no tenant filtering -- any user with logs:read scope can see ALL tenants' log data. The LogEventRow type in repositories/types.ts:4-12 also has no tenant_id field.

Impact: Enterprise-critical: In a multi-tenant deployment, any authenticated user from Tenant A can read all log events from Tenant B, C, etc. This is a complete data isolation failure. Enterprise customers with compliance requirements (SOC2, GDPR, HIPAA) cannot adopt the platform -- log data from one tenant leaks to all others. Competitors like Fivetran enforce strict tenant isolation on all data including logs.

Fix: Add a tenant_id TEXT NOT NULL column to logging.events, update the CreateLogEventData type to require tenantId, propagate tenant context through the pub/sub ingestion pipeline, and add a mandatory WHERE tenant_id = $N clause in all log query paths. Add a DB migration with backfill for existing rows.

Effort: Large


V4-C-17: logs:export and audit:read scopes missing from ALL_SCOPES -- endpoints permanently inaccessible

Severity: CRITICAL Personas: Enterprise Evaluator, Security Auditor, Platform Admin Component: services/auth/src/services/token-service.ts (line 29-49)

Problem: The ALL_SCOPES array defines every scope the platform recognizes:

const ALL_SCOPES: readonly string[] = [
  "data:read", "data:write", "ontology:read", "ontology:write",
  "pipelines:read", "pipelines:trigger", "pipelines:manage",
  "apps:read", "apps:deploy", "apps:manage",
  "plugins:read", "plugins:manage",
  "users:read", "users:manage",
  "logs:read", "webhooks:manage",
  "execution:read", "execution:run",
  "admin",
] as const;
Notably absent: logs:export and audit:read. The log export endpoint (services/logging/src/routes/logs.ts:86) requires logs:export scope and the audit endpoint (services/logging/src/routes/audit.ts:42) requires audit:read. Neither scope appears in ALL_SCOPES nor in any PREDEFINED_ROLE_SCOPES mapping. Since resolveScopes() only emits scopes from these two data structures, no JWT will ever contain these scopes. Even platform-admin gets admin which is in ALL_SCOPES -- but non-admin roles can never get logs:export or audit:read.

Impact: The log export streaming endpoint and audit event query endpoint are completely inaccessible to any non-platform-admin role. Tenant-admins cannot export logs or query audit trails, which are core enterprise compliance features. Only platform-admin (via the 'admin' scope catch-all) can use these endpoints, but the intended RBAC model is broken -- audit:read should be assignable to compliance officers without giving them full admin.

Fix: Add logs:export and audit:read to the ALL_SCOPES array. Add audit:read and logs:export to the tenant-admin predefined role scopes. Consider adding audit:read to the developer and editor roles as well for observability.

Effort: Small


V4-C-18: listRuns ignores pipelineId filter -- returns all tenant runs

Severity: CRITICAL Personas: Power User / Data Scientist, Data Engineer Component: services/pipeline/src/services/run-service.ts (line 271-276)

Problem: The listRuns method accepts pipelineId in its RunListQuery type (line 45: pipelineId?: string) but never passes it through to the repository. The code at lines 271-276 calls runRepo.findByTenantId(tenantId, {...}) without any pipeline_id filter:

async function listRuns(tenantId: string, query: RunListQuery): Promise<RunListResult> {
  const rows = await runRepo.findByTenantId(tenantId, {
    ...(query.cursor !== undefined ? { cursor: query.cursor } : {}),
    limit: query.limit,
    ...(query.filterStatus !== undefined ? { filterStatus: query.filterStatus } : {}),
  });
The route at services/pipeline/src/routes/pipelines.ts line 197 passes pipelineId: c.req.param('id') but the service silently ignores it. The SDK's listRuns(pipelineId, options) also sends this parameter. Meanwhile, the RunRepository has a dedicated findByPipelineId method (run-repository.ts line 67) that is never used by listRuns.

Impact: When a power user calls GET /api/v1/pipelines/:id/runs or sdk.pipelines.listRuns(pipelineId), they receive ALL runs across ALL pipelines in the tenant, not just runs for the specified pipeline. With hundreds of pipelines and thousands of runs, the response is wrong data mixed together. Pagination cursors become meaningless because they walk across unrelated pipelines.

Fix: In listRuns, when query.pipelineId is defined, call runRepo.findByPipelineId(query.pipelineId, { cursor, limit }) instead of runRepo.findByTenantId. Add tenant ownership verification before returning results. Alternatively, add a pipelineId filter parameter to findByTenantId.

Effort: Small


HIGH (45)

V4-H-01: README says UI is at localhost:3000 but frontend is on port 8080

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: README.md (line 43)

Problem: The README states:

The platform will be available at `http://localhost:3000`.

But in docker-compose.yml, the gateway exposes port 3000 (raw API only, no UI), and the frontend container exposes port 8080:

# gateway-service:
ports:
  - "3000:3000"     # line 259
# frontend:
ports:
  - "8080:80"       # line 638

Visiting localhost:3000 shows raw JSON API responses, not the UI. The DEPLOYMENT.md (line 36) repeats this error: open http://localhost:3000.

Impact: First-time self-hosters navigate to port 3000, see raw JSON or a 404, and think the platform is broken. They have no indication that the actual UI is on port 8080.

Fix: Change README.md and DEPLOYMENT.md to reference http://localhost:8080 for the UI, and mention that port 3000 is the API gateway for direct API access.

Effort: Small


V4-H-02: Quick Start omits required cp .env.example .env step

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: README.md (line 36-43)

Problem: The README Quick Start is:

git clone ...
cd oneplatform
docker compose up

But the .env.example file header explicitly says:

# Copy to .env and fill in values before running docker compose:
#   cp .env.example .env

Without copying the .env file, docker-compose uses inline defaults. While OP_MINIO_PASSWORD has a default (dev_minio_password_change_me) that passes the entrypoint check, other env vars may be unset. The DEPLOYMENT.md Quick Start (line 29) correctly includes cp .env.example .env, but the README does not.

Impact: Users who follow the README verbatim skip environment configuration. While it might work with defaults for development, it creates confusion about what configuration is needed and whether things are properly set up.

Fix: Add cp .env.example .env to the README Quick Start section between cd oneplatform and docker compose up, with a note to edit the file for production use.

Effort: Small


V4-H-03: NewConnectorPage calls non-existent /v1/connectors/types endpoint

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: packages/frontend/src/pages/connectors/NewConnectorPage.tsx (line 103-106)

Problem: The NewConnectorPage fetches connector types from:

queryFn: () => client.get<{ data: ConnectorTypeOption[] }>("/v1/connectors/types"),

But this route does not exist in the ingestion service. The connector routes at services/ingestion/src/routes/connectors.ts only define: GET /, POST /, GET /:id, PATCH /:id, DELETE /:id, POST /:id/test, POST /:id/trigger, GET /:id/syncs, GET /:id/syncs/:syncId/progress.

The request to /api/v1/connectors/types would be matched by GET /:id with id="types", returning a 404 error because no connector has id "types".

Impact: The connector creation wizard is broken at step 1. Users cannot see available connector types and cannot create any connectors, which is the core data ingestion functionality.

Fix: Add a GET /types route to the ingestion service's connector routes that returns available connector types from the plugin registry. This should be registered before GET /:id to take priority.

Effort: Medium


V4-H-04: Connector creation sends wrong field names to backend API

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: packages/frontend/src/pages/connectors/NewConnectorPage.tsx (line 109)

Problem: The frontend sends:

mutationFn: (body: { typeId: string; config: ConnectorFormValues }) =>
  client.post<ApiResponse<CreatedConnector>>("/v1/connectors", body),

But the backend createConnectorRequest schema at services/ingestion/src/schemas/index.ts:16-25 expects:

export const createConnectorRequest = z.object({
  pluginId: z.string().min(1),   // frontend sends 'typeId'
  name: z.string().min(1),       // frontend doesn't send this
  credentials: z.record(z.string()),  // frontend doesn't send this
  ...
});

The field name is pluginId not typeId, and required fields name and credentials are absent.

Impact: Even if the /connectors/types endpoint existed, the connector creation POST would always fail with a 422 validation error. The entire connector creation flow is non-functional.

Fix: Update the frontend mutation to send pluginId instead of typeId, and include the required name and credentials fields. The wizard needs a name input field (which is currently missing) and the ConnectorForm needs to separate config vs credential fields.

Effort: Medium


V4-H-05: Pre-save connection test calls non-existent POST /v1/connectors/test (no ID)

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: packages/frontend/src/pages/connectors/NewConnectorPage.tsx (line 140-141)

Problem: The NewConnectorPage test step calls:

await client.post(`/v1/connectors/test`, {
  typeId: selectedType.id,
  config: values,
});

But the ingestion service only has POST /:id/test (line 150 of connectors.ts) which requires an existing connector ID. There is no POST /connectors/test route without an ID. The request would not match any POST route and return a 404.

Impact: Users cannot test a connection before saving the connector. The test step of the wizard always fails, blocking the creation flow at step 3.

Fix: Add a POST /test route to the ingestion service that accepts a pluginId + config + credentials payload and tests connectivity without requiring an existing connector. Alternatively, update the frontend to skip the test step or test after creation.

Effort: Medium


V4-H-06: Dashboard Service Health panel calls non-existent /v1/health/services endpoint

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: packages/frontend/src/components/metrics/ServiceHealthGrid.tsx (line 55)

Problem: The ServiceHealthGrid component polls:

queryFn: () => client.get<{ data: ServiceHealth[] }>("/v1/health/services"),

But no such route exists in the gateway. The gateway's health routes (services/gateway/src/routes/health.ts) only define /healthz and /readyz. The proxy service's SERVICE_MAP has no "health" key, so the request gets a 404 from the proxy catch-all.

Impact: The dashboard's Service Health section always shows "No service health data available" or throws repeated errors in the console. Self-hosters see no way to verify that all 9 services are running correctly from the UI.

Fix: Implement a GET /api/v1/health/services route in the gateway that fans out health checks to all upstream services (auth, ingestion, ontology, pipeline, execution, app, logging, plugin) and returns their aggregate status.

Effort: Medium


V4-H-07: No .dockerignore file -- entire repo (including node_modules, .git) sent as build context

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: docker/Dockerfile.service (line 1-109)

Problem: There is no .dockerignore file in the repository root. Both Dockerfile.service and Dockerfile.frontend use context: .. (the repo root) as their build context:

build:
  context: ..
  dockerfile: docker/Dockerfile.service

Without a .dockerignore, Docker sends the entire repo to the daemon, including node_modules/ (hundreds of MB), .git/ (potentially large), dist/ directories, test files, and documentation.

Impact: The first docker compose build takes 5-10x longer than necessary due to the massive build context transfer. On a typical machine with a populated node_modules, this adds minutes of waiting to every build.

Fix: Create a .dockerignore file at the repo root with at minimum:

node_modules
.git
dist
*.log
.turbo

Effort: Small


V4-H-08: Sync progress double-counts completedBatches in processSyncJob

Severity: HIGH Personas: Data Engineer, Power User Component: services/ingestion/src/services/sync-service.ts (line 586-601)

Problem: In processSyncJob, the progress object's completedBatches is incremented during the pagination loop (line 589) before the batch job is even enqueued to BullMQ. Then in processBatchJob (lines 734-740), completedBatches is incremented again via progressData.completedBatches + 1. This means every batch is counted twice in the completedBatches metric.

In processSyncJob:

batchSeqNum += 1;
progress.totalRecords = totalRecords;
progress.completedBatches = batchSeqNum;

In processBatchJob:

await writeProgress({
  ...progressData,
  completedBatches: progressData.completedBatches + 1,

Impact: Data engineers monitoring sync progress see inflated completedBatches counts (double the actual value). Progress bars in the UI and CLI --wait polling show misleading percentages, making it impossible to track actual sync progress accurately.

Fix: In processSyncJob, track totalBatches (the number dispatched) separately from completedBatches (which should only be advanced by processBatchJob). Set progress.totalBatches = batchSeqNum instead of progress.completedBatches = batchSeqNum in the pagination loop.

Effort: Small


V4-H-09: listSyncs fetches up to 10,000 BullMQ jobs into memory for filtering

Severity: HIGH Personas: Data Engineer, Power User Component: services/ingestion/src/services/sync-service.ts (line 347-423)

Problem: The listSyncs method fetches up to 10,000 jobs from BullMQ into memory on every call, then filters by connectorId in JavaScript:

const jobs = await syncQueue.getJobs(states, 0, 10_000);

const filtered = jobs
  .filter((job) => job.data.connectorId === connectorId)
  .map((job): SyncJobSummary => {

For a platform with many connectors, this loads all jobs (up to 10K) into memory and iterates them fully even when the caller only needs a single page of 20 results for one connector.

Impact: Data engineers running op connector trigger --wait or viewing sync history for a single connector cause the ingestion service to load thousands of unrelated BullMQ jobs into memory. Under load with many concurrent connectors, this leads to high memory usage, GC pressure, and slow API responses for the sync history endpoint.

Fix: Use BullMQ's built-in job data filtering or maintain a separate sync_history table in PostgreSQL indexed by connector_id. At minimum, add an early break once enough filtered results are collected to fill the requested page.

Effort: Medium


V4-H-10: CLI connector list sends wrong query parameter names to API

Severity: HIGH Personas: Data Engineer, Power User Component: packages/cli/src/commands/connector/index.ts (line 57-63)

Problem: The CLI connector list command sends query parameters pluginId and status, but the API schema at services/ingestion/src/schemas/index.ts expects filter[pluginId][eq] and filter[status][eq]:

// CLI sends:
async function listAction(opts: ListOpts, ctx: CommandContext): Promise<void> {
  const query: Record<string, unknown> = {};
  if (opts.plugin) query["pluginId"] = opts.plugin;
  if (opts.status) query["status"] = opts.status;
// API expects:
export const listConnectorsQuery = z.object({
  "filter[status][eq]": z.enum(["enabled", "disabled"]).optional(),
  "filter[pluginId][eq]": z.string().optional(),

Impact: Data engineers using op connector list --plugin <id> or op connector list --status active get unfiltered results because the server ignores the unrecognized query parameters. Filtering connectors by plugin or status from the CLI does not work.

Fix: Change the CLI to send query["filter[pluginId][eq]"] and query["filter[status][eq]"] to match the API schema. Also update the CLI --status help text from active|paused|error to enabled|disabled to match the API enum values.

Effort: Small


V4-H-11: Mapping upsert issues one SQL query per record instead of batch

Severity: HIGH Personas: Data Engineer, Power User Component: services/ontology/src/services/mapping-service.ts (line 190-203)

Problem: The mapping service inserts valid records one at a time inside a transaction loop:

for (const rec of validRecords) {
  const cols = ["_source_id", ...userFieldSlugs.filter((s) => rec.data[s] !== undefined)];
  const vals = [rec.sourceId, ...userFieldSlugs.filter((s) => rec.data[s] !== undefined).map((s) => rec.data[s])];
  // ...
  await client.query(
    `INSERT INTO ${table} (${colNames.join(", ")})
     VALUES (${placeholders.join(", ")})
     ON CONFLICT ("_source_id") DO UPDATE SET ...`,
    vals,
  );
}

For a batch of 1,000 records with 10 mapping rules targeting the same entity, this executes 1,000 individual INSERT statements within a single transaction.

Impact: Data engineers running large syncs experience severely degraded mapping throughput. Each batch of records causes hundreds or thousands of round-trips to Postgres, creating a bottleneck that backs up the ontology:map queue and delays the entire pipeline.

Fix: Batch the upserts using unnest() arrays (like the raw table insertBatch method does) or use multi-row VALUES. Group records that have the same set of non-undefined columns and issue one bulk INSERT per group.

Effort: Medium


V4-H-12: listConnectors applies plugin filter in-memory, bypassing pagination limits

Severity: HIGH Personas: Data Engineer, Power User Component: services/ingestion/src/services/connector-service.ts (line 353-401)

Problem: When filterPluginId is provided, listConnectors fetches ALL connectors for that plugin, then applies tenant and status filters in-memory, ignoring the cursor and limit:

if (query.filterPluginId !== undefined) {
  const byPlugin = await connectorRepo.findByPluginId(query.filterPluginId);
  connectorRows = tenantId === "*"
    ? byPlugin
    : byPlugin.filter((c) => c.tenant_id === tenantId);
} else {
  connectorRows = await connectorRepo.findByTenantId(tenantId, {
    ...(query.cursor !== undefined ? { cursor: query.cursor } : {}),
    limit: query.limit,
  });
}

The findByPluginId call has no pagination. If a plugin has thousands of connectors, they are all loaded into memory. The cursor-based pagination parameters are completely ignored in this code path.

Impact: Data engineers filtering connectors by plugin ID get all results dumped at once with no pagination, causing memory pressure on the server and unusable API responses for plugins with many connectors.

Fix: Add cursor and limit parameters to the findByPluginId repository method, or use the list() method with both filterPluginId and pagination options applied at the SQL level.

Effort: Medium


V4-H-13: SDK apps.list() paginator expects {items} but server returns {data} with separate {pagination}

Severity: HIGH Personas: App Developer, Plugin Developer Component: packages/sdk/src/resources/apps.ts (line 150-165)

Problem: The SDK's list() method requests transport.request<{ items: App[], nextCursor, total }> (line 153-158). The Transport unwraps the { data: T } envelope (transport.ts line 420-422), so it returns the inner value. The server list endpoint returns:

{ "data": [...apps], "pagination": { "nextCursor": "...", "total": 42 } }
After Transport unwrapping data, the SDK receives the array of apps directly (not { items, nextCursor, total }). So result.items is undefined, result.nextCursor is undefined, and the Paginator yields pages with items: undefined.

Impact: All client.apps.list() calls return empty or undefined data. Developers iterating over apps get no results despite apps existing.

Fix: The list() Paginator callback needs to expect the actual unwrapped shape. Since Transport unwraps data, the callback receives the App[] array directly. Either: (1) change Transport to not unwrap for list endpoints, or (2) restructure the Paginator callback to handle the actual { data: App[], pagination: {...} } shape before unwrapping, or (3) have the server return the { items, nextCursor, total } shape the SDK expects inside data.

Effort: Medium


V4-H-14: SDK CreateAppRequest missing required 'slug' field

Severity: HIGH Personas: App Developer, Plugin Developer Component: packages/sdk/src/resources/platform-types.ts (line 199-202)

Problem: The SDK's CreateAppRequest type is:

export interface CreateAppRequest {
  readonly name: string;
  readonly description?: string;
}
(platform-types.ts line 199-202)

But the server's CreateAppSchema (schemas/index.ts line 7-15) requires slug:

slug: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/)
Slug has no default value and is mandatory. The accessMode field defaults to 'platform-user' so is less critical, but should still be exposed.

Impact: Every client.apps.create({ name: 'My App' }) call fails with a 400 validation error because slug is missing. Developers must cast or use as any to include it, defeating TypeScript safety.

Fix: Add readonly slug: string and readonly accessMode?: 'platform-user' | 'public' to CreateAppRequest. Also update UpdateAppRequest to include slug, accessMode, and allowedModules.

Effort: Small


V4-H-15: SDK App type mismatches actual server response -- missing slug, accessMode, tenantId; has non-existent 'status' field

Severity: HIGH Personas: App Developer, Plugin Developer Component: packages/sdk/src/resources/platform-types.ts (line 189-196)

Problem: The SDK's App type:

export interface App {
  readonly id: string;
  readonly name: string;
  readonly description: string | null;
  readonly status: 'active' | 'inactive';
  readonly createdAt: string;
  readonly updatedAt: string;
}
The actual server response (apps.ts lines 568-587, formatAppDetail) returns:
{ id, tenantId, name, slug, description, accessMode, currentBuildId, currentBuild, allowedModules, createdAt, updatedAt, createdBy }
The server never returns a status field. The SDK type is missing slug, tenantId, accessMode, currentBuildId, allowedModules, and createdBy.

Impact: App developers accessing app.slug or app.accessMode get no TypeScript support. The status property always reads as undefined at runtime despite being typed as required. Developers cannot rely on the type to build UIs.

Fix: Rewrite the App interface to match the actual server response: add slug, tenantId, accessMode, currentBuildId, allowedModules, createdBy and remove the non-existent status field.

Effort: Small


V4-H-16: Build service passes encrypted (ciphertext) env vars to the esbuild execution instead of decrypting them

Severity: HIGH Personas: App Developer, Plugin Developer Component: services/app/src/services/build-service.ts (line 402-408)

Problem: The build service reads env vars from the DB at line 403 and passes them directly to the Execution Service at line 407:

const envVarRows = await permRepo.listEnvVarsByApp(appId);
const envVars: Record<string, string> = {};
for (const ev of envVarRows) {
  if (!ev.is_secret) {
    envVars[ev.key] = ev.value;
  }
}
But ev.value is stored encrypted in the database (permission-service.ts line 216 encrypts with encrypt(input.value, masterKey) before persisting). The BFF runtime-config endpoint (bff.ts line 597-605) properly calls decrypt() before returning values. The build service does not decrypt, so esbuild receives ciphertext strings as env var values.

Impact: App builds that reference process.env.MY_VAR via esbuild define will get AES-GCM ciphertext blobs baked into their bundles instead of actual values. Runtime behavior is completely wrong.

Fix: Import and call decrypt(ev.value, masterKey) in the build service's env var loop, matching the pattern used in the BFF runtime-config handler. The masterKey is already available in BuildServiceDeps.

Effort: Small


V4-H-17: useAppStorage never loads persisted values due to BffClient not unwrapping data envelope

Severity: HIGH Personas: App Developer, Plugin Developer Component: packages/app-sdk/src/hooks/useAppStorage.ts (line 92-98)

Problem: The hook reads the BFF response:

bffClient
  .request<BffStorageGetResponse>(`/bff/storage/${encodeURIComponent(key)}`)
  .then((res) => {
    setValueState(res.value !== null ? (res.value as T) : defaultValue);
  })
The server returns { data: { key, value, updatedAt } } (bff.ts line 461-467). Since BffClient.request() returns response.json() without unwrapping, res is { data: { key, value, ... } }. So res.value is undefined, which passes the !== null check (since undefined !== null), and the hook casts undefined as T. The actual value nested at res.data.value is never reached.

Impact: useAppStorage appears to work (no errors thrown) but always returns the defaultValue on initial load. Persisted values are silently discarded. Developers see their preferences/settings reset on every page load.

Fix: Either have BffClient.request() unwrap the { data: T } envelope, or change the storage hook to access res.data.value instead of res.value, and update BffStorageGetResponse to reflect the actual { data: { key, value, updatedAt } } shape.

Effort: Small


V4-H-18: Auth provider scaffold generates getAuthorizationUrl with wrong signature (3 params vs 2)

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: packages/plugin-sdk/src/dev/scaffold.ts (line 349)

Problem: The AuthProvider interface defines getAuthorizationUrl with 2 parameters:

// auth-provider.ts line 95
getAuthorizationUrl(state: string, options: AuthOptions): string;

But the scaffold template generates it with 3 parameters:

// scaffold.ts line 349
getAuthorizationUrl(state: string, options: AuthOptions, config: Record<string, unknown>): string {

The extra config parameter does not exist in the interface. This means the scaffolded code will fail TypeScript compilation if strict interface checking is applied, or the config parameter will always be undefined at runtime since the caller only passes 2 arguments.

Impact: Every developer who scaffolds an auth-provider plugin gets broken code out of the box. The generated plugin won't compile with strict TypeScript, or if it somehow runs, the config["clientId"] access on line 351 reads from undefined and throws at runtime.

Fix: Change the scaffold template to match the interface signature: getAuthorizationUrl(state: string, options: AuthOptions): string. Access config through the closure or through the plugin's instance config stored elsewhere.

Effort: Small


V4-H-19: AuthContext missing fetch and credentials -- auth providers cannot exchange codes or access secrets

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: packages/plugin-sdk/src/types/auth-provider.ts (line 40-49)

Problem: The AuthContext interface provides only tenant, logger, and cache:

export interface AuthContext {
  tenant: TenantContext;
  logger: PluginLogger;
  cache: CacheAccessor;
}

But auth providers need to: (1) call external token endpoints to exchange authorization codes (handleCallback), requiring fetch; (2) access client secrets for token exchange, requiring credentials. The scaffold template for auth-provider even declares requiredApis: ["credentials", "fetch", "cache"] and requiredCredentials: [{ name: "clientSecret" }] in the manifest.

Without fetch, handleCallback cannot make HTTP calls to the identity provider's token endpoint. Without credentials, it cannot access the client secret needed for the code exchange.

Impact: Auth provider plugins are architecturally unable to implement OAuth2 token exchange. The scaffold generates a manifest that requests these capabilities, but the AuthContext interface does not provide them. Every auth provider plugin developer will hit a wall when implementing handleCallback.

Fix: Add fetch: FetchProxy and credentials: CredentialAccessor to the AuthContext interface. Ensure the execution sandbox grants credential access for auth-provider execution types.

Effort: Medium


V4-H-20: Hook stage field has no validation against valid HookStage values

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: packages/plugin-sdk/src/manifest/schema.ts (line 17)

Problem: The manifest Zod schema validates hook declarations' stage field as just z.string().min(1):

const HookDeclarationZ = z.object({
  stage: z.string().min(1),  // Accepts ANY non-empty string
  criticality: z.enum(["critical", "advisory"]),
  ...
});

The HookStage type union has 26 specific valid stages (plus parameterized pipeline.step:* patterns). But the manifest schema accepts literally any string. A developer could type "before:pipeline.execute" (the wrong name from HookPayloadDataMap -- see V4-PL-01) and the manifest would validate successfully. The hook would simply never fire because no service emits that stage.

Impact: Plugin developers get no feedback when they mistype a hook stage name. The plugin installs, manifests validate, but hooks silently never fire. Debugging why a hook doesn't trigger requires deep platform knowledge since there's no error -- the stage name just doesn't match any emitted event.

Fix: Replace z.string().min(1) with z.enum([...all valid HookStage values]) or z.string().regex(/^(before|after):(ingestion|ontology|pipeline|execution|auth|app)\.[a-z]+/) to catch typos at manifest validation time.

Effort: Small


V4-H-21: simulate-hook falls back to manifest.entrypoint which is the plugin object, not a hook function

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: packages/plugin-sdk/src/dev/simulate-hook.ts (line 121-140)

Problem: When --entrypoint is not provided, simulate-hook reads the manifest's entrypoint field:

entrypoint = rawManifest["entrypoint"];  // e.g. "MyConnector"

But the manifest's entrypoint is the named export of the plugin object (e.g., export const MyConnector: Connector = {...}). The code then attempts to call it as a function:

const fn = exports[__entrypoint];
if (typeof fn !== "function") {
  throw new Error("Bundle export ... is not a callable function (got " + typeof fn + ")");
}

Since the plugin object is an object (not a function), this always fails with: Bundle export "MyConnector" is not a callable function (got object). The hook entrypoints are separate named exports specified in the hooks[] array of the manifest, not the top-level entrypoint.

Impact: First-time plugin developers running op plugin simulate-hook before:ingestion.receive without --entrypoint get a confusing error. The error message doesn't explain that they need to pass the hook function's export name, not the plugin object name. The fallback to manifest.entrypoint is a trap.

Fix: When --entrypoint is not provided, look up the hook's entrypoint from the manifest's hooks[] array by matching the provided stage argument. If no matching hook is declared, show a helpful error listing the available hooks and their entrypoints. Fall back to manifest.entrypoint only as a last resort with a clear warning.

Effort: Small


V4-H-22: execution:read scope missing from ApiKeyScope Zod enum causes API key creation failures

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: services/auth/src/schemas/index.ts (line 29-48)

Problem: The ApiKeyScope enum (used for Zod validation of API key creation requests) does not include execution:read:

export const ApiKeyScope = z.enum([
  "data:read", "data:write", "ontology:read", "ontology:write",
  "pipelines:read", "pipelines:trigger", "pipelines:manage",
  "apps:read", "apps:deploy", "apps:manage",
  "plugins:read", "plugins:manage", "users:read", "users:manage",
  "logs:read", "webhooks:manage", "execution:run", "admin",
]);
However, the frontend's AVAILABLE_SCOPES list (packages/frontend/src/pages/settings/ApiKeysPage.tsx line 106) includes execution:read, and the token service ALL_SCOPES (services/auth/src/services/token-service.ts line 46) also includes it. When a user selects execution:read in the UI and submits, Zod rejects the request.

Impact: Admins who select execution:read in the API key creation form will get a cryptic validation error. They cannot create API keys with this scope despite it being a valid platform scope that JWTs include for multiple roles.

Fix: Add "execution:read" to the ApiKeyScope enum in services/auth/src/schemas/index.ts, between the existing scopes.

Effort: Small


V4-H-23: audit:read scope is required but never issued to any role

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: services/logging/src/routes/audit.ts (line 42-44)

Problem: The audit events endpoint requires audit:read scope (line 42-44):

if (
  !user.scopes.includes("audit:read") &&
  !user.scopes.includes("admin")
) {
  throw new ForbiddenError("audit:read scope is required");
}
However, audit:read does not appear anywhere in the auth service: not in ALL_SCOPES (token-service.ts), not in PREDEFINED_ROLE_SCOPES, not in ApiKeyScope, and not in the frontend's AVAILABLE_SCOPES. The only way to pass this check is via the admin scope (which only platform-admin gets). Tenant-admins with logs:read cannot access audit events because logs:read is not audit:read.

Impact: Tenant admins, developers, and editors are completely locked out of the audit log API even though they have the logs:read scope. Only platform-admin (with the admin super-scope) can query audit events. This defeats the purpose of tenant-level audit visibility.

Fix: Either add audit:read to ALL_SCOPES, PREDEFINED_ROLE_SCOPES (at minimum for tenant-admin), and ApiKeyScope; or change the audit route to accept logs:read instead of the nonexistent audit:read scope.

Effort: Small


V4-H-24: User reactivation (isActive=true) is silently ignored

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: services/auth/src/routes/users.ts (line 143-200)

Problem: The PUT /api/v1/users/:id handler only processes isActive === false (line 150), deactivating the user. There is no code path for isActive === true to reactivate a deactivated user. The update call at line 143-146 does not pass is_active to the repository:

const updated = await userRepository.update(id, {
  ...(parsed.data.displayName !== undefined ? { display_name: parsed.data.displayName } : {}),
  ...(parsed.data.roles !== undefined ? { roles: parsed.data.roles } : {}),
});

if (parsed.data.isActive === false) {
  await userRepository.deactivate(id);
  // ... session revocation logic
}
The isActive: true case falls through with no action. The response on line 200 even hardcodes isActive: parsed.data.isActive === false ? false : updated.is_active, masking the fact that no reactivation occurred.

Impact: Once an admin deactivates a user, there is no way to reactivate them through the API. The admin must update the database directly, which is unacceptable in production. The API silently accepts the request and returns a misleading 200 response.

Fix: Add an isActive === true branch that calls a new userRepository.activate(id) method (setting is_active = true, updated_at = now()), similar to how deactivate works. Alternatively, include is_active in the UpdateUserData interface and handle it in the repository update method.

Effort: Small


V4-H-25: GET /auth/me returns tenant ID as tenantName

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: services/auth/src/routes/auth.ts (line 105)

Problem: The /api/v1/auth/me endpoint returns the tenant_id (a UUID) as the tenantName:

return c.json({
  id: found.id,
  email: found.email,
  displayName: found.display_name ?? "",
  tenantId: found.tenant_id,
  tenantName: found.tenant_id,  // BUG: should be the tenant's name, not its UUID
  roles: found.roles,
});
The user repository's findById returns a User row which has tenant_id but not the tenant's human-readable name. The code should join with auth.tenants to fetch the name.

Impact: Any UI that displays the tenant name (e.g., the admin page header, profile page, org switcher) will show a UUID string like 550e8400-e29b-41d4-a716-446655440000 instead of the organization's actual name. This is confusing and unprofessional for platform admins.

Fix: Query the tenant repository to fetch the actual tenant name: const tenant = await tenantRepository.findById(found.tenant_id) and return tenantName: tenant?.name ?? found.tenant_id.

Effort: Small


V4-H-26: Role deletion does not check if users still hold the role

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: services/auth/src/routes/roles.ts (line 155-181)

Problem: The DELETE /api/v1/roles/:id handler (lines 157-181) checks if the role is predefined and if the caller has permissions, then calls roleRepository.delete(id) directly:

routes.delete("/api/v1/roles/:id", async (c) => {
  // ... permission checks ...
  const existing = await roleRepository.findById(user.tenantId, id);
  // ... predefined check ...
  const deleted = await roleRepository.delete(id);
  // No check for users who currently hold this role
  return new Response(null, { status: 204 });
});
There is no check for users who currently have this role assigned. Deleting a role that users hold leaves those users with a dangling role name in their roles array that no longer maps to any permissions.

Impact: If an admin deletes a custom role that is assigned to users, those users will have a phantom role that appears in their JWT but resolves to zero scopes (since resolveScopes only knows predefined roles or DB-backed custom roles). This effectively strips permissions from affected users without warning the admin.

Fix: Before deleting, query SELECT count(*) FROM auth.users WHERE tenant_id = $1 AND $2 = ANY(roles) to check if any users hold the role. If so, either reject the deletion with a clear error, or provide a bulk reassignment workflow.

Effort: Medium


V4-H-27: Role name update accepted by schema but silently dropped by handler

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: services/auth/src/routes/roles.ts (line 139-141)

Problem: The updateRoleRequest Zod schema (services/auth/src/schemas/index.ts line 303-307) accepts a name field:

export const updateRoleRequest = z.object({
  name: z.string().min(1).max(64).optional(),
  description: z.string().max(500).optional(),
  permissions: z.array(z.string()).max(50).optional(),
});
But the PUT /api/v1/roles/:id handler at line 139-141 only passes description and permissions to the repository:
const updated = await roleRepository.update(id, {
  ...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}),
  ...(parsed.data.permissions !== undefined ? { permissions: parsed.data.permissions } : {}),
});
The name field is validated and parsed but never persisted. The API returns 200 OK, misleading the caller.

Impact: Admins who attempt to rename a custom role via the API will receive a success response, but the name remains unchanged. This causes confusion and makes role management unreliable. CI/CD scripts that manage roles via API will silently fail to rename roles.

Fix: Either add name to the repository update call: ...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}), and add name to UpdateRoleData type and the repository's SET clause. Or remove name from the updateRoleRequest schema if renaming is intentionally unsupported (and document why).

Effort: Small


V4-H-28: logs:export scope is required but never declared in the platform scope system

Severity: HIGH Personas: Platform Admin, Security Auditor, Plugin Developer Component: services/logging/src/routes/logs.ts (line 86-89)

Problem: The log export endpoint (GET /api/v1/logs/export) requires logs:export scope:

if (
  !user.scopes.includes("logs:export") &&
  !user.scopes.includes("admin")
) {
  throw new ForbiddenError("logs:export scope is required");
}
But logs:export does not exist anywhere in the auth service: not in ALL_SCOPES (token-service.ts), not in any PREDEFINED_ROLE_SCOPES mapping, not in ApiKeyScope, and not in the frontend's AVAILABLE_SCOPES list. No JWT will ever contain this scope.

Impact: Only platform-admin users (via the admin super-scope) can export logs. All other roles, including tenant-admin, are locked out of the log export feature despite having logs:read. There is no way for admins to grant logs:export to any user or API key.

Fix: Add logs:export to ALL_SCOPES in token-service.ts, to PREDEFINED_ROLE_SCOPES for tenant-admin, to ApiKeyScope in schemas/index.ts, and to the frontend's AVAILABLE_SCOPES. Alternatively, change the export endpoint to accept logs:read if that is the intended access level.

Effort: Small


V4-H-29: Backup script Redis CLI commands lack ACL authentication

Severity: HIGH Personas: DevOps/SRE, First-Time Self-Hoster Component: docker/scripts/backup.sh (line 86-98)

Problem: The backup script calls redis-cli without credentials:

BEFORE_SAVE=$(docker compose exec -T redis redis-cli LASTSAVE ...)
docker compose exec -T redis redis-cli BGSAVE > /dev/null

Since the default Redis user is disabled (user default off nopass), these commands will fail with an authentication error. The healthcheck in docker-compose.yml correctly authenticates (redis-cli --user op_admin -a "$$PASS" ping), but the backup script does not follow the same pattern.

Impact: Operators cannot back up Redis data. The backup script fails silently on LASTSAVE (stderr redirected) then exits with an error on BGSAVE. The entire backup operation may appear to complete but the Redis RDB file will be stale or missing.

Fix: Authenticate using the op_admin user credentials from the init-data volume:

ADMIN_PASS=$(docker compose exec -T redis cat /data/init/redis_password_admin.txt | tr -d '[:space:]')
BEFORE_SAVE=$(docker compose exec -T redis redis-cli --user op_admin -a "$ADMIN_PASS" LASTSAVE ...)
docker compose exec -T redis redis-cli --user op_admin -a "$ADMIN_PASS" BGSAVE > /dev/null

Effort: Small


V4-H-30: Auth service SIGTERM handler has no shutdown timeout fallback

Severity: HIGH Personas: DevOps/SRE, First-Time Self-Hoster Component: services/auth/src/index.ts (line 364-368)

Problem: The auth service's SIGTERM handler has no hard-exit timeout:

process.on("SIGTERM", () => {
    server.close(() => {
      void cleanup().then(() => process.exit(0));
    });
  });

Every other service (gateway, ingestion, ontology, pipeline, execution, app, logging, plugin) implements a 30-second hard-exit setTimeout as a safety net. If server.close() or cleanup() hangs due to a stuck connection or unresponsive backing store, the auth service process will never exit voluntarily.

Impact: During deployments or restarts, the auth service may hang indefinitely waiting for connections to drain. Docker's stop_grace_period (45s) will eventually SIGKILL it, but this is a hard kill that skips cleanup (DB pool close, Redis quit), potentially leaving connections open and causing connection pool exhaustion on the backing stores.

Fix: Add a 30-second hard-exit timeout matching the pattern in all other services:

process.on("SIGTERM", () => {
    console.info("SIGTERM received -- starting graceful shutdown");
    const shutdownTimeout = setTimeout(() => {
      console.error("Shutdown timeout exceeded -- forcing exit");
      process.exit(1);
    }, 30_000);
    shutdownTimeout.unref();
    server.close(() => {
      void cleanup().then(() => {
        clearTimeout(shutdownTimeout);
        process.exit(0);
      });
    });
  });

Effort: Small


V4-H-31: Gateway /healthz liveness probe depends on Postgres and Redis availability

Severity: HIGH Personas: DevOps/SRE, First-Time Self-Hoster Component: services/gateway/src/routes/health.ts (line 16-43)

Problem: The gateway's /healthz endpoint performs full connectivity checks to both Postgres and Redis:

routes.get("/healthz", async (c) => {
    try {
      await pool.query("SELECT 1");
      checks["postgres"] = "ok";
    } catch {
      checks["postgres"] = "error";
      healthy = false;
    }
    try {
      await redis.ping();
      checks["redis"] = "ok";
    } catch {
      checks["redis"] = "error";
      healthy = false;
    }
    // returns 503 if either check fails

Docker Compose healthcheck (docker-compose.yml line 264) polls /healthz. When Postgres or Redis experiences a transient blip (network partition, brief restart), the healthcheck returns 503 and Docker marks the container unhealthy. After 5 consecutive failures (retries: 5), Docker restarts the gateway. This creates a restart cascade: all 9 services depend on the gateway being healthy, so a brief DB blip can bring down the entire platform.

Impact: A transient Postgres or Redis hiccup causes the gateway container to be restarted by Docker, which cascades to all services that depend on it (the frontend depends on gateway-service being healthy). This turns a recoverable backing-store blip into a full platform outage.

Fix: The /healthz liveness endpoint should only verify the process is alive (return 200 unconditionally or check only internal state). Move the Postgres/Redis dependency checks to /readyz only (which currently only checks Postgres). Update the docker-compose.yml healthcheck to use /healthz for liveness and configure a separate readiness check for load balancing if needed.

Effort: Small


V4-H-32: MinIO container has no resource limits -- can consume unbounded memory and CPU

Severity: HIGH Personas: DevOps/SRE, First-Time Self-Hoster Component: docker/docker-compose.yml (line 138-157)

Problem: The MinIO service definition has no deploy.resources.limits section:

minio:
    image: minio/minio:RELEASE.2024-05-10T01-41-38Z
    command: ["server", "/data", "--console-address", ":9001"]
    ...
    # No deploy: section

Every other stateful service (postgres, redis, pgbouncer) and all application services have explicit memory and CPU limits. MinIO handles object storage for plugin bundles, app build artifacts, and file uploads. Under heavy upload load or a memory leak, MinIO can consume all available host memory, causing OOM kills of other containers.

Impact: MinIO can starve other containers of memory and CPU resources on the host, potentially causing OOM kills of critical services (postgres, redis, application services). This is especially dangerous during large file uploads or when many concurrent operations access MinIO.

Fix: Add resource limits to the MinIO service definition:

minio:
    ...
    deploy:
      resources:
        limits:
          memory: 1g
          cpus: "1"

Effort: Small


V4-H-33: logs:export and audit:read scopes are unrepresented in ALL_SCOPES and role mappings

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: services/auth/src/services/token-service.ts (line 29-49)

Problem: The logging service route handlers require logs:export (services/logging/src/routes/logs.ts line 88) and audit:read (services/logging/src/routes/audit.ts line 43) scopes. However, neither scope appears in the token service's ALL_SCOPES array (lines 29-49) or in any PREDEFINED_ROLE_SCOPES mapping (lines 54-74). The only way to access these endpoints is via the admin scope, which short-circuits to ALL_SCOPES at line 88:

if (union.has("admin")) return [...ALL_SCOPES];

But even admin expansion does NOT include logs:export or audit:read since they are not in ALL_SCOPES. This means no JWT token can ever carry these scopes, making these routes completely inaccessible even to platform admins (unless the route fallback to user.scopes.includes("admin") catches it before the scope check).

Impact: The log export endpoint and audit event query endpoint check for scopes that cannot be granted to any user or API key. Platform admins can access them only through the separate admin string match in the route guard, but no non-admin role can ever be granted these capabilities. This blocks legitimate compliance and operational use cases.

Fix: Add logs:export and audit:read to ALL_SCOPES in token-service.ts, add them to appropriate role mappings in PREDEFINED_ROLE_SCOPES (e.g., tenant-admin and developer for logs:export, tenant-admin for audit:read), and add them to the ApiKeyScope enum in schemas/index.ts so they can be granted via API keys.

Effort: Small


V4-H-34: API key scope enum missing execution:read, preventing API key access to execution GET endpoints

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: services/auth/src/schemas/index.ts (line 29-48)

Problem: The ApiKeyScope Zod enum (lines 29-48) does not include execution:read, while the token service's ALL_SCOPES (token-service.ts line 46) does include it. The schema lists:

export const ApiKeyScope = z.enum([
  // ... other scopes ...
  "execution:run",
  "admin",
]);

Note execution:read is absent. This means users cannot create API keys with the execution:read scope, since the Zod validation at key creation time (POST /api/v1/api-keys) rejects it as an invalid enum value. JWT-based sessions receive execution:read via role resolution, but API key users cannot.

Impact: API integrations that need to read execution results (GET /api/v1/exec/{id}) via API keys are blocked. The Zod schema rejects the scope at creation time, forcing users to use the admin scope as an overprivileged workaround.

Fix: Add "execution:read" to the ApiKeyScope enum in services/auth/src/schemas/index.ts between logs:read and webhooks:manage.

Effort: Small


V4-H-35: User reactivation (isActive=true) silently ignored, leaving deactivated users permanently locked

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: services/auth/src/routes/users.ts (line 143-192)

Problem: The user update handler at line 150 only processes isActive === false (deactivation). There is no corresponding else if (parsed.data.isActive === true) branch to reactivate a user:

const updated = await userRepository.update(id, {
  ...(parsed.data.displayName !== undefined ? { display_name: parsed.data.displayName } : {}),
  ...(parsed.data.roles !== undefined ? { roles: parsed.data.roles } : {}),
});

if (parsed.data.isActive === false) {
  await userRepository.deactivate(id);
  // ... session revocation ...
}

When isActive=true is sent, the update() call at line 143 does not include is_active in its SET clause (it only handles display_name, roles, metadata, last_login_at, password_hash). The deactivation branch is skipped. The response at line 200 misleadingly returns the old is_active value from the updated record.

Impact: Admins with users:manage scope cannot reactivate deactivated users through the API. Once deactivated, a user is permanently locked out unless someone manually updates the database. This is a significant operational security concern as it means there is no self-service recovery path.

Fix: Add an else if (parsed.data.isActive === true) branch that calls a new userRepository.reactivate(id) method setting is_active = true. Also clear the revocation:user:{id} Redis key if it still exists.

Effort: Small


V4-H-36: User list pagination cursor is unsigned, enabling cursor forgery

Severity: HIGH Personas: First-Time Self-Hoster, DevOps/SRE Component: services/auth/src/repositories/user-repository.ts (line 179-189)

Problem: The listByTenant method uses a simple base64url-encoded JSON cursor with no cryptographic signature:

if (cursor !== undefined) {
  let decoded: { createdAt: string; id: string };
  try {
    decoded = JSON.parse(
      Buffer.from(cursor, "base64url").toString("utf8")
    ) as { createdAt: string; id: string };
  } catch {
    throw new Error("Invalid cursor: could not decode pagination cursor");
  }
  afterCreatedAt = decoded.createdAt;
  afterId = decoded.id;
}

In contrast, the logging service uses encodeCursor/decodeCursor with HMAC signing via OP_CURSOR_SECRET (log-event-repository.ts lines 124, 155-160; audit-event-repository.ts lines 131, 161-169). The user repository's unsigned cursor allows an attacker to craft arbitrary cursor values to enumerate users by manipulating createdAt and id values, potentially accessing user records from different pages than intended.

Impact: An attacker with users:read scope can forge cursor values to skip ahead, jump backward, or systematically enumerate all users in a tenant by crafting cursor values with arbitrary timestamps and UUIDs. While parameterized queries prevent SQL injection, the pagination manipulation breaks intended access patterns.

Fix: Use the core encodeCursor/decodeCursor functions (with OP_CURSOR_SECRET HMAC) consistently across all repositories, including the user repository. Add a Zod schema validation for the decoded cursor payload (as done in log-event-repository.ts with CursorPayloadSchema).

Effort: Small


V4-H-37: No multi-factor authentication (MFA/2FA) support

Severity: HIGH Personas: Enterprise Evaluator, Security Auditor, Platform Admin Component: services/auth/src/services/auth-service.ts (line 1-781)

Problem: A grep for 'MFA|mfa|multi.factor|totp|authenticator|2fa|two.factor' across the entire auth service returns zero results. The login flow in auth-service.ts at lines 274-424 only validates email + password with no second factor step. The auth routes (routes/auth.ts) have no MFA enrollment, challenge, or verification endpoints.

Impact: Enterprises with compliance requirements (SOC2, PCI-DSS, HIPAA) mandate MFA for administrative access. Without MFA, a single compromised password gives full account access. This is a compliance blocker for regulated industries. All enterprise competitors (Fivetran, Retool, n8n Cloud) support TOTP-based MFA at minimum.

Fix: Add TOTP-based MFA with enrollment (QR code generation), verification (6-digit code), and recovery codes. Store encrypted TOTP secrets in the users table. Add MFA enforcement policy per-tenant in tenant settings. Implement MFA challenge step in the login flow between password verification and token issuance.

Effort: Large


V4-H-38: Login flow does not capture IP address or user-agent in session records

Severity: HIGH Personas: Enterprise Evaluator, Security Auditor, Platform Admin Component: services/auth/src/services/auth-service.ts (line 370-380)

Problem: The session creation in the login flow stores no client metadata:

await db.query(
  `INSERT INTO auth.sessions
     (id, user_id, tenant_id, refresh_token_jti, family_id, expires_at)
   VALUES ($1, $2, $3, $4, $5, $6)`,
  [sessionId, user.id, data.tenantId, randomUUID(), familyId, expiresAt]
);
The Session type (repositories/types.ts:32-44) has user_agent: string | null and ip_address: string | null columns, but the login handler never extracts or passes these values from the request. The same issue exists in the OAuth callback at oauth-service.ts:308-313 and the registration flow at auth-service.ts:228-233.

Impact: Enterprise security teams cannot audit session origin (geographic location, device type) or detect anomalous login patterns (impossible travel, credential stuffing from bot networks). Session management UI cannot show users where their sessions are active. This is required for SOC2 compliance and is standard in all enterprise auth systems.

Fix: Extract c.req.header('User-Agent') and the client IP from the request context. Pass both through to the session INSERT in all code paths: login, registration, OAuth callback, and token refresh. Update the session listing API to expose these fields.

Effort: Medium


V4-H-39: Audit events lack source IP, user-agent, and request ID fields

Severity: HIGH Personas: Enterprise Evaluator, Security Auditor, Platform Admin Component: services/logging/src/services/audit-service.ts (line 11-22)

Problem: The AuditEventJobSchema captures only high-level action metadata:

const AuditEventJobSchema = z.object({
  timestamp: z.string().datetime(),
  traceId: z.string().default(""),
  actorId: z.string().min(1).max(255),
  actorType: z.enum(["user", "service", "system"]),
  tenantId: z.string().min(1).max(255),
  action: z.string().min(1).max(255),
  resourceType: z.string().min(1).max(255),
  resourceId: z.string().min(1).max(255),
  result: z.enum(["success", "failure"]),
  metadata: z.record(z.unknown()).default({}),
});
Missing: sourceIp, userAgent, requestId, changedFields (before/after values). The DB schema (audit_events table) also lacks these columns. While metadata could theoretically hold arbitrary data, no producer currently populates it with IP/UA information.

Impact: For compliance audits (SOC2, GDPR Article 30, HIPAA), auditors require knowing WHERE an action originated (IP), HOW it was performed (user-agent/API key), and WHAT changed (before/after). Without these fields, the audit trail is incomplete and will fail formal compliance audits. Enterprise competitors like Retool capture full request context in audit logs.

Fix: Add sourceIp, userAgent, and requestId fields to the AuditEventJobSchema and the audit_events DB table. Propagate request context from the gateway through event publishing. Add a changes JSONB column for before/after diffs on mutating operations.

Effort: Medium


V4-H-40: Repeated full findByRunId queries on every step iteration in the execution engine

Severity: HIGH Personas: Power User / Data Scientist, Data Engineer Component: services/pipeline/src/services/execution-engine.ts (line 809, 836, 1001)

Problem: During step traversal in processRun, the execution engine calls runStepRepo.findByRunId(runId) to fetch ALL run_step rows on every loop iteration: - Line 809: Inside cancellation check: const stepRows = await runStepRepo.findByRunId(runId); - Line 836: To find the current step's row: const stepRows = await runStepRepo.findByRunId(runId); - Line 1001: On failure to cancel pending steps: const stepRows = await runStepRepo.findByRunId(runId);

For a pipeline with N steps, the main traversal loop alone issues N full-table queries (one per step at line 836). Each query fetches ALL step rows (up to 100 per the schema max). A 50-step pipeline thus issues 50 SELECT queries each returning 50 rows, just to find the matching step_id.

Impact: Power users running complex 20-50 step pipelines see significantly slower execution times due to O(N^2) database queries. Each step adds ~5-10ms of unnecessary DB overhead, adding 250-500ms of pure waste for a 50-step pipeline. At scale with many concurrent runs, this creates severe DB connection pool pressure.

Fix: Load step rows once at the start of traversal into a Map<string, RunStepRow>. Re-use the map during traversal instead of re-querying on every step. Only re-query when step rows need to be updated for cancellation. The map can be built from the createBatch result at line 758.

Effort: Small


V4-H-41: SDK CreatePipelineRequest type is incompatible with the backend schema

Severity: HIGH Personas: Power User / Data Scientist, Data Engineer Component: packages/sdk/src/resources/platform-types.ts (line 60-66)

Problem: The SDK's CreatePipelineRequest type (line 60-66) expects:

export interface CreatePipelineRequest {
  readonly name: string;
  readonly description?: string;
  readonly trigger: PipelineTrigger;
}
But the backend CreatePipelineSchema (schemas/index.ts line 150-162) expects:
z.object({
  name: z.string(),
  slug: z.string().optional(),
  description: z.string().optional(),
  definition: PipelineDefinitionSchema,  // { version, entryStepId, steps, options }
  isActive: z.boolean().default(true),
})
The SDK type has trigger: PipelineTrigger (a union of manual/schedule/event objects) but the backend expects definition: PipelineDefinition (with version, entryStepId, steps array). There is no trigger field in the backend schema at all. Similarly, UpdatePipelineRequest (line 68-72) includes trigger? and status? fields that the backend does not accept.

Impact: Any developer using the SDK types as a guide to create pipelines will send malformed requests. The trigger field is silently ignored by the backend, and the required definition field is absent from the SDK type, causing a 400 Validation Error with no obvious explanation. This is the primary pipeline creation path for power users.

Fix: Update CreatePipelineRequest and UpdatePipelineRequest in platform-types.ts to match the backend schema: replace trigger with definition: PipelineDefinition, add slug?, isActive?, and define a PipelineDefinition type with version, entryStepId, steps, and options.

Effort: Medium


V4-H-42: listPipelines always returns lastRunAt as null

Severity: HIGH Personas: Power User / Data Scientist, Data Engineer Component: services/pipeline/src/services/pipeline-service.ts (line 488)

Problem: The listPipelines service method hardcodes lastRunAt: null for every pipeline:

return {
  data: rows.map((pipeline) => ({ pipeline, lastRunAt: null })),
  pagination: { nextCursor, total: null },
};
The PipelineListResult interface (line 147) declares lastRunAt: string | null as a field, and the CLI renders it in the PIPELINE_COLUMNS (cli/src/commands/pipeline/index.ts line 19). But the service never queries for it.

Impact: Power users listing pipelines via CLI (op pipeline list) or SDK always see a blank 'Last Run' column. This is critical information for monitoring pipeline health -- users cannot tell which pipelines have run recently without individually querying each pipeline's run history. With dozens of pipelines, this forces N+1 manual lookups.

Fix: Add a LEFT JOIN or subquery to fetch the most recent run's started_at (or created_at) for each pipeline in the list query. This can be done with a lateral join: LEFT JOIN LATERAL (SELECT created_at FROM pipeline.runs WHERE pipeline_id = p.id ORDER BY created_at DESC LIMIT 1) lr ON true. Also populate total in the pagination response.

Effort: Medium


V4-H-43: Cursor-based pagination uses id > cursor but ORDER BY created_at ASC -- unstable ordering

Severity: HIGH Personas: Power User / Data Scientist, Data Engineer Component: services/pipeline/src/repositories/pipeline-repository.ts (line 100-116)

Problem: The findByTenantId method uses id > $cursor as the cursor condition but orders by created_at ASC, id ASC:

if (options?.cursor !== undefined) {
  conditions.push(`id > $${idx++}`);
  values.push(options.cursor);
}
// ...
ORDER BY created_at ASC, id ASC
LIMIT $${idx}
The cursor is the last-seen id (a UUID), but the sort is on created_at. UUIDs are not monotonically increasing with respect to created_at (especially UUID v4). A pipeline created earlier could have a lexicographically larger UUID, causing id > cursor to skip it. This breaks pagination when rows have varying UUID patterns vs creation timestamps.

Impact: Power users paginating through large pipeline lists may miss pipelines or see duplicates across pages. The issue gets worse with concurrent pipeline creation across multiple users. The same cursor mismatch exists in the run repository's findByTenantId (run-repository.ts lines 97-131) and the schedule repository.

Fix: Use a composite cursor strategy: either (1) change the cursor to encode both created_at and id, using WHERE (created_at, id) > ($cursor_ts, $cursor_id), or (2) switch to ordering by id ASC with id > cursor since UUIDs provide sufficient uniqueness. Option 2 is simpler and sufficient for most use cases.

Effort: Medium


V4-H-44: Settings sidebar layout breaks on mobile screens

Severity: HIGH Personas: Casual / Non-Technical User, First-Time Self-Hoster Component: packages/frontend/src/pages/settings/SettingsPage.tsx (line 54)

Problem: The settings page uses a horizontal flex gap-8 layout with a fixed w-48 sidebar nav and the content area side by side. There are no responsive breakpoints to stack this layout vertically on small screens. On mobile, the 192px sidebar plus the gap-8 (32px) consumes most of the viewport width, leaving the content area (profile form, password form, etc.) crushed into an unreadably narrow column.

Code at line 54:

<div className="flex gap-8">
  {/* Sidebar nav */}
  <nav aria-label="Settings navigation" className="w-48 shrink-0">

Impact: Any user accessing Settings on a phone or narrow tablet cannot read or interact with the profile, teams, API keys, webhooks, or admin forms. The forms become too narrow to use.

Fix: Add responsive classes to stack the layout vertically on mobile. For example: className="flex flex-col gap-4 md:flex-row md:gap-8" for the container, and className="w-full md:w-48 shrink-0" for the nav. Also consider making the settings nav a horizontal scrollable tab bar on mobile.

Effort: Small


V4-H-45: API key scope selection uses raw code identifiers with no explanations

Severity: HIGH Personas: Casual / Non-Technical User, First-Time Self-Hoster Component: packages/frontend/src/pages/settings/ApiKeysPage.tsx (line 378-393)

Problem: When creating an API key, the scope selection shows raw machine-readable scope strings like data:read, ontology:write, pipelines:trigger, execution:run, and admin with no human-readable descriptions. A non-technical user has no idea what ontology:write or execution:run means, nor what the consequences of selecting admin are.

Code at lines 378-393:

<fieldset>
  <legend className="mb-2 text-sm font-medium">Scopes</legend>
  <div className="space-y-2">
    {AVAILABLE_SCOPES.map((scope) => (
      <label key={scope} className="flex cursor-pointer items-center gap-2 text-sm">
        <input type="checkbox" ... />
        <code className="font-mono text-xs">{scope}</code>
      </label>
    ))}
  </div>
</fieldset>

The 19 scopes are rendered as a flat list of monospace code strings.

Impact: Non-technical users creating API keys for integrations cannot understand what permissions they are granting. They may either over-grant (security risk) or under-grant (integration fails) because the scope names are opaque.

Fix: Add human-readable labels and brief descriptions for each scope. Group them by category (Data, Ontology, Pipelines, Apps, etc.). For example: data:read could show as "Read data - Query and retrieve records from the platform".

Effort: Medium


MEDIUM (61)

V4-M-01: BootstrapErrorPage shows wrong health check URL

Severity: MEDIUM Personas: First-Time Self-Hoster, DevOps/SRE Component: packages/frontend/src/pages/BootstrapErrorPage.tsx (line 79)

Problem: The troubleshooting section tells users:

Try <code>curl http://localhost:3000/health</code>.

But the actual health endpoint is /healthz (not /health), as defined in services/gateway/src/routes/health.ts:16:

routes.get("/healthz", async (c) => {

Also, the gateway is at port 3000 but the user is on port 8080 (frontend). Additionally, step 4 references OP_GATEWAY_PORT, a variable that does not exist in the config schema or docker-compose.yml.

Impact: Users following the troubleshooting steps get a 404 from the wrong health endpoint URL, further confusing debugging. The phantom OP_GATEWAY_PORT reference adds confusion about non-existent configuration.

Fix: Change the health check URL to curl http://localhost:3000/healthz and remove the reference to OP_GATEWAY_PORT. Replace with OP_BASE_URL or just mention the gateway is on port 3000.

Effort: Small


V4-M-02: OP_SMTP_FROM Zod validation rejects the documented RFC 5322 display-name format

Severity: MEDIUM Personas: First-Time Self-Hoster, DevOps/SRE Component: packages/core/src/config.ts (line 62)

Problem: The auth config schema validates:

OP_SMTP_FROM: z.string().email().optional(),

But .env.example documents the value as RFC 5322 display-name format:

OP_SMTP_FROM=OnePlatform <noreply@example.com>

Zod's .email() validator expects a bare email address (e.g., noreply@example.com), not the display-name format (Name <email>). Any user who follows the .env.example format will get a startup validation error on the auth service.

Impact: Auth service fails to start when self-hosters set OP_SMTP_FROM using the format shown in .env.example. The error message from Zod says "Invalid email" which is confusing since the value contains a valid email.

Fix: Either change the Zod validation to use a custom regex that accepts both bare email and RFC 5322 display-name format, or change the .env.example to show just the bare email: OP_SMTP_FROM=noreply@example.com.

Effort: Small


V4-M-03: Gateway service fallback URLs use wrong ports for upstream services

Severity: MEDIUM Personas: First-Time Self-Hoster, DevOps/SRE Component: docker/docker-compose.yml (line 224-259)

Problem: The gateway service's index.ts has hardcoded fallback URLs that use the old port convention:

// services/gateway/src/index.ts:319-320
ontologyServiceUrl: process.env["ONTOLOGY_SERVICE_URL"] ?? "http://ontology-service:3003",
ingestionServiceUrl: process.env["INGESTION_SERVICE_URL"] ?? "http://ingestion-service:3002",

But all services listen on port 3000 internally (the docker-compose correctly sets ONTOLOGY_SERVICE_URL: http://ontology-service:3000). If the env vars are ever unset, the fallback ports 3003 and 3002 would cause connection failures.

Impact: If environment variables are stripped (e.g., running outside docker-compose for debugging), the gateway tries to reach ontology on port 3003 and ingestion on port 3002, both of which are wrong. Connection timeouts with no clear error message.

Fix: Update the fallback URLs in services/gateway/src/index.ts lines 319-320 to use port 3000:

ontologyServiceUrl: process.env["ONTOLOGY_SERVICE_URL"] ?? "http://ontology-service:3000",
ingestionServiceUrl: process.env["INGESTION_SERVICE_URL"] ?? "http://ingestion-service:3000",

Effort: Small


V4-M-04: Frontend VITE_API_URL is described as build-time but never actually set

Severity: MEDIUM Personas: First-Time Self-Hoster, DevOps/SRE Component: docker/docker-compose.yml (line 627-639)

Problem: The docker-compose comment says:

# VITE_API_URL is baked into the SPA at build time (not a runtime env var).
# See Dockerfile.frontend where it is passed as a build arg.

But the Dockerfile.frontend (docker/Dockerfile.frontend) never sets or uses VITE_API_URL as a build arg. The frontend uses relative paths (/api/*) via the api-client.ts, which works correctly through nginx proxy. However, the misleading comment about VITE_API_URL being a build arg could lead a self-hoster to waste time trying to configure it.

Impact: Self-hosters read the comment and try to set VITE_API_URL, wasting time debugging a non-existent configuration option.

Fix: Remove the misleading VITE_API_URL comment from docker-compose.yml. The frontend correctly uses relative paths through nginx proxy and no build-time API URL configuration is needed.

Effort: Small


V4-M-05: DEPLOYMENT.md documents phantom OP_GATEWAY_PORT configuration variable

Severity: MEDIUM Personas: First-Time Self-Hoster, DevOps/SRE Component: docs/DEPLOYMENT.md (line 56)

Problem: The deployment guide's configuration table includes:

| `OP_GATEWAY_PORT` | `3000` | Port the API gateway listens on. |

And the troubleshooting section (line 228) references it:

Port conflict: Another service is using port 3000. Set `OP_GATEWAY_PORT` to a free port.

But OP_GATEWAY_PORT is not defined in any config schema (packages/core/src/config.ts), is not referenced in any service code, and is not in .env.example or docker-compose.yml. The gateway port is controlled by the PORT env var (hardcoded to 3000 in docker-compose) and the Docker port mapping.

Impact: Self-hosters with port conflicts try setting OP_GATEWAY_PORT and are confused when it has no effect. They cannot figure out how to change the port.

Fix: Remove OP_GATEWAY_PORT from DEPLOYMENT.md. Document the actual port change mechanism: modify the ports mapping in docker-compose.yml (e.g., change 3000:3000 to 4000:3000).

Effort: Small


V4-M-06: Mapping service fetches entity and fields per entity per batch without caching

Severity: MEDIUM Personas: Data Engineer, Power User Component: services/ontology/src/services/mapping-service.ts (line 62-64)

Problem: Inside the mapBatch method, for every entity in the rulesByEntity map, the service queries the entity and all its fields:

for (const [entityId, entityRules] of rulesByEntity) {
  const entity = await entityRepo.findById(tenantId, entityId);
  if (!entity) continue;
  const fields = await fieldRepo.findByEntityId(entityId);

Since batches arrive continuously during a sync, the same entity and fields are fetched from the database on every single batch. For a connector syncing 100K records in 100 batches targeting 3 entities, this results in 300 redundant entity lookups and 300 redundant field lookups.

Impact: Data engineers running high-throughput syncs see unnecessary database load on the ontology service. The repeated queries slow down mapping and add latency to every batch, accumulating to significant delays across a full sync.

Fix: Add an in-process LRU cache for entity and field lookups in the mapping service, keyed by entityId, with a short TTL (e.g., 60 seconds). The entity schema rarely changes during a sync.

Effort: Small


V4-M-07: CLI pipeline update uses PUT instead of PATCH, sending incomplete data

Severity: MEDIUM Personas: Data Engineer, Power User Component: packages/cli/src/commands/pipeline/index.ts (line 56-62)

Problem: The CLI pipeline update command uses ctx.http.put while the API expects PATCH /api/v1/pipelines/:id:

async function updateAction(id: string, opts: { file: string }, ctx: CommandContext): Promise<void> {
  const { load } = await import("js-yaml");
  const content = readFileSync(opts.file, "utf8");
  const definition = load(content) as unknown;
  await ctx.http.put(`/api/v1/pipelines/${encodeURIComponent(id)}`, definition);

The server defines routes.patch("/:id", ...) but no routes.put("/:id", ...). The PUT request will receive a 404 or method-not-allowed response.

Impact: Data engineers attempting to update a pipeline definition via op pipeline update <id> --file pipeline.yaml get an error because the HTTP method does not match. The update workflow is broken from the CLI.

Fix: Change ctx.http.put to ctx.http.patch in the pipeline update action.

Effort: Small


V4-M-08: CLI schedule pause/resume calls nonexistent API endpoints

Severity: MEDIUM Personas: Data Engineer, Power User Component: packages/cli/src/commands/schedule/index.ts (line 56-58)

Problem: The CLI schedule pause and resume commands call POST endpoints that do not exist in the API:

async function pauseAction(id: string, _opts: Record<string, never>, ctx: CommandContext): Promise<void> {
  await ctx.http.post(`/api/v1/schedules/${encodeURIComponent(id)}/pause`);

The schedule routes (services/pipeline/src/routes/schedules.ts) only define CRUD operations via the ScheduleService interface. There are no /pause or /resume sub-routes. The correct approach is to use PATCH to set enabled: false or enabled: true.

Impact: Data engineers running op schedule pause <id> or op schedule resume <id> get a 404 error. They must manually figure out to use op schedule update or make raw PATCH API calls to toggle the enabled flag.

Fix: Replace the pause/resume POST calls with PATCH requests that set { enabled: false } and { enabled: true } respectively on the schedule endpoint.

Effort: Small


V4-M-09: Mapping rules applied without priority ordering guarantee within an entity

Severity: MEDIUM Personas: Data Engineer, Power User Component: services/ontology/src/services/mapping-service.ts (line 78-165)

Problem: When multiple mapping rules target the same entity and the same target field, the rules are applied in the order they appear in the entityRules array. The repository fetches them with ORDER BY priority DESC, created_at, but if two rules map different source fields to the same target field slug, the last rule wins silently:

for (const rule of entityRules) {
  const sourceValue = getNestedValue(record.data, rule.source_field_path);
  // ... transform ...
  const targetField = fields.find((f) => f.id === rule.target_field_id);
  if (targetField) {
    mappedRecord[targetField.slug] = transformedValue;
  }
}

The higher-priority rule writes first, but a lower-priority rule targeting the same field silently overwrites it because there is no conflict detection.

Impact: Data engineers who configure multiple mapping rules targeting the same entity field (e.g., to handle different connector data shapes) get unpredictable results. The lower-priority rule overwrites the higher-priority one, which is the opposite of the expected behavior.

Fix: Skip writing to mappedRecord if the target field slug is already set by a higher-priority rule. Add a Set<string> of already-mapped field slugs and only write if the slug has not been claimed yet.

Effort: Small


V4-M-10: CLI connector trigger --wait does not pass sync mode or force options

Severity: MEDIUM Personas: Data Engineer, Power User Component: packages/cli/src/commands/connector/index.ts (line 153-178)

Problem: The CLI trigger command does not pass the --mode or --force flags through to the API, despite the API supporting them:

async function triggerAction(id: string, opts: TriggerOpts, ctx: CommandContext): Promise<void> {
  const resp = await ctx.http.post<{ syncJobId: string }>(
    `/api/v1/connectors/${encodeURIComponent(id)}/trigger`,
  );

The API schema supports { mode: 'full' | 'incremental', force: boolean } but the CLI TriggerOpts only has { wait?: boolean }. A data engineer cannot trigger a full sync or force a re-sync through the CLI.

Impact: Data engineers who need to force a full resync (e.g., after a data issue) or override the connector's default sync mode cannot do so from the CLI. They must use curl or the API directly.

Fix: Add --mode <full|incremental> and --force options to the CLI trigger command and pass them in the POST body.

Effort: Small


V4-M-11: Mapping rule update does not enforce tenant isolation

Severity: MEDIUM Personas: Data Engineer, Power User Component: services/ontology/src/routes/mapping-rules.ts (line 85-116)

Problem: The PATCH endpoint for mapping rules updates any rule by ID without verifying that the rule belongs to the requesting tenant:

routes.patch("/api/v1/ontology/:entityType/mappings/:ruleId", async (c) => {
  // ... scope check ...
  const body = await c.req.json();
  const parsed = updateMappingRuleRequest.safeParse(body);
  // ... no tenant check ...
  const updated = await mappingRuleRepo.update(c.req.param("ruleId"), updateData as ...);
  if (!updated) throw new NotFoundError("Mapping rule not found.");

Compare with the GET errors endpoint (line 140-145) which does enforce tenant isolation: if (rule.tenant_id !== user.tenantId). The same check is missing from PATCH and DELETE.

Impact: A data engineer authenticated as one tenant could modify or delete mapping rules belonging to another tenant if they know the rule UUID. This is a cross-tenant data integrity issue.

Fix: Before updating or deleting, fetch the rule by ID and verify rule.tenant_id === user.tenantId. Apply the same pattern used in the GET errors endpoint.

Effort: Small


V4-M-12: Execution engine re-queries all run steps from DB on every step iteration

Severity: MEDIUM Personas: Data Engineer, Power User Component: services/pipeline/src/services/execution-engine.ts (line 806-838)

Problem: Inside the step traversal loop, the engine fetches all step rows from the database on every iteration just to find the current step's row:

while (currentStepId !== null) {
  // ...
  const stepRows = await runStepRepo.findByRunId(runId);
  const runStepRow = stepRows.find((r) => r.step_id === currentStepId);

For a pipeline with 50 steps, this executes 50 SELECT queries that each return all 50 step rows, totaling 2,500 row scans.

Impact: Data engineers running pipelines with many steps experience unnecessary database load. Each step iteration triggers a full table scan of all run_steps for the run, adding latency proportional to the square of the step count.

Fix: Fetch all run step rows once before the traversal loop and build a Map. Alternatively, add a findByRunIdAndStepId repository method to query exactly one row.

Effort: Small


V4-M-13: CLI schedule create does not send inputTemplate or enabled fields

Severity: MEDIUM Personas: Data Engineer, Power User Component: packages/cli/src/commands/schedule/index.ts (line 42-53)

Problem: The schedule create command omits the inputTemplate and enabled fields that the API supports:

async function createAction(opts: CreateOpts, ctx: CommandContext): Promise<void> {
  validateCron(opts.cron);
  const body: Record<string, unknown> = {
    pipelineId: opts.pipeline,
    cronExpr: opts.cron,
  };
  if (opts.name) body["name"] = opts.name;
  body["timezone"] = opts.timezone ?? "UTC";

The API CreateScheduleSchema requires inputTemplate (defaults to {}) and enabled (defaults to true). While the API defaults handle absent fields, there is no CLI option to set --input-template for passing runtime parameters to scheduled runs, or --disabled to create a schedule in a paused state.

Impact: Data engineers who need to create schedules that pass runtime parameters to their pipeline runs cannot do so from the CLI. They must use curl or the API directly to set inputTemplate.

Fix: Add --input-template <json-file> and --disabled options to the schedule create command.

Effort: Small


V4-M-14: cancelSync does not cancel active BullMQ jobs, only waiting/delayed ones

Severity: MEDIUM Personas: Data Engineer, Power User Component: services/ingestion/src/services/sync-service.ts (line 429-448)

Problem: The cancelSync method only removes jobs in waiting or delayed states. If the job is already active (being processed by a worker), it is not affected:

async function cancelSync(syncJobId: string): Promise<void> {
  const job = await syncQueue.getJob(syncJobId);
  // ...
  const state = await job.getState();
  if (state === "waiting" || state === "delayed") {
    await job.remove();
  }
  // For active jobs, only writes a cancelled progress entry
  const existing = await getSyncProgress(syncJobId);
  if (existing !== null) {
    await writeProgress({ ...existing, status: "cancelled" });
  }

While the progress flag is set for active jobs, the sync worker only checks isCancelled() at the top of each batch iteration loop. If the sync is stuck on a single slow fetchBatch call (with a 65-second timeout), cancellation is delayed until that call completes.

Impact: Data engineers who cancel a sync that is currently executing a slow batch must wait up to 65 seconds (the fetch timeout) for cancellation to take effect. During that window, the sync continues processing and writing data.

Fix: Pass the AbortSignal from the cancellation flag check into the fetchBatch HTTP request. When a cancellation is detected, abort the in-flight request immediately rather than waiting for it to complete.

Effort: Medium


V4-M-15: SDK listBuilds() paginator has same items/data shape mismatch as apps.list()

Severity: MEDIUM Personas: App Developer, Plugin Developer Component: packages/sdk/src/resources/apps.ts (line 204-219)

Problem: Like apps.list(), the listBuilds() Paginator callback expects { items: AppBuild[], nextCursor, total } (line 207-210) after Transport unwraps data. But the server's builds list endpoint (versions.ts line 72-76) returns:

{ "data": [...builds], "pagination": { "nextCursor": "...", "total": N } }
After Transport unwraps, the SDK receives the builds array, not the expected { items, nextCursor, total } object.

Impact: Developers calling client.apps.listBuilds(id) get no build results. Pagination is broken for build listings.

Fix: Apply the same fix as V4-AP-04: restructure the Paginator callback to handle the actual response shape.

Effort: Small


V4-M-16: useQuery isLoading logic is always false after first render due to tautological check

Severity: MEDIUM Personas: App Developer, Plugin Developer Component: packages/app-sdk/src/hooks/useQuery.ts (line 237-243)

Problem: The isLoading computation at line 237-243:

const isLoading =
  !isReady || (enabled && cachedEntry === undefined && !cachedEntry);

return {
  ...
  isLoading: isLoading && cachedEntry === undefined,
};
The expression cachedEntry === undefined && !cachedEntry is tautological -- if cachedEntry === undefined, then !cachedEntry is always true (since !undefined === true). More importantly, the return value applies a second && cachedEntry === undefined check, making the condition: (!isReady || (enabled && cachedEntry === undefined)) && cachedEntry === undefined. When isReady is true and there IS no cached entry, isLoading becomes (false || (true && true)) && true = true. When isReady is false, isLoading becomes (true) && (cachedEntry === undefined) which is only true if there's no cache entry. This works but the double-check and tautological !cachedEntry are confusing and the intent is unclear.

Impact: The hook technically works but the code is misleading. The redundant !cachedEntry condition suggests the author intended additional logic (e.g., checking for null data arrays) that was lost. Developers reading the source to debug loading states will be confused.

Fix: Simplify to: const isLoading = !isReady || (enabled && cachedEntry === undefined); and return isLoading directly without the redundant second check.

Effort: Small


V4-M-17: useAppStorage setValue does not rollback optimistic update on network failure

Severity: MEDIUM Personas: App Developer, Plugin Developer Component: packages/app-sdk/src/hooks/useAppStorage.ts (line 114-144)

Problem: The setValue callback at line 114-144 performs an optimistic update via setValueState(newValue) at line 137, then fires the PUT request:

const setValue = React.useCallback(
  async (newValue: T): Promise<void> => {
    if (!isKeyValid) return;
    // ... size check ...
    setValueState(newValue); // optimistic update
    await bffClient.request(`/bff/storage/...`, { method: "PUT", body: { value: newValue } });
  },
  [isKeyValid, key, bffClient],
);
If the PUT request fails (network error, 5xx, etc.), the optimistic state remains. The UI shows the new value even though it was never persisted. There is no try/catch rollback to restore the previous value.

Impact: Developers saving user preferences see them 'saved' in the UI but they revert silently on next page load. This creates a confusing UX where the app appears to save but doesn't actually persist. The useMutation hook has proper rollback via snapshot/restore but useAppStorage does not.

Fix: Capture the previous value before the optimistic update. Wrap the PUT in a try/catch that rolls back setValueState to the previous value on error, similar to useMutation's snapshot pattern. Optionally surface the error via the meta return value.

Effort: Small


V4-M-18: validateFilePath rejects extensionless files needed for config (e.g. .prettierrc, .editorconfig)

Severity: MEDIUM Personas: App Developer, Plugin Developer Component: services/app/src/services/app-service.ts (line 113)

Problem: The validateFilePath function extracts the extension via:

const ext = path.slice(path.lastIndexOf("."));
For a file like /src/.prettierrc, lastIndexOf('.') returns 5, giving ext .prettierrc which is not in ALLOWED_EXTENSIONS. For /Makefile (no dot at all), lastIndexOf('.') returns -1 and "Makefile".slice(-1) returns "e". The ALLOWED_EXTENSIONS set (line 86-97) does not include common dotfiles like .prettierrc, .eslintrc, .editorconfig, or extensionless files. This means app developers cannot add standard tooling config files to their VFS.

Impact: Developers who want to include .prettierrc, .editorconfig, Makefile, .npmrc, or similar config files get a confusing 400 error. The error message lists all allowed extensions but the developer's desired extension is simply not there.

Fix: Add common dotfile/config extensions to ALLOWED_EXTENSIONS (e.g., add an allowlist for well-known dotfiles), or change the validation to only block known-dangerous extensions (e.g., .exe, .sh, .bat) rather than allowlisting. At minimum, add .prettierrc, .eslintrc, .editorconfig, .npmrc to the set.

Effort: Small


V4-M-19: HTML shell injects configJson without escaping, allowing XSS if appId/tenantId contain quotes

Severity: MEDIUM Personas: App Developer, Plugin Developer Component: services/app/src/index.ts (line 506-509)

Problem: The app serving route builds the HTML shell with:

const configJson = JSON.stringify({
  appId:     tenantApp.id,
  tenantId:  tenantApp.tenant_id,
  bffOrigin: "",
});
// ...
`  <script>`,
`    window.__OP_APP_CONFIG__ = ${configJson};`,
`  </script>`,
While JSON.stringify handles most special characters, it does not escape </script> sequences. If an appId or tenantId contains </script><script>alert(1)//, JSON.stringify produces a valid JSON string containing the literal </script> which closes the script tag early. The app name IS escaped via escapeHtml (line 502), but configJson is not.

Impact: An attacker who can control appId or tenantId values (e.g., through a compromised DB or admin API) could inject arbitrary JavaScript into every user who visits the app.

Fix: Escape </ sequences in the JSON output. Replace </ with <\/ in configJson before interpolation: `configJson.replace(/