diff --git a/README.md b/README.md index c53fa11..79dbb2b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ The SDK's organizational architecture strictly mirrors the Rust version: mutation. Exact policy identity and approval state are retained with audit evidence. Missing customer policy or approval emits stable warnings without changing persistence semantics; an explicit denial fails closed. +* **Portable Business ID Encoding**: Core scope/key types and the runtime + `daily-permuted-v1` encoder execute the canonical cross-language golden + vectors without exposing the internal sequence. * **TeaQL Federal Protocol Client**: `TeaQLFederalClient` and `TfpHttpProvider` execute governed canonical TFP v1 queries and audited mutations against a remote TeaQL endpoint such as Rust. Direct query execution returns @@ -131,6 +134,26 @@ The repeatable [`examples/opaque-entity-reference`](examples/opaque-entity-refer example proves the exact cross-language golden vector and purpose-substitution rejection. +## Business ID V1 Foundation + +The pure runtime encoder maps a durable internal sequence into the canonical +six-character, scope-specific Base36 code: + +```python +from teaql.core import BusinessIdEncodingKey, BusinessIdScope +from teaql.runtime import encode_business_id_permutation_v1 + +scope = BusinessIdScope( + "tenant-a", "commerce_order", "order_number", "20260925" +) +key = BusinessIdEncodingKey(1, key_from_secret_manager) +code = encode_business_id_permutation_v1(0, scope, key) +``` + +Durable concurrent allocation, aggregate retry reuse, and typed lookup are +separate lifecycle capabilities. Secret key material is application-owned and +must not be placed in KSML or generated source. + ### Mutation Policy installation Policy implementations are installed from trusted application startup through diff --git a/src/teaql/core/__init__.py b/src/teaql/core/__init__.py index dbb6896..ff5691c 100644 --- a/src/teaql/core/__init__.py +++ b/src/teaql/core/__init__.py @@ -24,6 +24,12 @@ from .eval import LoadState, EvalResult from .safe_expression import SafeExpression from .xls import XlsWorkbook, XlsPage, XlsBlock, XlsBlockBuildContext +from .business_id import ( + BusinessIdEncodingKey, + BusinessIdError, + BusinessIdErrorCode, + BusinessIdScope, +) __all__ = [ "Value", "DataType", "Timestamp", @@ -39,6 +45,8 @@ "GraphNode", "EntityDescriptor", "PropertyDescriptor", "SmartList", "TeaQLPage", "LoadState", "EvalResult", "SafeExpression", + "BusinessIdEncodingKey", "BusinessIdError", "BusinessIdErrorCode", + "BusinessIdScope", "XlsWorkbook", "XlsPage", "XlsBlock", "XlsBlockBuildContext" ] import builtins diff --git a/src/teaql/core/business_id.py b/src/teaql/core/business_id.py new file mode 100644 index 0000000..55431ea --- /dev/null +++ b/src/teaql/core/business_id.py @@ -0,0 +1,65 @@ +"""Portable core types for externally visible Aggregate-root Business IDs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class BusinessIdErrorCode(str, Enum): + DEFINITION_INVALID = "BUSINESS_ID_DEFINITION_INVALID" + RANGE_EXHAUSTED = "BUSINESS_ID_RANGE_EXHAUSTED" + ENCODING_FAILED = "BUSINESS_ID_ENCODING_FAILED" + + +class BusinessIdError(ValueError): + def __init__(self, code: BusinessIdErrorCode, message: str): + self.code = code + super().__init__(message) + + +@dataclass(frozen=True) +class BusinessIdScope: + domain_root_key: str + aggregate_type: str + namespace: str + period_key: str + + def __post_init__(self) -> None: + for name, value in ( + ("domain_root_key", self.domain_root_key), + ("aggregate_type", self.aggregate_type), + ("namespace", self.namespace), + ("period_key", self.period_key), + ): + if not isinstance(value, str) or not value.strip(): + raise BusinessIdError( + BusinessIdErrorCode.DEFINITION_INVALID, + f"{name} must not be blank", + ) + + +@dataclass(frozen=True) +class BusinessIdEncodingKey: + version: int + key: bytes + + def __post_init__(self) -> None: + if ( + not isinstance(self.version, int) + or isinstance(self.version, bool) + or not (1 <= self.version <= 0xFFFFFFFF) + ): + raise BusinessIdError( + BusinessIdErrorCode.DEFINITION_INVALID, + "Business ID key version must be a positive u32", + ) + if ( + not isinstance(self.key, (bytes, bytearray, memoryview)) + or len(self.key) != 32 + ): + raise BusinessIdError( + BusinessIdErrorCode.DEFINITION_INVALID, + "Business ID V1 key must contain exactly 32 bytes", + ) + object.__setattr__(self, "key", bytes(self.key)) diff --git a/src/teaql/runtime/__init__.py b/src/teaql/runtime/__init__.py index 11f61dc..63d9573 100644 --- a/src/teaql/runtime/__init__.py +++ b/src/teaql/runtime/__init__.py @@ -20,6 +20,13 @@ UNSAFE_RAW_ENTITY_REFERENCES_ACKNOWLEDGEMENT, UNSAFE_RAW_ENTITY_REFERENCES_ENVIRONMENT, ) +from .business_id import ( + BUSINESS_ID_PERMUTATION_V1_ALPHABET, + BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE, + BUSINESS_ID_PERMUTATION_V1_MAX_SEQUENCE, + BUSINESS_ID_PERMUTATION_V1_WIDTH, + encode_business_id_permutation_v1, +) from .mutation_policy import ( MISSING_APPROVAL, MISSING_POLICY, @@ -44,7 +51,7 @@ from .wire_fields import NormalizedWireInput, WireEntityMetadata, WireFieldMetadata, WireInputError, create_wire_entity_metadata, encode_wire_output, normalize_wire_input, retain_submitted_paths from teaql.core.entity import EntityKey, EntityChangeSet, EntityRoot -__all__ = ["WireFieldMetadata", "WireEntityMetadata", "NormalizedWireInput", "WireInputError", "create_wire_entity_metadata", "normalize_wire_input", "encode_wire_output", "retain_submitted_paths", "EntityKey", "EntityChangeSet", "EntityRoot", "ContextEntityRef", "ContextRootError", "CheckException", "CheckResult", "I18nCatalog", "JsonFieldNamingProfile", "Locale", "ObjectLocation", "UnsupportedLocaleError", "UserContext", "TeaqlRuntime", "SqlLogEntry", "SqlLogOperation", "DiagnosticSqlLogSink", "TextDiagnosticSqlLogSink", "ServiceRuntimeFromEnv", "RuntimeModule", "DataStore", "RawAuditEvent", "SafeAuditEvent", "MutationAuditKind", "BusinessClock", "FixedBusinessClock", "SystemBusinessClock", "AeadEntityReferenceCodec", "EntityReferenceClaims", "EntityReferenceCodec", "EntityReferenceTokenError", "ENTITY_REFERENCE_AAD", "UNSAFE_RAW_ENTITY_REFERENCES_ACKNOWLEDGEMENT", "UNSAFE_RAW_ENTITY_REFERENCES_ENVIRONMENT", "ContextTools", "ExecutableHttpTool", "HTTP_TOOL", "HttpIntentPhase", "HttpTool", "HttpToolProvider", "ToolDeniedError", "ToolError", "ToolPolicy", "ToolRisk", "Tools", "ToolToken", "ToolUnavailableError"] +__all__ = ["WireFieldMetadata", "WireEntityMetadata", "NormalizedWireInput", "WireInputError", "create_wire_entity_metadata", "normalize_wire_input", "encode_wire_output", "retain_submitted_paths", "EntityKey", "EntityChangeSet", "EntityRoot", "ContextEntityRef", "ContextRootError", "CheckException", "CheckResult", "I18nCatalog", "JsonFieldNamingProfile", "Locale", "ObjectLocation", "UnsupportedLocaleError", "UserContext", "TeaqlRuntime", "SqlLogEntry", "SqlLogOperation", "DiagnosticSqlLogSink", "TextDiagnosticSqlLogSink", "ServiceRuntimeFromEnv", "RuntimeModule", "DataStore", "RawAuditEvent", "SafeAuditEvent", "MutationAuditKind", "BusinessClock", "FixedBusinessClock", "SystemBusinessClock", "AeadEntityReferenceCodec", "EntityReferenceClaims", "EntityReferenceCodec", "EntityReferenceTokenError", "ENTITY_REFERENCE_AAD", "UNSAFE_RAW_ENTITY_REFERENCES_ACKNOWLEDGEMENT", "UNSAFE_RAW_ENTITY_REFERENCES_ENVIRONMENT", "BUSINESS_ID_PERMUTATION_V1_ALPHABET", "BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE", "BUSINESS_ID_PERMUTATION_V1_MAX_SEQUENCE", "BUSINESS_ID_PERMUTATION_V1_WIDTH", "encode_business_id_permutation_v1", "ContextTools", "ExecutableHttpTool", "HTTP_TOOL", "HttpIntentPhase", "HttpTool", "HttpToolProvider", "ToolDeniedError", "ToolError", "ToolPolicy", "ToolRisk", "Tools", "ToolToken", "ToolUnavailableError"] __all__ += [ "MISSING_APPROVAL", "MISSING_POLICY", diff --git a/src/teaql/runtime/business_id.py b/src/teaql/runtime/business_id.py new file mode 100644 index 0000000..d4535ec --- /dev/null +++ b/src/teaql/runtime/business_id.py @@ -0,0 +1,79 @@ +"""Canonical TeaQL Business ID fixed-domain permutation profile V1.""" + +from __future__ import annotations + +import hashlib +import hmac +import struct + +from teaql.core.business_id import ( + BusinessIdEncodingKey, + BusinessIdError, + BusinessIdErrorCode, + BusinessIdScope, +) + + +BUSINESS_ID_PERMUTATION_V1_WIDTH = 6 +BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE = 2_176_782_336 +BUSINESS_ID_PERMUTATION_V1_MAX_SEQUENCE = BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE - 1 +BUSINESS_ID_PERMUTATION_V1_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +_MAGIC = b"teaql-business-id-fp-v1\0" + + +def encode_business_id_permutation_v1( + sequence: int, scope: BusinessIdScope, key: BusinessIdEncodingKey +) -> str: + if ( + not isinstance(sequence, int) + or isinstance(sequence, bool) + or sequence < 0 + or sequence >= BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE + ): + raise BusinessIdError( + BusinessIdErrorCode.RANGE_EXHAUSTED, + f"Business ID V1 sequence must be in 0..{BUSINESS_ID_PERMUTATION_V1_MAX_SEQUENCE}", + ) + if not isinstance(scope, BusinessIdScope) or not isinstance(key, BusinessIdEncodingKey): + raise BusinessIdError( + BusinessIdErrorCode.DEFINITION_INVALID, + "Business ID V1 requires a BusinessIdScope and BusinessIdEncodingKey", + ) + + tweak = _canonical_tweak(scope, key.version) + candidate = sequence + while True: + candidate = _permute32(candidate, tweak, key.key) + if candidate < BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE: + break + encoded = ["0"] * BUSINESS_ID_PERMUTATION_V1_WIDTH + for index in range(BUSINESS_ID_PERMUTATION_V1_WIDTH - 1, -1, -1): + encoded[index] = BUSINESS_ID_PERMUTATION_V1_ALPHABET[candidate % 36] + candidate //= 36 + return "".join(encoded) + + +def _permute32(value: int, tweak: bytes, key: bytes) -> int: + left = (value >> 16) & 0xFFFF + right = value & 0xFFFF + for round_number in range(8): + digest = hmac.new( + key, tweak + bytes((round_number,)) + struct.pack(">H", right), hashlib.sha256 + ).digest() + output = struct.unpack_from(">H", digest)[0] + left, right = right, (left ^ output) & 0xFFFF + return (left << 16) | right + + +def _canonical_tweak(scope: BusinessIdScope, key_version: int) -> bytes: + fields = ( + scope.domain_root_key, + scope.aggregate_type, + scope.namespace, + scope.period_key, + ) + framed = [_MAGIC, bytes((1,)), struct.pack(">I", key_version)] + for value in fields: + encoded = value.encode("utf-8") + framed.extend((struct.pack(">I", len(encoded)), encoded)) + return b"".join(framed) diff --git a/test-vectors/business-id-permutation-v1.csv b/test-vectors/business-id-permutation-v1.csv new file mode 100644 index 0000000..517e253 --- /dev/null +++ b/test-vectors/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 diff --git a/tests/runtime/test_business_id.py b/tests/runtime/test_business_id.py new file mode 100644 index 0000000..33f4868 --- /dev/null +++ b/tests/runtime/test_business_id.py @@ -0,0 +1,78 @@ +import csv +import re +from pathlib import Path + +import pytest + +from teaql.core import ( + BusinessIdEncodingKey, + BusinessIdError, + BusinessIdErrorCode, + BusinessIdScope, +) +from teaql.runtime import ( + BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE, + encode_business_id_permutation_v1, +) + + +VECTOR = Path(__file__).parents[2] / "test-vectors" / "business-id-permutation-v1.csv" + + +def test_matches_every_cross_language_golden_vector() -> None: + with VECTOR.open(newline="", encoding="utf-8") as input_file: + rows = list(csv.DictReader(input_file)) + assert len(rows) == 10 + for row in rows: + scope = BusinessIdScope( + row["domain_root_key"], + row["aggregate_type"], + row["namespace"], + row["period_key"], + ) + key = BusinessIdEncodingKey( + int(row["key_version"]), bytes.fromhex(row["key_hex"]) + ) + actual = encode_business_id_permutation_v1(int(row["sequence"]), scope, key) + assert actual == row["expected_code"], row["case_id"] + assert f"ORD-{row['period_key']}-{actual}" == row["expected_business_id"] + + +def test_is_deterministic_unique_and_canonical_for_retained_range() -> None: + scope = BusinessIdScope("tenant-a", "commerce_order", "order_number", "20260925") + key = BusinessIdEncodingKey( + 1, + bytes.fromhex( + "000102030405060708090a0b0c0d0e0f" + "101112131415161718191a1b1c1d1e1f" + ), + ) + values: set[str] = set() + for sequence in range(20_000): + first = encode_business_id_permutation_v1(sequence, scope, key) + after_restart = encode_business_id_permutation_v1( + sequence, + BusinessIdScope( + "tenant-a", "commerce_order", "order_number", "20260925" + ), + BusinessIdEncodingKey(1, key.key), + ) + assert first == after_restart + assert re.fullmatch(r"[0-9A-Z]{6}", first) + assert first not in values + values.add(first) + + +def test_rejects_out_of_domain_sequence_and_malformed_definitions() -> None: + scope = BusinessIdScope("tenant-a", "commerce_order", "order_number", "20260925") + key = BusinessIdEncodingKey(1, bytes(32)) + for sequence in (-1, BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE, True): + with pytest.raises(BusinessIdError) as failure: + encode_business_id_permutation_v1(sequence, scope, key) + assert failure.value.code == BusinessIdErrorCode.RANGE_EXHAUSTED + with pytest.raises(BusinessIdError): + BusinessIdEncodingKey(0, bytes(32)) + with pytest.raises(BusinessIdError): + BusinessIdEncodingKey(1, bytes(31)) + with pytest.raises(BusinessIdError): + BusinessIdScope(" ", "commerce_order", "order_number", "20260925")