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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,29 @@ atomic allocation and floor advancement. See the runnable
[Internal ID example](examples/internal-id/README.md). The old synchronous
`generate_id`/`next_id` context calls are unsupported; use the async capability.

## Typed Context extension points

Trusted application wiring installs runtime capabilities through typed Context
methods. Malformed providers fail during registration, before a query or Save
reaches the database. The contracts are importable from `teaql.runtime`:

| Capability | Install on Context | Minimum Protocol |
| --- | --- | --- |
| Query authorization | `with_request_policy` | Callable query policy or `RequestPolicy.apply` |
| Checker/Fix | `with_checker_registry` | `CheckerRegistry.checker` returning a `Checker` |
| Physical schema | `with_schema_provider` | Async `SchemaCapability._ensure_schema` |
| Application audit | `with_app_audit_event_sink` | `SafeAuditEventSink.on_safe_event` |
| Runtime telemetry | `with_runtime_telemetry` | `RuntimeTelemetryAdapter.start`, or diagnostic-only `record_diagnostic` |
| Internal IDs | `with_internal_id_allocator` | Async `InternalIdAllocator.next_id` and `ensure_id_floor` |

The schema capability is independent of the query/mutation data service;
`context.ensure_schema()` remains the application entry point and runs
generated bootstrap after physical schema reconciliation. Existing callable
query policies still work. Telemetry start and diagnostic failures remain
fail-open, whereas query-policy and Checker denials remain fail-closed. The
[typed extension example](examples/typed-extension-spi/README.md) wires all
six contracts into one small SQLite application.

## Optional standard OpenTelemetry module

The local standard-module changes are not yet released. Install the optional
Expand Down
16 changes: 16 additions & 0 deletions examples/typed-extension-spi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Typed Context extension SPI example

This focused SQLite example installs six independent Context capabilities:
request policy, Checker/Fix registry, schema-only provider, safe application
audit sink, runtime telemetry, and Internal ID allocator. The schema provider
has no query/mutation methods. The example verifies accepted and denied
operations, twice-run schema ordering, and a persisted ID from the installed
allocator.

```bash
PYTHONPATH=src python examples/typed-extension-spi/main.py
```

