Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ 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.
* **Governed Business ID Lifecycle**: Core model contracts, a context-owned
profile/key/service boundary, retry-safe assignment, an in-memory allocator,
and explicit-schema durable SQLite allocation extend the portable
`daily-permuted-v1` encoder without exposing its 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
Expand Down Expand Up @@ -150,9 +151,11 @@ 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.
The retained [`examples/business-id`](examples/business-id) flow proves durable
concurrent allocation infrastructure and aggregate retry reuse. Generated
strongly typed fields and external lookup remain a separate generator
capability. Secret key material is application-owned and must not be placed in
KSML or generated source.

### Mutation Policy installation

Expand Down
9 changes: 9 additions & 0 deletions examples/business-id/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Governed Business ID example

This focused example proves explicit SQLite schema installation, a
context-owned business date and key provider, durable allocation, and
idempotent reuse of an already assigned Business ID.

```bash
PYTHONPATH=src python examples/business-id/main.py
```
63 changes: 63 additions & 0 deletions examples/business-id/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import asyncio
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory

from teaql.core import BusinessIdDefinition, BusinessIdEncodingKey
from teaql.provider.sqlite import create_sqlite_service
from teaql.runtime import (
DefaultBusinessIdProfileFactory,
DefaultBusinessIdService,
FixedBusinessClock,
StaticBusinessIdKeyProvider,
UserContext,
)
from teaql.sql import SqlBusinessIdAllocator


class OrderNumberSlot:
def __init__(self):
self.value = None

def current_value(self):
return self.value

def new_aggregate(self):
return True

def assign_canonical_value(self, value):
self.value = value


async def main():
with TemporaryDirectory() as directory:
service = create_sqlite_service(str(Path(directory) / "business-id.db"))
allocator = SqlBusinessIdAllocator(service.dialect, service.transport)
context = (
UserContext.new()
.insert_resource("dataService", service)
.with_business_clock(FixedBusinessClock(
datetime(2026, 10, 1, 8, 30, tzinfo=timezone.utc)
))
.with_business_id_key_provider(StaticBusinessIdKeyProvider(
BusinessIdEncodingKey(1, bytes(range(32)))
))
.with_business_id_profile_factory(DefaultBusinessIdProfileFactory())
.with_business_id_service(DefaultBusinessIdService(allocator))
)
await context.ensure_schema()
slot = OrderNumberSlot()
definition = BusinessIdDefinition.daily_permuted(
"order_number", "ORD", "order_number"
)
first = await context.ensure_business_id(
definition, "commerce", "commerce_order", slot
)
retry = await context.ensure_business_id(
definition, "commerce", "commerce_order", slot
)
assert first == retry and slot.value.startswith("ORD-20261001-")
print("PASS Python governed Business ID lifecycle example")


asyncio.run(main())
3 changes: 2 additions & 1 deletion scripts/verify-examples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
set -euo pipefail

repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
expected=(business-clock conformance mutation-policy opaque-entity-reference order-management query-policy school-management task_board)
expected=(business-clock business-id conformance mutation-policy opaque-entity-reference order-management query-policy school-management task_board)
mapfile -t actual < <(find "$repo/examples" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
if [[ "${actual[*]}" != "${expected[*]}" ]]; then
echo "example inventory changed; update scripts/verify-examples.sh: ${actual[*]}" >&2
Expand All @@ -26,6 +26,7 @@ PYTHONPATH="$repo/examples/school-management:$repo/src" python -m app.main
PYTHONPATH="$repo/src" python -m unittest discover -s "$repo/examples/school-management" -p 'test_sql_log_intent.py' -v
PYTHONPATH="$repo/src" python "$repo/examples/mutation-policy/main.py"
PYTHONPATH="$repo/src" python "$repo/examples/business-clock/main.py"
PYTHONPATH="$repo/src" python "$repo/examples/business-id/main.py"
PYTHONPATH="$repo/src" python "$repo/examples/query-policy/main.py"
PYTHONPATH="$repo/src" python "$repo/examples/opaque-entity-reference/main.py"
order_management_tmp="$(mktemp -d)"
Expand Down
16 changes: 15 additions & 1 deletion src/teaql/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,21 @@
from .safe_expression import SafeExpression
from .xls import XlsWorkbook, XlsPage, XlsBlock, XlsBlockBuildContext
from .business_id import (
BusinessIdAllocation,
BusinessIdAllocator,
BusinessIdDefinition,
BusinessIdEncodingKey,
BusinessIdError,
BusinessIdErrorCode,
BusinessIdGenerationRequest,
BusinessIdKeyProvider,
BusinessIdPlan,
BusinessIdProfile,
BusinessIdProfileFactory,
BusinessIdService,
BusinessIdSlot,
BusinessIdScope,
BusinessIdValue,
)

__all__ = [
Expand All @@ -45,8 +56,11 @@
"GraphNode",
"EntityDescriptor", "PropertyDescriptor", "SmartList", "TeaQLPage",
"LoadState", "EvalResult", "SafeExpression",
"BusinessIdAllocation", "BusinessIdAllocator", "BusinessIdDefinition",
"BusinessIdEncodingKey", "BusinessIdError", "BusinessIdErrorCode",
"BusinessIdScope",
"BusinessIdGenerationRequest", "BusinessIdKeyProvider", "BusinessIdPlan",
"BusinessIdProfile", "BusinessIdProfileFactory", "BusinessIdService",
"BusinessIdSlot", "BusinessIdScope", "BusinessIdValue",
"XlsWorkbook", "XlsPage", "XlsBlock", "XlsBlockBuildContext"
]
import builtins
Expand Down
163 changes: 163 additions & 0 deletions src/teaql/core/business_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,23 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import date
from enum import Enum
from typing import Protocol, TYPE_CHECKING

if TYPE_CHECKING:
from teaql.runtime.context import UserContext


class BusinessIdErrorCode(str, Enum):
PROFILE_NOT_FOUND = "BUSINESS_ID_PROFILE_NOT_FOUND"
DEFINITION_INVALID = "BUSINESS_ID_DEFINITION_INVALID"
RANGE_EXHAUSTED = "BUSINESS_ID_RANGE_EXHAUSTED"
ALLOCATION_RETRY_EXHAUSTED = "BUSINESS_ID_ALLOCATION_RETRY_EXHAUSTED"
FORMAT_INVALID = "BUSINESS_ID_FORMAT_INVALID"
DUPLICATE = "BUSINESS_ID_DUPLICATE"
IMMUTABLE = "BUSINESS_ID_IMMUTABLE"
KEY_NOT_FOUND = "BUSINESS_ID_KEY_NOT_FOUND"
ENCODING_FAILED = "BUSINESS_ID_ENCODING_FAILED"


Expand Down Expand Up @@ -38,6 +49,17 @@ def __post_init__(self) -> None:
f"{name} must not be blank",
)

def canonical_key(self) -> str:
def escape(value: str) -> str:
return value.replace("%", "%25").replace("|", "%7C")

return "|".join(escape(value) for value in (
self.domain_root_key,
self.aggregate_type,
self.namespace,
self.period_key,
))


@dataclass(frozen=True)
class BusinessIdEncodingKey:
Expand All @@ -63,3 +85,144 @@ def __post_init__(self) -> None:
"Business ID V1 key must contain exactly 32 bytes",
)
object.__setattr__(self, "key", bytes(self.key))


@dataclass(frozen=True)
class BusinessIdDefinition:
field_name: str
profile: str
prefix: str
date_format: str
reset: str
digits: int
separator: str
namespace: str
policy_version: int

DEFAULT_PROFILE = "daily-permuted-v1"
DEFAULT_DATE_FORMAT = "yyyyMMdd"
DEFAULT_DIGITS = 6

def __post_init__(self) -> None:
for name in (
"field_name", "profile", "prefix", "date_format", "reset",
"separator", "namespace",
):
value = getattr(self, name)
if not isinstance(value, str) or not value.strip():
raise BusinessIdError(
BusinessIdErrorCode.DEFINITION_INVALID,
f"{name} must not be blank",
)
if self.profile == self.DEFAULT_PROFILE and self.digits != self.DEFAULT_DIGITS:
raise BusinessIdError(
BusinessIdErrorCode.DEFINITION_INVALID,
"daily-permuted-v1 requires exactly 6 digits",
)
if not isinstance(self.digits, int) or isinstance(self.digits, bool) or not 1 <= self.digits <= 18:
raise BusinessIdError(
BusinessIdErrorCode.DEFINITION_INVALID,
"digits must be between 1 and 18",
)
if not isinstance(self.policy_version, int) or isinstance(self.policy_version, bool) or self.policy_version < 1:
raise BusinessIdError(
BusinessIdErrorCode.DEFINITION_INVALID,
"policy_version must be positive",
)

