From 2affc4ec3b2fffaf3d4286043388e32c761a89f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 17:50:53 +0700 Subject: [PATCH 01/55] fix(android): validate telemetry timestamps --- .../android/telemetry/TelemetryContract.kt | 10 +++++++- .../android/TelemetryContractTest.kt | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/com/databreeze/android/telemetry/TelemetryContract.kt b/apps/android/app/src/main/java/com/databreeze/android/telemetry/TelemetryContract.kt index b941ef80..09b739bc 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/telemetry/TelemetryContract.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/telemetry/TelemetryContract.kt @@ -1,5 +1,8 @@ package com.databreeze.android.telemetry +import java.time.Instant +import java.time.format.DateTimeParseException + /** Cross-runtime names and safe record helpers shared with @databreeze/telemetry/v1. */ object TelemetryContract { const val SchemaVersion = 1 @@ -143,9 +146,14 @@ object TelemetryContract { correlation.spanId, correlation.traceFlags, ) + val normalizedTimestamp = try { + Instant.parse(timestamp).toString() + } catch (_: DateTimeParseException) { + throw IllegalArgumentException("invalid telemetry timestamp") + } return TelemetryRecord( SchemaVersion, - timestamp, + normalizedTimestamp, level, event, component, diff --git a/apps/android/app/src/test/java/com/databreeze/android/TelemetryContractTest.kt b/apps/android/app/src/test/java/com/databreeze/android/TelemetryContractTest.kt index f3b9cfa5..de903e2c 100644 --- a/apps/android/app/src/test/java/com/databreeze/android/TelemetryContractTest.kt +++ b/apps/android/app/src/test/java/com/databreeze/android/TelemetryContractTest.kt @@ -97,4 +97,27 @@ class TelemetryContractTest { assertTrue(headerError.message.orEmpty().contains("not readable")) assertTrue(!headerError.message.orEmpty().contains("provider header cause")) } + + @Test + fun recordRequiresAndNormalizesAnAbsoluteTimestamp() { + val normalized = TelemetryContract.createRecord( + "info", + "sync.completed", + "android", + CorrelationContext(correlationId), + timestamp = "2026-01-01T07:00:00+07:00", + ) + assertEquals("2026-01-01T00:00:00Z", normalized.timestamp) + + val error = assertThrows(IllegalArgumentException::class.java) { + TelemetryContract.createRecord( + "info", + "sync.completed", + "android", + CorrelationContext(correlationId), + timestamp = "tomorrow in a provider timezone", + ) + } + assertEquals("invalid telemetry timestamp", error.message) + } } From ffe37cf33f8176fb799ffb32058baad9f76846df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 17:51:31 +0700 Subject: [PATCH 02/55] fix(telemetry): isolate exporter failures --- packages/telemetry/src/v1.ts | 6 +++++- packages/telemetry/test/telemetry-v1.test.mjs | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/telemetry/src/v1.ts b/packages/telemetry/src/v1.ts index e74ea2e2..66711b6d 100644 --- a/packages/telemetry/src/v1.ts +++ b/packages/telemetry/src/v1.ts @@ -375,7 +375,11 @@ export function createStructuredLoggerV1(options: StructuredLoggerOptionsV1) { record.spanId = normalized.spanId; if (normalized.traceFlags !== undefined) record.traceFlags = normalized.traceFlags; } - sink(record); + try { + sink(record); + } catch { + // Exporters are best-effort adapters and cannot become product authority. + } return record; }, }; diff --git a/packages/telemetry/test/telemetry-v1.test.mjs b/packages/telemetry/test/telemetry-v1.test.mjs index d4a53d02..d29cfcf4 100644 --- a/packages/telemetry/test/telemetry-v1.test.mjs +++ b/packages/telemetry/test/telemetry-v1.test.mjs @@ -233,3 +233,24 @@ test('structured logger carries normalized trace context into the record', () => assert.equal(record.spanId, '0123456789abcdef'); assert.equal(record.traceFlags, '00'); }); + +test('structured logger isolates exporter outages from product workflows', () => { + const logger = createStructuredLoggerV1({ + component: 'api', + clock: () => new Date('2026-01-01T00:00:00.000Z'), + sink() { + throw new Error('provider cause with customer source value'); + }, + }); + + const record = logger.emit( + 'warn', + 'telemetry.export_failed', + createCorrelationContextV1({ correlationId }), + { outcome: 'degraded', payload: 'must not be serialized' }, + ); + + assert.equal(record.event, 'telemetry.export_failed'); + assert.deepEqual(record.attributes, { outcome: 'degraded' }); + assert.doesNotMatch(JSON.stringify(record), /provider cause|customer source|must not/u); +}); From 5f998f4b8c23a003da192ea919ae4310203a8ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 17:52:55 +0700 Subject: [PATCH 03/55] fix(engine): preserve telemetry privacy for mappings --- .../engine/src/databreeze_engine/telemetry.py | 4 +-- services/engine/tests/test_telemetry.py | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/services/engine/src/databreeze_engine/telemetry.py b/services/engine/src/databreeze_engine/telemetry.py index b8418ab2..71a41afa 100644 --- a/services/engine/src/databreeze_engine/telemetry.py +++ b/services/engine/src/databreeze_engine/telemetry.py @@ -160,7 +160,7 @@ def _validate_key(key: object) -> str: return key -def sanitize_attributes(attributes: dict[str, Any]) -> dict[str, str | int | float | bool]: +def sanitize_attributes(attributes: Mapping[str, Any]) -> dict[str, str | int | float | bool]: """Return only bounded, allowlisted scalar attributes.""" if not isinstance(attributes, Mapping): @@ -336,7 +336,7 @@ def emit_record( "event": event, "component": component, "correlationId": normalized.correlation_id, - "attributes": sanitize_attributes(dict(attributes or {})), + "attributes": sanitize_attributes(attributes if attributes is not None else {}), } if normalized.trace_id and normalized.span_id: record["traceId"] = normalized.trace_id diff --git a/services/engine/tests/test_telemetry.py b/services/engine/tests/test_telemetry.py index 2860e7a9..15577a6b 100644 --- a/services/engine/tests/test_telemetry.py +++ b/services/engine/tests/test_telemetry.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator, Mapping + import pytest from databreeze_engine.telemetry import ( @@ -128,3 +130,28 @@ def test_engine_accepts_mixed_case_header_names() -> None: ) == context ) + + +def test_engine_record_builder_isolates_hostile_attribute_mappings() -> None: + class HostileAttributes(Mapping[str, object]): + def __getitem__(self, key: str) -> object: + raise RuntimeError("provider source value must not escape") + + def __iter__(self) -> Iterator[str]: + raise RuntimeError("provider source value must not escape") + + def __len__(self) -> int: + return 1 + + def items(self): # type: ignore[override] + raise RuntimeError("provider source value must not escape") + + record = emit_record( + "warn", + "processor.degraded", + "engine", + CorrelationContext("00000000-0000-4000-8000-000000000001"), + HostileAttributes(), + ) + assert record["attributes"] == {} + assert "provider source" not in str(record) From d5bdf2ac872ba5c6740e54387d9651a7707d17a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 17:53:43 +0700 Subject: [PATCH 04/55] fix(telemetry): bound clock adapter failures --- packages/telemetry/src/v1.ts | 8 +++++++- packages/telemetry/test/telemetry-v1.test.mjs | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/telemetry/src/v1.ts b/packages/telemetry/src/v1.ts index 66711b6d..a6e70786 100644 --- a/packages/telemetry/src/v1.ts +++ b/packages/telemetry/src/v1.ts @@ -361,9 +361,15 @@ export function createStructuredLoggerV1(options: StructuredLoggerOptionsV1) { if (!levelSet.has(level)) throw new Error('Invalid telemetry level'); if (!eventPattern.test(event)) throw new Error('Invalid telemetry event'); const normalized = createCorrelationContextV1(correlation); + let timestamp: string; + try { + timestamp = clock().toISOString(); + } catch { + timestamp = new Date().toISOString(); + } const record: TelemetryRecordV1 = { schemaVersion: TELEMETRY_SCHEMA_VERSION_V1, - timestamp: clock().toISOString(), + timestamp, level, event, component: options.component, diff --git a/packages/telemetry/test/telemetry-v1.test.mjs b/packages/telemetry/test/telemetry-v1.test.mjs index d29cfcf4..cc6e832d 100644 --- a/packages/telemetry/test/telemetry-v1.test.mjs +++ b/packages/telemetry/test/telemetry-v1.test.mjs @@ -254,3 +254,17 @@ test('structured logger isolates exporter outages from product workflows', () => assert.deepEqual(record.attributes, { outcome: 'degraded' }); assert.doesNotMatch(JSON.stringify(record), /provider cause|customer source|must not/u); }); + +test('structured logger uses a safe fallback when a clock adapter fails', () => { + const logger = createStructuredLoggerV1({ + component: 'engine', + clock() { + throw new Error('provider clock cause'); + }, + sink: () => undefined, + }); + + const record = logger.emit('info', 'processor.started', { correlationId }, {}); + assert.match(record.timestamp, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u); + assert.doesNotMatch(JSON.stringify(record), /provider clock cause/u); +}); From 920b2a293bd0b3630de972fd18094c11de671d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 17:56:28 +0700 Subject: [PATCH 05/55] fix(iam): reject duplicate MFA state identities --- .../adapter/in-memory-mfa-repository.adapter.ts | 5 +++++ .../api/test/features/iam/mfa.service.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts index 5ca1c46e..d67720e3 100644 --- a/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-mfa-repository.adapter.ts @@ -14,6 +14,11 @@ function cloneState(state: MfaStateV1): MfaStateV1 { } function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { + if ( + new Set(next.factors.map((factor) => factor.id)).size !== next.factors.length || + new Set(next.recoveryCodes.map((code) => code.id)).size !== next.recoveryCodes.length + ) + return false; const existingFactors = new Map(existing.factors.map((factor) => [factor.id, factor])); const existingCodes = new Map(existing.recoveryCodes.map((code) => [code.id, code])); if ( diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index b0574b69..a3cdeef3 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -143,4 +143,19 @@ void test('[IAM-012, IAM-014] in-memory MFA state rejects removal and invalid ne }), /IAM_MFA_REVISION_CONFLICT/u, ); + + await assert.rejects( + repository.saveState(userId as never, { + factors: [factor.value, factor.value], + recoveryCodes: [code.value], + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); + await assert.rejects( + repository.saveState(userId as never, { + factors: [factor.value], + recoveryCodes: [code.value, code.value], + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); }); From 1a0978b47f2c9909624e1c32e70e5503c297c4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 17:57:40 +0700 Subject: [PATCH 06/55] fix(iam): enforce Prisma MFA identity uniqueness --- .../adapter/prisma-mfa-repository.adapter.ts | 5 ++++ .../iam/prisma-mfa-repository.test.ts | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts index 4af5d094..2384a4e3 100644 --- a/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-mfa-repository.adapter.ts @@ -181,6 +181,11 @@ function recoveryRow(code: RecoveryCodeV1): MfaRecoveryCodeDatabaseRowV1 { } function immutableState(existing: MfaStateV1, next: MfaStateV1): boolean { + if ( + new Set(next.factors.map((factor) => factor.id)).size !== next.factors.length || + new Set(next.recoveryCodes.map((code) => code.id)).size !== next.recoveryCodes.length + ) + return false; const existingFactors = new Map(existing.factors.map((factor) => [factor.id, factor])); const existingCodes = new Map(existing.recoveryCodes.map((code) => [code.id, code])); if ( diff --git a/services/api/test/features/iam/prisma-mfa-repository.test.ts b/services/api/test/features/iam/prisma-mfa-repository.test.ts index daa55d3b..115535be 100644 --- a/services/api/test/features/iam/prisma-mfa-repository.test.ts +++ b/services/api/test/features/iam/prisma-mfa-repository.test.ts @@ -227,6 +227,30 @@ void test('[IAM-012, IAM-014] Prisma MFA persistence rejects a changed stale rev ); }); +void test('[IAM-012, IAM-014] Prisma MFA persistence rejects duplicate state identities', async () => { + const { client } = createDatabase(); + const adapter = new PrismaMfaRepositoryAdapter(client); + const input = state(); + const factor = input.factors[0]; + const code = input.recoveryCodes[0]; + if (!factor || !code) throw new Error('fixture missing MFA state'); + + await assert.rejects( + adapter.saveState(factor.userId, { + factors: [factor, factor], + recoveryCodes: [code], + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); + await assert.rejects( + adapter.saveState(factor.userId, { + factors: [factor], + recoveryCodes: [code, code], + }), + /IAM_MFA_REVISION_CONFLICT/u, + ); +}); + void test('[IAM-012, IAM-016] Prisma MFA persistence rejects a redemption race with compare-and-set', async () => { const { client } = createDatabase(); const adapter = new PrismaMfaRepositoryAdapter(client); From d1eeae2e3ad6b0eea4ed8d8128502a7583b2c5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 17:58:54 +0700 Subject: [PATCH 07/55] fix(iam): contain MFA verifier failures --- .../features/iam/application/mfa.service.ts | 19 ++++++++++------- .../api/test/features/iam/mfa.service.test.ts | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index f9f5519c..7d71fbf1 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -128,13 +128,18 @@ export class MfaService { const factor = state.factors.find((item) => item.id === factorId); if (!factor) return invalidState(); if (factor.status !== 'PENDING') return invalidState(); - const verified = await this.factorProofVerifier.verify({ - userId, - factorId, - method: factor.method, - secretReference: factor.secretReference, - proof: factorProof, - }); + let verified = false; + try { + verified = await this.factorProofVerifier.verify({ + userId, + factorId, + method: factor.method, + secretReference: factor.secretReference, + proof: factorProof, + }); + } catch { + verified = false; + } if (!verified) return Object.freeze({ accepted: false as const, code: 'FACTOR_PROOF_INVALID' as const }); const transitioned = transitionMfaFactorV1(factor, 'VERIFY', this.clock().toISOString()); diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index a3cdeef3..898e79a9 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -46,6 +46,27 @@ void test('[IAM-012, IAM-013, IAM-014] MFA enrollment and verification are revis assert.deepEqual(secondVerify, { accepted: false, code: 'INVALID_STATE' }); }); +void test('[IAM-013] MFA proof-provider failures become a safe verification result', async () => { + const repository = new InMemoryMfaRepositoryAdapter(); + const service = new MfaService( + repository, + { matches: (presented, stored) => presented === stored }, + { verify: () => Promise.reject(new Error('provider secret details must not escape')) }, + () => new Date(at), + ); + const enrolled = await service.enroll({ + id: factorId, + userId, + method: 'TOTP', + secretReference: 'secret-ref:totp:1', + }); + assert.equal(enrolled.accepted, true); + assert.deepEqual(await service.verifyFactor(userId, factorId, '654321'), { + accepted: false, + code: 'FACTOR_PROOF_INVALID', + }); +}); + void test('[IAM-015, IAM-016] recovery code redemption is one-time and does not expose digests', async () => { const repository = new InMemoryMfaRepositoryAdapter(); const code = createRecoveryCodeV1({ id: recoveryId, userId, digest: 'digest-1', createdAt: at }); From f9b67fd9f4b6a1a780cc0b4fa9b53d879aa76d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:00:21 +0700 Subject: [PATCH 08/55] fix(iam): contain MFA clock failures --- .../features/iam/application/mfa.service.ts | 20 ++++++++++++++++--- .../api/test/features/iam/mfa.service.test.ts | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/services/api/src/features/iam/application/mfa.service.ts b/services/api/src/features/iam/application/mfa.service.ts index 7d71fbf1..80de25e1 100644 --- a/services/api/src/features/iam/application/mfa.service.ts +++ b/services/api/src/features/iam/application/mfa.service.ts @@ -89,13 +89,23 @@ export class MfaService { private readonly clock: () => Date = () => new Date(), ) {} + private timestamp(): string | undefined { + try { + return this.clock().toISOString(); + } catch { + return undefined; + } + } + public async enroll(input: { readonly id: unknown; readonly userId: unknown; readonly method: unknown; readonly secretReference: unknown; }): Promise> { - const factor = createMfaFactorV1({ ...input, enrolledAt: this.clock().toISOString() }); + const enrolledAt = this.timestamp(); + if (!enrolledAt) return Object.freeze({ accepted: false, code: 'INVALID_TIMESTAMP' }); + const factor = createMfaFactorV1({ ...input, enrolledAt }); if (!factor.accepted) return Object.freeze({ accepted: false, code: factor.code }); return this.repository.withTransaction(async (transaction) => { const state = await transaction.findState(factor.value.userId); @@ -142,7 +152,9 @@ export class MfaService { } if (!verified) return Object.freeze({ accepted: false as const, code: 'FACTOR_PROOF_INVALID' as const }); - const transitioned = transitionMfaFactorV1(factor, 'VERIFY', this.clock().toISOString()); + const verifiedAt = this.timestamp(); + if (!verifiedAt) return Object.freeze({ accepted: false, code: 'INVALID_TIMESTAMP' }); + const transitioned = transitionMfaFactorV1(factor, 'VERIFY', verifiedAt); if (!transitioned.accepted) return Object.freeze({ accepted: false, code: transitioned.code }); const next = Object.freeze({ @@ -162,9 +174,11 @@ export class MfaService { if (!userId) return Object.freeze({ accepted: false, code: 'INVALID_IDENTIFIER' }); return this.repository.withTransaction(async (transaction) => { const state = await transaction.findState(userId); + const redeemedAt = this.timestamp(); + if (!redeemedAt) return Object.freeze({ accepted: false, code: 'INVALID_TIMESTAMP' }); const redeemed = redeemRecoveryCodeV1( state, - { userId, presentedDigest, at: this.clock().toISOString() }, + { userId, presentedDigest, at: redeemedAt }, this.recoveryMatcher, ); if (!redeemed.accepted) return Object.freeze({ accepted: false, code: redeemed.code }); diff --git a/services/api/test/features/iam/mfa.service.test.ts b/services/api/test/features/iam/mfa.service.test.ts index 898e79a9..e196387c 100644 --- a/services/api/test/features/iam/mfa.service.test.ts +++ b/services/api/test/features/iam/mfa.service.test.ts @@ -67,6 +67,26 @@ void test('[IAM-013] MFA proof-provider failures become a safe verification resu }); }); +void test('[IAM-012, IAM-015] MFA clock-provider failures become a stable timestamp result', async () => { + const service = new MfaService( + new InMemoryMfaRepositoryAdapter(), + { matches: (presented, stored) => presented === stored }, + undefined, + () => { + throw new Error('clock provider details must not escape'); + }, + ); + assert.deepEqual( + await service.enroll({ + id: factorId, + userId, + method: 'TOTP', + secretReference: 'secret-ref:totp:1', + }), + { accepted: false, code: 'INVALID_TIMESTAMP' }, + ); +}); + void test('[IAM-015, IAM-016] recovery code redemption is one-time and does not expose digests', async () => { const repository = new InMemoryMfaRepositoryAdapter(); const code = createRecoveryCodeV1({ id: recoveryId, userId, digest: 'digest-1', createdAt: at }); From 445fd246c8eb4c956a843534a4bfa4f5e65f480d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:02:02 +0700 Subject: [PATCH 09/55] fix(bua): reject duplicate usage identities --- .../in-memory-entitlement-repository.adapter.ts | 6 ++++++ .../features/bua/entitlement-repository.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts index d5364b93..7ae882eb 100644 --- a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts @@ -140,6 +140,12 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito async persistUsageState(context: IamTenantContextV1, state: UsageLedgerStateV1): Promise { await Promise.resolve(); + if ( + new Set(state.entries.map((entry) => entry.entryId)).size !== state.entries.length || + new Set(state.reservations.map((reservation) => reservation.reservationId)).size !== + state.reservations.length + ) + throw new Error('BUA_USAGE_STATE_CONFLICT'); for (const entry of state.entries) { const existing = this.entries.get(entry.entryId); if (existing) { diff --git a/services/api/test/features/bua/entitlement-repository.test.ts b/services/api/test/features/bua/entitlement-repository.test.ts index 451fc208..9e0799d8 100644 --- a/services/api/test/features/bua/entitlement-repository.test.ts +++ b/services/api/test/features/bua/entitlement-repository.test.ts @@ -161,6 +161,23 @@ void test('[BUA-008, BUA-009, BUA-010, BUA-011] usage state persists append-only }), /BUA_IMMUTABLE_USAGE_ENTRY/, ); + const persistedEntry = reserved.value.state.entries[0]; + if (!persistedEntry) throw new Error('fixture entry missing'); + await assert.rejects( + repository.persistUsageState(context(workspaceId), { + ...reserved.value.state, + entries: [persistedEntry, persistedEntry], + }), + /BUA_USAGE_STATE_CONFLICT/u, + ); + if (!activeReservation) throw new Error('fixture reservation missing'); + await assert.rejects( + repository.persistUsageState(context(workspaceId), { + ...reserved.value.state, + reservations: [activeReservation, activeReservation], + }), + /BUA_USAGE_STATE_CONFLICT/u, + ); await assert.rejects( repository.withTransaction(context(workspaceId), async (transaction) => { const second = reserveUsageV1(storedSnapshot, reserved.value.state, { From a7402f9c5c3b6c84301aa57fec705341586b730f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:03:16 +0700 Subject: [PATCH 10/55] fix(bua): enforce Prisma usage identity uniqueness --- .../prisma-entitlement-repository.adapter.ts | 6 ++++ .../bua/prisma-entitlement-repository.test.ts | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index e46a1882..a6feb1ec 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -585,6 +585,12 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV context: IamTenantContextV1, state: UsageLedgerStateV1, ): Promise { + if ( + new Set(state.entries.map((entry) => entry.entryId)).size !== state.entries.length || + new Set(state.reservations.map((reservation) => reservation.reservationId)).size !== + state.reservations.length + ) + throw new Error('BUA_USAGE_STATE_CONFLICT'); for (const entry of state.entries) { if (!tenantScopeContainsV1(context.tenantScope, entry.tenantScope)) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index 520cea03..c4ee6119 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -251,6 +251,35 @@ void test('[BUA-001, BUA-002, BUA-008, IAM-009] Prisma entitlement adapter persi ); }); +void test('[BUA-008, BUA-011] Prisma entitlement adapter rejects duplicate usage identities', async () => { + const repository = new PrismaEntitlementRepositoryAdapter(client()); + await repository.saveSnapshot(context(workspaceId, 'duplicate-snapshot'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit( + context(workspaceId, 'duplicate-admit'), + admissionInput('duplicate-admit', '1'), + ); + assert.equal(admitted.accepted, true); + if (!admitted.accepted) return; + const entry = admitted.value.state.entries[0]; + const reservation = admitted.value.state.reservations[0]; + if (!entry || !reservation) throw new Error('fixture usage state missing'); + await assert.rejects( + repository.persistUsageState(context(workspaceId, 'duplicate-entry'), { + ...admitted.value.state, + entries: [entry, entry], + }), + /BUA_USAGE_STATE_CONFLICT/u, + ); + await assert.rejects( + repository.persistUsageState(context(workspaceId, 'duplicate-reservation'), { + ...admitted.value.state, + reservations: [reservation, reservation], + }), + /BUA_USAGE_STATE_CONFLICT/u, + ); +}); + void test('[BUA-008, IAM-009] Prisma entitlement adapter round-trips project-scoped usage', async () => { const repository = new PrismaEntitlementRepositoryAdapter(client()); await repository.saveSnapshot(context(workspaceId, 'project-snapshot'), snapshot()); From f4e2f2f301c814488984054456cccf612f80d92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:05:16 +0700 Subject: [PATCH 11/55] feat(api): parse bounded traceparent context --- .../api/src/platform/http/request-context.ts | 37 +++++++++++++++++++ services/api/test/request-context.test.ts | 23 ++++++++++++ 2 files changed, 60 insertions(+) diff --git a/services/api/src/platform/http/request-context.ts b/services/api/src/platform/http/request-context.ts index 20f5f512..361255cd 100644 --- a/services/api/src/platform/http/request-context.ts +++ b/services/api/src/platform/http/request-context.ts @@ -16,11 +16,21 @@ export interface RequestContext { const requestContexts = new WeakMap(); const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const traceparentPattern = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i; export type CorrelationHeaderResult = | { readonly accepted: true; readonly correlationId: string } | { readonly accepted: false }; +export type TraceparentHeaderResult = + | { + readonly accepted: true; + readonly traceId?: string; + readonly spanId?: string; + readonly traceFlags?: string; + } + | { readonly accepted: false }; + export interface RequestContextOptions { readonly csrf?: Partial; } @@ -59,6 +69,33 @@ export function parseCorrelationHeader( return { accepted: true, correlationId: value }; } +/** Parses one W3C traceparent without reflecting malformed or provider values. */ +export function parseTraceparentHeader(values: readonly string[]): TraceparentHeaderResult { + if (values.length === 0) return { accepted: true }; + if (values.length !== 1) return { accepted: false }; + const value = values[0]; + if (value === undefined) return { accepted: false }; + const match = traceparentPattern.exec(value); + if (!match) return { accepted: false }; + const [, version, traceId, spanId, traceFlags] = match; + if ( + !version || + version.toLowerCase() === 'ff' || + !traceId || + traceId === '0'.repeat(32) || + !spanId || + spanId === '0'.repeat(16) || + !traceFlags + ) + return { accepted: false }; + return { + accepted: true, + traceId: traceId.toLowerCase(), + spanId: spanId.toLowerCase(), + traceFlags: traceFlags.toLowerCase(), + }; +} + export function getRequestContext(request: FastifyRequest): RequestContext { const context = requestContexts.get(request); if (context === undefined) throw new Error('Request context is unavailable'); diff --git a/services/api/test/request-context.test.ts b/services/api/test/request-context.test.ts index eecae0ed..75fcbc04 100644 --- a/services/api/test/request-context.test.ts +++ b/services/api/test/request-context.test.ts @@ -7,7 +7,9 @@ const suppliedCorrelationId = '123e4567-e89b-42d3-a456-426614174000'; void test('accepts zero or one valid bounded UUID correlation header and rejects ambiguous or unsafe input', async () => { const requestContextModule = await import('../src/platform/http/request-context.js'); const parseCorrelationHeader = requestContextModule.parseCorrelationHeader; + const parseTraceparentHeader = requestContextModule.parseTraceparentHeader; assert.equal(typeof parseCorrelationHeader, 'function'); + assert.equal(typeof parseTraceparentHeader, 'function'); assert.deepEqual(parseCorrelationHeader([], requestId), { accepted: true, @@ -25,4 +27,25 @@ void test('accepts zero or one valid bounded UUID correlation header and rejects ]) { assert.deepEqual(parseCorrelationHeader(values, requestId), { accepted: false }); } + + assert.deepEqual( + parseTraceparentHeader([ + '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01', + ]), + { + accepted: true, + traceId: '0123456789abcdef0123456789abcdef', + spanId: '0123456789abcdef', + traceFlags: '01', + }, + ); + for (const values of [ + ['not-a-traceparent'], + ['00-00000000000000000000000000000000-0123456789abcdef-01'], + ['00-0123456789abcdef0123456789abcdef-0000000000000000-01'], + ['ff-0123456789abcdef0123456789abcdef-0123456789abcdef-01'], + ['00-0123456789abcdef0123456789abcdef-0123456789abcdef-01', '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01'], + ]) { + assert.deepEqual(parseTraceparentHeader(values), { accepted: false }); + } }); From 1986d804e2dc1a0c313f9548a56b721ff68fe03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:06:47 +0700 Subject: [PATCH 12/55] feat(api): propagate validated trace context --- .../api/src/platform/http/request-context.ts | 39 ++++++++++++++++++- services/api/test/http-contract.test.ts | 13 +++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/services/api/src/platform/http/request-context.ts b/services/api/src/platform/http/request-context.ts index 361255cd..2f8de0e7 100644 --- a/services/api/src/platform/http/request-context.ts +++ b/services/api/src/platform/http/request-context.ts @@ -12,6 +12,9 @@ import { createProblem } from './problem-details.js'; export interface RequestContext { readonly correlationId: string; readonly requestId: string; + readonly traceId?: string; + readonly spanId?: string; + readonly traceFlags?: string; } const requestContexts = new WeakMap(); @@ -113,10 +116,15 @@ export function installRequestContext( requestContexts.set(request, context); reply.header('X-Request-Id', context.requestId); const values: string[] = []; + const traceValues: string[] = []; for (let index = 0; index < request.raw.rawHeaders.length; index += 2) { - if (request.raw.rawHeaders[index]?.toLowerCase() === 'x-correlation-id') { + const name = request.raw.rawHeaders[index]?.toLowerCase(); + if (name === 'x-correlation-id') { const value = request.raw.rawHeaders[index + 1]; if (value !== undefined) values.push(value); + } else if (name === 'traceparent') { + const value = request.raw.rawHeaders[index + 1]; + if (value !== undefined) traceValues.push(value); } } const parsed = parseCorrelationHeader(values, requestId); @@ -136,7 +144,34 @@ export function installRequestContext( ); return; } - const acceptedContext = { correlationId: parsed.correlationId, requestId }; + const parsedTrace = parseTraceparentHeader(traceValues); + if (!parsedTrace.accepted) { + reply.header('X-Correlation-Id', requestId); + reply + .code(400) + .type('application/problem+json') + .send( + createProblem({ + code: 'CORRELATION_ID_INVALID', + correlationId: requestId, + messageKey: 'api.error.correlation_id_invalid', + retryable: false, + status: 400, + }), + ); + return; + } + const acceptedContext = { + correlationId: parsed.correlationId, + requestId, + ...(parsedTrace.traceId + ? { + traceId: parsedTrace.traceId, + spanId: parsedTrace.spanId, + traceFlags: parsedTrace.traceFlags, + } + : {}), + }; requestContexts.set(request, acceptedContext); reply.header('X-Correlation-Id', acceptedContext.correlationId); const csrf = evaluateCsrfRequestV1( diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 1b6ef295..d20e9340 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -124,6 +124,19 @@ void test('rejects malformed and multiple correlation values without reflecting }); }); +void test('rejects malformed traceparent values without reflecting the header', async () => { + await withApp({}, async (app) => { + const leakedMarker = '00-00000000000000000000000000000000-0123456789abcdef-01'; + const response = await app.inject({ + method: 'GET', + url: '/v1/health', + headers: { traceparent: leakedMarker }, + }); + assert.equal(response.statusCode, 400); + assert.doesNotMatch(response.body, new RegExp(leakedMarker)); + }); +}); + void test('maps unknown routes to safe Problem Details without exposing the path or query', async () => { await withApp({}, async (app) => { const response = await app.inject({ From 91cfbdccc591fa67763f49642c12b962dd0d1ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:09:29 +0700 Subject: [PATCH 13/55] fix(bua): preserve visible inherited usage replays --- ...in-memory-entitlement-repository.adapter.ts | 18 ++++++++++++++---- .../bua/entitlement-repository.test.ts | 4 ++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts index 7ae882eb..4a81866d 100644 --- a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts @@ -150,7 +150,7 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito const existing = this.entries.get(entry.entryId); if (existing) { if (!sameUsageEntryV1(existing, entry)) throw new Error('BUA_IMMUTABLE_USAGE_ENTRY'); - continue; + if (visibleInScope(context.tenantScope, entry.tenantScope)) continue; } if (!scopeAllowsMutation(context, entry.tenantScope)) throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); @@ -168,13 +168,23 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito } for (const reservation of state.reservations) { const existing = this.reservations.get(reservation.reservationId); + if (existing) { + if (sameUsageReservationV1(existing, reservation)) { + if (visibleInScope(context.tenantScope, reservation.tenantScope)) continue; + } else if ( + !sameReservationExceptStatus(existing, reservation) || + existing.revision + 1 !== reservation.revision || + !validReservationTransition(existing, reservation) + ) { + throw new Error('BUA_RESERVATION_CONFLICT'); + } + } + if (!scopeAllowsMutation(context, reservation.tenantScope)) + throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); if (!existing) { - if (!scopeAllowsMutation(context, reservation.tenantScope)) - throw new Error('BUA_SCOPE_NARROWING_REQUIRED'); this.reservations.set(reservation.reservationId, cloneReservation(reservation)); continue; } - if (sameUsageReservationV1(existing, reservation)) continue; if ( existing.revision + 1 !== reservation.revision || !sameReservationExceptStatus(existing, reservation) || diff --git a/services/api/test/features/bua/entitlement-repository.test.ts b/services/api/test/features/bua/entitlement-repository.test.ts index 9e0799d8..8bb87691 100644 --- a/services/api/test/features/bua/entitlement-repository.test.ts +++ b/services/api/test/features/bua/entitlement-repository.test.ts @@ -145,6 +145,10 @@ void test('[BUA-008, BUA-009, BUA-010, BUA-011] usage state persists append-only if (!reserved.accepted) return; await repository.persistUsageState(context(workspaceId), reserved.value.state); assert.equal((await repository.listUsageState(context(workspaceId))).entries.length, 1); + await assert.rejects( + repository.persistUsageState(context(siblingWorkspaceId), reserved.value.state), + /BUA_SCOPE_NARROWING_REQUIRED/u, + ); const activeReservation = reserved.value.state.reservations[0]; if (!activeReservation) throw new Error('fixture reservation missing'); await assert.rejects( From 301121287ceb8eea988d4d47d89bcdea4e116c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:10:08 +0700 Subject: [PATCH 14/55] style(api): format trace context tests --- services/api/test/request-context.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/services/api/test/request-context.test.ts b/services/api/test/request-context.test.ts index 75fcbc04..e2a720d6 100644 --- a/services/api/test/request-context.test.ts +++ b/services/api/test/request-context.test.ts @@ -29,9 +29,7 @@ void test('accepts zero or one valid bounded UUID correlation header and rejects } assert.deepEqual( - parseTraceparentHeader([ - '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01', - ]), + parseTraceparentHeader(['00-0123456789abcdef0123456789abcdef-0123456789abcdef-01']), { accepted: true, traceId: '0123456789abcdef0123456789abcdef', @@ -44,7 +42,10 @@ void test('accepts zero or one valid bounded UUID correlation header and rejects ['00-00000000000000000000000000000000-0123456789abcdef-01'], ['00-0123456789abcdef0123456789abcdef-0000000000000000-01'], ['ff-0123456789abcdef0123456789abcdef-0123456789abcdef-01'], - ['00-0123456789abcdef0123456789abcdef-0123456789abcdef-01', '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01'], + [ + '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01', + '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01', + ], ]) { assert.deepEqual(parseTraceparentHeader(values), { accepted: false }); } From ed1e13082c4b1bae178348614ae5bac47e39e84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:41:30 +0700 Subject: [PATCH 15/55] docs(ops): align PR and promotion budgets --- docs/plans/000-platform-program.md | 4 ++-- docs/plans/003-luna-handoff-runbook.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/plans/000-platform-program.md b/docs/plans/000-platform-program.md index df6dc4b7..ce052afc 100644 --- a/docs/plans/000-platform-program.md +++ b/docs/plans/000-platform-program.md @@ -50,9 +50,9 @@ docs/plans/requirement-traceability.json records all 611 IDs with requested trac - `main` contains stable releases. `dev` is the integration branch. - New capabilities use `feat/`; corrections use `fix/`; operational and documentation work use conventional prefixes when more accurate. - Commit one coherent tested unit at a time. Do not combine unrelated applications or domains merely to reduce commit count. -- Feature/fix pull requests target `dev`, normally contain 30–70 commits, and must remain under 100 commits. Run local and hosted checks, but do not invoke CodeRabbit on these integration pull requests. Focused review-gate fixes are the only intentional small-PR exception. +- Feature/fix pull requests target `dev`, normally contain 30–50 commits; an exceptional completed-task boundary may reach 79 but must remain below 100 commits. Run local and hosted checks, but do not invoke CodeRabbit on these integration pull requests. Focused review-gate fixes are the only intentional small-PR exception. - Merge a green integration pull request while preserving its atomic rollback commits, then immediately open a separate `dev` → `main` promotion pull request for that completed batch. -- Invoke CodeRabbit exactly once on the otherwise-ready promotion pull request and request a full review. Do not invoke it if the promotion exceeds 280 changed files; split or revert the batch first. +- Invoke CodeRabbit exactly once on the otherwise-ready promotion pull request and request a full review. Keep the packet at or below 260 changed files; 280 is the hard review-stop threshold and must never be crossed before splitting or reverting the batch. - Reproduce every CodeRabbit claim against the reviewed commit. Fix valid findings in focused commits, document rejected findings with evidence, rerun all affected gates, and never request a second CodeRabbit review on that pull request. - Do not merge a promotion when the single review was skipped, timed out, has unresolved valid findings, or required hosted checks are not green. diff --git a/docs/plans/003-luna-handoff-runbook.md b/docs/plans/003-luna-handoff-runbook.md index ab799071..4f7f9aba 100644 --- a/docs/plans/003-luna-handoff-runbook.md +++ b/docs/plans/003-luna-handoff-runbook.md @@ -91,15 +91,15 @@ For each `#### TASK-ID —` item in `002-complete-execution-orchestration.md`: 10. Inspect generated/runtime debris before commit. Do not commit `.venv`, `node_modules`, Gradle state, build output, logs, caches, secrets, local databases, Terraform state, or test reports unless the repository explicitly tracks a sanitized fixture. 11. Commit one independently reversible outcome with a semantic message. Do not combine contracts, an unrelated fix, and a different feature just to increase commit count. -12. Recount the active PR slice against its base. Do not open below 30 commits; target 30–50, stop accepting new tasks at 50, and split at the next completed-task boundary. An exceptional boundary must never exceed 79 commits, preserving margin below CodeRabbit's 100-commit limit. Split before the promotion diff reaches 280 changed files; the packet target is 260. +12. Recount the active PR slice against its base. Do not open below 30 commits; target 30–50, stop accepting new tasks at 50, and split at the next completed-task boundary. An exceptional boundary must never exceed 79 commits, preserving margin below CodeRabbit's 100-commit limit. Keep the promotion packet at or below 260 changed files; 280 is the hard review-stop threshold, so split before crossing it. 13. Push after each stable task boundary. Update the ledger/checkpoint only with verified facts and leave a handoff record if stopping. ## Pull-request and CodeRabbit protocol -1. Branch from current `origin/dev` using `feat/` or `fix/`. Keep atomic commits; preferred PR size is 30–70 commits and hard maximum is 99. Cut earlier only for a coherent boundary, a mandatory promotion-gate fix, or a safety constraint. +1. Branch from current `origin/dev` using `feat/` or `fix/`. Keep atomic commits; preferred PR size is 30–50 commits and an exceptional completed-task boundary may reach 79, always remaining below CodeRabbit's 100-commit limit. Cut earlier only for a coherent boundary, a mandatory promotion-gate fix, or a safety constraint. 2. Before a feature PR, ensure it targets `dev`, has no unrelated commits, and passes local gates. Open it with requirement/task/evidence/rollback notes. CodeRabbit must not be invoked on this PR. 3. Wait for hosted checks. Diagnose failures; do not merge red or missing required checks. Merge with history that preserves the atomic rollback units, normally `--no-ff`/merge commit rather than squash. -4. Immediately compare `dev` and `main`. If `dev` contains the reviewed batch and no incompatible promotion is open, create `dev` → `main`. If the diff exceeds 280 changed files, do not invoke CodeRabbit: split/revert the feature batch or request user direction first. +4. Immediately compare `dev` and `main`. If `dev` contains the reviewed batch and no incompatible promotion is open, create `dev` → `main`. Keep the promotion packet at or below 260 changed files; if the diff would cross the hard 280-file review stop, split/revert the feature batch or request user direction before invoking CodeRabbit. 5. When the promotion PR is otherwise ready, invoke CodeRabbit exactly once with a full review request. Record the invocation URL/time. Do not invoke it on the feature PR and do not ask twice on the promotion PR. 6. Wait for the review. For each comment, reproduce the claimed behavior against the exact PR commit, classify it as valid/invalid/uncertain, and save the evidence. Use `superpowers:receiving-code-review` or the repository's CodeRabbit review skill when available. 7. Fix valid findings in focused `fix/*` commits or a focused promotion branch that is safely merged back through `dev`; ensure the promotion diff remains exactly `dev` → `main`. Document rejected comments with concise technical evidence. Do not request a second CodeRabbit pass. @@ -133,7 +133,7 @@ For each `#### TASK-ID —` item in `002-complete-execution-orchestration.md`: | Windows/Android signing key missing or compromised | Release signing/verification gate fails | Halt release, rotate/revoke via runbook, rebuild from provenance, and never ship an unsigned stable artifact | | CodeRabbit comment conflicts with specs/tests | Reproduction disproves claim | Document rejection with paths/tests; do not change code merely to satisfy the comment | | CodeRabbit uncovers a systemic issue late in promotion | Reproduction shows issue spans prior commits | Block promotion, create focused fix tasks, preserve the one review record, and obtain user direction if a fresh PR/review is necessary | -| Commit or changed-file budget would be exceeded | Preflight count reaches threshold | Cut a coherent PR before the hard limit; if over 280 files before promotion review, split/revert the batch before invoking CodeRabbit | +| Commit or changed-file budget would be exceeded | Preflight count reaches threshold | Cut a coherent PR before the hard limit; keep the packet at or below 260 files and split/revert before crossing the 280-file review stop | | User sends stop/override instruction | New message replaces or pauses active scope | Reach a safe boundary, preserve work, record exact state, and stop; do not continue autonomously | ## End-of-session handoff record @@ -175,7 +175,7 @@ Live verified checkpoint: branch [BRANCH], HEAD [HEAD], origin/dev [DEV], origin Resume batch [BATCH_ID] and task [TASK_ID] only after proving their dependency/entry gates, branch ownership, and commit/file budgets. Follow test-first atomic delivery: canonical contracts when the interface changes, failing domain/state tests, PostgreSQL migration/tenant/transaction/outbox tests when durable state changes, implementation through ports, vertical client/adapter coverage when the task involves client behavior, safe telemetry/recovery, traceability evidence, scoped checks, repo:check, repo:build, diff review, and one reversible commit. For documentation-only or other non-durable/non-client tasks, record why those conditional tests do not apply. Do not mark merged code verified without all evidence. Run pnpm installation/check/test/build commands sequentially within one worktree. -Git flow is fixed: feat/* or fix/* → PR to dev with hosted checks and no CodeRabbit; merge preserving atomic commits; immediately open dev→main; request exactly one CodeRabbit full review there; reproduce every comment, fix only valid findings, document rejected ones, never request a second review on that PR. Prefer 30–70 commits, hard cap 99, and do not invoke the promotion review over 280 changed files. +Git flow is fixed: feat/* or fix/* → PR to dev with hosted checks and no CodeRabbit; merge preserving atomic commits; immediately open dev→main; request exactly one CodeRabbit full review there; reproduce every comment, fix only valid findings, document rejected ones, never request a second review on that PR. Prefer 30–50 commits, exceptional ceiling 79, and keep the promotion packet at or below 260 changed files without crossing the 280-file review stop. Keep PostgreSQL authoritative, Redis ephemeral, tenant scope explicit, originals/versions immutable, Hybrid default, Local content/path out of cloud, workers without database credentials, Desktop/sidecar allowlisted, Android background payloads content-free, Vietnamese and English complete, and external providers replaceable. Stop and record state for destructive migration risk, unknown overlapping changes, privacy/security fail-open behavior, missing production/signing authority, or a spec conflict. End with the exact handoff record from the runbook. ``` From ca2df3df5a7b038519d5c3c8ee0a8ce6748da74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:42:29 +0700 Subject: [PATCH 16/55] docs(trace): separate planned and reconciled evidence --- docs/plans/requirement-traceability.json | 96 ++++++++++++++++++------ 1 file changed, 72 insertions(+), 24 deletions(-) diff --git a/docs/plans/requirement-traceability.json b/docs/plans/requirement-traceability.json index 0934124d..bb04f2d5 100644 --- a/docs/plans/requirement-traceability.json +++ b/docs/plans/requirement-traceability.json @@ -1041,7 +1041,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1116,7 +1118,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1141,7 +1145,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1166,7 +1172,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1216,7 +1224,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1241,7 +1251,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1266,7 +1278,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1291,7 +1305,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1466,7 +1482,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1541,7 +1559,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1566,7 +1586,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1591,7 +1613,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1641,7 +1665,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1666,7 +1692,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1716,7 +1744,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1741,7 +1771,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1766,7 +1798,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1791,7 +1825,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -1816,7 +1852,9 @@ "services/api/test/http-contract.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9244,7 +9282,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9337,7 +9377,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9399,7 +9441,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9461,7 +9505,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", @@ -9492,7 +9538,9 @@ "services/api/test/platform/http/csrf-protection.test.ts" ], "releaseEvidence": [ - "docs/operations/identity-audit-entitlement-reconciliation-2026-08-03.md" + "requirement-linked-tests", + "security-and-tenant-gate", + "release-manager-approval" ], "status": "planned", "coverage": "planned", From f38134109e373a9e40df0ee4565d3f90fc8acd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:43:57 +0700 Subject: [PATCH 17/55] feat(domain): expose canonical tenant scope keys --- packages/domain/src/tenant-scope/v1.ts | 8 ++++++++ packages/domain/test/tenant-scope-v1.test.mjs | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/domain/src/tenant-scope/v1.ts b/packages/domain/src/tenant-scope/v1.ts index b8353cea..15dc8c25 100644 --- a/packages/domain/src/tenant-scope/v1.ts +++ b/packages/domain/src/tenant-scope/v1.ts @@ -164,6 +164,14 @@ export function tenantScopeContainsV1(container: TenantScopeV1, candidate: Tenan return candidate.scopeType === 'project' && container.projectId === candidate.projectId; } +/** Stable storage and cursor key for a fully qualified tenant scope. */ +export function tenantScopeKeyV1(scope: TenantScopeV1): string { + if (scope.scopeType === 'organization') return `organization:${scope.organizationId}`; + if (scope.scopeType === 'workspace') + return `workspace:${scope.organizationId}:${scope.workspaceId}`; + return `project:${scope.organizationId}:${scope.workspaceId}:${scope.projectId}`; +} + export function narrowTenantScopeV1( current: TenantScopeV1, candidate: TenantScopeV1, diff --git a/packages/domain/test/tenant-scope-v1.test.mjs b/packages/domain/test/tenant-scope-v1.test.mjs index 9604c06b..8c8c5654 100644 --- a/packages/domain/test/tenant-scope-v1.test.mjs +++ b/packages/domain/test/tenant-scope-v1.test.mjs @@ -156,6 +156,20 @@ test('[IAM-019] equality and containment require complete matching ancestry', as assert.equal(api.tenantScopeContainsV1(projA, wsA), false); }); +test('[IAM-019] tenant scope keys preserve complete ancestry', async () => { + const api = await loadTenantScope(); + assert.ok(api); + const orgA = expectAccepted(api.parseTenantScopeV1(organizationA)); + const wsA = expectAccepted(api.parseTenantScopeV1(workspaceA)); + const projA = expectAccepted(api.parseTenantScopeV1(projectA)); + assert.equal(api.tenantScopeKeyV1(orgA), `organization:${ids.organizationA}`); + assert.equal(api.tenantScopeKeyV1(wsA), `workspace:${ids.organizationA}:${ids.workspaceA}`); + assert.equal( + api.tenantScopeKeyV1(projA), + `project:${ids.organizationA}:${ids.workspaceA}:${ids.projectA}`, + ); +}); + test('[IAM-019] narrowing permits descendants but never parents or siblings', async () => { const api = await loadTenantScope(); assert.ok(api); From cbc4409367dd2ffbcfaa19643a60485e8179a974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:44:11 +0700 Subject: [PATCH 18/55] refactor(audit): use canonical tenant scope keys --- packages/domain/src/audit/v1.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/domain/src/audit/v1.ts b/packages/domain/src/audit/v1.ts index 8210c396..f23b8c3d 100644 --- a/packages/domain/src/audit/v1.ts +++ b/packages/domain/src/audit/v1.ts @@ -2,6 +2,7 @@ import { parseStableIdentifierV1, parseStrictUtcTimestampV1, parseTenantScopeV1, + tenantScopeKeyV1, type StableIdentifierV1, type StrictUtcTimestampV1, type TenantScopeV1, @@ -157,13 +158,6 @@ function positiveInteger(input: unknown): number | undefined { return typeof input === 'number' && Number.isSafeInteger(input) && input >= 1 ? input : undefined; } -function scopeKey(scope: TenantScopeV1): string { - if (scope.scopeType === 'organization') return `organization:${scope.organizationId}`; - if (scope.scopeType === 'workspace') - return `workspace:${scope.organizationId}:${scope.workspaceId}`; - return `project:${scope.organizationId}:${scope.workspaceId}:${scope.projectId}`; -} - function canonicalSummary(summary: AuditSummaryV1): string { return JSON.stringify( Object.fromEntries( @@ -244,7 +238,8 @@ export function appendAuditEventV1( const existing = state.events.find( (event) => - event.idempotencyKey === idempotencyKey && scopeKey(event.tenantScope) === scopeKey(scope), + event.idempotencyKey === idempotencyKey && + tenantScopeKeyV1(event.tenantScope) === tenantScopeKeyV1(scope), ); if (existing) { return existing.eventId === eventId @@ -253,7 +248,7 @@ export function appendAuditEventV1( } const scopedEvents = state.events.filter( - (event) => scopeKey(event.tenantScope) === scopeKey(scope), + (event) => tenantScopeKeyV1(event.tenantScope) === tenantScopeKeyV1(scope), ); const previous = scopedEvents.at(-1); const sequence = (previous?.sequence ?? 0) + 1; @@ -291,7 +286,7 @@ export function verifyAuditChainV1( ): AuditResultV1 { const byScope = new Map(); for (const event of events) { - const key = scopeKey(event.tenantScope); + const key = tenantScopeKeyV1(event.tenantScope); const list = byScope.get(key) ?? []; list.push(event); byScope.set(key, list); @@ -332,7 +327,7 @@ export function createAuditSealV1( if (!scope) return rejected('INVALID_SCOPE'); if (!sealedAt) return rejected('INVALID_TIMESTAMP'); const scopedEvents = events - .filter((event) => scopeKey(event.tenantScope) === scopeKey(scope)) + .filter((event) => tenantScopeKeyV1(event.tenantScope) === tenantScopeKeyV1(scope)) .sort((left, right) => left.sequence - right.sequence); if (scopedEvents.length === 0) return rejected('INVALID_SEQUENCE'); const chain = verifyAuditChainV1(scopedEvents, digestPort); From e640373ae83066b1c11a109b14cb5c55de1f59bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:45:00 +0700 Subject: [PATCH 19/55] refactor(aud): share page input validation --- .../in-memory-audit-repository.adapter.ts | 19 ++-------- .../prisma-audit-repository.adapter.ts | 19 ++-------- .../src/features/aud/api/audit.controller.ts | 7 ++-- .../aud/application/audit-page-cursor.ts | 35 +++++++++++++------ 4 files changed, 36 insertions(+), 44 deletions(-) diff --git a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts index 94533370..b204d8ae 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts @@ -13,7 +13,7 @@ import type { } from '../application/audit-repository.port.js'; import { createAuditPageCursorV1, - parseAuditPageCursorV1, + auditPageOffsetV1, } from '../application/audit-page-cursor.js'; import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -39,19 +39,6 @@ function cloneSeal(seal: AuditSealV1): AuditSealV1 { return Object.freeze({ ...seal, tenantScope: Object.freeze({ ...seal.tenantScope }) }); } -function pageOffset( - input: AuditPageInputV1, - kind: 'events' | 'seals', - scope: TenantScopeV1, -): number { - if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100) - throw new Error('AUD_PAGE_LIMIT_INVALID'); - if (input.cursor === undefined) return 0; - const parsed = parseAuditPageCursorV1(input.cursor, kind, scope); - if (!parsed.accepted) throw new Error('AUD_CURSOR_INVALID'); - return parsed.offset; -} - /** In-memory adapter with PostgreSQL-equivalent append-only and scope checks. */ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { private events = new Map(); @@ -100,7 +87,7 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { input: AuditPageInputV1, ): Promise> { await Promise.resolve(); - const offset = pageOffset(input, 'events', context.tenantScope); + const offset = auditPageOffsetV1(input, 'events', context.tenantScope); const visible = [...this.events.values()] .filter((event) => visibleInScope(context.tenantScope, event.tenantScope)) .sort((left, right) => @@ -167,7 +154,7 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { input: AuditPageInputV1, ): Promise> { await Promise.resolve(); - const offset = pageOffset(input, 'seals', context.tenantScope); + const offset = auditPageOffsetV1(input, 'seals', context.tenantScope); const visible = [...this.seals.values()] .filter((seal) => visibleInScope(context.tenantScope, seal.tenantScope)) .sort((left, right) => diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index 2a2834ee..fe5e9c4c 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -26,7 +26,7 @@ import type { } from '../application/audit-repository.port.js'; import { createAuditPageCursorV1, - parseAuditPageCursorV1, + auditPageOffsetV1, } from '../application/audit-page-cursor.js'; import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; @@ -302,19 +302,6 @@ function visibilityWhere(scope: TenantScopeV1): Readonly }; } -function pageOffset( - input: AuditPageInputV1, - kind: 'events' | 'seals', - scope: TenantScopeV1, -): number { - if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100) - throw new Error('AUD_PAGE_LIMIT_INVALID'); - if (input.cursor === undefined) return 0; - const parsed = parseAuditPageCursorV1(input.cursor, kind, scope); - if (!parsed.accepted) throw new Error('AUD_CURSOR_INVALID'); - return parsed.offset; -} - class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { public constructor( private readonly client: AuditDatabaseClientV1, @@ -442,7 +429,7 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { context: IamTenantContextV1, input: AuditPageInputV1, ): Promise> { - const offset = pageOffset(input, 'events', context.tenantScope); + const offset = auditPageOffsetV1(input, 'events', context.tenantScope); const rows = await this.client.auditEventRecord.findMany({ where: visibilityWhere(context.tenantScope), orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], @@ -476,7 +463,7 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { context: IamTenantContextV1, input: AuditPageInputV1, ): Promise> { - const offset = pageOffset(input, 'seals', context.tenantScope); + const offset = auditPageOffsetV1(input, 'seals', context.tenantScope); const rows = await this.client.auditSealRecord.findMany({ where: visibilityWhere(context.tenantScope), orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index 96cc053d..d3bb7522 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -17,12 +17,15 @@ import { type RequestTenantContextPortV1, } from '../../../platform/http/request-tenant-context.port.js'; import { AuditProblemError } from '../application/audit-problem.error.js'; -import { parseAuditPageCursorV1 } from '../application/audit-page-cursor.js'; +import { + AUDIT_PAGE_LIMIT_MAX_V1, + parseAuditPageCursorV1, +} from '../application/audit-page-cursor.js'; import { InputValidationException } from '../../../platform/http/input-validation.exception.js'; function pageLimit(input: string | undefined): number { const value = input === undefined ? 50 : Number(input); - if (!Number.isSafeInteger(value) || value < 1 || value > 100) { + if (!Number.isSafeInteger(value) || value < 1 || value > AUDIT_PAGE_LIMIT_MAX_V1) { throw new InputValidationException([{ field: 'limit', code: 'INVALID_PAGE_LIMIT' }]); } return value; diff --git a/services/api/src/features/aud/application/audit-page-cursor.ts b/services/api/src/features/aud/application/audit-page-cursor.ts index 271a6127..bff19227 100644 --- a/services/api/src/features/aud/application/audit-page-cursor.ts +++ b/services/api/src/features/aud/application/audit-page-cursor.ts @@ -1,6 +1,11 @@ -import type { TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; +import { + tenantScopeKeyV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { AuditPageInputV1 } from './audit-repository.port.js'; export type AuditPageKindV1 = 'events' | 'seals'; +export const AUDIT_PAGE_LIMIT_MAX_V1 = 100 as const; export type AuditPageCursorResultV1 = | { readonly accepted: true; readonly offset: number } @@ -8,13 +13,6 @@ export type AuditPageCursorResultV1 = const MAX_CURSOR_LENGTH_V1 = 512; -function scopeKey(scope: TenantScopeV1): string { - if (scope.scopeType === 'organization') return `organization:${scope.organizationId}`; - if (scope.scopeType === 'workspace') - return `workspace:${scope.organizationId}:${scope.workspaceId}`; - return `project:${scope.organizationId}:${scope.workspaceId}:${scope.projectId}`; -} - function rejected(): AuditPageCursorResultV1 { return Object.freeze({ accepted: false, code: 'INVALID_CURSOR' }); } @@ -26,7 +24,7 @@ export function createAuditPageCursorV1( ): string { if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('AUD_CURSOR_OFFSET_INVALID'); return Buffer.from( - JSON.stringify({ version: 1, kind, scope: scopeKey(scope), offset }), + JSON.stringify({ version: 1, kind, scope: tenantScopeKeyV1(scope), offset }), 'utf8', ).toString('base64url'); } @@ -52,7 +50,7 @@ export function parseAuditPageCursorV1( Object.keys(record).sort().join(',') !== 'kind,offset,scope,version' || record['version'] !== 1 || record['kind'] !== kind || - record['scope'] !== scopeKey(scope) || + record['scope'] !== tenantScopeKeyV1(scope) || !Number.isSafeInteger(record['offset']) || (record['offset'] as number) < 0 ) @@ -62,3 +60,20 @@ export function parseAuditPageCursorV1( return rejected(); } } + +export function auditPageOffsetV1( + input: AuditPageInputV1, + kind: AuditPageKindV1, + scope: TenantScopeV1, +): number { + if ( + !Number.isSafeInteger(input.limit) || + input.limit < 1 || + input.limit > AUDIT_PAGE_LIMIT_MAX_V1 + ) + throw new Error('AUD_PAGE_LIMIT_INVALID'); + if (input.cursor === undefined) return 0; + const parsed = parseAuditPageCursorV1(input.cursor, kind, scope); + if (!parsed.accepted) throw new Error('AUD_CURSOR_INVALID'); + return parsed.offset; +} From 34129618fcfbce4dfd60df39b34612931f39f661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:45:16 +0700 Subject: [PATCH 20/55] refactor(audit): reuse tenant scope key helper --- .../adapter/prisma-audit-repository.adapter.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index fe5e9c4c..894efdd9 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -13,6 +13,7 @@ import { parseStrictUtcTimestampV1, parseTenantScopeV1, tenantScopeContainsV1, + tenantScopeKeyV1, type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; import { randomUUID } from 'node:crypto'; @@ -126,13 +127,6 @@ function databaseScope(scope: TenantScopeV1) { } as const; } -function scopeKey(scope: TenantScopeV1): string { - if (scope.scopeType === 'organization') return `organization:${scope.organizationId}`; - if (scope.scopeType === 'workspace') - return `workspace:${scope.organizationId}:${scope.workspaceId}`; - return `project:${scope.organizationId}:${scope.workspaceId}:${scope.projectId}`; -} - function persistedScope(row: { readonly scopeType: string; readonly organizationId: string; @@ -248,7 +242,7 @@ function eventCreateData(event: AuditEventV1): AuditEventCreateDataV1 { id: event.eventId, schemaVersion: event.schemaVersion, action: event.action, - scopeKey: scopeKey(event.tenantScope), + scopeKey: tenantScopeKeyV1(event.tenantScope), actorType: event.actor.actorType, actorId: event.actor.actorId, entityType: event.entityType, @@ -270,7 +264,7 @@ function sealCreateData(seal: AuditSealV1): AuditSealCreateDataV1 { ...databaseScope(seal.tenantScope), id: randomUUID(), schemaVersion: seal.schemaVersion, - scopeKey: scopeKey(seal.tenantScope), + scopeKey: tenantScopeKeyV1(seal.tenantScope), firstSequence: seal.firstSequence, lastSequence: seal.lastSequence, eventCount: seal.eventCount, @@ -325,7 +319,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { if (!sameAuditEventV1(current, event)) throw new Error('AUD_IMMUTABLE_EVENT'); return current; } - const eventScopeKey = scopeKey(event.tenantScope); + const eventScopeKey = tenantScopeKeyV1(event.tenantScope); const [duplicate, latest] = await Promise.all([ this.client.auditEventRecord.findFirst({ where: { scopeKey: eventScopeKey, idempotencyKey: event.idempotencyKey }, @@ -366,7 +360,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { if (!tenantScopeContainsV1(context.tenantScope, scope)) throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); const rows = await this.client.auditEventRecord.findMany({ - where: { scopeKey: scopeKey(scope) }, + where: { scopeKey: tenantScopeKeyV1(scope) }, orderBy: { sequence: 'asc' }, }); const events = rows.map(persistedEvent); @@ -380,7 +374,7 @@ class PrismaAuditTransactionAdapter implements AuditTransactionPortV1 { throw new Error('AUD_SCOPE_NARROWING_REQUIRED'); const existing = await this.client.auditSealRecord.findFirst({ where: { - scopeKey: scopeKey(seal.tenantScope), + scopeKey: tenantScopeKeyV1(seal.tenantScope), firstSequence: seal.firstSequence, lastSequence: seal.lastSequence, }, From 66b3548019bfc73c910163743f3feb1038e86baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:46:07 +0700 Subject: [PATCH 21/55] fix(aud): align paginated chain ordering --- .../in-memory-audit-repository.adapter.ts | 29 +++++++++++-------- .../prisma-audit-repository.adapter.ts | 4 +-- .../aud/application/audit-repository.port.ts | 8 +++-- .../aud/prisma-audit-repository.test.ts | 1 - 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts index b204d8ae..3f890b03 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts @@ -1,4 +1,5 @@ import { + tenantScopeKeyV1, tenantScopeContainsV1, type AuditEventV1, type AuditSealV1, @@ -90,11 +91,12 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { const offset = auditPageOffsetV1(input, 'events', context.tenantScope); const visible = [...this.events.values()] .filter((event) => visibleInScope(context.tenantScope, event.tenantScope)) - .sort((left, right) => - left.occurredAt === right.occurredAt - ? left.eventId.localeCompare(right.eventId) - : left.occurredAt.localeCompare(right.occurredAt), - ); + .sort((left, right) => { + const scopeOrder = tenantScopeKeyV1(left.tenantScope).localeCompare( + tenantScopeKeyV1(right.tenantScope), + ); + return scopeOrder || left.sequence - right.sequence || left.eventId.localeCompare(right.eventId); + }); const items = visible.slice(offset, offset + input.limit).map(cloneEvent); return Object.freeze({ items: Object.freeze(items), @@ -157,11 +159,16 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { const offset = auditPageOffsetV1(input, 'seals', context.tenantScope); const visible = [...this.seals.values()] .filter((seal) => visibleInScope(context.tenantScope, seal.tenantScope)) - .sort((left, right) => - left.sealedAt === right.sealedAt - ? left.rootDigest.localeCompare(right.rootDigest) - : left.sealedAt.localeCompare(right.sealedAt), - ); + .sort((left, right) => { + const scopeOrder = tenantScopeKeyV1(left.tenantScope).localeCompare( + tenantScopeKeyV1(right.tenantScope), + ); + return ( + scopeOrder || + left.lastSequence - right.lastSequence || + left.rootDigest.localeCompare(right.rootDigest) + ); + }); const items = visible.slice(offset, offset + input.limit).map(cloneSeal); return Object.freeze({ items: Object.freeze(items), @@ -192,10 +199,8 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { try { return await work({ appendEvent: this.appendEvent.bind(this), - listEvents: this.listEvents.bind(this), listEventsForScope: this.listEventsForScope.bind(this), saveSeal: this.saveSeal.bind(this), - listSeals: this.listSeals.bind(this), }); } catch (error) { this.events = beforeEvents; diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index 894efdd9..a960f398 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -426,7 +426,7 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { const offset = auditPageOffsetV1(input, 'events', context.tenantScope); const rows = await this.client.auditEventRecord.findMany({ where: visibilityWhere(context.tenantScope), - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + orderBy: [{ scopeKey: 'asc' }, { sequence: 'asc' }, { id: 'asc' }], skip: offset, take: input.limit + 1, }); @@ -460,7 +460,7 @@ export class PrismaAuditRepositoryAdapter implements AuditRepositoryPortV1 { const offset = auditPageOffsetV1(input, 'seals', context.tenantScope); const rows = await this.client.auditSealRecord.findMany({ where: visibilityWhere(context.tenantScope), - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + orderBy: [{ scopeKey: 'asc' }, { lastSequence: 'asc' }, { id: 'asc' }], skip: offset, take: input.limit + 1, }); diff --git a/services/api/src/features/aud/application/audit-repository.port.ts b/services/api/src/features/aud/application/audit-repository.port.ts index ac4650f3..2bb92343 100644 --- a/services/api/src/features/aud/application/audit-repository.port.ts +++ b/services/api/src/features/aud/application/audit-repository.port.ts @@ -15,15 +15,19 @@ export interface AuditPageV1 { readonly nextCursor?: string; } +/** + * Paginated events are ordered by tenant scope key, chain sequence, and event id; + * seals use tenant scope key, last sequence, and seal id. The tie-breakers keep + * in-memory and PostgreSQL adapters cursor-compatible. + */ + export interface AuditTransactionPortV1 { appendEvent(context: IamTenantContextV1, event: AuditEventV1): Promise; - listEvents(context: IamTenantContextV1): Promise; listEventsForScope( context: IamTenantContextV1, scope: TenantScopeV1, ): Promise; saveSeal(context: IamTenantContextV1, seal: AuditSealV1): Promise; - listSeals(context: IamTenantContextV1): Promise; } export interface AuditRepositoryPortV1 extends AuditTransactionPortV1 { diff --git a/services/api/test/features/aud/prisma-audit-repository.test.ts b/services/api/test/features/aud/prisma-audit-repository.test.ts index 9a6ac157..f690a808 100644 --- a/services/api/test/features/aud/prisma-audit-repository.test.ts +++ b/services/api/test/features/aud/prisma-audit-repository.test.ts @@ -212,7 +212,6 @@ void test('[AUD-002] Prisma audit transactions do not retain an event when the u assert.equal(created.accepted, true); if (!created.accepted) return; await transaction.appendEvent(context(workspaceId, 'nested'), created.value.event); - await transaction.listEvents(context(workspaceId, 'inspect')); throw new Error('rollback-me'); }), /rollback-me/u, From 68632db9565d95ec1fe7d7fe1a9c461b68a9dea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:46:19 +0700 Subject: [PATCH 22/55] test(aud): cover forged pagination cursors --- .../test/features/aud/audit-page-cursor.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/api/test/features/aud/audit-page-cursor.test.ts b/services/api/test/features/aud/audit-page-cursor.test.ts index 846a25c3..19dd1a2e 100644 --- a/services/api/test/features/aud/audit-page-cursor.test.ts +++ b/services/api/test/features/aud/audit-page-cursor.test.ts @@ -49,3 +49,19 @@ void test('[AUD-001] audit page cursors fail closed for malformed or oversized v }); } }); + +void test('[AUD-001] audit page cursors reject forged offsets and unknown fields', () => { + const forge = (payload: Record) => + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); + const scope = `workspace:${organizationId}:${workspaceId}`; + for (const payload of [ + { version: 1, kind: 'events', scope, offset: -1 }, + { version: 1, kind: 'events', scope, offset: 1.5 }, + { version: 1, kind: 'events', scope, offset: 0, extra: 'x' }, + ]) { + assert.deepEqual(parseAuditPageCursorV1(forge(payload), 'events', workspaceScope), { + accepted: false, + code: 'INVALID_CURSOR', + }); + } +}); From 2406de70377c1aad065ce57e88c83e7fc0d45fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:46:39 +0700 Subject: [PATCH 23/55] fix(aud): fail closed on integrity errors --- services/api/src/features/aud/api/audit.controller.ts | 10 ++++++++-- .../features/aud/application/audit-problem.error.ts | 2 +- .../api/src/platform/http/problem-details.filter.ts | 9 ++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index d3bb7522..ff23ae9b 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -63,7 +63,10 @@ export class AuditController { limit, ...(cursor === undefined ? {} : { cursor }), }); - } catch { + } catch (error) { + if (error instanceof Error && error.message === 'AUD_CHAIN_INVALID') { + throw new AuditProblemError('AUDIT_INTEGRITY_INVALID'); + } throw new AuditProblemError('AUDIT_UNAVAILABLE'); } } @@ -91,7 +94,10 @@ export class AuditController { limit, ...(cursor === undefined ? {} : { cursor }), }); - } catch { + } catch (error) { + if (error instanceof Error && error.message === 'AUD_CHAIN_INVALID') { + throw new AuditProblemError('AUDIT_INTEGRITY_INVALID'); + } throw new AuditProblemError('AUDIT_UNAVAILABLE'); } } diff --git a/services/api/src/features/aud/application/audit-problem.error.ts b/services/api/src/features/aud/application/audit-problem.error.ts index 3dc235a8..61b181f7 100644 --- a/services/api/src/features/aud/application/audit-problem.error.ts +++ b/services/api/src/features/aud/application/audit-problem.error.ts @@ -1,5 +1,5 @@ export class AuditProblemError extends Error { - public constructor(readonly code: 'AUDIT_UNAVAILABLE') { + public constructor(readonly code: 'AUDIT_UNAVAILABLE' | 'AUDIT_INTEGRITY_INVALID') { super(code); this.name = 'AuditProblemError'; } diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index febe07a1..97940fca 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -98,12 +98,15 @@ function describe(error: unknown, correlationId: string): ProblemInput { }; } if (error instanceof AuditProblemError) { + const integrityInvalid = error.code === 'AUDIT_INTEGRITY_INVALID'; return { code: error.code, correlationId, - messageKey: 'api.error.audit_unavailable', - retryable: true, - status: HttpStatus.SERVICE_UNAVAILABLE, + messageKey: integrityInvalid + ? 'api.error.audit_integrity_invalid' + : 'api.error.audit_unavailable', + retryable: !integrityInvalid, + status: integrityInvalid ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.SERVICE_UNAVAILABLE, }; } if (error instanceof ArtifactExportProblemError) { From 00e688c901f9d821eedfe4b3dae050dcaf90f1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:46:55 +0700 Subject: [PATCH 24/55] test(api): classify audit integrity failures --- services/api/test/http-contract.test.ts | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index d20e9340..0a3401c6 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -759,6 +759,43 @@ void test('audit read outages return retryable service-unavailable problems', as ); }); +void test('audit integrity failures are non-retryable and do not look transient', async () => { + const auditRepository = Object.assign(new InMemoryAuditRepositoryAdapter(), { + listEventPage: () => Promise.reject(new Error('AUD_CHAIN_INVALID')), + listSealPage: () => Promise.reject(new Error('AUD_CHAIN_INVALID')), + }); + const principal = { + userId: '00000000-0000-4000-8000-000000000001', + organizationId: '00000000-0000-4000-8000-000000000002', + workspaceId: '00000000-0000-4000-8000-000000000003', + securityEpoch: 1, + mfaRequired: false, + } as const; + await withApp( + { + auditRepository, + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(principal), + findPrincipalByAccessToken: () => Promise.resolve(principal), + }, + }, + async (app) => { + for (const url of ['/v1/audit/events', '/v1/audit/seals']) { + const response = await app.inject({ + method: 'GET', + url, + headers: { authorization: 'Bearer audit-access-token-123456789' }, + }); + assertProblem(response, 500, 'AUDIT_INTEGRITY_INVALID'); + assert.doesNotMatch(response.body, /AUD_CHAIN_INVALID/u); + } + }, + ); +}); + void test('MFA HTTP lifecycle derives the user from the authenticated tenant context and returns redacted state', async () => { const actorId = '00000000-0000-4000-8000-000000000001'; const mfaService = new MfaService( From 89002da8fa07cd4302d9b2b824491d58c168ad56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:48:59 +0700 Subject: [PATCH 25/55] docs(api): describe paginated audit responses --- services/api/openapi/v1.json | 32 +++++++++++++++++++ .../src/features/aud/api/audit.controller.ts | 14 ++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index da8befd7..33259bec 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6772,6 +6772,22 @@ "responses": { "200": { "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "nextCursor": { "type": "string", "maxLength": 512 } + }, + "additionalProperties": false + } + } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", @@ -6865,6 +6881,22 @@ "responses": { "200": { "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "nextCursor": { "type": "string", "maxLength": 512 } + }, + "additionalProperties": false + } + } + }, "headers": { "X-Correlation-Id": { "description": "Stable UUID that correlates related requests and errors.", diff --git a/services/api/src/features/aud/api/audit.controller.ts b/services/api/src/features/aud/api/audit.controller.ts index ff23ae9b..fcc62655 100644 --- a/services/api/src/features/aud/api/audit.controller.ts +++ b/services/api/src/features/aud/api/audit.controller.ts @@ -31,6 +31,16 @@ function pageLimit(input: string | undefined): number { return value; } +const AUDIT_PAGE_RESPONSE_SCHEMA = { + type: 'object', + required: ['items'] as string[], + properties: { + items: { type: 'array', items: { type: 'object', additionalProperties: true } }, + nextCursor: { type: 'string', maxLength: 512 }, + }, + additionalProperties: false, +}; + @ApiTags('audit') @ApiBearerAuth() @Controller('v1/audit') @@ -42,7 +52,7 @@ export class AuditController { @Get('events') @ApiOperation({ summary: 'List immutable audit events visible to the caller' }) - @ApiOkResponse() + @ApiOkResponse({ schema: AUDIT_PAGE_RESPONSE_SCHEMA }) @ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 }) @ApiQuery({ name: 'cursor', required: false, type: String, maxLength: 512 }) @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) @@ -73,7 +83,7 @@ export class AuditController { @Get('seals') @ApiOperation({ summary: 'List verified audit seals visible to the caller' }) - @ApiOkResponse() + @ApiOkResponse({ schema: AUDIT_PAGE_RESPONSE_SCHEMA }) @ApiQuery({ name: 'limit', required: false, type: Number, minimum: 1, maximum: 100 }) @ApiQuery({ name: 'cursor', required: false, type: String, maxLength: 512 }) @ApiServiceUnavailableResponse({ description: 'Audit persistence is unavailable.' }) From 1add34b968267fe7ecfd0a614c72f6c8debde011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:49:32 +0700 Subject: [PATCH 26/55] docs(api): publish entitlement success responses --- services/api/openapi/v1.json | 48 +++++++++++++++++++ .../bua/api/entitlement.controller.ts | 18 +++++++ 2 files changed, 66 insertions(+) diff --git a/services/api/openapi/v1.json b/services/api/openapi/v1.json index 33259bec..24a6ccda 100644 --- a/services/api/openapi/v1.json +++ b/services/api/openapi/v1.json @@ -6977,6 +6977,22 @@ } ], "responses": { + "200": { + "description": "", + "content": { + "application/json": { "schema": { "type": "object", "additionalProperties": true } } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, "400": { "description": "The snapshot identifier is invalid.", "headers": { @@ -7053,6 +7069,38 @@ } ], "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["entries", "reservations"], + "properties": { + "entries": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + }, + "reservations": { + "type": "array", + "items": { "type": "object", "additionalProperties": true } + } + }, + "additionalProperties": false + } + } + }, + "headers": { + "X-Correlation-Id": { + "description": "Stable UUID that correlates related requests and errors.", + "schema": { "format": "uuid", "type": "string" } + }, + "X-Request-Id": { + "description": "Unique UUID generated for this HTTP request.", + "schema": { "format": "uuid", "type": "string" } + } + } + }, "400": { "description": "The request was malformed or failed closed validation.", "content": { diff --git a/services/api/src/features/bua/api/entitlement.controller.ts b/services/api/src/features/bua/api/entitlement.controller.ts index 0b242308..82ee4876 100644 --- a/services/api/src/features/bua/api/entitlement.controller.ts +++ b/services/api/src/features/bua/api/entitlement.controller.ts @@ -3,6 +3,7 @@ import { ApiBadRequestResponse, ApiBearerAuth, ApiNotFoundResponse, + ApiOkResponse, ApiOperation, ApiServiceUnavailableResponse, ApiTags, @@ -20,6 +21,21 @@ import { } from '../../../platform/http/request-tenant-context.port.js'; import { EntitlementProblemError } from '../application/entitlement-problem.error.js'; +const ENTITLEMENT_SNAPSHOT_RESPONSE_SCHEMA = { + type: 'object', + additionalProperties: true, +}; + +const USAGE_LEDGER_RESPONSE_SCHEMA = { + type: 'object', + required: ['entries', 'reservations'] as string[], + properties: { + entries: { type: 'array', items: { type: 'object', additionalProperties: true } }, + reservations: { type: 'array', items: { type: 'object', additionalProperties: true } }, + }, + additionalProperties: false, +}; + @ApiTags('entitlements') @ApiBearerAuth() @Controller('v1/entitlements') @@ -33,6 +49,7 @@ export class EntitlementController { @Get('snapshots/:snapshotId') @ApiOperation({ summary: 'Read one immutable entitlement snapshot in the caller scope' }) + @ApiOkResponse({ schema: ENTITLEMENT_SNAPSHOT_RESPONSE_SCHEMA }) @ApiBadRequestResponse({ description: 'The snapshot identifier is invalid.' }) @ApiNotFoundResponse({ description: 'The entitlement snapshot is not visible.' }) @ApiServiceUnavailableResponse({ description: 'Entitlement persistence is unavailable.' }) @@ -55,6 +72,7 @@ export class EntitlementController { @Get('usage') @ApiOperation({ summary: 'Read the append-only usage ledger state in the caller scope' }) + @ApiOkResponse({ schema: USAGE_LEDGER_RESPONSE_SCHEMA }) @ApiServiceUnavailableResponse({ description: 'Usage persistence is unavailable.' }) async usage(@Req() request: unknown): Promise { const context = await this.requestContext.resolve(request); From 28268b100dcf68d86291da9f2b16d3eef2d03646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:49:59 +0700 Subject: [PATCH 27/55] fix(iam): fence cross-tenant membership collisions --- .../adapter/prisma-iam-repository.adapter.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index 87a75667..e9c9e7fa 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -136,6 +136,15 @@ function scopeSpecificity(scope: TenantScopeV1): number { return 1; } +function isUniqueConstraintViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { readonly code?: unknown }).code === 'P2002' + ); +} + class PrismaIamTransactionAdapter implements IamTransactionPortV1 { public constructor(private readonly client: IamTransactionDatabaseClientV1) {} @@ -188,15 +197,23 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { throw new Error('IAM_SCOPE_NARROWING_REQUIRED'); const validated = validateMembershipV1({ ...membership, principalType: 'USER' }); if (!validated.accepted) throw new Error(`IAM_${validated.code}`); - const existingRow = await this.client.membershipIdentity.findFirst({ - where: { - id: membership.id, - organizationId: context.tenantScope.organizationId, - }, + const existingById = await this.client.membershipIdentity.findFirst({ + where: { id: membership.id }, }); + if ( + existingById !== null && + existingById.organizationId !== context.tenantScope.organizationId + ) + throw new Error('IAM_REVISION_CONFLICT'); + const existingRow = existingById; if (!existingRow) { if (context.expectedRevision !== undefined) throw new Error('IAM_REVISION_CONFLICT'); - await this.client.membershipIdentity.create({ data: membershipRow(validated.value) }); + try { + await this.client.membershipIdentity.create({ data: membershipRow(validated.value) }); + } catch (error) { + if (isUniqueConstraintViolation(error)) throw new Error('IAM_REVISION_CONFLICT'); + throw error; + } return; } const existing = membershipFromRow(existingRow); From cf159c7885215f27ec707b88539d69c40d0d527b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:50:02 +0700 Subject: [PATCH 28/55] test(iam): cover membership identity collisions --- .../iam/prisma-iam-repository.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 942780ff..8405c5a7 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -280,5 +280,26 @@ void test('[IAM-009, IAM-019] Prisma membership mutation lookup includes tenant revision: 1, }); - assert.deepEqual(firstQueries, [{ id: stable('23'), organizationId }]); + assert.deepEqual(firstQueries, [{ id: stable('23') }]); +}); + +void test('[IAM-009] Prisma membership writes reject cross-organization identifier collisions', async () => { + const foreignOrganizationId = id('99'); + const foreign = { ...row(id('24'), 'WORKSPACE', workspaceId, 'viewer'), organizationId: foreignOrganizationId }; + const { client, memberships } = createDatabase([foreign]); + const repository = new PrismaIamRepositoryAdapter(client); + const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; + + await assert.rejects( + repository.saveMembership(context(workspaceScope), { + id: stable('24'), + principalId, + scope: workspaceScope, + roleId: 'operator', + status: 'ACTIVE', + revision: 1, + }), + /IAM_REVISION_CONFLICT/u, + ); + assert.equal(memberships.get(id('24'))?.organizationId, foreignOrganizationId); }); From f54976d54550440ba0945367a60c0b20e1d1c5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:50:46 +0700 Subject: [PATCH 29/55] refactor(iam): share membership authority selection --- .../in-memory-iam-repository.adapter.ts | 20 ++----------- .../adapter/prisma-iam-repository.adapter.ts | 25 +++++----------- .../iam/application/membership-authority.ts | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+), 36 deletions(-) create mode 100644 services/api/src/features/iam/application/membership-authority.ts diff --git a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts index 3e5c0650..a0ae8ca5 100644 --- a/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/in-memory-iam-repository.adapter.ts @@ -10,17 +10,12 @@ import type { IamTransactionPortV1, } from '../application/iam-repository.port.js'; import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import { selectAuthoritativeMembership } from '../application/membership-authority.js'; function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): boolean { return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); } -function scopeSpecificity(scope: TenantScopeV1): number { - if (scope.scopeType === 'project') return 3; - if (scope.scopeType === 'workspace') return 2; - return 1; -} - function cloneMemberships(source: readonly IamMembershipRecordV1[]): IamMembershipRecordV1[] { return source.map((membership) => Object.freeze({ ...membership, scope: { ...membership.scope } }), @@ -41,18 +36,7 @@ export class InMemoryIamRepositoryAdapter implements IamRepositoryPortV1 { principalId: StableIdentifierV1, ): Promise { await Promise.resolve(); - return this.memberships - .filter( - (membership) => - membership.principalId === principalId && - membership.status === 'ACTIVE' && - tenantScopeContainsV1(membership.scope, context.tenantScope), - ) - .sort( - (left, right) => - scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || - left.id.localeCompare(right.id), - )[0]; + return selectAuthoritativeMembership(this.memberships, context, principalId); } async listMemberships(context: IamTenantContextV1): Promise { diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index e9c9e7fa..c2ffe5cb 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -13,6 +13,7 @@ import type { IamTransactionPortV1, } from '../application/iam-repository.port.js'; import type { IamTenantContextV1 } from '../application/tenant-context.js'; +import { selectAuthoritativeMembership } from '../application/membership-authority.js'; export interface IamMembershipDatabaseRowV1 { readonly id: string; @@ -130,12 +131,6 @@ function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): bool return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); } -function scopeSpecificity(scope: TenantScopeV1): number { - if (scope.scopeType === 'project') return 3; - if (scope.scopeType === 'workspace') return 2; - return 1; -} - function isUniqueConstraintViolation(error: unknown): boolean { return ( typeof error === 'object' && @@ -160,20 +155,14 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { }, orderBy: { id: 'asc' }, }); - return rows + return selectAuthoritativeMembership( + rows .map(membershipFromRowOrSkip) .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) - .filter( - (membership) => - membership.principalId === principalId && - membership.status === 'ACTIVE' && - tenantScopeContainsV1(membership.scope, context.tenantScope), - ) - .sort( - (left, right) => - scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || - left.id.localeCompare(right.id), - )[0]; + , + context, + principalId, + ); } public async listMemberships( diff --git a/services/api/src/features/iam/application/membership-authority.ts b/services/api/src/features/iam/application/membership-authority.ts new file mode 100644 index 00000000..21ab54e3 --- /dev/null +++ b/services/api/src/features/iam/application/membership-authority.ts @@ -0,0 +1,30 @@ +import { tenantScopeContainsV1, type StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamMembershipRecordV1 } from './iam-repository.port.js'; +import type { IamTenantContextV1 } from './tenant-context.js'; + +function scopeSpecificity(scope: IamMembershipRecordV1['scope']): number { + if (scope.scopeType === 'project') return 3; + if (scope.scopeType === 'workspace') return 2; + return 1; +} + +/** Select the deterministic active authority that contains the requested tenant scope. */ +export function selectAuthoritativeMembership( + memberships: readonly IamMembershipRecordV1[], + context: IamTenantContextV1, + principalId: StableIdentifierV1, +): IamMembershipRecordV1 | undefined { + return memberships + .filter( + (membership) => + membership.principalId === principalId && + membership.status === 'ACTIVE' && + tenantScopeContainsV1(membership.scope, context.tenantScope), + ) + .sort( + (left, right) => + scopeSpecificity(right.scope) - scopeSpecificity(left.scope) || + left.id.localeCompare(right.id), + )[0]; +} From d8a2f13f28dce682449e7f3feabcd14462d5cbe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:51:03 +0700 Subject: [PATCH 30/55] test(iam): lock shared authority ordering --- .../features/iam/membership-authority.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 services/api/test/features/iam/membership-authority.test.ts diff --git a/services/api/test/features/iam/membership-authority.test.ts b/services/api/test/features/iam/membership-authority.test.ts new file mode 100644 index 00000000..528d648f --- /dev/null +++ b/services/api/test/features/iam/membership-authority.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { selectAuthoritativeMembership } from '../../../src/features/iam/application/membership-authority.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; + +const organizationId = '00000000-0000-4000-8000-000000000201'; +const workspaceId = '00000000-0000-4000-8000-000000000202'; +const projectId = '00000000-0000-4000-8000-000000000203'; +const principalId = '00000000-0000-4000-8000-000000000204'; + +function context() { + const parsed = createIamTenantContextV1({ + tenantScope: { scopeType: 'project', organizationId, workspaceId, projectId }, + actorId: principalId, + correlationId: '00000000-0000-4000-8000-000000000205', + idempotencyKey: 'membership-authority-test', + authorizationEpoch: 1, + }); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid membership authority context'); + return parsed.value; +} + +void test('[IAM-003, IAM-014] shared membership authority picks the narrowest active scope', () => { + const selected = selectAuthoritativeMembership( + [ + { + id: '00000000-0000-4000-8000-000000000212', + principalId, + scope: { scopeType: 'organization', organizationId }, + roleId: 'owner', + status: 'ACTIVE', + revision: 1, + }, + { + id: '00000000-0000-4000-8000-000000000211', + principalId, + scope: { scopeType: 'workspace', organizationId, workspaceId }, + roleId: 'viewer', + status: 'ACTIVE', + revision: 1, + }, + { + id: '00000000-0000-4000-8000-000000000213', + principalId, + scope: { scopeType: 'project', organizationId, workspaceId, projectId }, + roleId: 'operator', + status: 'ACTIVE', + revision: 1, + }, + ], + context(), + principalId, + ); + assert.equal(selected?.roleId, 'operator'); +}); From 56df707180877dd102f5addb1183736c637bfad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:52:24 +0700 Subject: [PATCH 31/55] perf(iam): push membership visibility into Prisma --- .../adapter/prisma-iam-repository.adapter.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index c2ffe5cb..48b72629 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -131,6 +131,29 @@ function visibleInScope(context: TenantScopeV1, membership: TenantScopeV1): bool return tenantScopeContainsV1(context, membership) || tenantScopeContainsV1(membership, context); } +function membershipVisibilityWhere(context: IamTenantContextV1): Readonly> { + const scope = context.tenantScope; + if (scope.scopeType === 'organization') return { organizationId: scope.organizationId }; + if (scope.scopeType === 'workspace') { + return { + organizationId: scope.organizationId, + OR: [ + { scopeType: 'ORGANIZATION' }, + { scopeType: 'WORKSPACE', workspaceId: scope.workspaceId }, + { scopeType: 'PROJECT', workspaceId: scope.workspaceId }, + ], + }; + } + return { + organizationId: scope.organizationId, + OR: [ + { scopeType: 'ORGANIZATION' }, + { scopeType: 'WORKSPACE', workspaceId: scope.workspaceId }, + { scopeType: 'PROJECT', projectId: scope.projectId }, + ], + }; +} + function isUniqueConstraintViolation(error: unknown): boolean { return ( typeof error === 'object' && @@ -169,7 +192,7 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { context: IamTenantContextV1, ): Promise { const rows = await this.client.membershipIdentity.findMany({ - where: { organizationId: context.tenantScope.organizationId }, + where: membershipVisibilityWhere(context), orderBy: { id: 'asc' }, }); return rows From 0e13e9597307b33464371206800eaf0ccd749334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:52:28 +0700 Subject: [PATCH 32/55] test(iam): assert scoped membership queries --- .../features/iam/membership-authority.test.ts | 48 ++++++++++++++----- .../iam/prisma-iam-repository.test.ts | 46 +++++++++++++----- 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/services/api/test/features/iam/membership-authority.test.ts b/services/api/test/features/iam/membership-authority.test.ts index 528d648f..7309f3ce 100644 --- a/services/api/test/features/iam/membership-authority.test.ts +++ b/services/api/test/features/iam/membership-authority.test.ts @@ -3,17 +3,30 @@ import test from 'node:test'; import { selectAuthoritativeMembership } from '../../../src/features/iam/application/membership-authority.js'; import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { parseStableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; const organizationId = '00000000-0000-4000-8000-000000000201'; const workspaceId = '00000000-0000-4000-8000-000000000202'; const projectId = '00000000-0000-4000-8000-000000000203'; const principalId = '00000000-0000-4000-8000-000000000204'; +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid membership authority identifier'); + return parsed.value; +} + function context() { const parsed = createIamTenantContextV1({ - tenantScope: { scopeType: 'project', organizationId, workspaceId, projectId }, - actorId: principalId, - correlationId: '00000000-0000-4000-8000-000000000205', + tenantScope: { + scopeType: 'project', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + projectId: stable(projectId), + }, + actorId: stable(principalId), + correlationId: stable('00000000-0000-4000-8000-000000000205'), idempotencyKey: 'membership-authority-test', authorizationEpoch: 1, }); @@ -26,32 +39,41 @@ void test('[IAM-003, IAM-014] shared membership authority picks the narrowest ac const selected = selectAuthoritativeMembership( [ { - id: '00000000-0000-4000-8000-000000000212', - principalId, - scope: { scopeType: 'organization', organizationId }, + id: stable('00000000-0000-4000-8000-000000000212'), + principalId: stable(principalId), + scope: { scopeType: 'organization', organizationId: stable(organizationId) }, roleId: 'owner', status: 'ACTIVE', revision: 1, }, { - id: '00000000-0000-4000-8000-000000000211', - principalId, - scope: { scopeType: 'workspace', organizationId, workspaceId }, + id: stable('00000000-0000-4000-8000-000000000211'), + principalId: stable(principalId), + scope: { + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }, roleId: 'viewer', status: 'ACTIVE', revision: 1, }, { - id: '00000000-0000-4000-8000-000000000213', - principalId, - scope: { scopeType: 'project', organizationId, workspaceId, projectId }, + id: stable('00000000-0000-4000-8000-000000000213'), + principalId: stable(principalId), + scope: { + scopeType: 'project', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + projectId: stable(projectId), + }, roleId: 'operator', status: 'ACTIVE', revision: 1, }, ], context(), - principalId, + stable(principalId), ); assert.equal(selected?.roleId, 'operator'); }); diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 8405c5a7..bd085748 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -71,28 +71,37 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { readonly memberships: Map; readonly forceUpdateConflict: { value: boolean }; readonly firstQueries: ReadonlyArray>>; + readonly manyQueries: ReadonlyArray>>; } { const memberships = new Map(rows.map((value) => [value.id, value])); const forceUpdateConflict = { value: false }; const firstQueries: Array>> = []; + const manyQueries: Array>> = []; + const matches = ( + candidate: IamMembershipDatabaseRowV1, + where: Readonly>, + ): boolean => + Object.entries(where).every(([key, value]) => { + if (key === 'OR' && Array.isArray(value)) { + return value.some( + (alternative) => + typeof alternative === 'object' && + alternative !== null && + matches(candidate, alternative as Readonly>), + ); + } + return candidate[key as keyof IamMembershipDatabaseRowV1] === value; + }); const client = { membershipIdentity: { findFirst: async ({ where }: { readonly where: Readonly> }) => { firstQueries.push(where); return ( - [...memberships.values()].find((candidate) => - Object.entries(where).every( - ([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value, - ), - ) ?? null + [...memberships.values()].find((candidate) => matches(candidate, where)) ?? null ); }, findMany: async ({ where }: { readonly where: Readonly> }) => - [...memberships.values()].filter((candidate) => - Object.entries(where).every( - ([key, value]) => candidate[key as keyof IamMembershipDatabaseRowV1] === value, - ), - ), + (manyQueries.push(where), [...memberships.values()].filter((candidate) => matches(candidate, where))), create: async ({ data }: { readonly data: IamMembershipDatabaseRowV1 }) => { memberships.set(data.id, data); return data; @@ -125,11 +134,11 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { } }, } as unknown as IamDatabaseClientV1; - return { client, memberships, forceUpdateConflict, firstQueries }; + return { client, memberships, forceUpdateConflict, firstQueries, manyQueries }; } void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and hide siblings', async () => { - const { client } = createDatabase([ + const { client, manyQueries } = createDatabase([ row(id('10'), 'WORKSPACE', workspaceId, 'viewer'), row(id('11'), 'WORKSPACE', siblingWorkspaceId, 'owner'), row(id('12'), 'ORGANIZATION', null, 'admin'), @@ -146,6 +155,19 @@ void test('[IAM-009, IAM-019] Prisma IAM membership reads are tenant scoped and (await repository.findMembership(context(workspaceScope), principalId))?.id, stable('10'), ); + assert.ok( + manyQueries.some( + (query) => + Array.isArray(query['OR']) && + query['organizationId'] === organizationId && + (query['OR'] as readonly unknown[]).some( + (candidate) => + typeof candidate === 'object' && + candidate !== null && + (candidate as Record)['scopeType'] === 'PROJECT', + ), + ), + ); }); void test('[IAM-009, IAM-019] malformed membership rows fail closed without blocking valid reads', async () => { From 663fdbc32edd31eb4579747bb6acf4b6d180ada1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:53:25 +0700 Subject: [PATCH 33/55] feat(iam): expose safe malformed-row diagnostics --- .../adapter/prisma-iam-repository.adapter.ts | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index 48b72629..d30b202e 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -101,10 +101,16 @@ function membershipFromRow(row: IamMembershipDatabaseRowV1): IamMembershipRecord function membershipFromRowOrSkip( row: IamMembershipDatabaseRowV1, + onMalformedMembershipRow?: (membershipId: string) => void, ): IamMembershipRecordV1 | undefined { try { return membershipFromRow(row); } catch { + try { + onMalformedMembershipRow?.(row.id); + } catch { + // Diagnostics are best-effort and must not change fail-closed authority selection. + } return undefined; } } @@ -163,8 +169,15 @@ function isUniqueConstraintViolation(error: unknown): boolean { ); } +interface IamRepositoryDiagnosticsV1 { + readonly onMalformedMembershipRow?: (membershipId: string) => void; +} + class PrismaIamTransactionAdapter implements IamTransactionPortV1 { - public constructor(private readonly client: IamTransactionDatabaseClientV1) {} + public constructor( + private readonly client: IamTransactionDatabaseClientV1, + private readonly diagnostics: IamRepositoryDiagnosticsV1 = {}, + ) {} public async findMembership( context: IamTenantContextV1, @@ -180,7 +193,7 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { }); return selectAuthoritativeMembership( rows - .map(membershipFromRowOrSkip) + .map((row) => membershipFromRowOrSkip(row, this.diagnostics.onMalformedMembershipRow)) .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) , context, @@ -196,7 +209,7 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { orderBy: { id: 'asc' }, }); return rows - .map(membershipFromRowOrSkip) + .map((row) => membershipFromRowOrSkip(row, this.diagnostics.onMalformedMembershipRow)) .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) .filter((membership) => visibleInScope(context.tenantScope, membership.scope)); } @@ -249,19 +262,28 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { } export class PrismaIamRepositoryAdapter implements IamRepositoryPortV1 { - public constructor(private readonly client: IamDatabaseClientV1) {} + public constructor( + private readonly client: IamDatabaseClientV1, + private readonly diagnostics: IamRepositoryDiagnosticsV1 = {}, + ) {} public findMembership(context: IamTenantContextV1, principalId: StableIdentifierV1) { - return new PrismaIamTransactionAdapter(this.client).findMembership(context, principalId); + return new PrismaIamTransactionAdapter(this.client, this.diagnostics).findMembership( + context, + principalId, + ); } public listMemberships(context: IamTenantContextV1) { - return new PrismaIamTransactionAdapter(this.client).listMemberships(context); + return new PrismaIamTransactionAdapter(this.client, this.diagnostics).listMemberships(context); } public saveMembership(context: IamTenantContextV1, membership: IamMembershipRecordV1) { return this.client.$transaction((transaction) => - new PrismaIamTransactionAdapter(transaction).saveMembership(context, membership), + new PrismaIamTransactionAdapter(transaction, this.diagnostics).saveMembership( + context, + membership, + ), ); } @@ -270,7 +292,7 @@ export class PrismaIamRepositoryAdapter implements IamRepositoryPortV1 { work: (transaction: IamTransactionPortV1) => Promise, ): Promise { return this.client.$transaction((transaction) => - work(new PrismaIamTransactionAdapter(transaction)), + work(new PrismaIamTransactionAdapter(transaction, this.diagnostics)), ); } } From aab57cb0540456c72e7b893378d9a8b63da84a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:53:29 +0700 Subject: [PATCH 34/55] test(iam): record skipped membership identifiers --- .../api/test/features/iam/prisma-iam-repository.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index bd085748..ee5aecfe 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -176,7 +176,10 @@ void test('[IAM-009, IAM-019] malformed membership rows fail closed without bloc ...row(id('21'), 'WORKSPACE', workspaceId, 'viewer'), workspaceId: 'not-a-workspace-id', }; - const repository = new PrismaIamRepositoryAdapter(createDatabase([valid, malformed]).client); + const skipped: string[] = []; + const repository = new PrismaIamRepositoryAdapter(createDatabase([valid, malformed]).client, { + onMalformedMembershipRow: (membershipId) => skipped.push(membershipId), + }); assert.deepEqual( ( @@ -186,6 +189,7 @@ void test('[IAM-009, IAM-019] malformed membership rows fail closed without bloc ).map((membership) => membership.id), [valid.id], ); + assert.deepEqual(skipped, [malformed.id]); }); void test('[IAM-003, IAM-014] Prisma membership authority chooses the narrowest containing scope', async () => { From f93d44e30d822b591cfe16fe14a8349ce067afde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:55:05 +0700 Subject: [PATCH 35/55] fix(iam): batch and validate bootstrap candidates --- ...a-identity-bootstrap-repository.adapter.ts | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts index 86c1515d..0a2125f7 100644 --- a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -104,7 +104,8 @@ function ownedFieldsMatch(existing: TRow, expected: TRow): export interface IdentityBootstrapDatabaseClientV1 { readonly userIdentity: UserDelegateV1; - readonly organizationIdentity: IdentityDelegateV1; + readonly organizationIdentity: IdentityDelegateV1 & + ListDelegateV1; readonly workspaceIdentity: IdentityDelegateV1 & ListDelegateV1; readonly projectIdentity: IdentityDelegateV1 & @@ -122,8 +123,12 @@ function stableId(input: unknown): StableIdentifierV1 | undefined { function timestamp(input: Date | null | undefined): StrictUtcTimestampV1 | undefined { if (!input) return undefined; - const parsed = parseStrictUtcTimestampV1(input.toISOString()); - return parsed.accepted ? parsed.value : undefined; + try { + const parsed = parseStrictUtcTimestampV1(input.toISOString()); + return parsed.accepted ? parsed.value : undefined; + } catch { + return undefined; + } } function safeText(input: unknown, maxLength: number): string | undefined { @@ -198,6 +203,11 @@ function bootstrapFromRows( project.status !== 'ACTIVE' ) throw new Error('IAM_PERSISTED_PROJECT_INVALID'); + const startsAt = timestamp(membership.startsAt); + const expiresAt = timestamp(membership.expiresAt); + if ((membership.startsAt !== null && membership.startsAt !== undefined && !startsAt) || + (membership.expiresAt !== null && membership.expiresAt !== undefined && !expiresAt)) + throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); const parsedMembership = validateMembershipV1({ id: membership.id, principalType: membership.principalType, @@ -205,8 +215,8 @@ function bootstrapFromRows( scope: { scopeType: 'organization', organizationId: membership.organizationId }, roleId: membership.roleId, status: membership.status, - ...(membership.startsAt ? { startsAt: timestamp(membership.startsAt) } : {}), - ...(membership.expiresAt ? { expiresAt: timestamp(membership.expiresAt) } : {}), + ...(startsAt ? { startsAt } : {}), + ...(expiresAt ? { expiresAt } : {}), revision: membership.revision, }); if ( @@ -272,18 +282,24 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran projectId: null, }, }); + const sortedMemberships = [...memberships].sort((left, right) => + left.id.localeCompare(right.id), + ); + const candidateOrganizationIds = sortedMemberships.map((membership) => { + const candidateOrganizationId = stableId(membership.organizationId); + if (!candidateOrganizationId) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); + return candidateOrganizationId; + }); + const organizations = await this.client.organizationIdentity.findMany({ + where: { id: { in: candidateOrganizationIds }, personal: true }, + }); + const organizationsById = new Map(organizations.map((organization) => [organization.id, organization])); const personalCandidates: Array<{ readonly membership: MembershipIdentityDatabaseRowV1; readonly organization: OrganizationIdentityDatabaseRowV1; }> = []; - for (const membership of [...memberships].sort((left, right) => - left.id.localeCompare(right.id), - )) { - const candidateOrganizationId = stableId(membership.organizationId); - if (!candidateOrganizationId) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); - const candidate = await this.client.organizationIdentity.findUnique({ - where: { id: candidateOrganizationId }, - }); + for (const membership of sortedMemberships) { + const candidate = organizationsById.get(membership.organizationId); if (candidate?.personal) personalCandidates.push({ membership, organization: candidate }); } if (personalCandidates.length === 0) return undefined; From f8f7747b507a7cf3209ae0d62042be1af706ef58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:55:09 +0700 Subject: [PATCH 36/55] test(iam): cover bootstrap candidate hazards --- ...isma-identity-bootstrap-repository.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts index f8454d07..e3a039e8 100644 --- a/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts +++ b/services/api/test/features/iam/prisma-identity-bootstrap-repository.test.ts @@ -42,6 +42,7 @@ function createDatabase(): { readonly memberships: Map; readonly transactionCalls: { value: number }; readonly transactionWriteCalls: { value: number }; + readonly organizationFindManyCalls: { value: number }; } { const users = new Map([ [ @@ -63,6 +64,7 @@ function createDatabase(): { const memberships = new Map(); const transactionCalls = { value: 0 }; const transactionWriteCalls = { value: 0 }; + const organizationFindManyCalls = { value: 0 }; const client = { userIdentity: { findUnique: async ({ where }: { readonly where: { readonly id: string } }) => @@ -75,6 +77,16 @@ function createDatabase(): { }, findUnique: async ({ where }: { readonly where: { readonly id: string } }) => organizations.get(where.id) ?? null, + findMany: async ({ where }: { readonly where: Readonly> }) => { + organizationFindManyCalls.value += 1; + const ids = + typeof where['id'] === 'object' && where['id'] !== null && 'in' in where['id'] + ? ((where['id'] as { readonly in?: readonly string[] }).in ?? []) + : []; + return [...organizations.values()].filter( + (row) => ids.includes(row.id) && where['personal'] === row.personal, + ); + }, }, workspaceIdentity: { create: async ({ data }: { readonly data: WorkspaceIdentityDatabaseRowV1 }) => { @@ -183,6 +195,7 @@ function createDatabase(): { memberships, transactionCalls, transactionWriteCalls, + organizationFindManyCalls, }; } @@ -195,6 +208,7 @@ void test('[IAM-001, IAM-009, IAM-011] Prisma bootstrap persists and reconstruct memberships, transactionCalls, transactionWriteCalls, + organizationFindManyCalls, } = createDatabase(); const adapter = new PrismaIdentityBootstrapRepositoryAdapter(client); const validated = bootstrapPersonalOrganizationV1(input); @@ -209,6 +223,7 @@ void test('[IAM-001, IAM-009, IAM-011] Prisma bootstrap persists and reconstruct assert.equal(projects.size, 1); assert.equal(memberships.size, 1); assert.deepEqual(await adapter.findByUserId(validated.value.user.id), validated.value); + assert.equal(organizationFindManyCalls.value, 1); }); void test('[IAM-011] repeated bootstrap is immutable and conflicting hierarchy is rejected', async () => { @@ -273,6 +288,60 @@ void test('[IAM-001, IAM-011] bootstrap lookup selects the personal organization ); }); +void test('[IAM-011] bootstrap lookup rejects two personal organizations', async () => { + const state = createDatabase(); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + const secondOrganizationId = '00000000-0000-4000-8000-000000000008'; + const secondMembershipId = '00000000-0000-4000-8000-000000000009'; + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client); + await adapter.save(validated.value); + state.organizations.set(secondOrganizationId, { + id: secondOrganizationId, + name: 'Second personal organization', + personal: true, + status: 'ACTIVE', + createdAt, + }); + state.memberships.set(secondMembershipId, { + id: secondMembershipId, + principalType: 'USER', + principalId: userId, + scopeType: 'ORGANIZATION', + organizationId: secondOrganizationId, + workspaceId: null, + projectId: null, + roleId: 'owner', + status: 'ACTIVE', + startsAt: null, + expiresAt: null, + revision: 1, + }); + + await assert.rejects( + adapter.findByUserId(validated.value.user.id), + /IAM_PERSISTED_ORGANIZATION_INVALID/u, + ); +}); + +void test('[IAM-009] bootstrap lookup rejects unparseable membership timestamps', async () => { + const state = createDatabase(); + const validated = bootstrapPersonalOrganizationV1(input); + assert.equal(validated.accepted, true); + if (!validated.accepted) return; + const adapter = new PrismaIdentityBootstrapRepositoryAdapter(state.client); + await adapter.save(validated.value); + const membership = state.memberships.get(membershipId); + assert.ok(membership); + state.memberships.set(membershipId, { ...membership, expiresAt: new Date('invalid') }); + + await assert.rejects( + adapter.findByUserId(validated.value.user.id), + /IAM_PERSISTED_MEMBERSHIP_INVALID/u, + ); +}); + void test('[IAM-001, IAM-011] bootstrap lookup survives personal workspace and project renames', async () => { const state = createDatabase(); const validated = bootstrapPersonalOrganizationV1(input); From 1c4ef10a50be11b75df26f58d04b34dc4751c5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:56:00 +0700 Subject: [PATCH 37/55] fix(api): map MFA revision conflicts --- services/api/src/features/iam/api/mfa.controller.ts | 5 ++++- .../features/iam/application/mfa-problem.error.ts | 5 ++++- .../api/src/platform/http/problem-details.filter.ts | 13 +++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/services/api/src/features/iam/api/mfa.controller.ts b/services/api/src/features/iam/api/mfa.controller.ts index 04256dbf..a4d2a9ab 100644 --- a/services/api/src/features/iam/api/mfa.controller.ts +++ b/services/api/src/features/iam/api/mfa.controller.ts @@ -26,7 +26,10 @@ export class MfaController { private async execute(work: () => Promise): Promise { try { return await work(); - } catch { + } catch (error) { + if (error instanceof Error && error.message === 'IAM_MFA_REVISION_CONFLICT') { + throw new MfaProblemError('IAM_MFA_REVISION_CONFLICT'); + } throw new MfaProblemError('MFA_UNAVAILABLE'); } } diff --git a/services/api/src/features/iam/application/mfa-problem.error.ts b/services/api/src/features/iam/application/mfa-problem.error.ts index 723029a5..fd5c6568 100644 --- a/services/api/src/features/iam/application/mfa-problem.error.ts +++ b/services/api/src/features/iam/application/mfa-problem.error.ts @@ -1,4 +1,7 @@ -export type MfaProblemCodeV1 = 'MFA_REQUEST_REJECTED' | 'MFA_UNAVAILABLE'; +export type MfaProblemCodeV1 = + | 'MFA_REQUEST_REJECTED' + | 'MFA_UNAVAILABLE' + | 'IAM_MFA_REVISION_CONFLICT'; export class MfaProblemError extends Error { constructor(readonly code: MfaProblemCodeV1) { diff --git a/services/api/src/platform/http/problem-details.filter.ts b/services/api/src/platform/http/problem-details.filter.ts index 97940fca..9112e266 100644 --- a/services/api/src/platform/http/problem-details.filter.ts +++ b/services/api/src/platform/http/problem-details.filter.ts @@ -51,12 +51,21 @@ function describe(error: unknown, correlationId: string): ProblemInput { } if (error instanceof MfaProblemError) { const unavailable = error.code === 'MFA_UNAVAILABLE'; + const revisionConflict = error.code === 'IAM_MFA_REVISION_CONFLICT'; return { code: error.code, correlationId, - messageKey: unavailable ? 'api.error.mfa_unavailable' : 'api.error.mfa_request_rejected', + messageKey: unavailable + ? 'api.error.mfa_unavailable' + : revisionConflict + ? 'api.error.mfa_revision_conflict' + : 'api.error.mfa_request_rejected', retryable: unavailable, - status: unavailable ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_REQUEST, + status: unavailable + ? HttpStatus.SERVICE_UNAVAILABLE + : revisionConflict + ? HttpStatus.CONFLICT + : HttpStatus.BAD_REQUEST, }; } if (error instanceof EntitlementProblemError) { From 3a7bcae1b7ce7311734386a00bd359d547013a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:56:03 +0700 Subject: [PATCH 38/55] test(api): expose MFA conflict responses --- services/api/test/http-contract.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 0a3401c6..64ccafbe 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -885,4 +885,20 @@ void test('MFA HTTP lifecycle derives the user from the authenticated tenant con }); assertProblem(response, 503, 'MFA_UNAVAILABLE'); }); + + const conflictingMfa = { + enroll: () => Promise.reject(new Error('IAM_MFA_REVISION_CONFLICT')), + } as unknown as MfaService; + await withApp({ mfaService: conflictingMfa, requestTenantContext }, async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/mfa/factors', + payload: { + id: '00000000-0000-4000-8000-000000000010', + method: 'TOTP', + secretReference: 'vault://iam/mfa/test-factor', + }, + }); + assertProblem(response, 409, 'IAM_MFA_REVISION_CONFLICT'); + }); }); From 3197edd927deaf3611f21cd31405062c38553c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:56:24 +0700 Subject: [PATCH 39/55] fix(iam): preserve sign-out authentication errors --- .../api/src/features/iam/api/authentication.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/iam/api/authentication.controller.ts b/services/api/src/features/iam/api/authentication.controller.ts index 08e43d4a..664da70a 100644 --- a/services/api/src/features/iam/api/authentication.controller.ts +++ b/services/api/src/features/iam/api/authentication.controller.ts @@ -176,9 +176,9 @@ export class AuthenticationController { @Res({ passthrough: true }) reply: FastifyReply, ): Promise { if (this.sessions === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + if (this.requestContext === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); + const context = await this.requestContext.resolve(request); try { - if (this.requestContext === undefined) throw new SessionProblemError('SESSION_UNAVAILABLE'); - const context = await this.requestContext.resolve(request); const principal = await this.sessions.findPrincipal(input.sessionId); if ( !principal || From b513053c457dbe50b1b8b669f3ed2fbab19b4372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 18:57:47 +0700 Subject: [PATCH 40/55] test(api): preserve sign-out auth failures --- services/api/test/http-contract.test.ts | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index 64ccafbe..bba52acb 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -10,6 +10,7 @@ import { createIamTenantContextV1 } from '../src/features/iam/application/tenant import { InMemoryMfaRepositoryAdapter } from '../src/features/iam/adapter/in-memory-mfa-repository.adapter.js'; import { MfaService } from '../src/features/iam/application/mfa.service.js'; import { InMemoryAuditRepositoryAdapter } from '../src/features/aud/adapter/in-memory-audit-repository.adapter.js'; +import { RequestTenantContextProblemError } from '../src/platform/http/request-tenant-context.port.js'; interface InjectResponse { readonly body: string; @@ -81,6 +82,7 @@ void test('reports ready only through the injectable readiness port and minimize assert.doesNotMatch(response.body, /postgres/i); }, ); + }); void test('maps an unconfigured tenant context provider to authentication unavailability', async () => { @@ -479,6 +481,7 @@ void test('refresh rotates Web cookies without returning the refresh token and p assertProblem(response, 503, 'SESSION_UNAVAILABLE'); }, ); + }); void test('sign-out revokes idempotently and clears browser credentials', async () => { @@ -600,6 +603,33 @@ void test('sign-out revokes idempotently and clears browser credentials', async assertProblem(response, 503, 'SESSION_UNAVAILABLE'); }, ); + + await withApp( + { + requestTenantContext: { + resolve: () => Promise.reject(new RequestTenantContextProblemError('AUTHENTICATION_FAILED')), + }, + sessions: { + issue: () => Promise.reject(new Error('not used')), + refresh: () => Promise.reject(new Error('not used')), + revoke: () => Promise.resolve(true), + findPrincipal: () => Promise.resolve(signOutPrincipal), + findPrincipalByAccessToken: () => Promise.resolve(signOutPrincipal), + }, + }, + async (app) => { + const response = await app.inject({ + method: 'POST', + url: '/v1/auth/sign-out', + headers: { 'idempotency-key': 'sign-out-auth-failure-001' }, + payload: { + clientPlatform: 'android', + sessionId: '00000000-0000-4000-8000-000000000010', + }, + }); + assertProblem(response, 401, 'AUTHENTICATION_FAILED'); + }, + ); }); void test('protected artifact reads derive tenant scope from an authenticated access token', async () => { From dd5ad9afcd8205feec8c90f49619d73dd877d5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:00:15 +0700 Subject: [PATCH 41/55] fix(i18n): publish device API error messages --- packages/i18n/src/catalogs-v1.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/i18n/src/catalogs-v1.ts b/packages/i18n/src/catalogs-v1.ts index 89e268bf..550bb866 100644 --- a/packages/i18n/src/catalogs-v1.ts +++ b/packages/i18n/src/catalogs-v1.ts @@ -126,6 +126,11 @@ const vietnameseCatalogV1 = { ), 'error.sourceOffline': entry('Thiết bị chứa dữ liệu gốc hiện đang ngoại tuyến.'), 'error.sessionExpired': entry('Phiên làm việc đã hết hạn. Hãy đăng nhập lại để tiếp tục.'), + 'api.error.device_unavailable': entry('Thiết bị hiện không khả dụng.'), + 'api.error.device_not_found': entry('Không tìm thấy thiết bị.'), + 'api.error.device_request_rejected': entry('Thiết bị đã từ chối yêu cầu.'), + 'api.error.device_revision_conflict': entry('Thiết bị đã thay đổi. Hãy tải lại và thử lại.'), + 'api.error.device_scope_denied': entry('Bạn không có quyền truy cập thiết bị này.'), 'retry.now': entry('Thử lại ngay'), 'retry.later': entry('Hãy thử lại sau. Dữ liệu đã nhập vẫn được giữ nguyên.'), 'retry.afterSeconds.one': entry('Thử lại sau {seconds} giây.', { seconds: 'number' }), @@ -266,6 +271,11 @@ const englishCatalogV1: MessageCatalogV1 = { ), 'error.sourceOffline': entry('The device containing the original data is offline.'), 'error.sessionExpired': entry('Your session expired. Sign in again to continue.'), + 'api.error.device_unavailable': entry('The device is temporarily unavailable.'), + 'api.error.device_not_found': entry('The device was not found.'), + 'api.error.device_request_rejected': entry('The device rejected the request.'), + 'api.error.device_revision_conflict': entry('The device changed. Reload and try again.'), + 'api.error.device_scope_denied': entry('You do not have access to this device.'), 'retry.now': entry('Try again now'), 'retry.later': entry('Try again later. Your entered data has been preserved.'), 'retry.afterSeconds.one': entry('Try again in {seconds} second.', { seconds: 'number' }), From 6e318ff46930209b97bfa5f965fd6c5840060eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:00:18 +0700 Subject: [PATCH 42/55] test(i18n): enforce device error catalog coverage --- packages/i18n/test/catalogs-v1.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/i18n/test/catalogs-v1.test.mjs b/packages/i18n/test/catalogs-v1.test.mjs index 444f3872..10a601c9 100644 --- a/packages/i18n/test/catalogs-v1.test.mjs +++ b/packages/i18n/test/catalogs-v1.test.mjs @@ -97,6 +97,11 @@ const REQUIRED_KEYS = Object.freeze([ 'error.networkUnavailable', 'error.sourceOffline', 'error.sessionExpired', + 'api.error.device_unavailable', + 'api.error.device_not_found', + 'api.error.device_request_rejected', + 'api.error.device_revision_conflict', + 'api.error.device_scope_denied', 'retry.now', 'retry.later', 'retry.afterSeconds.one', From 25f90179a4d97d7eed10dd5f54624985e3835105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:03:16 +0700 Subject: [PATCH 43/55] refactor(iam): publish cookie parser limits --- .../src/features/iam/api/session-cookies.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/services/api/src/features/iam/api/session-cookies.ts b/services/api/src/features/iam/api/session-cookies.ts index b00aed6f..4e2993a6 100644 --- a/services/api/src/features/iam/api/session-cookies.ts +++ b/services/api/src/features/iam/api/session-cookies.ts @@ -1,9 +1,12 @@ const COOKIE_NAME_PATTERN_V1 = /^[A-Za-z0-9_]+$/u; const COOKIE_VALUE_PATTERN_V1 = /^[A-Za-z0-9._~-]+$/u; -const MAX_COOKIE_HEADER_LENGTH_V1 = 8_192; -const MAX_COOKIE_NAME_LENGTH_V1 = 64; -const MAX_COOKIE_VALUE_LENGTH_V1 = 4_096; -const MAX_COOKIE_SEGMENTS_V1 = 64; + +export const COOKIE_LIMITS_V1 = Object.freeze({ + headerLength: 8_192, + nameLength: 64, + valueLength: 4_096, + segments: 64, +} as const); export const REFRESH_COOKIE_NAME_V1 = 'databreeze_refresh'; export const CSRF_COOKIE_NAME_V1 = 'databreeze_csrf'; @@ -14,11 +17,11 @@ export interface CookieOptionsV1 { } function validCookieNameV1(name: string): boolean { - return name.length <= MAX_COOKIE_NAME_LENGTH_V1 && COOKIE_NAME_PATTERN_V1.test(name); + return name.length <= COOKIE_LIMITS_V1.nameLength && COOKIE_NAME_PATTERN_V1.test(name); } function validCookieValueV1(value: string): boolean { - return value.length <= MAX_COOKIE_VALUE_LENGTH_V1 && COOKIE_VALUE_PATTERN_V1.test(value); + return value.length <= COOKIE_LIMITS_V1.valueLength && COOKIE_VALUE_PATTERN_V1.test(value); } export function serializeCookieV1(name: string, value: string, options: CookieOptionsV1): string { @@ -58,13 +61,13 @@ export function clearCookieV1(name: string, options: Pick MAX_COOKIE_HEADER_LENGTH_V1 || + rawCookie.length > COOKIE_LIMITS_V1.headerLength || !validCookieNameV1(name) ) { return undefined; } const segments = rawCookie.split(';'); - if (segments.length > MAX_COOKIE_SEGMENTS_V1) return undefined; + if (segments.length > COOKIE_LIMITS_V1.segments) return undefined; let found: string | undefined; for (const segment of segments) { const trimmed = segment.trim(); From 3a94fb55164a78dd990b280adac9ce7b5a9d3935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:03:19 +0700 Subject: [PATCH 44/55] test(iam): cover cookie parser boundaries --- .../test/features/iam/session-cookies.test.ts | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/services/api/test/features/iam/session-cookies.test.ts b/services/api/test/features/iam/session-cookies.test.ts index 9061e8c4..bdedc155 100644 --- a/services/api/test/features/iam/session-cookies.test.ts +++ b/services/api/test/features/iam/session-cookies.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { CSRF_COOKIE_NAME_V1, + COOKIE_LIMITS_V1, REFRESH_COOKIE_NAME_V1, clearCookieV1, readCookieValueV1, @@ -50,31 +51,83 @@ void test('reads one exact cookie value and fails closed for ambiguity or malfor }); void test('rejects cookie headers and fields beyond parser resource bounds', () => { + const headerPrefix = `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; padding=`; + const secondSegmentPrefix = '; second='; + const secondValueLength = + COOKIE_LIMITS_V1.headerLength - + headerPrefix.length - + COOKIE_LIMITS_V1.valueLength - + secondSegmentPrefix.length; + const headerAtLimit = + headerPrefix + + 'a'.repeat(COOKIE_LIMITS_V1.valueLength) + + secondSegmentPrefix + + 'b'.repeat(secondValueLength); + + assert.equal(headerAtLimit.length, COOKIE_LIMITS_V1.headerLength); + assert.equal(readCookieValueV1(headerAtLimit, REFRESH_COOKIE_NAME_V1), refreshToken); + assert.equal(readCookieValueV1(`${headerAtLimit}x`, REFRESH_COOKIE_NAME_V1), undefined); + assert.equal( + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${Array.from( + { length: COOKIE_LIMITS_V1.segments - 2 }, + (_, index) => `c${index}=v`, + ).join('; ')}; last=v`, + REFRESH_COOKIE_NAME_V1, + ), + refreshToken, + ); + assert.equal( + serializeCookieV1('a'.repeat(COOKIE_LIMITS_V1.nameLength), token, { + httpOnly: true, + maxAgeSeconds: 1, + }).startsWith(`${'a'.repeat(COOKIE_LIMITS_V1.nameLength)}=`), + true, + ); + assert.equal( + serializeCookieV1(REFRESH_COOKIE_NAME_V1, 'a'.repeat(COOKIE_LIMITS_V1.valueLength), { + httpOnly: true, + maxAgeSeconds: 1, + }).includes(`=${'a'.repeat(COOKIE_LIMITS_V1.valueLength)};`), + true, + ); assert.equal( readCookieValueV1( - `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; padding=${'a'.repeat(8_192)}`, + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; padding=${'a'.repeat( + COOKIE_LIMITS_V1.headerLength, + )}`, REFRESH_COOKIE_NAME_V1, ), undefined, ); assert.equal( readCookieValueV1( - `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${Array.from({ length: 64 }, (_, index) => `c${index}=v`).join('; ')}`, + `${REFRESH_COOKIE_NAME_V1}=${refreshToken}; ${Array.from( + { length: COOKIE_LIMITS_V1.segments }, + (_, index) => `c${index}=v`, + ).join('; ')}`, REFRESH_COOKIE_NAME_V1, ), undefined, ); assert.equal( - readCookieValueV1(`${REFRESH_COOKIE_NAME_V1}=${'a'.repeat(4_097)}`, REFRESH_COOKIE_NAME_V1), + readCookieValueV1( + `${REFRESH_COOKIE_NAME_V1}=${'a'.repeat(COOKIE_LIMITS_V1.valueLength + 1)}`, + REFRESH_COOKIE_NAME_V1, + ), undefined, ); assert.throws( - () => serializeCookieV1('a'.repeat(65), token, { httpOnly: true, maxAgeSeconds: 1 }), + () => + serializeCookieV1('a'.repeat(COOKIE_LIMITS_V1.nameLength + 1), token, { + httpOnly: true, + maxAgeSeconds: 1, + }), /Cookie name or value is invalid/, ); assert.throws( () => - serializeCookieV1(REFRESH_COOKIE_NAME_V1, 'a'.repeat(4_097), { + serializeCookieV1(REFRESH_COOKIE_NAME_V1, 'a'.repeat(COOKIE_LIMITS_V1.valueLength + 1), { httpOnly: true, maxAgeSeconds: 1, }), From f7fa8d3313edba2d241bd2c5025ffeb5b85f1225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:04:31 +0700 Subject: [PATCH 45/55] test(iam): keep malformed membership diagnostics visible --- services/api/test/features/iam/prisma-iam-repository.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index ee5aecfe..101aee82 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -174,7 +174,7 @@ void test('[IAM-009, IAM-019] malformed membership rows fail closed without bloc const valid = row(id('20'), 'WORKSPACE', workspaceId, 'viewer'); const malformed = { ...row(id('21'), 'WORKSPACE', workspaceId, 'viewer'), - workspaceId: 'not-a-workspace-id', + roleId: 'malformed-role', }; const skipped: string[] = []; const repository = new PrismaIamRepositoryAdapter(createDatabase([valid, malformed]).client, { From 366d787828ab277b1ec88ea746871de840b85ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:05:09 +0700 Subject: [PATCH 46/55] test(prisma): bind migration assertions to stable names --- services/api/test/prisma-foundation.test.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 1422edc1..67dbb46f 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -505,7 +505,11 @@ test('the schema diff and centrally ordered migration inventory establish platfo /CREATE UNIQUE INDEX "artifact_lineage_derived_version_key"\s+ON "iae"\."artifact_lineage"\("derived_artifact_version_id"\);/u, ); const sessionScopeMigration = await readFile( - path.join(migrationsDirectory, inventory[33], 'migration.sql'), + path.join( + migrationsDirectory, + '20260803010000_iam_session_scope_binding', + 'migration.sql', + ), 'utf8', ); for (const statement of [ @@ -519,4 +523,6 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + assert.match(sessionScopeMigration, /no production or legacy data migration/u); + assert.match(sessionScopeMigration, /guessing tenant scope would be unsafe/u); }); From bbe07236af99e70dc0360a654eebfcc1712dbbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:06:41 +0700 Subject: [PATCH 47/55] refactor(bua): centralize reservation transition policy --- ...n-memory-entitlement-repository.adapter.ts | 20 +++++-------------- .../prisma-entitlement-repository.adapter.ts | 16 +++------------ .../bua/application/entitlement-equality.ts | 7 +++++++ 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts index 4a81866d..01d7046f 100644 --- a/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/in-memory-entitlement-repository.adapter.ts @@ -19,6 +19,7 @@ import { sameUsageEntryV1, sameUsageReservationExceptStatusV1, sameUsageReservationV1, + validUsageReservationTransitionV1, } from '../application/entitlement-equality.js'; function visibleInScope(context: TenantScopeV1, record: TenantScopeV1): boolean { @@ -73,17 +74,6 @@ function cloneState(state: UsageLedgerStateV1): UsageLedgerStateV1 { }); } -function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { - return sameUsageReservationExceptStatusV1(left, right); -} - -function validReservationTransition( - current: UsageReservationV1, - next: UsageReservationV1, -): boolean { - return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); -} - /** In-memory adapter with append-only usage and immutable plan/snapshot semantics. */ export class InMemoryEntitlementRepositoryAdapter implements EntitlementRepositoryPortV1 { private plans = new Map(); @@ -172,9 +162,9 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito if (sameUsageReservationV1(existing, reservation)) { if (visibleInScope(context.tenantScope, reservation.tenantScope)) continue; } else if ( - !sameReservationExceptStatus(existing, reservation) || + !sameUsageReservationExceptStatusV1(existing, reservation) || existing.revision + 1 !== reservation.revision || - !validReservationTransition(existing, reservation) + !validUsageReservationTransitionV1(existing, reservation) ) { throw new Error('BUA_RESERVATION_CONFLICT'); } @@ -187,8 +177,8 @@ export class InMemoryEntitlementRepositoryAdapter implements EntitlementReposito } if ( existing.revision + 1 !== reservation.revision || - !sameReservationExceptStatus(existing, reservation) || - !validReservationTransition(existing, reservation) + !sameUsageReservationExceptStatusV1(existing, reservation) || + !validUsageReservationTransitionV1(existing, reservation) ) throw new Error('BUA_RESERVATION_CONFLICT'); this.reservations.set(reservation.reservationId, cloneReservation(reservation)); diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index a6feb1ec..4a925fbb 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -28,6 +28,7 @@ import { sameUsageEntryV1, sameUsageReservationExceptStatusV1, sameUsageReservationV1, + validUsageReservationTransitionV1, } from '../application/entitlement-equality.js'; const planCodes = new Set(['free', 'development', 'admin_granted']); @@ -453,17 +454,6 @@ function inheritedUsageScopeKeys(scope: TenantScopeV1): readonly string[] | unde return Object.freeze(inherited); } -function sameReservationExceptStatus(left: UsageReservationV1, right: UsageReservationV1): boolean { - return sameUsageReservationExceptStatusV1(left, right); -} - -function validReservationTransition( - current: UsageReservationV1, - next: UsageReservationV1, -): boolean { - return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); -} - class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV1 { public constructor(private readonly client: EntitlementDatabaseClientV1) {} @@ -627,9 +617,9 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV const current = persistedReservation(existing); if (sameUsageReservationV1(current, reservation)) continue; if ( - !sameReservationExceptStatus(current, reservation) || + !sameUsageReservationExceptStatusV1(current, reservation) || reservation.revision !== current.revision + 1 || - !validReservationTransition(current, reservation) + !validUsageReservationTransitionV1(current, reservation) ) throw new Error('BUA_RESERVATION_CONFLICT'); if (!this.client.usageReservationRecord.updateMany) throw new Error('BUA_UPDATE_UNAVAILABLE'); diff --git a/services/api/src/features/bua/application/entitlement-equality.ts b/services/api/src/features/bua/application/entitlement-equality.ts index d77e472e..775b8d9a 100644 --- a/services/api/src/features/bua/application/entitlement-equality.ts +++ b/services/api/src/features/bua/application/entitlement-equality.ts @@ -114,3 +114,10 @@ export function sameUsageReservationExceptStatusV1( left.createdAt === right.createdAt ); } + +export function validUsageReservationTransitionV1( + current: UsageReservationV1, + next: UsageReservationV1, +): boolean { + return current.status === 'ACTIVE' && (next.status === 'FINALIZED' || next.status === 'RELEASED'); +} From 67790d2b1f70658b2ad84e5a483f100f9452d7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:06:46 +0700 Subject: [PATCH 48/55] test(bua): prove shared reservation transitions --- .../features/bua/entitlement-equality.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 services/api/test/features/bua/entitlement-equality.test.ts diff --git a/services/api/test/features/bua/entitlement-equality.test.ts b/services/api/test/features/bua/entitlement-equality.test.ts new file mode 100644 index 00000000..e35acf38 --- /dev/null +++ b/services/api/test/features/bua/entitlement-equality.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { UsageReservationV1 } from '@databreeze/domain/entitlements/v1'; +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import { validUsageReservationTransitionV1 } from '../../../src/features/bua/application/entitlement-equality.js'; + +function stable(value: string) { + const parsed = parseStableIdentifierV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid test identifier'); + return parsed.value; +} + +function timestamp(value: string) { + const parsed = parseStrictUtcTimestampV1(value); + assert.equal(parsed.accepted, true); + if (!parsed.accepted) throw new Error('invalid test timestamp'); + return parsed.value; +} + +const active: UsageReservationV1 = Object.freeze({ + reservationId: stable('00000000-0000-4000-8000-000000000001'), + tenantScope: Object.freeze({ + scopeType: 'organization', + organizationId: stable('00000000-0000-4000-8000-000000000002'), + }), + metric: 'job_count', + reservedUnits: 1, + status: 'ACTIVE', + createdAt: timestamp('2026-01-01T00:00:00.000Z'), + revision: 1, +}); + +void test('[BUA-012] reservation transitions share one terminal-state policy', () => { + assert.equal( + validUsageReservationTransitionV1(active, { ...active, status: 'FINALIZED', revision: 2 }), + true, + ); + assert.equal( + validUsageReservationTransitionV1(active, { ...active, status: 'RELEASED', revision: 2 }), + true, + ); + assert.equal( + validUsageReservationTransitionV1(active, { ...active, status: 'ACTIVE', revision: 2 }), + false, + ); + assert.equal( + validUsageReservationTransitionV1( + { ...active, status: 'FINALIZED' }, + { ...active, status: 'RELEASED', revision: 2 }, + ), + false, + ); +}); From fa42a5fd4a5f970e2755c4059068b96ee1c5c3b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:08:15 +0700 Subject: [PATCH 49/55] perf(bua): batch inherited usage reads --- .../prisma-entitlement-repository.adapter.ts | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts index 4a925fbb..73974301 100644 --- a/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts +++ b/services/api/src/features/bua/adapter/prisma-entitlement-repository.adapter.ts @@ -14,6 +14,7 @@ import { parseStrictUtcTimestampV1, parseTenantScopeV1, tenantScopeContainsV1, + tenantScopeKeyV1, type TenantScopeV1, } from '@databreeze/domain/tenant-scope/v1'; @@ -447,10 +448,14 @@ function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { function inheritedUsageScopeKeys(scope: TenantScopeV1): readonly string[] | undefined { if (scope.scopeType === 'organization') return undefined; const inherited = [ - `organization:${scope.organizationId}`, - `workspace:${scope.organizationId}:${scope.workspaceId}`, + tenantScopeKeyV1({ scopeType: 'organization', organizationId: scope.organizationId }), + tenantScopeKeyV1({ + scopeType: 'workspace', + organizationId: scope.organizationId, + workspaceId: scope.workspaceId, + }), ]; - if (scope.scopeType === 'project') inherited.push(scopeKey(scope)); + if (scope.scopeType === 'project') inherited.push(tenantScopeKeyV1(scope)); return Object.freeze(inherited); } @@ -533,30 +538,20 @@ class PrismaEntitlementTransactionAdapter implements EntitlementTransactionPortV public async listUsageState(context: IamTenantContextV1): Promise { const scopeKeys = inheritedUsageScopeKeys(context.tenantScope); - const entryQueries = (scopeKeys ?? [undefined]).map((key) => + const where = + scopeKeys === undefined + ? { organizationId: context.tenantScope.organizationId } + : { scopeKey: { in: scopeKeys } }; + const [entryRows, reservationRows] = await Promise.all([ this.client.usageLedgerEntryRecord.findMany({ - where: - key === undefined - ? { organizationId: context.tenantScope.organizationId } - : { scopeKey: key }, + where, orderBy: { sequence: 'asc' }, }), - ); - const reservationQueries = (scopeKeys ?? [undefined]).map((key) => this.client.usageReservationRecord.findMany({ - where: - key === undefined - ? { organizationId: context.tenantScope.organizationId } - : { scopeKey: key }, + where, orderBy: { createdAt: 'asc' }, }), - ); - const [entryGroups, reservationGroups] = await Promise.all([ - Promise.all(entryQueries), - Promise.all(reservationQueries), ]); - const entryRows = entryGroups.flat(); - const reservationRows = reservationGroups.flat(); return Object.freeze({ entries: Object.freeze( entryRows From e9f0f90893f1a790587683987e164114232777fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:08:18 +0700 Subject: [PATCH 50/55] test(bua): assert batched inherited usage queries --- .../bua/prisma-entitlement-repository.test.ts | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index c4ee6119..dd33f429 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -8,6 +8,7 @@ import { } from '@databreeze/domain/entitlements/v1'; import { parseStableIdentifierV1, + tenantScopeKeyV1, type StrictUtcTimestampV1, } from '@databreeze/domain/tenant-scope/v1'; @@ -90,6 +91,7 @@ function delegate>( rows: TRow[], forceRevisionConflict = false, firstQueries?: Array>>, + manyQueries?: Array>>, ) { const matches = (row: TRow, where: Readonly>): boolean => Object.entries(where).every(([key, value]) => { @@ -101,6 +103,14 @@ function delegate>( matches(row, candidate as Readonly>), ); } + if ( + typeof value === 'object' && + value !== null && + 'in' in value && + Array.isArray((value as { readonly in?: unknown }).in) + ) { + return ((value as { readonly in: readonly unknown[] }).in).includes(row[key]); + } return row[key] === value; }); return { @@ -130,9 +140,8 @@ function delegate>( readonly where: Readonly>; readonly orderBy?: Readonly>; }) { - const filtered = rows.filter((row) => - Object.entries(where).every(([key, value]) => row[key] === value), - ); + manyQueries?.push(where); + const filtered = rows.filter((row) => matches(row, where)); const [field, direction] = Object.entries(orderBy ?? {})[0] ?? []; return Promise.resolve( [...filtered].sort((left, right) => { @@ -175,6 +184,7 @@ function client( options: { readonly forceRevisionConflict?: boolean; readonly firstQueries?: Array>>; + readonly manyQueries?: Array>>; readonly transactionCalls?: { value: number }; } = {}, ): EntitlementDatabaseClientV1 { @@ -185,11 +195,17 @@ function client( const database = { entitlementPlanRecord: delegate(planRows), entitlementSnapshotRecord: delegate(snapshotRows, false, options.firstQueries), - usageLedgerEntryRecord: delegate(entryRows, false, options.firstQueries), + usageLedgerEntryRecord: delegate( + entryRows, + false, + options.firstQueries, + options.manyQueries, + ), usageReservationRecord: delegate( reservationRows, options.forceRevisionConflict, options.firstQueries, + options.manyQueries, ), async $transaction( work: (transaction: EntitlementDatabaseClientV1) => Promise, @@ -308,6 +324,38 @@ void test('[BUA-008, IAM-009] Prisma entitlement adapter round-trips project-sco }); }); +void test('[BUA-008, IAM-009] inherited usage reads use one scope-key query per record family', async () => { + const manyQueries: Array>> = []; + const repository = new PrismaEntitlementRepositoryAdapter(client({ manyQueries })); + await repository.saveSnapshot(context(workspaceId, 'batched-snapshot'), snapshot()); + const service = new EntitlementAdmissionService(repository); + const admitted = await service.admit(projectContext('batched-admit'), { + ...admissionInput('batched-admit', '1'), + tenantScope: { scopeType: 'project', organizationId, workspaceId, projectId }, + }); + assert.equal(admitted.accepted, true); + + await repository.listUsageState(projectContext('batched-read')); + assert.equal(manyQueries.length, 2); + const expectedKeys = [ + tenantScopeKeyV1({ scopeType: 'organization', organizationId: stable(organizationId) }), + tenantScopeKeyV1({ + scopeType: 'workspace', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + }), + tenantScopeKeyV1({ + scopeType: 'project', + organizationId: stable(organizationId), + workspaceId: stable(workspaceId), + projectId: stable(projectId), + }), + ]; + for (const query of manyQueries) { + assert.deepEqual(query['scopeKey'], { in: expectedKeys }); + } +}); + void test('[BUA-008, BUA-011] direct usage persistence executes in one database transaction', async () => { const transactionCalls = { value: 0 }; const repository = new PrismaEntitlementRepositoryAdapter(client({ transactionCalls })); From e33ad174fcf77e241046b9d6421f065d84daf767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:09:20 +0700 Subject: [PATCH 51/55] refactor(iam): share session revocation lifecycle --- .../prisma-session-lifecycle.adapter.ts | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts index a3119754..6c57ace8 100644 --- a/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-session-lifecycle.adapter.ts @@ -238,26 +238,34 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { this.clock = options.clock ?? (() => new Date()); } - private async revokeRefreshFamily( + private async revokeSession( transaction: SessionLifecycleDatabaseClientV1, - sessionId: StableIdentifierV1, - familyId: StableIdentifierV1, + session: Pick, now: Date, ): Promise { await transaction.refreshTokenRecord.updateMany({ - where: { familyId, status: 'ACTIVE' }, + where: { familyId: session.familyId, status: 'ACTIVE' }, data: { status: 'REVOKED' }, }); await transaction.sessionRecord.update({ - where: { id: sessionId }, - data: { status: 'REVOKED', revokedAt: now }, + where: { id: session.id }, + data: { status: 'REVOKED', revokedAt: session.revokedAt ?? now }, }); await transaction.accessTokenRecord.updateMany({ - where: { sessionId, status: 'ACTIVE' }, + where: { sessionId: session.id, status: 'ACTIVE' }, data: { status: 'REVOKED', revokedAt: now }, }); } + private async revokeRefreshFamily( + transaction: SessionLifecycleDatabaseClientV1, + sessionId: StableIdentifierV1, + familyId: StableIdentifierV1, + now: Date, + ): Promise { + await this.revokeSession(transaction, { id: sessionId, familyId }, now); + } + private async expireSession( transaction: SessionLifecycleDatabaseClientV1, sessionId: StableIdentifierV1, @@ -475,18 +483,7 @@ export class PrismaSessionLifecycleAdapter implements SessionLifecyclePortV1 { where: { id: sessionId.value }, }); if (!session) return false; - await transaction.sessionRecord.update({ - where: { id: sessionId.value }, - data: { status: 'REVOKED', revokedAt: session.revokedAt ?? now }, - }); - await transaction.refreshTokenRecord.updateMany({ - where: { familyId: session.familyId, status: 'ACTIVE' }, - data: { status: 'REVOKED' }, - }); - await transaction.accessTokenRecord.updateMany({ - where: { sessionId: session.id, status: 'ACTIVE' }, - data: { status: 'REVOKED', revokedAt: now }, - }); + await this.revokeSession(transaction, session, now); return true; }); } From 9888494ec5cadeed8485dd5ab78e46b391ccebee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:09:23 +0700 Subject: [PATCH 52/55] test(iam): preserve idempotent revocation timestamps --- .../api/test/features/iam/prisma-session-lifecycle.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/api/test/features/iam/prisma-session-lifecycle.test.ts b/services/api/test/features/iam/prisma-session-lifecycle.test.ts index b08dd7fc..acad931c 100644 --- a/services/api/test/features/iam/prisma-session-lifecycle.test.ts +++ b/services/api/test/features/iam/prisma-session-lifecycle.test.ts @@ -242,11 +242,14 @@ void test('[IAM-005] refresh cannot restart an expired inactivity window', async }); void test('[IAM-005] revocation is idempotent and hides session principals afterward', async () => { - const { client } = createDatabase(); + const { client, sessions } = createDatabase(); const adapter = new PrismaSessionLifecycleAdapter(client); const session = await adapter.issue(principal, 'web'); assert.equal(await adapter.revoke(session.sessionId), true); + const firstRevokedAt = sessions.get(session.sessionId)?.revokedAt?.getTime(); + assert.equal(typeof firstRevokedAt, 'number'); assert.equal(await adapter.revoke(session.sessionId), true); + assert.equal(sessions.get(session.sessionId)?.revokedAt?.getTime(), firstRevokedAt); assert.equal(await adapter.findPrincipal(session.sessionId), undefined); assert.equal(await adapter.findPrincipalByAccessToken(session.accessToken), undefined); }); From 54ca306e0c21f8a20d35973af7ce4110404652ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:10:24 +0700 Subject: [PATCH 53/55] style: normalize review gate changes --- packages/domain/src/audit/v1.ts | 2 +- .../adapter/in-memory-audit-repository.adapter.ts | 9 ++++----- .../adapter/prisma-audit-repository.adapter.ts | 5 +---- .../features/aud/application/audit-page-cursor.ts | 5 +---- .../iam/adapter/prisma-iam-repository.adapter.ts | 10 +++------- ...risma-identity-bootstrap-repository.adapter.ts | 10 +++++++--- .../bua/prisma-entitlement-repository.test.ts | 9 ++------- .../features/iam/prisma-iam-repository.test.ts | 15 +++++++++------ services/api/test/http-contract.test.ts | 5 ++--- services/api/test/prisma-foundation.test.mjs | 6 +----- 10 files changed, 31 insertions(+), 45 deletions(-) diff --git a/packages/domain/src/audit/v1.ts b/packages/domain/src/audit/v1.ts index f23b8c3d..ba3a4ce6 100644 --- a/packages/domain/src/audit/v1.ts +++ b/packages/domain/src/audit/v1.ts @@ -238,7 +238,7 @@ export function appendAuditEventV1( const existing = state.events.find( (event) => - event.idempotencyKey === idempotencyKey && + event.idempotencyKey === idempotencyKey && tenantScopeKeyV1(event.tenantScope) === tenantScopeKeyV1(scope), ); if (existing) { diff --git a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts index 3f890b03..3449764e 100644 --- a/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/in-memory-audit-repository.adapter.ts @@ -12,10 +12,7 @@ import type { AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; -import { - createAuditPageCursorV1, - auditPageOffsetV1, -} from '../application/audit-page-cursor.js'; +import { createAuditPageCursorV1, auditPageOffsetV1 } from '../application/audit-page-cursor.js'; import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; @@ -95,7 +92,9 @@ export class InMemoryAuditRepositoryAdapter implements AuditRepositoryPortV1 { const scopeOrder = tenantScopeKeyV1(left.tenantScope).localeCompare( tenantScopeKeyV1(right.tenantScope), ); - return scopeOrder || left.sequence - right.sequence || left.eventId.localeCompare(right.eventId); + return ( + scopeOrder || left.sequence - right.sequence || left.eventId.localeCompare(right.eventId) + ); }); const items = visible.slice(offset, offset + input.limit).map(cloneEvent); return Object.freeze({ diff --git a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts index a960f398..c0517b7f 100644 --- a/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts +++ b/services/api/src/features/aud/adapter/prisma-audit-repository.adapter.ts @@ -25,10 +25,7 @@ import type { AuditRepositoryPortV1, AuditTransactionPortV1, } from '../application/audit-repository.port.js'; -import { - createAuditPageCursorV1, - auditPageOffsetV1, -} from '../application/audit-page-cursor.js'; +import { createAuditPageCursorV1, auditPageOffsetV1 } from '../application/audit-page-cursor.js'; import { sameAuditEventV1, sameAuditSealV1 } from '../application/audit-equality.js'; export interface AuditEventDatabaseRowV1 { diff --git a/services/api/src/features/aud/application/audit-page-cursor.ts b/services/api/src/features/aud/application/audit-page-cursor.ts index bff19227..4977150f 100644 --- a/services/api/src/features/aud/application/audit-page-cursor.ts +++ b/services/api/src/features/aud/application/audit-page-cursor.ts @@ -1,7 +1,4 @@ -import { - tenantScopeKeyV1, - type TenantScopeV1, -} from '@databreeze/domain/tenant-scope/v1'; +import { tenantScopeKeyV1, type TenantScopeV1 } from '@databreeze/domain/tenant-scope/v1'; import type { AuditPageInputV1 } from './audit-repository.port.js'; export type AuditPageKindV1 = 'events' | 'seals'; diff --git a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts index d30b202e..48590c17 100644 --- a/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-iam-repository.adapter.ts @@ -193,9 +193,8 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { }); return selectAuthoritativeMembership( rows - .map((row) => membershipFromRowOrSkip(row, this.diagnostics.onMalformedMembershipRow)) - .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined) - , + .map((row) => membershipFromRowOrSkip(row, this.diagnostics.onMalformedMembershipRow)) + .filter((membership): membership is IamMembershipRecordV1 => membership !== undefined), context, principalId, ); @@ -225,10 +224,7 @@ class PrismaIamTransactionAdapter implements IamTransactionPortV1 { const existingById = await this.client.membershipIdentity.findFirst({ where: { id: membership.id }, }); - if ( - existingById !== null && - existingById.organizationId !== context.tenantScope.organizationId - ) + if (existingById !== null && existingById.organizationId !== context.tenantScope.organizationId) throw new Error('IAM_REVISION_CONFLICT'); const existingRow = existingById; if (!existingRow) { diff --git a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts index 0a2125f7..9bf9437a 100644 --- a/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts +++ b/services/api/src/features/iam/adapter/prisma-identity-bootstrap-repository.adapter.ts @@ -205,8 +205,10 @@ function bootstrapFromRows( throw new Error('IAM_PERSISTED_PROJECT_INVALID'); const startsAt = timestamp(membership.startsAt); const expiresAt = timestamp(membership.expiresAt); - if ((membership.startsAt !== null && membership.startsAt !== undefined && !startsAt) || - (membership.expiresAt !== null && membership.expiresAt !== undefined && !expiresAt)) + if ( + (membership.startsAt !== null && membership.startsAt !== undefined && !startsAt) || + (membership.expiresAt !== null && membership.expiresAt !== undefined && !expiresAt) + ) throw new Error('IAM_PERSISTED_MEMBERSHIP_INVALID'); const parsedMembership = validateMembershipV1({ id: membership.id, @@ -293,7 +295,9 @@ class PrismaIdentityBootstrapTransactionAdapter implements IdentityBootstrapTran const organizations = await this.client.organizationIdentity.findMany({ where: { id: { in: candidateOrganizationIds }, personal: true }, }); - const organizationsById = new Map(organizations.map((organization) => [organization.id, organization])); + const organizationsById = new Map( + organizations.map((organization) => [organization.id, organization]), + ); const personalCandidates: Array<{ readonly membership: MembershipIdentityDatabaseRowV1; readonly organization: OrganizationIdentityDatabaseRowV1; diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index dd33f429..21cff126 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -109,7 +109,7 @@ function delegate>( 'in' in value && Array.isArray((value as { readonly in?: unknown }).in) ) { - return ((value as { readonly in: readonly unknown[] }).in).includes(row[key]); + return (value as { readonly in: readonly unknown[] }).in.includes(row[key]); } return row[key] === value; }); @@ -195,12 +195,7 @@ function client( const database = { entitlementPlanRecord: delegate(planRows), entitlementSnapshotRecord: delegate(snapshotRows, false, options.firstQueries), - usageLedgerEntryRecord: delegate( - entryRows, - false, - options.firstQueries, - options.manyQueries, - ), + usageLedgerEntryRecord: delegate(entryRows, false, options.firstQueries, options.manyQueries), usageReservationRecord: delegate( reservationRows, options.forceRevisionConflict, diff --git a/services/api/test/features/iam/prisma-iam-repository.test.ts b/services/api/test/features/iam/prisma-iam-repository.test.ts index 101aee82..7ae9e578 100644 --- a/services/api/test/features/iam/prisma-iam-repository.test.ts +++ b/services/api/test/features/iam/prisma-iam-repository.test.ts @@ -96,12 +96,12 @@ function createDatabase(rows: readonly IamMembershipDatabaseRowV1[] = []): { membershipIdentity: { findFirst: async ({ where }: { readonly where: Readonly> }) => { firstQueries.push(where); - return ( - [...memberships.values()].find((candidate) => matches(candidate, where)) ?? null - ); + return [...memberships.values()].find((candidate) => matches(candidate, where)) ?? null; }, - findMany: async ({ where }: { readonly where: Readonly> }) => - (manyQueries.push(where), [...memberships.values()].filter((candidate) => matches(candidate, where))), + findMany: async ({ where }: { readonly where: Readonly> }) => ( + manyQueries.push(where), + [...memberships.values()].filter((candidate) => matches(candidate, where)) + ), create: async ({ data }: { readonly data: IamMembershipDatabaseRowV1 }) => { memberships.set(data.id, data); return data; @@ -311,7 +311,10 @@ void test('[IAM-009, IAM-019] Prisma membership mutation lookup includes tenant void test('[IAM-009] Prisma membership writes reject cross-organization identifier collisions', async () => { const foreignOrganizationId = id('99'); - const foreign = { ...row(id('24'), 'WORKSPACE', workspaceId, 'viewer'), organizationId: foreignOrganizationId }; + const foreign = { + ...row(id('24'), 'WORKSPACE', workspaceId, 'viewer'), + organizationId: foreignOrganizationId, + }; const { client, memberships } = createDatabase([foreign]); const repository = new PrismaIamRepositoryAdapter(client); const workspaceScope = { scopeType: 'workspace', organizationId, workspaceId } as const; diff --git a/services/api/test/http-contract.test.ts b/services/api/test/http-contract.test.ts index bba52acb..a797afa6 100644 --- a/services/api/test/http-contract.test.ts +++ b/services/api/test/http-contract.test.ts @@ -82,7 +82,6 @@ void test('reports ready only through the injectable readiness port and minimize assert.doesNotMatch(response.body, /postgres/i); }, ); - }); void test('maps an unconfigured tenant context provider to authentication unavailability', async () => { @@ -481,7 +480,6 @@ void test('refresh rotates Web cookies without returning the refresh token and p assertProblem(response, 503, 'SESSION_UNAVAILABLE'); }, ); - }); void test('sign-out revokes idempotently and clears browser credentials', async () => { @@ -607,7 +605,8 @@ void test('sign-out revokes idempotently and clears browser credentials', async await withApp( { requestTenantContext: { - resolve: () => Promise.reject(new RequestTenantContextProblemError('AUTHENTICATION_FAILED')), + resolve: () => + Promise.reject(new RequestTenantContextProblemError('AUTHENTICATION_FAILED')), }, sessions: { issue: () => Promise.reject(new Error('not used')), diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index 67dbb46f..ea60efed 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -505,11 +505,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo /CREATE UNIQUE INDEX "artifact_lineage_derived_version_key"\s+ON "iae"\."artifact_lineage"\("derived_artifact_version_id"\);/u, ); const sessionScopeMigration = await readFile( - path.join( - migrationsDirectory, - '20260803010000_iam_session_scope_binding', - 'migration.sql', - ), + path.join(migrationsDirectory, '20260803010000_iam_session_scope_binding', 'migration.sql'), 'utf8', ); for (const statement of [ From 5013ab14c6338aae84c946f3b160618005ba5a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:11:46 +0700 Subject: [PATCH 54/55] docs(ops): record promotion review dispositions --- .../coderabbit-pr-35-disposition.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/operations/coderabbit-pr-35-disposition.md diff --git a/docs/operations/coderabbit-pr-35-disposition.md b/docs/operations/coderabbit-pr-35-disposition.md new file mode 100644 index 00000000..d7c0fb22 --- /dev/null +++ b/docs/operations/coderabbit-pr-35-disposition.md @@ -0,0 +1,65 @@ +# CodeRabbit disposition for promotion PR 35 + +Promotion PR [#35](https://github.com/DatabreezeService/databreeze-platform/pull/35) +received exactly one automatic CodeRabbit review +(`9880ed80-ef8e-4545-8823-97499ec88913`) for its promotion range. CodeRabbit +was not invoked again. The review's ten actionable inline comments, two +outside-diff claims, and review-body suggestions were reproduced against the +current `dev`-bound fix branch before disposition. + +## Actionable inline comments + +| ID | Claim | Disposition | Evidence | +|---|---|---|---| +| CR35-01 | The platform-program branch/PR budget still described the old 30–70 policy. | Accepted and fixed. | `ed1e130` aligns the normal 30–50, exceptional-under-79, and 280 hard-stop language. | +| CR35-02 | The Luna handoff runbook repeated stale 30–70/99/280 limits. | Accepted and fixed. | `ed1e130` aligns the runbook with the same branch and promotion limits. | +| CR35-03 | Audit event pagination must preserve scope key, chain sequence, and deterministic event identity. | Accepted and fixed. | `f381341`, `66b3548`, and `68632db`; both adapters use the canonical ordering and forged-cursor regressions pass. | +| CR35-04 | An invalid audit chain must not be reported as a retryable outage. | Accepted and fixed. | `2406de7` and `00e688c`; integrity failures map to a non-retryable 500 problem without the raw chain marker. | +| CR35-05 | Membership writes could treat a cross-organization identifier collision as a generic uniqueness error. | Accepted and fixed. | `28268b1` and `cf159c7`; identity lookup is organization-independent before the scope guard and P2002 races map to `IAM_REVISION_CONFLICT`. | +| CR35-06 | Bootstrap could silently discard malformed non-null membership timestamps. | Accepted and fixed. | `f93d44e` and `f8f7747`; invalid `startsAt`/`expiresAt` values are rejected and covered. | +| CR35-07 | MFA revision conflicts need an explicit HTTP conflict response. | Accepted and fixed. | `1c4ef10` and `3a7bcae`; `IAM_MFA_REVISION_CONFLICT` maps to HTTP 409. | +| CR35-08 | Request-context resolution was inside the sign-out catch and could hide authentication failures. | Accepted and fixed. | `3197edd` and `b513053`; the HTTP contract preserves `AUTHENTICATION_FAILED`. | +| CR35-09 | Device problem-message keys were absent from the bilingual catalogs. | Accepted and fixed. | `dd5ad9a` and `6e318ff`; all five device keys now exist in Vietnamese and English and are required by the catalog test. | +| CR35-10 | The migration test should exercise existing-session migration behavior. | Rejected as an unsafe backfill request; the safe migration contract was strengthened. | The migration explicitly has no legacy-data migration because tenant scope cannot be inferred safely. `366d787` resolves the migration by stable name and asserts that no unsafe backfill is present. Existing sessions must be recreated under the locked no-legacy-migration assumption. | + +## Outside-diff claims + +| ID | Claim | Disposition | Evidence | +|---|---|---|---| +| CR35-OD-01 | `GET /v1/entitlements/usage` should publish a success response schema. | Accepted and fixed. | `1add34b`; the generated OpenAPI now declares the usage page shape. | +| CR35-OD-02 | `GET /v1/entitlements/snapshots/{snapshotId}` should publish a success response schema. | Accepted and fixed. | `1add34b`; the generated OpenAPI now declares the snapshot success shape. | + +## Review-body suggestions + +The following suggestions were also considered. Accepted suggestions are +implemented in the cited commits; suggestions that conflict with the locked +architecture are explicitly rejected rather than implemented speculatively. + +| Suggestion | Disposition | +|---|---| +| Do not cite reconciliation evidence for planned traceability rows. | Accepted: `ca2df3d` separates planned entries from reconciled evidence. | +| Resolve migrations by stable name rather than an inventory index. | Accepted: `366d787`. | +| Centralize the BUA reservation transition invariant. | Accepted: `bbe0723` and `67790d2`; both adapters call one application policy and a direct policy test protects it. | +| Add forged audit-cursor coverage. | Accepted: `68632db`. | +| Add an audit-seal descendant scheduler and alerting loop. | Rejected for this slice: the current AUD contract has no active-scope scheduler port or alert ownership; inventing one would bypass the ordered audit/sealing plan. Deferred to the AUD sealing task. | +| Add a page response schema to the audit controller. | Accepted: `89002da`; generated OpenAPI includes the bounded page envelope. | +| Share the audit page-offset validator and maximum. | Accepted: `e640373`. | +| Document the audit ordering contract. | Accepted: `66b3548` and `89002da`; the port and API docs state the chain ordering. | +| Remove unused transaction-level audit list methods. | Accepted: `66b3548`; the transaction port no longer exposes unrelated enumeration methods. | +| Share the canonical tenant scope-key helper. | Accepted: `f381341` and `3412961`. | +| Flatten inherited BUA usage reads and avoid one query per scope. | Accepted: `fa42a5f`; one `scopeKey IN (...)` query is issued per record family. | +| Add query-count/performance regression coverage for inherited usage. | Accepted: `e9f0f90`. | +| Share the IAM session-revocation lifecycle. | Accepted: `e33ad17` and `9888494`; repeated revocation preserves the original timestamp. | +| Add safe malformed-membership diagnostics. | Accepted: `663fdbc`, `aab57cb`, and `f7fa8d3`; malformed rows are skipped, diagnostics are best-effort, and the fixture remains visible to the scoped query. | +| Push IAM membership visibility into the database query and preserve scoped reads. | Accepted: `56df707` and `0e13e95`; the adapter emits organization/workspace/project predicates and tests inspect them. | +| Share membership-authority selection across adapters. | Accepted: `f54976d` and `d8a2f13`. | +| Test two personal organizations and reject ambiguity. | Accepted: `f93d44e` and `f8f7747`. | +| Batch bootstrap organization lookup. | Accepted: `f93d44e` and `f8f7747`; candidates are selected with one bounded `findMany`. | +| Export cookie parser limits for fixtures and test exact boundaries. | Accepted: `25f9017` and `3a94fb5`. | +| Backfill existing sessions in the scope-binding migration. | Rejected: inferring organization/workspace from a legacy session is unsafe and contradicts the repository's explicit no-legacy-data assumption. `366d787` tests that the migration preserves this guard. | +| Change audit ordering to created-at/ID ordering. | Rejected: chain pagination must follow the persisted per-scope sequence and deterministic event ID so cursor pages reconstruct the verified chain. | + +The accepted changes are collected on +`fix/coderabbit-pr-35-reconciliation` and will enter `dev` through its focused +fix PR. Promotion PR 35 remains a single-review, immutable review packet; no +second CodeRabbit run will be requested for it. From 5fd191c4395372accf382f16ecc99d6a5d3ed838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Mon, 3 Aug 2026 19:15:45 +0700 Subject: [PATCH 55/55] test(bua): isolate usage query count assertion --- .../api/test/features/bua/prisma-entitlement-repository.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/api/test/features/bua/prisma-entitlement-repository.test.ts b/services/api/test/features/bua/prisma-entitlement-repository.test.ts index 21cff126..ec4fcf58 100644 --- a/services/api/test/features/bua/prisma-entitlement-repository.test.ts +++ b/services/api/test/features/bua/prisma-entitlement-repository.test.ts @@ -330,6 +330,7 @@ void test('[BUA-008, IAM-009] inherited usage reads use one scope-key query per }); assert.equal(admitted.accepted, true); + manyQueries.length = 0; await repository.listUsageState(projectContext('batched-read')); assert.equal(manyQueries.length, 2); const expectedKeys = [