feat(ticket): emit ticket-lifecycle events via the Redis outbox (Wave A.2 Phase 1a) #5

Merged
hibryda merged 4 commits from feat/redis-outbox-mint into main 2026-05-28 17:31:14 +02:00
Owner

Wave A.2 Phase 1a — emit ticket-lifecycle events via the Redis outbox

Wires identity-service's Centrifugo ticket mint + the three lifecycle events (identity.ticket.{minted,expired,revoked}.v1) through the Phase 0b redis-outbox-backend (im2be.outbox.backend=REDIS), so the ticket mint and its TicketMinted event are atomic — the mint is itself a Redis op, so a Postgres outbox could not provide mint+event atomicity (ADR-0014 D-3 / docs/decisions/0014-supporting/identity-redis-outbox-backend.md).

Mint atomicity (the load-bearing piece)

A single identity-owned Lua EVAL (mint-and-enqueue.lua) does SETEX ticket + outbox HSET/ZADD + :expiry-due ZADD + expiry-context SET, fail-closed ordered (refinement #1): outbox writes FIRST, ticket SETEX LAST, so "a ticket without its TicketMinted event" is structurally impossible (an orphan event is harmless — consumer-deduped). TYPE-precheck all keys + HEXISTS idempotency mirror the platform-libs outbox-enqueue.lua. Ticket + expiry-context TTLs use PX (millis) for sub-second precision (and to avoid a Duration.toSeconds()→0 truncation footgun).

Events

  • TicketMinted / TicketRevoked = UUIDv7; TicketExpired = deterministic UUIDv3(ticket_id|expires_at) — also the :expiry-due member, so a double-fire collapses on the same id (exactly-once TicketExpired).
  • Pointer-based payloads (no inline PII, ADR-0002 §3b); the payload event_id is in lockstep with the outbox row's.
  • A @Scheduled due-time worker (TicketExpiryWorker) drives findDueExpiriesdecideExpiry, suppressing already-revoked tickets via a Redis-resident revocation marker written atomically in the revoke EVAL (narrows the Phase 0b expiry TOCTOU now that the caller exists). An expiry-context companion key carries (ticket_id, user_id, expires_at, trace_id) so the worker can build TicketExpired after the ticket key has TTL-evicted. The Validate (GETDEL) path writes a consumed-marker so the consumed field is accurate.

Error handling (rule 2)

The mint catch distinguishes RedisConnectionFailureException (→ TicketMintFailedException, 503, retryable — genuine outage) from RedisSystemException (Lua error_reply/WRONGTYPE/bad-arg → IllegalStateException, 500, not retryable — a contract bug, no longer masked as an outage).

Hash-tag re-tag DEFERRED (operator-confirmed)

The ticket key is not co-slotted with {identity:outbox}. The 2026-05-28 audit confirmed standalone Valkey (cluster_enabled:0), where a multi-key EVAL is atomic regardless of slots — so the composed mint works today without changing TICKET_KEY_PREFIX (shared with realtime-service's Validate path). mint-and-enqueue.lua documents the cluster-readiness TODO + the CROSSSLOT caveat.

Dependency

redis-outbox-backend 1.1.0-SNAPSHOT; the transitive spring-boot-starter-data-jpa is excluded (Redis-only service, no DataSource), and the PG OutboxAutoConfiguration is gated off for backend=REDIS by the B2 fix (platform-libs PR #9, b594a6c).

Verification

  • mvn clean verifyBUILD SUCCESS, 0 [WARNING].
  • 141 unit + 13 Testcontainers-Valkey IT green. ITs: atomic mint+enqueue, fail-closed WRONGTYPE-abort-keeps-no-ticket, UUID lockstep, hot-relay→Kafka, expired-enqueue, already-revoked suppression, UUIDv3 double-fire collapse, consumed-flag, not-due no-op, revoke DEL+marker+enqueue.

Note: the Forgejo mvn install CI check is red on the platform repos for all commits (pre-existing runner-toolchain bug, PR-PLATFORM-CI-1) and identity depends on a not-yet-registry-published 1.1.0-SNAPSHOT; verified locally green.

## Wave A.2 Phase 1a — emit ticket-lifecycle events via the Redis outbox Wires identity-service's Centrifugo ticket mint + the three lifecycle events (`identity.ticket.{minted,expired,revoked}.v1`) through the Phase 0b `redis-outbox-backend` (`im2be.outbox.backend=REDIS`), so the **ticket mint and its `TicketMinted` event are atomic** — the mint is itself a Redis op, so a Postgres outbox could not provide mint+event atomicity (ADR-0014 D-3 / `docs/decisions/0014-supporting/identity-redis-outbox-backend.md`). ### Mint atomicity (the load-bearing piece) A single identity-owned Lua `EVAL` (`mint-and-enqueue.lua`) does `SETEX ticket` + outbox `HSET`/`ZADD` + `:expiry-due ZADD` + expiry-context `SET`, **fail-closed ordered** (refinement #1): outbox writes FIRST, **ticket `SETEX` LAST**, so "a ticket without its `TicketMinted` event" is structurally impossible (an orphan event is harmless — consumer-deduped). TYPE-precheck all keys + `HEXISTS` idempotency mirror the platform-libs `outbox-enqueue.lua`. Ticket + expiry-context TTLs use `PX` (millis) for sub-second precision (and to avoid a `Duration.toSeconds()→0` truncation footgun). ### Events - `TicketMinted` / `TicketRevoked` = UUIDv7; `TicketExpired` = deterministic `UUIDv3(ticket_id|expires_at)` — also the `:expiry-due` member, so a double-fire collapses on the same id (exactly-once `TicketExpired`). - Pointer-based payloads (no inline PII, ADR-0002 §3b); the payload `event_id` is in lockstep with the outbox row's. - A `@Scheduled` due-time worker (`TicketExpiryWorker`) drives `findDueExpiries`→`decideExpiry`, suppressing already-revoked tickets via a **Redis-resident revocation marker** written atomically in the revoke `EVAL` (narrows the Phase 0b expiry TOCTOU now that the caller exists). An expiry-context companion key carries `(ticket_id, user_id, expires_at, trace_id)` so the worker can build `TicketExpired` after the ticket key has TTL-evicted. The Validate (GETDEL) path writes a consumed-marker so the `consumed` field is accurate. ### Error handling (rule 2) The mint catch distinguishes `RedisConnectionFailureException` (→ `TicketMintFailedException`, 503, retryable — genuine outage) from `RedisSystemException` (Lua `error_reply`/WRONGTYPE/bad-arg → `IllegalStateException`, 500, **not** retryable — a contract bug, no longer masked as an outage). ### Hash-tag re-tag DEFERRED (operator-confirmed) The ticket key is **not** co-slotted with `{identity:outbox}`. The 2026-05-28 audit confirmed **standalone Valkey** (`cluster_enabled:0`), where a multi-key `EVAL` is atomic regardless of slots — so the composed mint works today without changing `TICKET_KEY_PREFIX` (shared with realtime-service's Validate path). `mint-and-enqueue.lua` documents the cluster-readiness TODO + the CROSSSLOT caveat. ### Dependency `redis-outbox-backend 1.1.0-SNAPSHOT`; the transitive `spring-boot-starter-data-jpa` is excluded (Redis-only service, no DataSource), and the PG `OutboxAutoConfiguration` is gated off for `backend=REDIS` by the B2 fix (platform-libs PR #9, `b594a6c`). ### Verification - `mvn clean verify` → **BUILD SUCCESS**, **0 `[WARNING]`**. - **141 unit + 13 Testcontainers-Valkey IT** green. ITs: atomic mint+enqueue, fail-closed WRONGTYPE-abort-keeps-no-ticket, UUID lockstep, hot-relay→Kafka, expired-enqueue, already-revoked suppression, UUIDv3 double-fire collapse, consumed-flag, not-due no-op, revoke DEL+marker+enqueue. Note: the Forgejo `mvn install` CI check is red on the platform repos for all commits (pre-existing runner-toolchain bug, PR-PLATFORM-CI-1) and identity depends on a not-yet-registry-published `1.1.0-SNAPSHOT`; verified locally green.
Wire identity-service's Centrifugo ticket mint + the three lifecycle events
(identity.ticket.{minted,expired,revoked}.v1) through the Phase 0b
redis-outbox-backend (im2be.outbox.backend=REDIS), so the ticket mint and its
TicketMinted event are ATOMIC — the mint is itself a Redis op, so a Postgres
outbox could not give mint+event atomicity (ADR-0014 D-3 / 0014-supporting).

Mint atomicity (the load-bearing piece): a single identity-owned Lua EVAL
(mint-and-enqueue.lua) does SETEX ticket + outbox HSET/ZADD + expiry-due ZADD +
expiry-context SET, fail-closed ordered (refinement #1) — outbox writes FIRST,
ticket SETEX LAST, so "a ticket without its TicketMinted event" is structurally
impossible (an orphan event is harmless, consumer-deduped). TYPE-precheck all
keys + HEXISTS idempotency mirror the platform-libs outbox-enqueue.lua. Ticket
+ expiry-context TTLs use PX (millis) for sub-second precision.

Events: TicketMinted/TicketRevoked = UUIDv7; TicketExpired = deterministic
UUIDv3(ticket_id|expires_at) — also the :expiry-due member, so a double-fire
collapses on the same id. Pointer-based payloads (no inline PII, ADR-0002 §3b);
event_id is in lockstep between the outbox row and the payload. A @Scheduled
due-time worker (TicketExpiryWorker) drives findDueExpiries→decideExpiry,
suppressing already-revoked tickets via a Redis-resident revocation marker
written atomically in the revoke EVAL (narrows the Phase 0b expiry TOCTOU). The
expiry-context companion key carries (ticket_id,user_id,expires_at,trace_id) so
the worker can build TicketExpired after the ticket key has TTL-evicted. The
Validate (GETDEL) path writes a consumed-marker so the consumed field is
accurate.

Error handling (rule 2): the mint catch distinguishes
RedisConnectionFailureException (→ TicketMintFailedException, 503, retryable —
genuine outage) from RedisSystemException (Lua error_reply / WRONGTYPE / bad-arg
→ IllegalStateException, 500, NOT retryable — a contract bug, not masked as an
outage).

Hash-tag re-tag DEFERRED (operator-confirmed): the ticket key is NOT co-slotted
with {identity:outbox}; the 2026-05-28 audit confirmed standalone Valkey
(cluster_enabled:0) where a multi-key EVAL is atomic regardless of slots, so the
composed mint works today without changing TICKET_KEY_PREFIX (shared with
realtime-service's Validate path). mint-and-enqueue.lua documents the
cluster-readiness TODO + the CROSSSLOT caveat.

Dependency: redis-outbox-backend 1.1.0-SNAPSHOT; the transitive
spring-boot-starter-data-jpa is excluded (Redis-only service, no DataSource) and
the PG OutboxAutoConfiguration is now gated off for backend=REDIS by the B2 fix
(platform-libs b594a6c).

Tests: 141 unit + 13 Testcontainers-Valkey IT. ITs cover atomic mint+enqueue,
fail-closed WRONGTYPE-abort-keeps-no-ticket, UUID lockstep, hot-relay→Kafka,
expired-enqueue, already-revoked suppression, UUIDv3 double-fire collapse,
consumed-flag, not-due no-op, revoke DEL+marker+enqueue. mvn clean verify →
BUILD SUCCESS, 0 [WARNING].

Superseded by round 2.

Show previous round

hib-pr-reviewer review — PR #5 (affinity-intelligence-rework/im2be-identity-service)

Round 1 — head 506435070dfc, base main, trigger opened

TL;DR: CONDITIONAL_APPROVE — 2 minor + 2 info findings kept this round.

Summary

Arbiter reconciled 3 (A) + 0 (B) → 4 findings.

CI status (head 506435070dfc)

No CI checks reported for this commit.

Findings (4)

[MINOR] releaseLock compare-and-delete is non-atomic (TOCTOU race)

src/main/java/com/aim2be/identity/ticket/event/TicketExpiryWorker.java:363

Verified at lines 363–374. The comment at line 364 promises "Compare-and-delete so a tick that overran its lock TTL does not delete the new holder's lock" — but the implementation uses two separate Redis round-trips:

final String current = redis.opsForValue().get(SWEEP_LOCK_KEY);  // step 1
if (owner.equals(current)) {
    redis.delete(SWEEP_LOCK_KEY);  // step 2 — NOT atomic with step 1
}

Race window: if the lock TTL expires between step 1 and step 2, Replica B acquires the lock with a new owner UUID, and Replica A's delete() removes Replica B's lock. Result: two replicas sweep concurrently. UUIDv3 dedup preserves event-correctness, but the single-writer guarantee and the stated goal of avoiding wasted work and log noise are defeated.

Fix: Atomise with a short inline Lua EVAL (mirrors the mint/revoke scripts already in this PR):

-- release-lock.lua
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

Alternatively: redis.execute(releaseScript, List.of(SWEEP_LOCK_KEY), owner) via a RedisScript<Long> field.

[MINOR] Bare new ObjectMapper() diverges from the application mapper and is a latent serialisation hazard

src/main/java/com/aim2be/identity/ticket/event/TicketLifecyclePayload.java:60

Verified at line 60:

private static final ObjectMapper MAPPER = new ObjectMapper();  // no JavaTimeModule, no custom serialisers

This mapper has no JavaTimeModule, no findAndRegisterModules(), and none of the custom serialisers registered on the Spring-managed ObjectMapper. For the current payload fields (UUID, Long, Boolean, String) Jackson's defaults happen to produce correct JSON. The latent hazard: any future addition of Instant, LocalDate, OffsetDateTime, or a Jackson-annotated enum will silently produce wrong output — e.g., Instant serialises as a [seconds, nanos] array instead of the epoch-ms long the schema declares.

Fix: Pass the application ObjectMapper as a parameter rather than relying on a bare static instance:

public byte[] toJsonBytes(ObjectMapper mapper) throws JsonProcessingException {
    return mapper.writeValueAsBytes(this);
}

Callers (TicketEventEmitter.serialisePayload, TicketExpiryWorker.serialise) already hold the wired objectMapper — the change is mechanical and eliminates the divergence.

[INFO] Lua variable ticket_value carries the user-context JSON, not the base64url ticket credential

src/main/resources/META-INF/scripts/mint-and-enqueue.lua:95

Verified: Line 69 of the script comment reads ARGV[4] = ticket_value (the user-context JSON blob stored under the ticket key). Line 95 assigns it:

local ticket_value = ARGV[4]   -- user-context JSON blob, NOT the base64url credential

Line 152 stores it: redis.call('SET', ticket_key, ticket_value, 'PX', ticket_ttl_ms) — confirming ticket_value holds the JSON blob that is the value of the Redis key, not the base64url bearer credential (which is embedded in KEYS[1] = centrifugo:ticket:<base64url>).

The name collides with the natural reading of "the value of the ticket" (i.e., the credential). A future Lua editor could mistake ARGV[4] for the bearer credential.

Suggested rename: user_context_json or ticket_ctx_json, matching the Java call-site parameter name userContextJson.

[INFO] TicketEventEmitter is a bare @Component with no @ConditionalOnProperty guard despite injecting conditional-only beans

src/main/java/com/aim2be/identity/ticket/event/TicketEventEmitter.java:24

Verified: TicketEventEmitter is annotated @Component (line 24, no conditional). It injects RedisOutboxKeys, RedisOutboxRelay, and TicketOutboxScripts — beans that exist only when im2be.outbox.backend=REDIS (as indicated by @ConditionalOnProperty on TicketExpiryWorker at line 71 of that file and by RedisOutboxAutoConfiguration's own conditional).

In a deployment where im2be.outbox.backend is absent or set to a different value, TicketEventEmitter will fail with a cryptic NoSuchBeanDefinitionException at startup rather than a clear validation error. TicketExpiryWorker already has the correct guard; TicketEventEmitter should match it:

@Component
@ConditionalOnProperty(prefix = "im2be.outbox", name = "backend", havingValue = "REDIS")
public class TicketEventEmitter {  }

Alternatively, pair this with a @ConfigurationProperties validator that rejects unknown backend values at startup.

Verdict

CONDITIONAL_APPROVE


hib-pr-reviewer • round 1 • 4 findings (2m/2i) • 2026-05-28T13:52:45.106Z → 2026-05-28T13:56:41.449Z • posted-as: pr-reviewer-bot • [bookkeeping fallback]

<!-- hib-pr-reviewer collapsed --> > _Superseded by round 2._ <details> <summary>Show previous round</summary> ## hib-pr-reviewer review — PR #5 (affinity-intelligence-rework/im2be-identity-service) **Round 1** — head `506435070dfc`, base `main`, trigger `opened` **TL;DR:** CONDITIONAL_APPROVE — 2 minor + 2 info findings kept this round. ### Summary Arbiter reconciled 3 (A) + 0 (B) → 4 findings. ### CI status (head `506435070dfc`) _No CI checks reported for this commit._ ### Findings (4) #### **[MINOR]** `releaseLock` compare-and-delete is non-atomic (TOCTOU race) _src/main/java/com/aim2be/identity/ticket/event/TicketExpiryWorker.java:363_ **Verified at lines 363–374.** The comment at line 364 promises "Compare-and-delete so a tick that overran its lock TTL does not delete the new holder's lock" — but the implementation uses two separate Redis round-trips: ```java final String current = redis.opsForValue().get(SWEEP_LOCK_KEY); // step 1 if (owner.equals(current)) { redis.delete(SWEEP_LOCK_KEY); // step 2 — NOT atomic with step 1 } ``` Race window: if the lock TTL expires between step 1 and step 2, Replica B acquires the lock with a new owner UUID, and Replica A's `delete()` removes Replica B's lock. Result: two replicas sweep concurrently. UUIDv3 dedup preserves event-correctness, but the single-writer guarantee and the stated goal of avoiding wasted work and log noise are defeated. **Fix:** Atomise with a short inline Lua EVAL (mirrors the mint/revoke scripts already in this PR): ```lua -- release-lock.lua if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end ``` Alternatively: `redis.execute(releaseScript, List.of(SWEEP_LOCK_KEY), owner)` via a `RedisScript<Long>` field. #### **[MINOR]** Bare `new ObjectMapper()` diverges from the application mapper and is a latent serialisation hazard _src/main/java/com/aim2be/identity/ticket/event/TicketLifecyclePayload.java:60_ **Verified at line 60:** ```java private static final ObjectMapper MAPPER = new ObjectMapper(); // no JavaTimeModule, no custom serialisers ``` This mapper has no `JavaTimeModule`, no `findAndRegisterModules()`, and none of the custom serialisers registered on the Spring-managed `ObjectMapper`. For the current payload fields (`UUID`, `Long`, `Boolean`, `String`) Jackson's defaults happen to produce correct JSON. The latent hazard: any future addition of `Instant`, `LocalDate`, `OffsetDateTime`, or a Jackson-annotated enum will silently produce wrong output — e.g., `Instant` serialises as a `[seconds, nanos]` array instead of the epoch-ms `long` the schema declares. **Fix:** Pass the application `ObjectMapper` as a parameter rather than relying on a bare static instance: ```java public byte[] toJsonBytes(ObjectMapper mapper) throws JsonProcessingException { return mapper.writeValueAsBytes(this); } ``` Callers (`TicketEventEmitter.serialisePayload`, `TicketExpiryWorker.serialise`) already hold the wired `objectMapper` — the change is mechanical and eliminates the divergence. #### **[INFO]** Lua variable `ticket_value` carries the user-context JSON, not the base64url ticket credential _src/main/resources/META-INF/scripts/mint-and-enqueue.lua:95_ **Verified:** Line 69 of the script comment reads `ARGV[4] = ticket_value (the user-context JSON blob stored under the ticket key)`. Line 95 assigns it: ```lua local ticket_value = ARGV[4] -- user-context JSON blob, NOT the base64url credential ``` Line 152 stores it: `redis.call('SET', ticket_key, ticket_value, 'PX', ticket_ttl_ms)` — confirming `ticket_value` holds the JSON blob that is *the value of the Redis key*, not the base64url bearer credential (which is embedded in `KEYS[1] = centrifugo:ticket:<base64url>`). The name collides with the natural reading of "the value of the ticket" (i.e., the credential). A future Lua editor could mistake ARGV[4] for the bearer credential. **Suggested rename:** `user_context_json` or `ticket_ctx_json`, matching the Java call-site parameter name `userContextJson`. #### **[INFO]** `TicketEventEmitter` is a bare `@Component` with no `@ConditionalOnProperty` guard despite injecting conditional-only beans _src/main/java/com/aim2be/identity/ticket/event/TicketEventEmitter.java:24_ **Verified:** `TicketEventEmitter` is annotated `@Component` (line 24, no conditional). It injects `RedisOutboxKeys`, `RedisOutboxRelay`, and `TicketOutboxScripts` — beans that exist only when `im2be.outbox.backend=REDIS` (as indicated by `@ConditionalOnProperty` on `TicketExpiryWorker` at line 71 of that file and by `RedisOutboxAutoConfiguration`'s own conditional). In a deployment where `im2be.outbox.backend` is absent or set to a different value, `TicketEventEmitter` will fail with a cryptic `NoSuchBeanDefinitionException` at startup rather than a clear validation error. `TicketExpiryWorker` already has the correct guard; `TicketEventEmitter` should match it: ```java @Component @ConditionalOnProperty(prefix = "im2be.outbox", name = "backend", havingValue = "REDIS") public class TicketEventEmitter { … } ``` Alternatively, pair this with a `@ConfigurationProperties` validator that rejects unknown backend values at startup. ### Verdict **CONDITIONAL_APPROVE** --- <sub>hib-pr-reviewer • round 1 • 4 findings (2m/2i) • 2026-05-28T13:52:45.106Z → 2026-05-28T13:56:41.449Z • posted-as: pr-reviewer-bot • [bookkeeping fallback]</sub> </details>
R1 verdict CONDITIONAL_APPROVE, findings (kept=4: 2 MINOR / 2 INFO):

(1) MINOR TicketExpiryWorker.releaseLock — non-atomic compare-and-delete
    (get-then-delete TOCTOU): if the sweep-lock TTL lapsed between the GET and
    the DEL, this replica could delete a DIFFERENT replica's re-acquired lock →
    two concurrent sweeps (UUIDv3 keeps events correct, but the single-writer
    guarantee is defeated).
(2) MINOR TicketLifecyclePayload — bare new ObjectMapper() (no JavaTimeModule /
    app serialisers): a latent hazard for any future Instant/date field.
(3) INFO mint-and-enqueue.lua ARGV[4] `ticket_value` mis-named (it is the
    user-context JSON = the key's VALUE, not the base64url credential in KEYS[1]).
(4) INFO TicketEventEmitter bare @Component injecting REDIS-only beans → cryptic
    NoSuchBeanDefinitionException on a non-REDIS profile.

Fix:
- (1) new identity-owned release-sweep-lock.lua (owner-token compare-and-delete
  in one EVAL), loaded via TicketOutboxScripts.releaseSweepLock(); releaseLock
  now runs that EVAL — the GET→DEL race window is closed, single-writer holds.
- (2) dropped the static mapper; toJsonBytes(ObjectMapper) takes it as a param;
  all 3 call sites (TicketEventEmitter, TicketExpiryWorker, the test) pass the
  Spring-managed mapper they already hold.
- (3) renamed ARGV[4] → user_context_json (+ comment block + the SET); confirmed
  revoke-and-enqueue.lua has no analogous mis-naming.
- (4) @ConditionalOnProperty(im2be.outbox.backend=REDIS) on TicketEventEmitter,
  matching TicketExpiryWorker. Ripple: its sole consumer CentrifugoTokenController
  is gated on the same property so the mint slice (controller + emitter) wires or
  omits as ONE unit — a non-REDIS profile cleanly omits the mint endpoint instead
  of a cryptic bean error. This is also the correct fail-safe: per ADR-0014 D-12
  the ticket-lifecycle events are locked at-least-once-via-outbox, so identity
  must NOT mint a ticket it cannot emit the audit/billing/parental-control event
  for; identity's only valid config is backend=REDIS.

Verification:
- mvn -B clean verify → BUILD SUCCESS, 0 [WARNING]
- 141 unit + 13 Testcontainers-Valkey IT green (TicketExpiryWorkerIT exercises
  the atomic releaseLock every sweep).

Superseded by round 3.

Show previous round

hib-pr-reviewer review — PR #5 (affinity-intelligence-rework/im2be-identity-service)

Round 2 — head 1ff8e6ef8d43, base main, trigger synchronize

TL;DR: CONDITIONAL_APPROVE — 2 minor + 2 info findings kept this round.

Summary

Arbiter reconciled 3 (A) + 1 (B) → 4 findings.

CI status (head 1ff8e6ef8d43)

No CI checks reported for this commit.

Findings (4)

[MINOR] relay.relayAsync() inside the broad RuntimeException catch — a post-EVAL relay-submit failure mis-reports a committed mint as failed

src/main/java/com/aim2be/identity/ticket/event/TicketEventEmitter.java:223

After evalMint() at line 210 completes the ticket SETEX + outbox HSET/ZADD are durably committed in Redis. relay.relayAsync() at lines 223-224 then submits the hot-relay task. If that submission throws (e.g. RejectedExecutionException from a saturated pool) it propagates to catch (RuntimeException ex) at line 264, which records OUTCOME_FAILED and re-throws — the controller returns 500 and the client never receives the ticket value.

The impact: a live ticket sits in Redis with its TicketMinted event in the outbox; the cold poller eventually delivers the event to Kafka, producing a phantom TicketMinted that no client holds. The comment at line 221 already calls this path "fire-and-forget" and explicitly states the cold poller covers relay failures — the exception handler contradicts that contract.

Suggested fix: wrap the relay call in its own guarded block:

try {
    relay.relayAsync(toRecord(eventId, userUuid, TicketEventTopics.MINTED,
            TicketLifecyclePayload.MINTED_SCHEMA_VERSION, payload, nowMs));
} catch (RuntimeException relayEx) {
    log.warn("post-mint hot-relay submit failed; cold poller will drain; ticketIdHash={}",
            hashTicket(ticketValue), relayEx);
}

This matches the stated fire-and-forget contract and leaves the cold poller as the documented fallback.

[MINOR] IT fixture wires a bare new ObjectMapper() — diverges from the application mapper

src/test/java/com/aim2be/identity/integration/ticket/AbstractTicketOutboxIT.java:93

objectMapper = new ObjectMapper() at line 93 creates a library-default mapper with no JavaTimeModule, Jdk8Module, or application serializers. Lines 108-110 wire this mapper into TicketEventEmitter and TicketExpiryWorker. At runtime Spring injects the auto-configured ObjectMapper that carries those modules.

Current TicketLifecyclePayload and TicketExpiryContext fields are UUID/long/String/boolean and round-trip identically under both mappers — so no tests fail today. However adding any Instant or LocalDate field to either payload record would silently produce [year,month,day] arrays in the ITs vs ISO-8601 strings in production, the exact scenario the R2 production fix documented.

Suggested fix:

objectMapper = new ObjectMapper().findAndRegisterModules();

This matches the auto-configured mapper's module set without pulling in the Spring context.

[INFO] markRevoked() Javadoc implies a pre-enqueue two-step call pattern — stale; method has no production caller

src/main/java/com/aim2be/identity/ticket/event/TicketRevocationMarker.java:90

Lines 88-91 read: 'Called by the revoke path BEFORE the TicketRevoked enqueue so a concurrent expiry decision observes the revocation.' In practice markRevoked() is never called from any production code path — grep confirms it appears only in TicketRevocationMarkerTest and TicketExpiryWorkerIT (for test setup). Production revocation goes through TicketEventEmitter.revokeAndEnqueue() which writes the marker atomically inside revoke-and-enqueue.lua.

A future developer reading the Javadoc may call markRevoked() separately before revokeAndEnqueue(), producing a redundant non-atomic pre-write that bypasses the Lua guarantee.

Suggested Javadoc update: 'For test setup only. In production the revocation marker is written atomically inside revoke-and-enqueue.lua via TicketEventEmitter.revokeAndEnqueue(); calling this method independently bypasses the atomic Lua guarantee.'

Deferred: the revoke endpoint is out of scope for Phase 1a; Javadoc cleanup belongs to the Wave that adds the revoke RPC.

[INFO] TicketRevocationMarker is a bare @Component while all its REDIS-outbox callers carry @ConditionalOnProperty(REDIS)

src/main/java/com/aim2be/identity/ticket/event/TicketRevocationMarker.java:42

Line 42 is @Component only. Both production callers in this PR — TicketExpiryWorker and TicketEventEmitter — carry @ConditionalOnProperty(prefix="im2be.outbox", name="backend", havingValue="REDIS"). In a non-REDIS configuration the bean is still instantiated (injecting StringRedisTemplate) while neither caller is present — an orphaned, unreachable bean.

Currently harmless (the service always runs REDIS per application.properties). The concern is forward-looking: if the bean acquires a @PostConstruct side-effect or Redis-Cluster-aware init logic, the missing guard would activate it in configurations that have no use for it.

Suggested fix:

@Component
@ConditionalOnProperty(prefix = "im2be.outbox", name = "backend", havingValue = "REDIS")
public class TicketRevocationMarker {

(Note: TicketConsumedMarker is intentionally unconditional — consumed by the unconditional ConnectProxyServiceImpl validate path — and should remain ungated.)

Deferred: harmless in current always-REDIS topology; worth a follow-on ticket rather than blocking this PR.

Verdict

CONDITIONAL_APPROVE


hib-pr-reviewer • round 2 • 4 findings (2m/2i) • 2026-05-28T14:13:28.193Z → 2026-05-28T14:15:20.847Z • posted-as: pr-reviewer-bot • [bookkeeping fallback]

<!-- hib-pr-reviewer collapsed --> > _Superseded by round 3._ <details> <summary>Show previous round</summary> ## hib-pr-reviewer review — PR #5 (affinity-intelligence-rework/im2be-identity-service) **Round 2** — head `1ff8e6ef8d43`, base `main`, trigger `synchronize` **TL;DR:** CONDITIONAL_APPROVE — 2 minor + 2 info findings kept this round. ### Summary Arbiter reconciled 3 (A) + 1 (B) → 4 findings. ### CI status (head `1ff8e6ef8d43`) _No CI checks reported for this commit._ ### Findings (4) #### **[MINOR]** `relay.relayAsync()` inside the broad `RuntimeException` catch — a post-EVAL relay-submit failure mis-reports a committed mint as failed _src/main/java/com/aim2be/identity/ticket/event/TicketEventEmitter.java:223_ After `evalMint()` at line 210 completes the ticket SETEX + outbox HSET/ZADD are durably committed in Redis. `relay.relayAsync()` at lines 223-224 then submits the hot-relay task. If that submission throws (e.g. `RejectedExecutionException` from a saturated pool) it propagates to `catch (RuntimeException ex)` at line 264, which records `OUTCOME_FAILED` and re-throws — the controller returns 500 and the client never receives the ticket value. The impact: a live ticket sits in Redis with its `TicketMinted` event in the outbox; the cold poller eventually delivers the event to Kafka, producing a phantom `TicketMinted` that no client holds. The comment at line 221 already calls this path "fire-and-forget" and explicitly states the cold poller covers relay failures — the exception handler contradicts that contract. **Suggested fix:** wrap the relay call in its own guarded block: ```java try { relay.relayAsync(toRecord(eventId, userUuid, TicketEventTopics.MINTED, TicketLifecyclePayload.MINTED_SCHEMA_VERSION, payload, nowMs)); } catch (RuntimeException relayEx) { log.warn("post-mint hot-relay submit failed; cold poller will drain; ticketIdHash={}", hashTicket(ticketValue), relayEx); } ``` This matches the stated fire-and-forget contract and leaves the cold poller as the documented fallback. #### **[MINOR]** IT fixture wires a bare `new ObjectMapper()` — diverges from the application mapper _src/test/java/com/aim2be/identity/integration/ticket/AbstractTicketOutboxIT.java:93_ `objectMapper = new ObjectMapper()` at line 93 creates a library-default mapper with no `JavaTimeModule`, `Jdk8Module`, or application serializers. Lines 108-110 wire this mapper into `TicketEventEmitter` and `TicketExpiryWorker`. At runtime Spring injects the auto-configured `ObjectMapper` that carries those modules. Current `TicketLifecyclePayload` and `TicketExpiryContext` fields are UUID/long/String/boolean and round-trip identically under both mappers — so no tests fail today. However adding any `Instant` or `LocalDate` field to either payload record would silently produce `[year,month,day]` arrays in the ITs vs ISO-8601 strings in production, the exact scenario the R2 production fix documented. **Suggested fix:** ```java objectMapper = new ObjectMapper().findAndRegisterModules(); ``` This matches the auto-configured mapper's module set without pulling in the Spring context. #### **[INFO]** `markRevoked()` Javadoc implies a pre-enqueue two-step call pattern — stale; method has no production caller _src/main/java/com/aim2be/identity/ticket/event/TicketRevocationMarker.java:90_ Lines 88-91 read: *'Called by the revoke path BEFORE the `TicketRevoked` enqueue so a concurrent expiry decision observes the revocation.'* In practice `markRevoked()` is never called from any production code path — grep confirms it appears only in `TicketRevocationMarkerTest` and `TicketExpiryWorkerIT` (for test setup). Production revocation goes through `TicketEventEmitter.revokeAndEnqueue()` which writes the marker atomically inside `revoke-and-enqueue.lua`. A future developer reading the Javadoc may call `markRevoked()` separately before `revokeAndEnqueue()`, producing a redundant non-atomic pre-write that bypasses the Lua guarantee. **Suggested Javadoc update:** *'For test setup only. In production the revocation marker is written atomically inside `revoke-and-enqueue.lua` via `TicketEventEmitter.revokeAndEnqueue()`; calling this method independently bypasses the atomic Lua guarantee.'* Deferred: the revoke endpoint is out of scope for Phase 1a; Javadoc cleanup belongs to the Wave that adds the revoke RPC. #### **[INFO]** `TicketRevocationMarker` is a bare `@Component` while all its REDIS-outbox callers carry `@ConditionalOnProperty(REDIS)` _src/main/java/com/aim2be/identity/ticket/event/TicketRevocationMarker.java:42_ Line 42 is `@Component` only. Both production callers in this PR — `TicketExpiryWorker` and `TicketEventEmitter` — carry `@ConditionalOnProperty(prefix="im2be.outbox", name="backend", havingValue="REDIS")`. In a non-REDIS configuration the bean is still instantiated (injecting `StringRedisTemplate`) while neither caller is present — an orphaned, unreachable bean. Currently harmless (the service always runs REDIS per `application.properties`). The concern is forward-looking: if the bean acquires a `@PostConstruct` side-effect or Redis-Cluster-aware init logic, the missing guard would activate it in configurations that have no use for it. **Suggested fix:** ```java @Component @ConditionalOnProperty(prefix = "im2be.outbox", name = "backend", havingValue = "REDIS") public class TicketRevocationMarker { ``` (Note: `TicketConsumedMarker` is intentionally unconditional — consumed by the unconditional `ConnectProxyServiceImpl` validate path — and should remain ungated.) Deferred: harmless in current always-REDIS topology; worth a follow-on ticket rather than blocking this PR. ### Verdict **CONDITIONAL_APPROVE** --- <sub>hib-pr-reviewer • round 2 • 4 findings (2m/2i) • 2026-05-28T14:13:28.193Z → 2026-05-28T14:15:20.847Z • posted-as: pr-reviewer-bot • [bookkeeping fallback]</sub> </details>
R2 verdict CONDITIONAL_APPROVE, findings (kept=4: 2 MINOR / 2 INFO):

(1) MINOR (real correctness bug) TicketEventEmitter — the relayAsync() submit sat
    inside the broad catch(RuntimeException). The mint is durably committed
    BEFORE the relay submit; a submit throw (e.g. RejectedExecutionException on a
    saturated pool) was recorded OUTCOME_FAILED + re-thrown → controller 500 →
    client got no ticket while a live ticket + PENDING TicketMinted sat in Redis
    (phantom event + client retry → second ticket). Contradicts the documented
    fire-and-forget contract.
(2) MINOR AbstractTicketOutboxIT bare new ObjectMapper() — diverges from the app
    mapper (no module discovery).
(3) INFO TicketRevocationMarker.markRevoked() stale Javadoc (implied a two-step
    pre-enqueue pattern; it is test-only — prod writes the marker atomically in
    revoke-and-enqueue.lua).
(4) INFO TicketRevocationMarker bare @Component while both callers are REDIS-gated
    → orphaned bean in a non-REDIS config.

Fix:
- (1) wrapped ONLY the relayAsync() submit in its own try/catch that WARN-logs
  (ticket id hashed; raw credential/user-context never logged) + swallows — the
  mint is committed and the cold poller is the documented fallback. A
  post-commit relay-submit failure is NOT a mint failure, so it no longer
  records OUTCOME_FAILED; added a distinct OUTCOME_RELAY_DEGRADED label on
  identity_ticket_lifecycle_events_total so on-call reads it as relay-degraded,
  not a lost mint. RIPPLE (rule 63): the revoke path had the identical
  post-commit-submit-in-broad-catch bug — fixed both. New regression IT
  relaySubmitFailureAfterCommitDoesNotFailTheMint (injects a relay throwing
  RejectedExecutionException; asserts the mint does NOT throw + the ticket +
  PENDING TicketMinted are committed — rule 18).
- (2) IT fixture now new ObjectMapper().findAndRegisterModules() (app-equivalent).
- (3) Javadoc → "For test setup only; prod writes atomically via
  revoke-and-enqueue.lua; calling independently bypasses the Lua guarantee."
- (4) @ConditionalOnProperty(im2be.outbox.backend=REDIS) on TicketRevocationMarker.

Verification:
- mvn -B clean verify → BUILD SUCCESS, 0 [WARNING]
- 141 unit + 14 IT green (TicketEventEmitterMintIT 5→6).

Superseded by round 4.

Show previous round

hib-pr-reviewer review — PR #5 (affinity-intelligence-rework/im2be-identity-service)

Round 3 — head ff55b65ae734, base main, trigger synchronize

TL;DR: NEEDS_WORK — kept 1 agreed major finding (pom.xml SNAPSHOT, no remote repo entry confirmed), dropped 1 unique-to-A finding (TicketConsumedMarker @Nullable premise disproved: class is plain @Component, not @ConditionalOnProperty).

Summary

Arbitration — Round 3

What I verified

Finding 1 — pom.xml SNAPSHOT (agreed A + B): Read pom.xml lines 85-109. The <platform-libs.version>1.1.0-SNAPSHOT</platform-libs.version> tag is at line 99 (Reviewer A correct; Reviewer B cited line 90, the start of the enclosing comment block). Critically, a full grep for repositories and distributionManagement in pom.xml returned no matches — confirming B's central claim that Maven has no remote repository entry to resolve this SNAPSHOT coordinate, so any clean CI agent will fail. Finding kept; severity elevated to major (B's assessment is grounded: CI build failure is a blocking consequence, not merely a portability concern).

Finding 2 — ConnectProxyServiceImpl missing @Nullable (unique to A): Reviewer A's premise is that TicketConsumedMarker carries @ConditionalOnProperty(havingValue="REDIS"), making it an absent bean on non-REDIS deployments. Reading TicketConsumedMarker.java lines 1-60 directly disproves this: the class is annotated only with @Component (line 32), no @ConditionalOnProperty. It is registered unconditionally whenever Spring boots. The NoSuchBeanDefinitionException scenario Reviewer A described cannot occur from a missing conditional property, so the @Nullable prescription is unwarranted. Reviewer A appears to have conflated this class with TicketRevocationMarker (which does carry the REDIS guard at line 50). Finding dropped.

Outcome

Kept 1 agreed finding (severity raised to major per verification); dropped 1 unique-to-A finding (premise disproved by source read).

Blast Radius

The SNAPSHOT dependency affects all build environments and every downstream consumer of this service's artifacts. The lack of a remote repository entry means the failure is systemic across any CI agent or developer machine that has not manually pre-installed the SNAPSHOT. The remaining code changes are well-scoped to the new ticket-lifecycle outbox subsystem.

BLAST_SCORE: 6/10

Risk Indicators

Indicator Value
Sensitive functions TicketConsumedMarker.markConsumed, ConnectProxyServiceImpl.doValidate, CentrifugoTokenController
Migration touched
Test delta
Dependency changes

CI status (head ff55b65ae734)

No CI checks reported for this commit.

Findings (1)

[MAJOR] platform-libs.version=1.1.0-SNAPSHOT is a mutable SNAPSHOT with no remote repository — clean CI builds will fail

pom.xml:99

<platform-libs.version>1.1.0-SNAPSHOT</platform-libs.version> (line 99) introduces a mutable SNAPSHOT coordinate into the production POM. A full search of the file confirms there is no <repositories> or <distributionManagement> block; Maven cannot resolve this coordinate from any remote location beyond the local ~/.m2 cache.

Consequences:

  1. Any clean CI build (fresh agent, ephemeral Docker image without a pre-baked ~/.m2 layer) will fail with Could not resolve … redis-outbox-backend:1.1.0-SNAPSHOT.
  2. SNAPSHOT is mutable — two builds from the same commit can silently produce different binaries if the SNAPSHOT is re-installed between runs, violating reproducible-build guarantees.
  3. The Spring Boot version skew (compiled against 3.5.14, consumed on 3.3.13) is documented, but re-publishing the SNAPSHOT can change that silently without a version bump.

Concrete fix (pick one):

  • Promote redis-outbox-backend to a release coordinate (e.g. 1.1.0) and publish to the team's shared Nexus/Artifactory; update the version property here.
  • If the SNAPSHOT lifecycle must remain, add a <repositories> stanza pointing at the internal snapshot repo so CI can resolve without pre-installation, and document the prerequisite explicitly in the README.
  • For a monorepo setup, bring platform-libs in as a Maven multi-module reactor dependency so it builds in the same mvn install invocation.

Verdict

NEEDS_WORK


hib-pr-reviewer • round 3 • 1 finding (1M) • 2026-05-28T14:30:42.817Z → 2026-05-28T14:32:11.389Z • posted-as: pr-reviewer-bot

<!-- hib-pr-reviewer collapsed --> > _Superseded by round 4._ <details> <summary>Show previous round</summary> ## hib-pr-reviewer review — PR #5 (affinity-intelligence-rework/im2be-identity-service) **Round 3** — head `ff55b65ae734`, base `main`, trigger `synchronize` **TL;DR:** NEEDS_WORK — kept 1 agreed major finding (pom.xml SNAPSHOT, no remote repo entry confirmed), dropped 1 unique-to-A finding (TicketConsumedMarker @Nullable premise disproved: class is plain @Component, not @ConditionalOnProperty). ### Summary ## Arbitration — Round 3 ### What I verified **Finding 1 — pom.xml SNAPSHOT (agreed A + B):** Read pom.xml lines 85-109. The `<platform-libs.version>1.1.0-SNAPSHOT</platform-libs.version>` tag is at **line 99** (Reviewer A correct; Reviewer B cited line 90, the start of the enclosing comment block). Critically, a full `grep` for `repositories` and `distributionManagement` in pom.xml returned **no matches** — confirming B's central claim that Maven has no remote repository entry to resolve this SNAPSHOT coordinate, so any clean CI agent will fail. Finding kept; severity elevated to **major** (B's assessment is grounded: CI build failure is a blocking consequence, not merely a portability concern). **Finding 2 — `ConnectProxyServiceImpl` missing `@Nullable` (unique to A):** Reviewer A's premise is that `TicketConsumedMarker` carries `@ConditionalOnProperty(havingValue="REDIS")`, making it an absent bean on non-REDIS deployments. Reading `TicketConsumedMarker.java` lines 1-60 directly disproves this: the class is annotated **only** with `@Component` (line 32), no `@ConditionalOnProperty`. It is registered unconditionally whenever Spring boots. The `NoSuchBeanDefinitionException` scenario Reviewer A described cannot occur from a missing conditional property, so the `@Nullable` prescription is unwarranted. Reviewer A appears to have conflated this class with `TicketRevocationMarker` (which does carry the REDIS guard at line 50). **Finding dropped.** ### Outcome Kept 1 agreed finding (severity raised to major per verification); dropped 1 unique-to-A finding (premise disproved by source read). ### Blast Radius The SNAPSHOT dependency affects all build environments and every downstream consumer of this service's artifacts. The lack of a remote repository entry means the failure is systemic across any CI agent or developer machine that has not manually pre-installed the SNAPSHOT. The remaining code changes are well-scoped to the new ticket-lifecycle outbox subsystem. **BLAST_SCORE: 6/10** ### Risk Indicators | Indicator | Value | |---|---| | Sensitive functions | `TicketConsumedMarker.markConsumed`, `ConnectProxyServiceImpl.doValidate`, `CentrifugoTokenController` | | Migration touched | — | | Test delta | — | | Dependency changes | — | ### CI status (head `ff55b65ae734`) _No CI checks reported for this commit._ ### Findings (1) #### **[MAJOR]** `platform-libs.version=1.1.0-SNAPSHOT` is a mutable SNAPSHOT with no remote repository — clean CI builds will fail _pom.xml:99_ `<platform-libs.version>1.1.0-SNAPSHOT</platform-libs.version>` (line 99) introduces a mutable SNAPSHOT coordinate into the production POM. A full search of the file confirms there is **no `<repositories>` or `<distributionManagement>` block**; Maven cannot resolve this coordinate from any remote location beyond the local `~/.m2` cache. Consequences: 1. **Any clean CI build** (fresh agent, ephemeral Docker image without a pre-baked `~/.m2` layer) will fail with `Could not resolve … redis-outbox-backend:1.1.0-SNAPSHOT`. 2. **SNAPSHOT is mutable** — two builds from the same commit can silently produce different binaries if the SNAPSHOT is re-installed between runs, violating reproducible-build guarantees. 3. The Spring Boot version skew (compiled against 3.5.14, consumed on 3.3.13) is documented, but re-publishing the SNAPSHOT can change that silently without a version bump. **Concrete fix (pick one):** - Promote `redis-outbox-backend` to a release coordinate (e.g. `1.1.0`) and publish to the team's shared Nexus/Artifactory; update the version property here. - If the SNAPSHOT lifecycle must remain, add a `<repositories>` stanza pointing at the internal snapshot repo so CI can resolve without pre-installation, and document the prerequisite explicitly in the README. - For a monorepo setup, bring `platform-libs` in as a Maven multi-module reactor dependency so it builds in the same `mvn install` invocation. ### Verdict **NEEDS_WORK** --- <sub>hib-pr-reviewer • round 3 • 1 finding (1M) • 2026-05-28T14:30:42.817Z → 2026-05-28T14:32:11.389Z • posted-as: pr-reviewer-bot</sub> </details>
R3 verdict NEEDS_WORK, 1 MAJOR:

(1) MAJOR pom.xml — platform-libs 1.1.0-SNAPSHOT had no remote repository, so a
    clean/ephemeral build (no pre-baked ~/.m2) could not resolve the coordinate.

Fix (operator-chosen "build the Maven registry first" over the Option-B
local-install precedent): platform-libs is now published to the Forgejo Maven
package registry (PR-PLATFORM-CI-1, platform-libs PR #10); identity adds a
matching <repositories id="forgejo-air"> so the SNAPSHOT resolves remotely. The
stale "installed in the local ~/.m2" comment is corrected.

Verified: a FRESH empty local repo resolved
com.aim2be:redis-outbox-backend:1.1.0-SNAPSHOT from the registry (BUILD SUCCESS);
registry read is anonymous/public (HTTP 200, no token) so a clean CI resolves
with zero creds. mvn clean verify → BUILD SUCCESS, 0 [WARNING], 141 unit + 14 IT.

hib-pr-reviewer review — PR #5 (affinity-intelligence-rework/im2be-identity-service)

Round 4 — head 4c7ea60775e7, base main, trigger synchronize

TL;DR: NO_NEW_FINDINGS — No new findings this round.

Summary

[quorum-converged] A=0 = B=0. # hib-pr-reviewer/quorum/A — round 4

Reviewing PR #5 in affinity-intelligence-rework/im2be-identity-service
(head 4c7ea60, base main).

CI status (head 4c7ea60775e7)

No CI checks reported for this commit.

Findings

No new findings this round.

Quorum converged on empty findings (A + B both returned 0).

Verdict

NO_NEW_FINDINGS


hib-pr-reviewer • round 4 • 0 findings • 2026-05-28T15:17:17.754Z → 2026-05-28T15:24:31.359Z • posted-as: pr-reviewer-bot • [bookkeeping fallback]

## hib-pr-reviewer review — PR #5 (affinity-intelligence-rework/im2be-identity-service) **Round 4** — head `4c7ea60775e7`, base `main`, trigger `synchronize` **TL;DR:** NO_NEW_FINDINGS — No new findings this round. ### Summary [quorum-converged] A=0 = B=0. # hib-pr-reviewer/quorum/A — round 4 Reviewing PR **#5** in `affinity-intelligence-rework/im2be-identity-service` (head `4c7ea60`, base `main`). ### CI status (head `4c7ea60775e7`) _No CI checks reported for this commit._ ### Findings **No new findings this round.** _Quorum converged on empty findings (A + B both returned 0)._ ### Verdict **NO_NEW_FINDINGS** --- <sub>hib-pr-reviewer • round 4 • 0 findings • 2026-05-28T15:17:17.754Z → 2026-05-28T15:24:31.359Z • posted-as: pr-reviewer-bot • [bookkeeping fallback]</sub>
hibryda deleted branch feat/redis-outbox-mint 2026-05-28 17:31:14 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
affinity-intelligence-rework/im2be-identity-service!5
No description provided.