@classmethod
def daily_permuted(cls, field_name: str, prefix: str, namespace: str) -> "BusinessIdDefinition":
return cls(
field_name, cls.DEFAULT_PROFILE, prefix, cls.DEFAULT_DATE_FORMAT,
"daily", cls.DEFAULT_DIGITS, "-", namespace, 1,
)

def maximum_sequence(self) -> int:
if self.profile == self.DEFAULT_PROFILE:
return 2_176_782_335
return 10 ** self.digits - 1


@dataclass(frozen=True)
class BusinessIdGenerationRequest:
definition: BusinessIdDefinition
domain_root_key: str
aggregate_type: str
business_date: date


@dataclass(frozen=True)
class BusinessIdPlan:
definition: BusinessIdDefinition
scope: BusinessIdScope
business_date: date
date_text: str
initial_sequence: int
maximum_sequence: int

def __post_init__(self) -> None:
if self.initial_sequence < 0 or self.maximum_sequence < self.initial_sequence:
raise BusinessIdError(
BusinessIdErrorCode.DEFINITION_INVALID,
"Business ID allocation range must satisfy 0 <= initial_sequence <= maximum_sequence",
)


@dataclass(frozen=True)
class BusinessIdAllocation:
scope: BusinessIdScope
sequence: int


@dataclass(frozen=True)
class BusinessIdValue:
value: str
profile: str
policy_version: int

def __post_init__(self) -> None:
if not isinstance(self.value, str) or not self.value.strip():
raise BusinessIdError(
BusinessIdErrorCode.FORMAT_INVALID,
"Business ID value must not be blank",
)


class BusinessIdSlot(Protocol):
def current_value(self) -> str | None: ...
def new_aggregate(self) -> bool: ...
def assign_canonical_value(self, value: str) -> None: ...


class BusinessIdAllocator(Protocol):
async def allocate(self, plan: BusinessIdPlan) -> BusinessIdAllocation: ...


class BusinessIdProfile(Protocol):
def plan(self, request: BusinessIdGenerationRequest) -> BusinessIdPlan: ...
def format(self, plan: BusinessIdPlan, allocation: BusinessIdAllocation) -> BusinessIdValue: ...
def validate(self, definition: BusinessIdDefinition, value: str) -> BusinessIdValue: ...


class BusinessIdProfileFactory(Protocol):
def create(self, context: "UserContext", definition: BusinessIdDefinition) -> BusinessIdProfile: ...


class BusinessIdKeyProvider(Protocol):
def current_key(
self,
context: "UserContext",
definition: BusinessIdDefinition,
scope: BusinessIdScope,
) -> BusinessIdEncodingKey: ...


class BusinessIdService(Protocol):
async def ensure(
self,
context: "UserContext",
definition: BusinessIdDefinition,
domain_root_key: str,
aggregate_type: str,
slot: BusinessIdSlot,
) -> BusinessIdValue: ...
7 changes: 6 additions & 1 deletion src/teaql/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
BUSINESS_ID_PERMUTATION_V1_DOMAIN_SIZE,
BUSINESS_ID_PERMUTATION_V1_MAX_SEQUENCE,
BUSINESS_ID_PERMUTATION_V1_WIDTH,
DefaultBusinessIdProfileFactory,
DefaultBusinessIdService,
InMemoryBusinessIdAllocator,
PermutedDailyBusinessIdProfile,
StaticBusinessIdKeyProvider,
encode_business_id_permutation_v1,
)
from .mutation_policy import (
Expand All @@ -51,7 +56,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", "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__ = ["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", "DefaultBusinessIdProfileFactory", "DefaultBusinessIdService", "InMemoryBusinessIdAllocator", "PermutedDailyBusinessIdProfile", "StaticBusinessIdKeyProvider", "encode_business_id_permutation_v1", "ContextTools", "ExecutableHttpTool", "HTTP_TOOL", "HttpIntentPhase", "HttpTool", "HttpToolProvider", "ToolDeniedError", "ToolError", "ToolPolicy", "ToolRisk", "Tools", "ToolToken", "ToolUnavailableError"]

__all__ += [
"MISSING_APPROVAL", "MISSING_POLICY",
Expand Down
Loading
Loading