From 09208f56e486ce471c43329bb956ccb728d4e7e0 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 27 Sep 2026 08:15:55 +0800 Subject: [PATCH] feat: add Business ID permutation V1 (#181) --- examples/business-id-runtime/README.md | 13 +- .../BusinessIdRuntimeExampleTest.java | 133 ++++++++++++++---- .../JdbcBusinessIdLiveDialectIT.java | 15 +- .../jdbc/JdbcBusinessIdAllocator.java | 9 +- .../core/businessid/BusinessIdDefinition.java | 25 +++- .../businessid/BusinessIdEncodingKey.java | 32 +++++ .../core/businessid/BusinessIdErrorCode.java | 4 +- .../businessid/BusinessIdKeyProvider.java | 9 ++ .../teaql/core/businessid/BusinessIdPlan.java | 8 ++ .../io/teaql/runtime/DefaultUserContext.java | 4 + .../java/io/teaql/runtime/TeaQLRuntime.java | 15 ++ .../businessid/BusinessIdPermutationV1.java | 102 ++++++++++++++ .../DailySequenceBusinessIdProfile.java | 3 +- .../DefaultBusinessIdProfileFactory.java | 9 ++ .../InMemoryBusinessIdAllocator.java | 6 +- .../PermutedDailyBusinessIdProfile.java | 101 +++++++++++++ .../StaticBusinessIdKeyProvider.java | 22 +++ .../BusinessIdPermutationV1Test.java | 90 ++++++++++++ .../InMemoryBusinessIdAllocatorTest.java | 23 ++- .../resources/business-id-permutation-v1.csv | 11 ++ 20 files changed, 579 insertions(+), 55 deletions(-) create mode 100644 teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdEncodingKey.java create mode 100644 teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdKeyProvider.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/businessid/BusinessIdPermutationV1.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/businessid/PermutedDailyBusinessIdProfile.java create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/businessid/StaticBusinessIdKeyProvider.java create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/businessid/BusinessIdPermutationV1Test.java create mode 100644 teaql-runtime/src/test/resources/business-id-permutation-v1.csv diff --git a/examples/business-id-runtime/README.md b/examples/business-id-runtime/README.md index 83e3a48f..3b959c99 100644 --- a/examples/business-id-runtime/README.md +++ b/examples/business-id-runtime/README.md @@ -1,8 +1,9 @@ # Business ID focused runtime example -This example verifies the new Java runtime Business ID boundary without running -the full multi-module regression suite. It covers the default eight-digit -daily sequence, controlled Context date, root scoping, Mutation Ledger retry +This example verifies the Java runtime Business ID boundary without running +the full multi-module regression suite. It covers the default six-character +volume-obscuring Base36 profile, controlled Context date, Domain Root scoping, +the byte-identical Java/Rust golden vectors, Mutation Ledger retry stability, explicit SQLite schema setup, cross-instance allocation, restart, strong `OrderNumber` parsing, and immutable established aggregates. @@ -14,6 +15,12 @@ the context's `BusinessClock`; tests can replace that clock. The JDBC allocator uses root/aggregate/namespace/date scoping, optimistic allocation, bounded retry, and a range error when the configured digits are exhausted. +The default `daily-permuted-v1` profile additionally requires a +`BusinessIdKeyProvider` capability. The example installs a deterministic +32-byte test key; production applications must obtain versioned key material +from their own secret provider. The profile is volume-obscuring, not an +authentication token or substitute for authorization. + For an existing counter table, `context.ensureSchema()` verifies the four required columns, a single-column `scope_key` primary or unique key, a text `scope_key` with capacity at least 512, and non-null 64-bit integral counter, diff --git a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java index fab97c69..651604f3 100644 --- a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java +++ b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/BusinessIdRuntimeExampleTest.java @@ -27,7 +27,7 @@ public class BusinessIdRuntimeExampleTest { public record OrderNumber(String value) { public OrderNumber { - if (value == null || !value.matches("CO-\\d{8}-\\d{8}")) { + if (value == null || !value.matches("CO-\\d{8}-[0-9A-Z]{6}")) { throw new IllegalArgumentException("invalid OrderNumber: " + value); } } @@ -82,7 +82,7 @@ public void inMemoryProfileIsScopedTypedAndRetryStable() { Order first = new Order(101); BusinessIdValue initial = context.businessIds().ensure( context, ORDER_NUMBER, "tenant-a", "commerce_order", first); - Assert.assertEquals("CO-20260920-00000001", initial.value()); + Assert.assertEquals("CO-20260920-KSHQ82", initial.value()); Assert.assertEquals(new OrderNumber(initial.value()), first.orderNumber()); Assert.assertEquals( first.orderNumber(), @@ -96,20 +96,20 @@ public void inMemoryProfileIsScopedTypedAndRetryStable() { Order second = new Order(102); Assert.assertEquals( - "CO-20260920-00000002", + "CO-20260920-B8S7XN", context.businessIds().ensure( context, ORDER_NUMBER, "tenant-a", "commerce_order", second).value()); Order otherTenant = new Order(103); Assert.assertEquals( - "CO-20260920-00000001", + "CO-20260920-Y5Z052", context.businessIds().ensure( context, ORDER_NUMBER, "tenant-b", "commerce_order", otherTenant).value()); UserContext nextDay = context(LocalDate.of(2026, 9, 21)); nextDay.putAttribute(BusinessIdService.class.getName(), service); Assert.assertEquals( - "CO-20260921-00000001", + "CO-20260921-XAUWT0", nextDay.businessIds().ensure( nextDay, ORDER_NUMBER, "tenant-a", "commerce_order", new Order(104)).value()); @@ -124,18 +124,67 @@ public void inMemoryProfileIsScopedTypedAndRetryStable() { "commerce_order", establishedWithoutNumber)); Assert.assertEquals(BusinessIdErrorCode.BUSINESS_ID_IMMUTABLE, immutable.getCode()); + + Order impossibleDate = new Order(109); + impossibleDate.orderNumber = new OrderNumber("CO-20260231-ABC123"); + BusinessIdException invalidDate = Assert.assertThrows( + BusinessIdException.class, + () -> context.businessIds().ensure( + context, + ORDER_NUMBER, + "tenant-a", + "commerce_order", + impossibleDate)); + Assert.assertEquals( + BusinessIdErrorCode.BUSINESS_ID_FORMAT_INVALID, invalidDate.getCode()); + } + + @Test + public void defaultProfileFailsClosedWithoutKeyProvider() { + UserContext context = context(LocalDate.of(2026, 9, 20), null, false); + DefaultBusinessIdService service = + new DefaultBusinessIdService(new InMemoryBusinessIdAllocator()); + context.putAttribute(BusinessIdService.class.getName(), service); + + BusinessIdException failure = Assert.assertThrows( + BusinessIdException.class, + () -> context.businessIds().ensure( + context, + ORDER_NUMBER, + "domain-root-a", + "commerce_order", + new Order(106))); + Assert.assertEquals( + BusinessIdErrorCode.BUSINESS_ID_KEY_NOT_FOUND, failure.getCode()); + } + + @Test + public void explicitLegacyProfileRetainsOneBasedDecimalSequence() { + BusinessIdDefinition legacy = BusinessIdDefinition.legacyDailySequence( + "legacy_order_number", "CO", "legacy_order_number"); + DailySequenceBusinessIdProfile profile = new DailySequenceBusinessIdProfile(); + BusinessIdPlan plan = profile.plan(new BusinessIdGenerationRequest( + legacy, + "domain-root-a", + "commerce_order", + LocalDate.of(2026, 9, 20))); + InMemoryBusinessIdAllocator allocator = new InMemoryBusinessIdAllocator(); + Assert.assertEquals( + "CO-20260920-00000001", + profile.format(plan, allocator.allocate(plan)).value()); + Assert.assertEquals( + "CO-20260920-00000002", + profile.format(plan, allocator.allocate(plan)).value()); + Assert.assertEquals( + "CO-20260920-00000001", + profile.validate(legacy, "CO-20260920-00000001").value()); } @Test public void sqliteAllocatorIsExplicitCrossInstanceAndRestartSafe() throws Exception { Path databasePath = Files.createTempFile("teaql-business-id", ".db"); try { - BusinessIdPlan plan = new DailySequenceBusinessIdProfile().plan( - new BusinessIdGenerationRequest( - ORDER_NUMBER, - "tenant-a", - "commerce_order", - LocalDate.of(2026, 9, 20))); + BusinessIdPlan plan = plan(LocalDate.of(2026, 9, 20)); try (Connection schemaConnection = open(databasePath)) { JdbcBusinessIdAllocator allocator = @@ -179,7 +228,18 @@ public void sqliteAllocatorIsExplicitCrossInstanceAndRestartSafe() throws Except try (Connection restarted = open(databasePath)) { JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator(database(restarted)); - Assert.assertEquals(51, allocator.allocate(plan).sequence()); + Assert.assertEquals(50, allocator.allocate(plan).sequence()); + + BusinessIdDefinition legacy = BusinessIdDefinition.legacyDailySequence( + "legacy_order_number", "CO", "legacy_order_number"); + BusinessIdPlan legacyPlan = new DailySequenceBusinessIdProfile().plan( + new BusinessIdGenerationRequest( + legacy, + "domain-root-a", + "commerce_order", + LocalDate.of(2026, 9, 20))); + Assert.assertEquals(1, allocator.allocate(legacyPlan).sequence()); + Assert.assertEquals(2, allocator.allocate(legacyPlan).sequence()); } } finally { Files.deleteIfExists(databasePath); @@ -192,10 +252,7 @@ public void sqliteAllocatorReportsMissingSchemaWithoutRetryExhaustion() throws E try (Connection connection = open(databasePath)) { JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator(database(connection)); - BusinessIdPlan plan = new DailySequenceBusinessIdProfile().plan( - new BusinessIdGenerationRequest( - ORDER_NUMBER, "tenant-a", "commerce_order", - LocalDate.of(2026, 9, 20))); + BusinessIdPlan plan = plan(LocalDate.of(2026, 9, 20)); IllegalStateException failure = Assert.assertThrows( IllegalStateException.class, () -> allocator.allocate(plan)); @@ -215,9 +272,7 @@ public void sqliteAllocatorRejectsMalformedSequenceRowWithoutRetryExhaustion() JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator(database(connection)); LocalDate businessDate = LocalDate.of(2026, 9, 20); - BusinessIdPlan plan = new DailySequenceBusinessIdProfile().plan( - new BusinessIdGenerationRequest( - ORDER_NUMBER, "tenant-a", "commerce_order", businessDate)); + BusinessIdPlan plan = plan(businessDate); UserContext context = context(businessDate, allocator); context.putAttribute(SchemaExecutor.class.getName(), noOpSchemaExecutor()); context.ensureSchema(); @@ -319,11 +374,9 @@ public void sqliteAllocatorAcceptsNonNullableUniqueScopeKey() throws Exception { context.putAttribute(SchemaExecutor.class.getName(), noOpSchemaExecutor()); context.ensureSchema(); context.ensureSchema(); - BusinessIdPlan plan = new DailySequenceBusinessIdProfile().plan( - new BusinessIdGenerationRequest(ORDER_NUMBER, "tenant-a", - "commerce_order", LocalDate.of(2026, 9, 20))); + BusinessIdPlan plan = plan(LocalDate.of(2026, 9, 20)); + Assert.assertEquals(0L, allocator.allocate(plan).sequence()); Assert.assertEquals(1L, allocator.allocate(plan).sequence()); - Assert.assertEquals(2L, allocator.allocate(plan).sequence()); } finally { Files.deleteIfExists(databasePath); } @@ -355,9 +408,7 @@ public void sqliteAllocatorRejectsExhaustedAndOverflowedSequencesWithoutMutation Path databasePath = Files.createTempFile("teaql-business-id-range", ".db"); try { LocalDate businessDate = LocalDate.of(2026, 9, 20); - BusinessIdPlan plan = new DailySequenceBusinessIdProfile().plan( - new BusinessIdGenerationRequest( - ORDER_NUMBER, "tenant-a", "commerce_order", businessDate)); + BusinessIdPlan plan = plan(businessDate); try (Connection connection = open(databasePath)) { JdbcBusinessIdAllocator allocator = new JdbcBusinessIdAllocator(database(connection)); @@ -411,6 +462,13 @@ private static UserContext context(LocalDate date) { static UserContext context( LocalDate date, JdbcBusinessIdAllocator businessIdInfrastructure) { + return context(date, businessIdInfrastructure, true); + } + + static UserContext context( + LocalDate date, + JdbcBusinessIdAllocator businessIdInfrastructure, + boolean registerKeyProvider) { TeaQLRuntime.Builder builder = TeaQLRuntime.builder() .metadata(new EntityMetaFactory() { @Override @@ -427,6 +485,9 @@ public List allEntityDescriptors() { } }) .executionLogging(false); + if (registerKeyProvider) { + builder.businessIdKeyProvider(testKeyProvider()); + } if (businessIdInfrastructure != null) { builder.businessIdInfrastructure(businessIdInfrastructure); } @@ -439,6 +500,26 @@ public List allEntityDescriptors() { return context; } + static BusinessIdPlan plan(LocalDate businessDate) { + return plan(businessDate, "tenant-a"); + } + + static BusinessIdPlan plan(LocalDate businessDate, String domainRootKey) { + UserContext context = context(businessDate); + return new PermutedDailyBusinessIdProfile(context, testKeyProvider()).plan( + new BusinessIdGenerationRequest( + ORDER_NUMBER, domainRootKey, "commerce_order", businessDate)); + } + + static StaticBusinessIdKeyProvider testKeyProvider() { + return new StaticBusinessIdKeyProvider(new BusinessIdEncodingKey(1, new byte[] { + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f + })); + } + private static Connection open(Path path) throws SQLException { Connection connection = DriverManager.getConnection("jdbc:sqlite:" + path); try (Statement statement = connection.createStatement()) { diff --git a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/JdbcBusinessIdLiveDialectIT.java b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/JdbcBusinessIdLiveDialectIT.java index b5f4de10..7c08da65 100644 --- a/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/JdbcBusinessIdLiveDialectIT.java +++ b/examples/business-id-runtime/src/test/java/io/teaql/examples/businessid/JdbcBusinessIdLiveDialectIT.java @@ -3,10 +3,7 @@ import io.teaql.businessid.jdbc.JdbcBusinessIdAllocator; import io.teaql.core.SchemaExecutor; import io.teaql.core.UserContext; -import io.teaql.core.businessid.BusinessIdDefinition; -import io.teaql.core.businessid.BusinessIdGenerationRequest; import io.teaql.core.businessid.BusinessIdPlan; -import io.teaql.runtime.businessid.DailySequenceBusinessIdProfile; import io.teaql.core.sql.dialect.OracleDialect; import java.sql.Connection; import java.sql.DriverManager; @@ -137,12 +134,8 @@ private void verify(String dialect) throws Exception { url.contains("teaql_live_")); } - BusinessIdDefinition definition = BusinessIdDefinition.dailySequence( - "order_number", "CO", "commerce_order"); - BusinessIdPlan plan = new DailySequenceBusinessIdProfile().plan( - new BusinessIdGenerationRequest( - definition, "tenant-" + UUID.randomUUID(), - "commerce_order", BUSINESS_DATE)); + BusinessIdPlan plan = BusinessIdRuntimeExampleTest.plan( + BUSINESS_DATE, "tenant-" + UUID.randomUUID()); try (Connection schemaConnection = DriverManager.getConnection(url, user, password)) { JdbcBusinessIdAllocator allocator = allocator(schemaConnection, dialect); @@ -178,7 +171,7 @@ private void verify(String dialect) throws Exception { } int total = 2 * ALLOCATIONS_PER_INSTANCE; Assert.assertEquals(total, allocated.size()); - for (long sequence = 1; sequence <= total; sequence++) { + for (long sequence = 0; sequence < total; sequence++) { Assert.assertTrue("Missing sequence " + sequence, allocated.contains(sequence)); } } finally { @@ -187,7 +180,7 @@ private void verify(String dialect) throws Exception { try (Connection restarted = DriverManager.getConnection(url, user, password)) { JdbcBusinessIdAllocator allocator = allocator(restarted, dialect); - Assert.assertEquals(2L * ALLOCATIONS_PER_INSTANCE + 1, + Assert.assertEquals(2L * ALLOCATIONS_PER_INSTANCE, allocator.allocate(plan).sequence()); } } diff --git a/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java b/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java index 00c70e28..d0f96e30 100644 --- a/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java +++ b/teaql-business-id-jdbc/src/main/java/io/teaql/businessid/jdbc/JdbcBusinessIdAllocator.java @@ -231,8 +231,8 @@ public BusinessIdAllocation allocate(BusinessIdPlan plan) { inserted = database.executeUpdate( "INSERT INTO " + table + " (scope_key, current_value, version, updated_at)" - + " VALUES (?, 1, 1, ?)", - new Object[]{scopeKey, updatedAt}); + + " VALUES (?, ?, 1, ?)", + new Object[]{scopeKey, plan.initialSequence(), updatedAt}); } catch (RuntimeException insertionFailure) { lastConflict = insertionFailure; // A competing instance may have inserted this key. Do not retry an @@ -258,7 +258,8 @@ public BusinessIdAllocation allocate(BusinessIdPlan plan) { } if (inserted != null) { if (inserted == 1) { - return new BusinessIdAllocation(plan.scope(), 1); + return new BusinessIdAllocation( + plan.scope(), plan.initialSequence()); } throw new IllegalStateException( "Expected one inserted Business ID row for " + scopeKey @@ -268,7 +269,7 @@ public BusinessIdAllocation allocate(BusinessIdPlan plan) { Map row = rows.get(0); long current = number(row, "current_value"); long version = number(row, "version"); - if (current < 1 || version < 1) { + if (current < 0 || version < 1) { throw new IllegalStateException( "Invalid Business ID sequence row for " + scopeKey); } diff --git a/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdDefinition.java b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdDefinition.java index 25940ab9..6bb265cf 100644 --- a/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdDefinition.java +++ b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdDefinition.java @@ -11,9 +11,10 @@ public record BusinessIdDefinition( String namespace, int policyVersion) { - public static final String DEFAULT_PROFILE = "daily-sequence"; + public static final String DEFAULT_PROFILE = "daily-permuted-v1"; + public static final String LEGACY_DAILY_SEQUENCE_PROFILE = "daily-sequence"; public static final String DEFAULT_DATE_FORMAT = "yyyyMMdd"; - public static final int DEFAULT_DIGITS = 8; + public static final int DEFAULT_DIGITS = 6; public BusinessIdDefinition { fieldName = requireText(fieldName, "fieldName"); @@ -23,6 +24,9 @@ public record BusinessIdDefinition( reset = requireText(reset, "reset"); separator = requireText(separator, "separator"); namespace = requireText(namespace, "namespace"); + if (DEFAULT_PROFILE.equals(profile) && digits != DEFAULT_DIGITS) { + throw invalid("daily-permuted-v1 requires exactly 6 digits"); + } if (digits < 1 || digits > 18) { throw invalid("digits must be between 1 and 18"); } @@ -46,6 +50,9 @@ public static BusinessIdDefinition dailySequence( } public long maximumSequence() { + if (DEFAULT_PROFILE.equals(profile)) { + return 2_176_782_335L; + } long maximum = 1; for (int i = 0; i < digits; i++) { maximum = Math.multiplyExact(maximum, 10); @@ -53,6 +60,20 @@ public long maximumSequence() { return maximum - 1; } + public static BusinessIdDefinition legacyDailySequence( + String fieldName, String prefix, String namespace) { + return new BusinessIdDefinition( + fieldName, + LEGACY_DAILY_SEQUENCE_PROFILE, + prefix, + DEFAULT_DATE_FORMAT, + "daily", + 8, + "-", + namespace, + 1); + } + private static String requireText(String value, String field) { if (value == null || value.isBlank()) { throw invalid(field + " must not be blank"); diff --git a/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdEncodingKey.java b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdEncodingKey.java new file mode 100644 index 00000000..0c17210f --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdEncodingKey.java @@ -0,0 +1,32 @@ +package io.teaql.core.businessid; + +import java.util.Arrays; + +/** Versioned 256-bit key material supplied by an application-owned provider. */ +public final class BusinessIdEncodingKey { + private final int version; + private final byte[] bytes; + + public BusinessIdEncodingKey(int version, byte[] bytes) { + if (version < 1) { + throw new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_DEFINITION_INVALID, + "Business ID key version must be positive"); + } + if (bytes == null || bytes.length != 32) { + throw new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_DEFINITION_INVALID, + "Business ID V1 key must contain exactly 32 bytes"); + } + this.version = version; + this.bytes = Arrays.copyOf(bytes, bytes.length); + } + + public int version() { + return version; + } + + public byte[] bytes() { + return Arrays.copyOf(bytes, bytes.length); + } +} diff --git a/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdErrorCode.java b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdErrorCode.java index 6e7e0dfc..7371b91b 100644 --- a/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdErrorCode.java +++ b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdErrorCode.java @@ -7,5 +7,7 @@ public enum BusinessIdErrorCode { BUSINESS_ID_ALLOCATION_RETRY_EXHAUSTED, BUSINESS_ID_FORMAT_INVALID, BUSINESS_ID_DUPLICATE, - BUSINESS_ID_IMMUTABLE + BUSINESS_ID_IMMUTABLE, + BUSINESS_ID_KEY_NOT_FOUND, + BUSINESS_ID_ENCODING_FAILED } diff --git a/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdKeyProvider.java b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdKeyProvider.java new file mode 100644 index 00000000..3a287de6 --- /dev/null +++ b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdKeyProvider.java @@ -0,0 +1,9 @@ +package io.teaql.core.businessid; + +import io.teaql.core.UserContext; + +/** Resolves current key material without placing secrets in generated code or metadata. */ +public interface BusinessIdKeyProvider { + BusinessIdEncodingKey currentKey( + UserContext context, BusinessIdDefinition definition, BusinessIdScope scope); +} diff --git a/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdPlan.java b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdPlan.java index f10520a1..ae92ecb3 100644 --- a/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdPlan.java +++ b/teaql-core/src/main/java/io/teaql/core/businessid/BusinessIdPlan.java @@ -7,5 +7,13 @@ public record BusinessIdPlan( BusinessIdScope scope, LocalDate businessDate, String dateText, + long initialSequence, long maximumSequence) { + public BusinessIdPlan { + if (initialSequence < 0 || maximumSequence < initialSequence) { + throw new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_DEFINITION_INVALID, + "Business ID allocation range must satisfy 0 <= initialSequence <= maximumSequence"); + } + } } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java index d0cae95d..e0f2f968 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultUserContext.java @@ -15,6 +15,7 @@ import java.util.function.Supplier; import io.teaql.core.businessid.BusinessClock; import io.teaql.core.businessid.BusinessIdProfileFactory; +import io.teaql.core.businessid.BusinessIdKeyProvider; import io.teaql.core.businessid.BusinessIdSchemaContributor; import io.teaql.core.businessid.BusinessIdService; import io.teaql.runtime.businessid.DefaultBusinessIdProfileFactory; @@ -180,6 +181,9 @@ public T capability(Class capabilityType) { if (value == null && capabilityType == BusinessIdSchemaContributor.class) { value = runtime.getBusinessIdSchemaContributor(); } + if (value == null && capabilityType == BusinessIdKeyProvider.class) { + value = runtime.getBusinessIdKeyProvider(); + } if (value == null && capabilityType == RoundTripReferenceCodec.class) { value = runtime.getRoundTripReferenceCodec(); } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index 528302ef..cad78fb2 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -8,6 +8,7 @@ import io.teaql.core.checker.CheckResult; import io.teaql.core.checker.Checker; import io.teaql.core.businessid.BusinessIdAllocator; +import io.teaql.core.businessid.BusinessIdKeyProvider; import io.teaql.core.businessid.BusinessIdSchemaContributor; import io.teaql.core.businessid.BusinessIdService; import io.teaql.runtime.businessid.DefaultBusinessIdService; @@ -27,6 +28,7 @@ public class TeaQLRuntime { private final SchemaExecutor schemaExecutor; private final BusinessIdService businessIdService; private final BusinessIdSchemaContributor businessIdSchemaContributor; + private final BusinessIdKeyProvider businessIdKeyProvider; private final RoundTripReferenceCodec roundTripReferenceCodec; private final Map> checkers = new java.util.concurrent.ConcurrentHashMap<>(); private final List generatedBootstraps = @@ -44,6 +46,7 @@ private TeaQLRuntime(Builder builder) { this.schemaExecutor = builder.schemaExecutor; this.businessIdService = builder.businessIdService; this.businessIdSchemaContributor = builder.businessIdSchemaContributor; + this.businessIdKeyProvider = builder.businessIdKeyProvider; this.roundTripReferenceCodec = builder.roundTripReferenceProvider == null ? null : new RoundTripReferenceCodec(builder.roundTripReferenceProvider); @@ -101,6 +104,10 @@ BusinessIdSchemaContributor getBusinessIdSchemaContributor() { return businessIdSchemaContributor; } + BusinessIdKeyProvider getBusinessIdKeyProvider() { + return businessIdKeyProvider; + } + RoundTripReferenceCodec getRoundTripReferenceCodec() { return roundTripReferenceCodec; } @@ -931,6 +938,7 @@ public static class Builder { private SchemaExecutor schemaExecutor; private BusinessIdService businessIdService; private BusinessIdSchemaContributor businessIdSchemaContributor; + private BusinessIdKeyProvider businessIdKeyProvider; private RoundTripReferenceProvider roundTripReferenceProvider; public Builder metadata(EntityMetaFactory metadata) { @@ -982,6 +990,13 @@ Builder businessIdInfrastructure(A allocator) { return this; } + /** Installs application-owned versioned key material for the default profile. */ + public Builder businessIdKeyProvider(BusinessIdKeyProvider keyProvider) { + this.businessIdKeyProvider = + java.util.Objects.requireNonNull(keyProvider, "keyProvider"); + return this; + } + /** Installs context-bound identity serialization without coupling it to a Web framework. */ public Builder roundTripReferenceProvider(RoundTripReferenceProvider provider) { this.roundTripReferenceProvider = java.util.Objects.requireNonNull(provider, "provider"); diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/businessid/BusinessIdPermutationV1.java b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/BusinessIdPermutationV1.java new file mode 100644 index 00000000..3d13d8a9 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/BusinessIdPermutationV1.java @@ -0,0 +1,102 @@ +package io.teaql.runtime.businessid; + +import io.teaql.core.businessid.BusinessIdEncodingKey; +import io.teaql.core.businessid.BusinessIdErrorCode; +import io.teaql.core.businessid.BusinessIdException; +import io.teaql.core.businessid.BusinessIdScope; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Locale; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Canonical TeaQL Business ID fixed-domain permutation profile V1. */ +public final class BusinessIdPermutationV1 { + public static final int WIDTH = 6; + public static final long DOMAIN_SIZE = 2_176_782_336L; + public static final long MAX_SEQUENCE = DOMAIN_SIZE - 1; + public static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final byte[] MAGIC = + "teaql-business-id-fp-v1\0".getBytes(StandardCharsets.US_ASCII); + private static final int ROUNDS = 8; + + private BusinessIdPermutationV1() { + } + + public static String encode( + long sequence, BusinessIdScope scope, BusinessIdEncodingKey key) { + if (sequence < 0 || sequence >= DOMAIN_SIZE) { + throw new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_RANGE_EXHAUSTED, + "Business ID V1 sequence must be in 0.." + MAX_SEQUENCE); + } + byte[] tweak = canonicalTweak(scope, key.version()); + long candidate = sequence; + do { + candidate = permute32(candidate, tweak, key.bytes()); + } while (candidate >= DOMAIN_SIZE); + String encoded = Long.toString(candidate, 36).toUpperCase(Locale.ROOT); + return "0".repeat(WIDTH - encoded.length()) + encoded; + } + + private static long permute32(long value, byte[] tweak, byte[] key) { + int left = (int) ((value >>> 16) & 0xffff); + int right = (int) (value & 0xffff); + for (int round = 0; round < ROUNDS; round++) { + int output = roundFunction(key, tweak, round, right); + int nextLeft = right; + int nextRight = (left ^ output) & 0xffff; + left = nextLeft; + right = nextRight; + } + return Integer.toUnsignedLong((left << 16) | right); + } + + private static int roundFunction(byte[] key, byte[] tweak, int round, int right) { + try { + Mac hmac = Mac.getInstance("HmacSHA256"); + hmac.init(new SecretKeySpec(key, "HmacSHA256")); + hmac.update(tweak); + hmac.update((byte) round); + hmac.update((byte) (right >>> 8)); + hmac.update((byte) right); + byte[] digest = hmac.doFinal(); + return ((digest[0] & 0xff) << 8) | (digest[1] & 0xff); + } catch (GeneralSecurityException error) { + throw new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_ENCODING_FAILED, + "HMAC-SHA256 is unavailable for Business ID V1", + error); + } + } + + private static byte[] canonicalTweak(BusinessIdScope scope, int keyVersion) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.write(MAGIC); + output.writeByte(1); + output.writeInt(keyVersion); + writeField(output, scope.domainRootKey()); + writeField(output, scope.aggregateType()); + writeField(output, scope.namespace()); + writeField(output, scope.periodKey()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_ENCODING_FAILED, + "Unable to frame Business ID V1 scope", + impossible); + } + } + + private static void writeField(DataOutputStream output, String value) throws IOException { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + output.writeInt(encoded.length); + output.write(encoded); + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/businessid/DailySequenceBusinessIdProfile.java b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/DailySequenceBusinessIdProfile.java index d17172d0..cbbf3fd7 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/businessid/DailySequenceBusinessIdProfile.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/DailySequenceBusinessIdProfile.java @@ -8,7 +8,7 @@ public final class DailySequenceBusinessIdProfile implements BusinessIdProfile { @Override public BusinessIdPlan plan(BusinessIdGenerationRequest request) { BusinessIdDefinition definition = request.definition(); - if (!BusinessIdDefinition.DEFAULT_PROFILE.equals(definition.profile()) + if (!BusinessIdDefinition.LEGACY_DAILY_SEQUENCE_PROFILE.equals(definition.profile()) || !"daily".equals(definition.reset())) { throw new BusinessIdException( BusinessIdErrorCode.BUSINESS_ID_DEFINITION_INVALID, @@ -33,6 +33,7 @@ public BusinessIdPlan plan(BusinessIdGenerationRequest request) { scope, request.businessDate(), dateText, + 1, definition.maximumSequence()); } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/businessid/DefaultBusinessIdProfileFactory.java b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/DefaultBusinessIdProfileFactory.java index 08641ec2..22c2d0cc 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/businessid/DefaultBusinessIdProfileFactory.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/DefaultBusinessIdProfileFactory.java @@ -10,6 +10,15 @@ public final class DefaultBusinessIdProfileFactory implements BusinessIdProfileF @Override public BusinessIdProfile create(UserContext context, BusinessIdDefinition definition) { if (BusinessIdDefinition.DEFAULT_PROFILE.equals(definition.profile())) { + BusinessIdKeyProvider keyProvider = context.capability(BusinessIdKeyProvider.class); + if (keyProvider == null) { + throw new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_KEY_NOT_FOUND, + "BusinessIdKeyProvider capability is not registered"); + } + return new PermutedDailyBusinessIdProfile(context, keyProvider); + } + if (BusinessIdDefinition.LEGACY_DAILY_SEQUENCE_PROFILE.equals(definition.profile())) { return DAILY; } throw new BusinessIdException( diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/businessid/InMemoryBusinessIdAllocator.java b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/InMemoryBusinessIdAllocator.java index cbef984b..8cc8495b 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/businessid/InMemoryBusinessIdAllocator.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/InMemoryBusinessIdAllocator.java @@ -13,17 +13,17 @@ public final class InMemoryBusinessIdAllocator implements BusinessIdAllocator { @Override public BusinessIdAllocation allocate(BusinessIdPlan plan) { AtomicLong counter = counters.computeIfAbsent( - plan.scope(), ignored -> new AtomicLong()); + plan.scope(), ignored -> new AtomicLong(plan.initialSequence())); while (true) { long current = counter.get(); - if (current >= plan.maximumSequence()) { + if (current > plan.maximumSequence()) { throw new BusinessIdException( BusinessIdErrorCode.BUSINESS_ID_RANGE_EXHAUSTED, "Business ID range exhausted for " + plan.scope().canonicalKey()); } long next = current + 1; if (counter.compareAndSet(current, next)) { - return new BusinessIdAllocation(plan.scope(), next); + return new BusinessIdAllocation(plan.scope(), current); } } } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/businessid/PermutedDailyBusinessIdProfile.java b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/PermutedDailyBusinessIdProfile.java new file mode 100644 index 00000000..f63fc1f9 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/PermutedDailyBusinessIdProfile.java @@ -0,0 +1,101 @@ +package io.teaql.runtime.businessid; + +import io.teaql.core.UserContext; +import io.teaql.core.businessid.*; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.regex.Pattern; + +/** Default six-character Base36 Business ID profile. */ +public final class PermutedDailyBusinessIdProfile implements BusinessIdProfile { + private final UserContext context; + private final BusinessIdKeyProvider keyProvider; + + public PermutedDailyBusinessIdProfile( + UserContext context, BusinessIdKeyProvider keyProvider) { + this.context = java.util.Objects.requireNonNull(context, "context"); + this.keyProvider = java.util.Objects.requireNonNull(keyProvider, "keyProvider"); + } + + @Override + public BusinessIdPlan plan(BusinessIdGenerationRequest request) { + BusinessIdDefinition definition = request.definition(); + if (!BusinessIdDefinition.DEFAULT_PROFILE.equals(definition.profile()) + || !"daily".equals(definition.reset()) + || !BusinessIdDefinition.DEFAULT_DATE_FORMAT.equals(definition.dateFormat()) + || definition.digits() != BusinessIdPermutationV1.WIDTH) { + throw invalidDefinition( + "daily-permuted-v1 requires reset=daily, dateFormat=yyyyMMdd and digits=6"); + } + String dateText = request.businessDate().format(DateTimeFormatter.BASIC_ISO_DATE); + BusinessIdScope scope = new BusinessIdScope( + request.domainRootKey(), + request.aggregateType(), + definition.namespace(), + dateText); + return new BusinessIdPlan( + definition, + scope, + request.businessDate(), + dateText, + 0, + BusinessIdPermutationV1.MAX_SEQUENCE); + } + + @Override + public BusinessIdValue format(BusinessIdPlan plan, BusinessIdAllocation allocation) { + if (!plan.scope().equals(allocation.scope())) { + throw invalidDefinition("Allocation scope does not match Business ID plan"); + } + BusinessIdEncodingKey key = keyProvider.currentKey( + context, plan.definition(), plan.scope()); + if (key == null) { + throw new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_KEY_NOT_FOUND, + "Business ID key provider returned no current key"); + } + String code = BusinessIdPermutationV1.encode(allocation.sequence(), plan.scope(), key); + BusinessIdDefinition definition = plan.definition(); + String value = definition.prefix() + + definition.separator() + + plan.dateText() + + definition.separator() + + code; + return new BusinessIdValue(value, definition.profile(), definition.policyVersion()); + } + + @Override + public BusinessIdValue validate(BusinessIdDefinition definition, String value) { + if (value == null || value.isBlank()) { + throw invalidFormat(value); + } + String[] parts = value.split(Pattern.quote(definition.separator()), -1); + if (parts.length != 3 + || !definition.prefix().equals(parts[0]) + || parts[2].length() != BusinessIdPermutationV1.WIDTH + || !parts[2].chars().allMatch(PermutedDailyBusinessIdProfile::isBase36Uppercase)) { + throw invalidFormat(value); + } + try { + java.time.LocalDate.parse(parts[1], DateTimeFormatter.BASIC_ISO_DATE); + } catch (DateTimeParseException | IllegalArgumentException error) { + throw invalidFormat(value); + } + return new BusinessIdValue(value, definition.profile(), definition.policyVersion()); + } + + private static boolean isBase36Uppercase(int value) { + return (value >= '0' && value <= '9') || (value >= 'A' && value <= 'Z'); + } + + private BusinessIdException invalidDefinition(String message) { + return new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_DEFINITION_INVALID, message); + } + + private BusinessIdException invalidFormat(String value) { + return new BusinessIdException( + BusinessIdErrorCode.BUSINESS_ID_FORMAT_INVALID, + "Invalid daily-permuted-v1 Business ID: " + value); + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/businessid/StaticBusinessIdKeyProvider.java b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/StaticBusinessIdKeyProvider.java new file mode 100644 index 00000000..34412722 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/businessid/StaticBusinessIdKeyProvider.java @@ -0,0 +1,22 @@ +package io.teaql.runtime.businessid; + +import io.teaql.core.UserContext; +import io.teaql.core.businessid.BusinessIdDefinition; +import io.teaql.core.businessid.BusinessIdEncodingKey; +import io.teaql.core.businessid.BusinessIdKeyProvider; +import io.teaql.core.businessid.BusinessIdScope; + +/** Deterministic key provider for tests and explicitly configured single-key deployments. */ +public final class StaticBusinessIdKeyProvider implements BusinessIdKeyProvider { + private final BusinessIdEncodingKey key; + + public StaticBusinessIdKeyProvider(BusinessIdEncodingKey key) { + this.key = java.util.Objects.requireNonNull(key, "key"); + } + + @Override + public BusinessIdEncodingKey currentKey( + UserContext context, BusinessIdDefinition definition, BusinessIdScope scope) { + return key; + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/businessid/BusinessIdPermutationV1Test.java b/teaql-runtime/src/test/java/io/teaql/runtime/businessid/BusinessIdPermutationV1Test.java new file mode 100644 index 00000000..946b4b3d --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/businessid/BusinessIdPermutationV1Test.java @@ -0,0 +1,90 @@ +package io.teaql.runtime.businessid; + +import io.teaql.core.businessid.BusinessIdEncodingKey; +import io.teaql.core.businessid.BusinessIdErrorCode; +import io.teaql.core.businessid.BusinessIdException; +import io.teaql.core.businessid.BusinessIdScope; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Set; +import org.junit.Assert; +import org.junit.Test; + +public class BusinessIdPermutationV1Test { + @Test + public void matchesEveryCrossLanguageGoldenVector() throws Exception { + InputStream input = getClass().getResourceAsStream("/business-id-permutation-v1.csv"); + Assert.assertNotNull("golden vector resource", input); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(input, StandardCharsets.UTF_8))) { + Assert.assertEquals( + "case_id,key_hex,key_version,domain_root_key,aggregate_type,namespace," + + "period_key,sequence,expected_code,expected_business_id", + reader.readLine()); + String line; + int rows = 0; + while ((line = reader.readLine()) != null) { + String[] fields = line.split(",", -1); + Assert.assertEquals("vector columns for " + line, 10, fields.length); + BusinessIdScope scope = + new BusinessIdScope(fields[3], fields[4], fields[5], fields[6]); + BusinessIdEncodingKey key = new BusinessIdEncodingKey( + Integer.parseInt(fields[2]), hex(fields[1])); + String actual = BusinessIdPermutationV1.encode( + Long.parseLong(fields[7]), scope, key); + Assert.assertEquals(fields[0], fields[8], actual); + Assert.assertEquals(fields[9], "ORD-" + fields[6] + "-" + actual); + rows++; + } + Assert.assertEquals(10, rows); + } + } + + @Test + public void isDeterministicUniqueAndAlwaysCanonicalForRetainedRange() { + BusinessIdScope scope = + new BusinessIdScope("tenant-a", "commerce_order", "order_number", "20260925"); + BusinessIdEncodingKey key = new BusinessIdEncodingKey(1, hex( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")); + Set values = new HashSet<>(); + for (long sequence = 0; sequence < 20_000; sequence++) { + String first = BusinessIdPermutationV1.encode(sequence, scope, key); + String afterRestart = BusinessIdPermutationV1.encode( + sequence, + new BusinessIdScope( + "tenant-a", "commerce_order", "order_number", "20260925"), + new BusinessIdEncodingKey(1, key.bytes())); + Assert.assertEquals(first, afterRestart); + Assert.assertTrue(first.matches("[0-9A-Z]{6}")); + Assert.assertTrue("duplicate at sequence " + sequence, values.add(first)); + } + } + + @Test + public void rejectsOutOfDomainSequenceAndMalformedKey() { + BusinessIdScope scope = + new BusinessIdScope("tenant-a", "commerce_order", "order_number", "20260925"); + BusinessIdEncodingKey key = new BusinessIdEncodingKey(1, new byte[32]); + for (long sequence : new long[]{-1, BusinessIdPermutationV1.DOMAIN_SIZE}) { + BusinessIdException error = Assert.assertThrows( + BusinessIdException.class, + () -> BusinessIdPermutationV1.encode(sequence, scope, key)); + Assert.assertEquals(BusinessIdErrorCode.BUSINESS_ID_RANGE_EXHAUSTED, error.getCode()); + } + Assert.assertThrows( + BusinessIdException.class, () -> new BusinessIdEncodingKey(1, new byte[31])); + Assert.assertThrows( + BusinessIdException.class, () -> new BusinessIdEncodingKey(0, new byte[32])); + } + + private static byte[] hex(String value) { + byte[] result = new byte[value.length() / 2]; + for (int index = 0; index < result.length; index++) { + result[index] = (byte) Integer.parseInt(value.substring(index * 2, index * 2 + 2), 16); + } + return result; + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/businessid/InMemoryBusinessIdAllocatorTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/businessid/InMemoryBusinessIdAllocatorTest.java index 2dd150df..781a86db 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/businessid/InMemoryBusinessIdAllocatorTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/businessid/InMemoryBusinessIdAllocatorTest.java @@ -10,6 +10,21 @@ import org.junit.Test; public class InMemoryBusinessIdAllocatorTest { + @Test + public void invalidInitialSequenceRangeFailsBeforeAllocation() { + BusinessIdDefinition definition = BusinessIdDefinition.dailySequence( + "order_number", "CO", "commerce_order"); + LocalDate date = LocalDate.of(2026, 9, 23); + BusinessIdScope scope = new BusinessIdScope( + "domain-root-a", "commerce_order", "commerce_order", "20260923"); + + BusinessIdException invalid = Assert.assertThrows( + BusinessIdException.class, + () -> new BusinessIdPlan(definition, scope, date, "20260923", 2, 1)); + Assert.assertEquals( + BusinessIdErrorCode.BUSINESS_ID_DEFINITION_INVALID, invalid.getCode()); + } + @Test public void exhaustedAllocationDoesNotConsumeTheNextSequence() { InMemoryBusinessIdAllocator allocator = new InMemoryBusinessIdAllocator(); @@ -19,9 +34,9 @@ public void exhaustedAllocationDoesNotConsumeTheNextSequence() { BusinessIdScope scope = new BusinessIdScope( "tenant-a", "commerce_order", "commerce_order", "20260923"); BusinessIdPlan limited = new BusinessIdPlan( - definition, scope, date, "20260923", 1); + definition, scope, date, "20260923", 0, 0); - Assert.assertEquals(1, allocator.allocate(limited).sequence()); + Assert.assertEquals(0, allocator.allocate(limited).sequence()); for (int attempt = 0; attempt < 2; attempt++) { BusinessIdException exhausted = Assert.assertThrows( BusinessIdException.class, () -> allocator.allocate(limited)); @@ -32,7 +47,7 @@ public void exhaustedAllocationDoesNotConsumeTheNextSequence() { // A later policy may increase the range; rejected attempts must not make a gap. BusinessIdPlan expanded = new BusinessIdPlan( - definition, scope, date, "20260923", 2); - Assert.assertEquals(2, allocator.allocate(expanded).sequence()); + definition, scope, date, "20260923", 0, 1); + Assert.assertEquals(1, allocator.allocate(expanded).sequence()); } } diff --git a/teaql-runtime/src/test/resources/business-id-permutation-v1.csv b/teaql-runtime/src/test/resources/business-id-permutation-v1.csv new file mode 100644 index 00000000..517e2532 --- /dev/null +++ b/teaql-runtime/src/test/resources/business-id-permutation-v1.csv @@ -0,0 +1,11 @@ +case_id,key_hex,key_version,domain_root_key,aggregate_type,namespace,period_key,sequence,expected_code,expected_business_id +first,000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f,1,tenant-a,commerce_order,order_number,20260925,0,S1Z2CG,ORD-20260925-S1Z2CG +middle,000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f,1,tenant-a,commerce_order,order_number,20260925,1088391168,5ZFLQT,ORD-20260925-5ZFLQT +last,000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f,1,tenant-a,commerce_order,order_number,20260925,2176782335,MDX0RC,ORD-20260925-MDX0RC +next-date,000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f,1,tenant-a,commerce_order,order_number,20260926,0,62RSAF,ORD-20260926-62RSAF +other-domain-root,000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f,1,tenant-b,commerce_order,order_number,20260925,0,Z70J3I,ORD-20260925-Z70J3I +other-aggregate,000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f,1,tenant-a,maintenance_order,order_number,20260925,0,DMVSGA,ORD-20260925-DMVSGA +other-namespace,000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f,1,tenant-a,commerce_order,receipt_number,20260925,0,642SAP,ORD-20260925-642SAP +same-key-new-version,000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f,2,tenant-a,commerce_order,order_number,20260925,0,15Q9BU,ORD-20260925-15Q9BU +rotated-key,f0e0d0c0b0a090807060504030201000112233445566778899aabbccddeeff00,2,tenant-a,commerce_order,order_number,20260925,0,V71L2B,ORD-20260925-V71L2B +leading-zero,000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f,1,tenant-a,commerce_order,order_number,20260925,16,0H5ZEP,ORD-20260925-0H5ZEP