Extensions are trusted application wiring. Registration rejects missing
methods before business execution; a policy or Checker denial still fails
closed. Optional telemetry adapter failures do not change a business result.
146 changes: 146 additions & 0 deletions examples/typed-extension-spi/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Small, complete demonstration of six typed Context extension contracts."""

import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory

import aiosqlite

from teaql.core.meta import EntityDescriptor, PropertyDescriptor
from teaql.core.mutation import InsertCommand, MutationRequest
from teaql.core.query import SelectQuery
from teaql.core.value import DataType
from teaql.data_service import QueryRequest
from teaql.provider.sqlite import SimpleSchemaProvider, create_sqlite_service
from teaql.runtime import RuntimeModule


class SchoolPolicy:
def __init__(self):
self.deny = False
self.calls = 0

def apply(self, query):
self.calls += 1
if self.deny:
raise PermissionError("school query denied")


class SchoolChecker:
def check_and_fix(self, context, record, location, results):
if not record.get("name") or not record["name"].val:
raise ValueError("school name required")


class SchoolCheckerRegistry:
def checker(self, entity):
return SchoolChecker() if entity == "School" else None


class SchemaOnly:
def __init__(self, service):
self.service = service
self.calls = 0

async def _ensure_schema(self, context, capability):
self.calls += 1
await self.service._ensure_schema(context, capability)


class AuditSink:
def __init__(self):
self.events = []

def on_safe_event(self, context, event):
self.events.append(event)


class Telemetry:
def __init__(self):
self.operations = []

def start(self, operation):
self.operations.append(operation.name)

class Scope:
def success(self, attributes=None): pass
def failure(self, error): pass

return Scope()


class InternalIds:
def __init__(self):
self.level = 699
self.lock = asyncio.Lock()

async def next_id(self, entity):
async with self.lock:
self.level += 1
return self.level

async def ensure_id_floor(self, entity, floor):
async with self.lock:
self.level = max(self.level, floor)


async def run():
with TemporaryDirectory() as directory:
path = Path(directory) / "typed-spi.db"
school = (EntityDescriptor("School").table_name("school_data")
.property(PropertyDescriptor("id", DataType.I64).is_id())
.property(PropertyDescriptor("version", DataType.I64).is_version())
.property(PropertyDescriptor("name", DataType.Text)))
provider = SimpleSchemaProvider()
provider.register_entity(school)
service = create_sqlite_service(str(path), provider)
policy, audit, telemetry = SchoolPolicy(), AuditSink(), Telemetry()
schema, ids = SchemaOnly(service), InternalIds()
context = (RuntimeModule.new().entity(school).into_context()
.insert_resource("dataService", service)
.with_schema_provider(schema)
.with_request_policy(policy)
.with_checker_registry(SchoolCheckerRegistry())
.with_app_audit_event_sink(audit)
.with_runtime_telemetry(telemetry)
.with_internal_id_allocator(ids))
context.disable_sql_log()
assert not hasattr(schema, "query") and not hasattr(schema, "mutate")
await context.ensure_schema()
await context.ensure_schema()
assert schema.calls == 2

await service.mutate(context, MutationRequest(
InsertCommand.new("School").value("name", "Riverside"),
comment="create reviewed school"))
request = QueryRequest(SelectQuery("School").project("id", "name"),
_comment="load schools", _purpose="verify accepted school")
rows = (await service.query(context, context.prepare_query_request(request))).rows
assert [(row["id"], row["name"]) for row in rows] == [(700, "Riverside")]
assert policy.calls == 1 and len(audit.events) == 1
assert telemetry.operations

policy.deny = True
try:
context.prepare_query_request(request)
except PermissionError:
pass
else:
raise AssertionError("denied policy must block query preparation")
try:
await service.mutate(context, MutationRequest(
InsertCommand.new("School").value("name", ""),
comment="reject nameless school"))
except ValueError:
pass
else:
raise AssertionError("checker must reject invalid school")
async with aiosqlite.connect(path) as db:
async with db.execute("SELECT id FROM school_data") as cursor:
assert [row[0] for row in await cursor.fetchall()] == [700]
await service.close()
print("PASS Python typed Context extension SPI example")


if __name__ == "__main__":
asyncio.run(run())
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 business-id conformance facet-trace internal-id mutation-policy object-storage opaque-entity-reference order-management query-policy school-management task_board trace-chain)
expected=(business-clock business-id conformance facet-trace internal-id mutation-policy object-storage opaque-entity-reference order-management query-policy school-management task_board trace-chain typed-extension-spi)
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 Down Expand Up @@ -30,6 +30,7 @@ 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/internal-id/main.py"
PYTHONPATH="$repo/src" python "$repo/examples/typed-extension-spi/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
15 changes: 0 additions & 15 deletions src/teaql/data_service/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,21 +328,6 @@ async def begin(self) -> Transaction:
...


@dataclass
class _SchemaRequest:
entity_name: str


@dataclass
class _SchemaResult:
changed: bool


class _SchemaExecutor(DataServiceExecutor, Protocol):
async def _ensure_schema_request(self, request: _SchemaRequest) -> _SchemaResult:
...


class IdGeneratorExecutor(DataServiceExecutor, Protocol):
async def next_id(self, entity: str) -> int:
...
Expand Down
8 changes: 8 additions & 0 deletions src/teaql/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
from .audit import RawAuditEvent, SafeAuditEvent, MutationAuditKind
from .business_clock import BusinessClock, FixedBusinessClock, SystemBusinessClock
from .internal_id import InternalIdAllocator
from .extensions import (
Checker, CheckerRegistry, DiagnosticTelemetryAdapter, RequestPolicy,
RequestPolicyInput, RuntimeTelemetryAdapter, RuntimeTelemetryInput,
SafeAuditEventSink, SchemaCapability,
)
from .entity_reference import (
AeadEntityReferenceCodec,
EntityReferenceClaims,
Expand Down Expand Up @@ -61,6 +66,9 @@
__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", "InternalIdAllocator", "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__ += [
"Checker", "CheckerRegistry", "DiagnosticTelemetryAdapter", "RequestPolicy",
"RequestPolicyInput", "RuntimeTelemetryAdapter", "RuntimeTelemetryInput",
"SafeAuditEventSink", "SchemaCapability",
"GraphMutationSession", "GraphCommittedError",
"MISSING_APPROVAL", "MISSING_POLICY",
"DelegatingMutationGovernanceSink",
Expand Down
Loading
Loading