mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
833 lines
38 KiB
Python
833 lines
38 KiB
Python
"""Scoped approval and atomic append-only dispatch spends.
|
|
|
|
Every real transport attempt (including OAuth, redirects and retries) needs its
|
|
own persisted native cursor. A permit authorizes exactly one dispatch; uncertain
|
|
or failed attempts never refund quota and cannot be reissued under another key.
|
|
"""
|
|
|
|
from collections.abc import Callable
|
|
from datetime import datetime, timedelta
|
|
from typing import Literal, overload
|
|
from uuid import uuid4
|
|
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from models.dify_builder import DifyBuilderExecutionRequest
|
|
from models.dify_builder_native_tests import (
|
|
NativeEffectAttempt,
|
|
NativeEffectDispatchClaim,
|
|
NativeEffectGrant,
|
|
NativeEffectOperation,
|
|
NativeEffectOutcome,
|
|
NativeInvocationState,
|
|
NativePendingAction,
|
|
NativeTestScope,
|
|
)
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest
|
|
from runtime_contracts.builder.native.contracts import (
|
|
ApprovalWait,
|
|
DispatchPermit,
|
|
EffectDecision,
|
|
EffectIntent,
|
|
EffectReceipt,
|
|
NativeTestContextRef,
|
|
Outcome,
|
|
PolicyStop,
|
|
RecordedResult,
|
|
operation_digest,
|
|
)
|
|
from services.dify_builder.native_testing.pending_intents import PendingDecisionGuard
|
|
from services.dify_builder.native_testing.private_reads import validate_invocation_workflow, validate_private_read
|
|
from services.dify_builder.native_testing.public_contracts import NativeTestPublicReceipt
|
|
from services.dify_builder.native_testing.repository import (
|
|
BudgetReservation,
|
|
NativeBinding,
|
|
OwnedNativeTest,
|
|
OwnedNativeTestLoader,
|
|
admitted_workflow_revision,
|
|
native_workflow_revision,
|
|
utc_now,
|
|
)
|
|
from services.dify_builder.native_testing.transitions import (
|
|
dispatch_readiness,
|
|
pending_reason,
|
|
require_ancestor_authority,
|
|
validate_pending_fence,
|
|
validate_pending_history,
|
|
)
|
|
|
|
|
|
def validate_binding(intent: EffectIntent, binding: NativeBinding) -> None:
|
|
if canonical_digest(intent.model_dump(mode="json")) != binding.intent_digest or (
|
|
intent.kind,
|
|
intent.resource_ref,
|
|
intent.implementation_revision,
|
|
intent.account_ref,
|
|
intent.capability_digest,
|
|
) != (
|
|
binding.kind,
|
|
binding.resource_ref,
|
|
binding.implementation_revision,
|
|
binding.account_ref,
|
|
binding.capability_digest,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_binding_mismatch")
|
|
if binding.destructive:
|
|
raise BuilderExecutionPolicyError("destructive_effect_forbidden")
|
|
|
|
|
|
def task_grant_allowed(binding: NativeBinding, *, task_approved: bool, confirmed_model: bool = False) -> bool:
|
|
if confirmed_model and binding.kind == "model":
|
|
return task_approved and not binding.destructive
|
|
return (
|
|
task_approved
|
|
and binding.kind in {"code", "file"}
|
|
and binding.isolated
|
|
and not binding.egress
|
|
and not binding.new_data
|
|
and not binding.destructive
|
|
)
|
|
|
|
|
|
def _scope_digest(intent: EffectIntent, binding: NativeBinding) -> str:
|
|
# Legacy batches exclude logical occurrence and the new default field so
|
|
# their immutable grant digests stay byte-for-byte compatible. Operation
|
|
# approval includes the actual key and explicit server-selected mode.
|
|
batch = binding.approval_scope == "batch"
|
|
binding_exclusions = {"intent_digest", "execution_id", "invocation_digest"}
|
|
if batch:
|
|
binding_exclusions.add("approval_scope")
|
|
return canonical_digest(
|
|
{
|
|
"intent": intent.model_dump(mode="json", exclude={"key"} if batch else set()),
|
|
"binding": binding.model_dump(mode="json", exclude=binding_exclusions),
|
|
}
|
|
)
|
|
|
|
|
|
def _owner(owned: OwnedNativeTest) -> dict[str, str]:
|
|
return {"tenant_id": owned.tenant_id, "request_id": owned.ref.request_id, "invocation_id": owned.ref.invocation_id}
|
|
|
|
|
|
def _owned_query(model, owned: OwnedNativeTest):
|
|
return select(model).where(
|
|
model.tenant_id == owned.tenant_id,
|
|
model.request_id == owned.ref.request_id,
|
|
model.invocation_id == owned.ref.invocation_id,
|
|
)
|
|
|
|
|
|
def _completed_config_read_readiness(session: Session, owned: OwnedNativeTest) -> None:
|
|
"""SQL-only authority for a dependency of an already spent, live shell.
|
|
|
|
A later valid approval fence does not revoke the original command. It still
|
|
fences every new dispatch. Other waits and nonactive owners grant no read.
|
|
Scope cancellation/expiry and the exact original permit are checked by the
|
|
enclosing completed-claim validator, before and after every external read.
|
|
"""
|
|
states = {
|
|
state.invocation_id: state
|
|
for state in session.scalars(
|
|
select(NativeInvocationState).where(
|
|
NativeInvocationState.request_id == owned.ref.request_id,
|
|
NativeInvocationState.tenant_id == owned.tenant_id,
|
|
)
|
|
)
|
|
}
|
|
roots = [state for state in states.values() if state.parent_invocation_id is None]
|
|
if len(roots) != 1 or (roots[0].app_id, roots[0].workflow_id) != (owned.app_id, owned.workflow_id):
|
|
raise BuilderExecutionPolicyError("native_dispatch_closed")
|
|
selected, seen = owned.ref.invocation_id, set()
|
|
while selected is not None:
|
|
state = states.get(selected)
|
|
if (
|
|
selected in seen
|
|
or state is None
|
|
or state.state not in {"running", "waiting"}
|
|
or type(state.pending_ids) is not list
|
|
or state.wait_reason not in {None, "approval"}
|
|
or (state.state == "waiting" and state.wait_reason is None)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_dispatch_closed")
|
|
seen.add(selected)
|
|
ref = owned.ref.model_copy(update={"invocation_id": selected})
|
|
if state.wait_reason == "approval":
|
|
rows = session.scalars(
|
|
select(NativePendingAction).where(
|
|
NativePendingAction.tenant_id == owned.tenant_id,
|
|
NativePendingAction.request_id == owned.ref.request_id,
|
|
NativePendingAction.invocation_id == selected,
|
|
NativePendingAction.id.in_(state.pending_ids),
|
|
)
|
|
).all()
|
|
pending = validate_pending_fence(
|
|
rows, state=state, ref=ref, tenant_id=owned.tenant_id, pending_ids=state.pending_ids
|
|
)
|
|
if pending_reason(pending) != "approval":
|
|
raise BuilderExecutionPolicyError("native_pending_mismatch")
|
|
elif state.pending_ids:
|
|
validate_pending_history(session, ref=ref, tenant_id=owned.tenant_id, state=state, states=states)
|
|
selected = state.parent_invocation_id
|
|
|
|
|
|
class EffectAuthorizationService:
|
|
def __init__(self, loader: OwnedNativeTestLoader, *, clock: Callable[[], datetime] = utc_now, state_store=None):
|
|
self.loader = loader
|
|
self.clock = clock
|
|
self.state_store = state_store
|
|
|
|
def _resolver(self, ref, key):
|
|
from services.dify_builder.native_testing.agent_operations import AgentOperationBindingResolver
|
|
|
|
resolver = self.loader.resolver
|
|
if self.state_store is not None and isinstance(resolver, AgentOperationBindingResolver):
|
|
return resolver.for_operation(store=self.state_store, ref=ref, key=key)
|
|
return resolver
|
|
|
|
def _binding(
|
|
self, session: Session, owned: OwnedNativeTest, intent: EffectIntent, *, resolver=None
|
|
) -> NativeBinding:
|
|
if intent.key.invocation_id != owned.ref.invocation_id:
|
|
raise BuilderExecutionPolicyError("native_binding_mismatch")
|
|
raw = owned.bindings.get(operation_digest(intent.key))
|
|
if raw is None:
|
|
raise BuilderExecutionPolicyError("native_cursor_mismatch")
|
|
try:
|
|
frozen = NativeBinding.model_validate(raw)
|
|
current = (resolver or self.loader.resolver).resolve_binding(session=session, owned=owned, key=intent.key)
|
|
current = NativeBinding.model_validate(current.model_dump(mode="json"))
|
|
except (ValidationError, AttributeError, TypeError):
|
|
raise BuilderExecutionPolicyError("native_binding_mismatch") from None
|
|
if current != frozen:
|
|
raise BuilderExecutionPolicyError("native_binding_drift")
|
|
validate_binding(intent, current)
|
|
return current
|
|
|
|
def before_call(self, *, ref: NativeTestContextRef, intent: EffectIntent) -> EffectDecision:
|
|
try:
|
|
# Revalidate DTOs after in-process model_copy/model_construct boundaries.
|
|
ref = NativeTestContextRef.model_validate(ref.model_dump(mode="json"))
|
|
intent = EffectIntent.model_validate(intent.model_dump(mode="json"))
|
|
self.loader.load(ref=ref)
|
|
resolver = self._resolver(ref, intent.key)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
result = self._claim(session, owned, scope, intent, resolver=resolver)
|
|
# The transaction committed before the permit crosses the I/O boundary.
|
|
return result
|
|
except BuilderExecutionPolicyError as error:
|
|
return PolicyStop(reason_code=error.reason_code)
|
|
except (ValidationError, AttributeError, TypeError):
|
|
return PolicyStop(reason_code="invalid_native_effect")
|
|
|
|
def _claim(
|
|
self, session: Session, owned: OwnedNativeTest, scope: NativeTestScope, intent: EffectIntent, *, resolver=None
|
|
) -> EffectDecision:
|
|
now = self.clock()
|
|
operation = session.scalar(
|
|
_owned_query(NativeEffectOperation, owned).where(
|
|
NativeEffectOperation.operation_digest == operation_digest(intent.key)
|
|
)
|
|
)
|
|
digest = canonical_digest(intent.model_dump(mode="json"))
|
|
if operation is not None and operation.intent_digest != digest:
|
|
return PolicyStop(reason_code="native_intent_conflict")
|
|
binding = self._binding(session, owned, intent, resolver=resolver)
|
|
scoped = _scope_digest(intent, binding)
|
|
if operation is not None and operation.state != "pending":
|
|
return self._existing(session, owned, operation, now)
|
|
if now >= scope.expires_at:
|
|
return PolicyStop(reason_code="native_scope_expired")
|
|
grant = session.scalar(
|
|
select(NativeEffectGrant).where(
|
|
NativeEffectGrant.tenant_id == owned.tenant_id,
|
|
NativeEffectGrant.request_id == owned.ref.request_id,
|
|
NativeEffectGrant.scope_digest == scoped,
|
|
)
|
|
)
|
|
if grant is not None and grant.decision == "denied":
|
|
return PolicyStop(reason_code="effect_denied")
|
|
fence = dispatch_readiness(session, ref=owned.ref, tenant_id=owned.tenant_id, now=now, coalesce_self=True)
|
|
if fence is not None:
|
|
from services.dify_builder.native_testing.agent_operations import confirmed_deferred_tool_approval
|
|
|
|
if not confirmed_deferred_tool_approval(
|
|
session=session, owned=owned, intent=intent, binding=binding, fence=fence
|
|
):
|
|
return ApprovalWait(pending_operation_id=fence.id, wait_reason=fence.category)
|
|
if operation is None:
|
|
operation = NativeEffectOperation(
|
|
**_owner(owned),
|
|
operation_digest=operation_digest(intent.key),
|
|
intent_digest=digest,
|
|
scope_digest=scoped,
|
|
state="pending",
|
|
result_ref=None,
|
|
)
|
|
session.add(operation)
|
|
session.flush()
|
|
if fence is not None:
|
|
return self._pending(session, owned, operation)
|
|
# Only the server-owned confirmed Agent producer can bind this exact
|
|
# selected model to an already confirmed outer task. Tool calls still
|
|
# require their actual-argument operation grant.
|
|
from services.dify_builder.native_testing.agent_operations import confirmed_task_model
|
|
|
|
if grant is None and task_grant_allowed(
|
|
binding,
|
|
task_approved=owned.task_approved,
|
|
confirmed_model=confirmed_task_model(session=session, owned=owned, intent=intent),
|
|
):
|
|
grant = NativeEffectGrant(
|
|
tenant_id=owned.tenant_id,
|
|
request_id=owned.ref.request_id,
|
|
actor_id=owned.actor_id,
|
|
scope_digest=scoped,
|
|
decision="approved",
|
|
expires_at=scope.expires_at,
|
|
max_calls=scope.max_calls,
|
|
max_dispatches=scope.max_dispatches,
|
|
max_cost_units=scope.max_cost_units,
|
|
spent_calls=0,
|
|
spent_dispatches=0,
|
|
spent_cost_units=0,
|
|
)
|
|
session.add(grant)
|
|
session.flush()
|
|
if grant is None or grant.expires_at <= now or not self._fits(grant, binding.cost_units):
|
|
return self._pending(session, owned, operation)
|
|
if not self._fits(scope, binding.cost_units):
|
|
return PolicyStop(reason_code="native_budget_exhausted")
|
|
spent = dict(scope.budget_spent)
|
|
for name, amount in binding.budget.items():
|
|
limit, profile, previous = scope.budget_limits.get(name), scope.profile.get(name), spent.get(name, 0)
|
|
if (
|
|
type(limit) is not int
|
|
or type(profile) is not int
|
|
or type(previous) is not int
|
|
or min(limit, profile, previous) < 0
|
|
or amount > profile
|
|
or previous + amount > limit
|
|
):
|
|
return PolicyStop(reason_code="native_budget_exhausted")
|
|
spent[name] = previous + amount
|
|
for quota in (scope, grant):
|
|
quota.spent_calls += 1
|
|
quota.spent_dispatches += 1
|
|
quota.spent_cost_units += binding.cost_units
|
|
scope.budget_spent = spent
|
|
permit_id = str(uuid4())
|
|
attempt = NativeEffectAttempt(
|
|
**_owner(owned),
|
|
operation_id=operation.id,
|
|
grant_id=grant.id,
|
|
dispatch_index=1,
|
|
permit_digest=canonical_digest(permit_id),
|
|
expires_at=min(now + timedelta(minutes=5), grant.expires_at, scope.expires_at),
|
|
cost_units=binding.cost_units,
|
|
budget_reservation_ref=str(uuid4()),
|
|
execution_id=binding.execution_id,
|
|
invocation_digest=binding.invocation_digest,
|
|
budget=dict(binding.budget),
|
|
profile=dict(scope.profile),
|
|
)
|
|
session.add(attempt)
|
|
operation.state = "claimed"
|
|
session.flush()
|
|
return DispatchPermit(
|
|
permit_id=permit_id,
|
|
attempt_id=attempt.id,
|
|
operation_id=operation.id,
|
|
expires_at=attempt.expires_at.isoformat(),
|
|
)
|
|
|
|
@staticmethod
|
|
def _fits(quota, cost_units: int) -> bool:
|
|
return (
|
|
quota.spent_calls < quota.max_calls
|
|
and quota.spent_dispatches < quota.max_dispatches
|
|
and quota.spent_cost_units + cost_units <= quota.max_cost_units
|
|
)
|
|
|
|
def _existing(
|
|
self, session: Session, owned: OwnedNativeTest, operation: NativeEffectOperation, now: datetime
|
|
) -> EffectDecision:
|
|
if operation.state == "completed" and operation.result_ref is not None:
|
|
return RecordedResult(operation_id=operation.id, result_ref=operation.result_ref)
|
|
if operation.state == "claimed":
|
|
attempt = session.scalar(
|
|
_owned_query(NativeEffectAttempt, owned).where(NativeEffectAttempt.operation_id == operation.id)
|
|
)
|
|
if attempt is None:
|
|
raise BuilderExecutionPolicyError("native_ledger_inconsistent")
|
|
if attempt.expires_at > now:
|
|
return PolicyStop(reason_code="dispatch_in_progress")
|
|
self._append_outcome(session, owned, operation, attempt, "unknown", None)
|
|
return PolicyStop(reason_code="outcome_unknown")
|
|
return PolicyStop(
|
|
reason_code={"unknown": "outcome_unknown", "denied": "effect_denied", "cancelled": "native_cancelled"}.get(
|
|
operation.state, "effect_already_spent"
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _pending(session: Session, owned: OwnedNativeTest, operation: NativeEffectOperation) -> ApprovalWait:
|
|
state = session.scalar(_owned_query(NativeInvocationState, owned))
|
|
if state is None:
|
|
raise BuilderExecutionPolicyError("native_invocation_mismatch")
|
|
pending = session.scalar(
|
|
_owned_query(NativePendingAction, owned).where(
|
|
NativePendingAction.operation_id == operation.id, NativePendingAction.generation == state.generation
|
|
)
|
|
)
|
|
if pending is None:
|
|
pending = NativePendingAction(
|
|
**_owner(owned),
|
|
generation=state.generation,
|
|
category="approval",
|
|
reference_digest=canonical_digest(operation.id),
|
|
reference=operation.id,
|
|
owner_ref=owned.app_id,
|
|
owner_version=operation.scope_digest,
|
|
operation_id=operation.id,
|
|
)
|
|
session.add(pending)
|
|
session.flush()
|
|
return ApprovalWait(pending_operation_id=pending.id)
|
|
|
|
@overload
|
|
def decide(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
intent: EffectIntent,
|
|
decision: Literal["approved", "denied"],
|
|
max_calls: int,
|
|
max_dispatches: int,
|
|
max_cost_units: int,
|
|
expires_at: datetime,
|
|
pending_guard: PendingDecisionGuard,
|
|
) -> NativeTestPublicReceipt: ...
|
|
|
|
@overload
|
|
def decide(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
intent: EffectIntent,
|
|
decision: Literal["approved", "denied"],
|
|
max_calls: int,
|
|
max_dispatches: int,
|
|
max_cost_units: int,
|
|
expires_at: datetime,
|
|
pending_guard: None = None,
|
|
) -> str: ...
|
|
|
|
def decide(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
intent: EffectIntent,
|
|
decision: Literal["approved", "denied"],
|
|
max_calls: int,
|
|
max_dispatches: int,
|
|
max_cost_units: int,
|
|
expires_at: datetime,
|
|
pending_guard: PendingDecisionGuard | None = None,
|
|
) -> str | NativeTestPublicReceipt:
|
|
"""Internal C04 command boundary; caller authenticates explicit user confirmation.
|
|
|
|
Public API callers must supply pending_guard; existing trusted internal
|
|
ledger callers omit it and retain their grant-ID return contract. Public
|
|
commands return the first receipt saved under the serialized decision lock.
|
|
Existing grants are immutable, including limits and expiry. Repeated
|
|
decisions are idempotent; a retry cannot mint a new quota allowance.
|
|
"""
|
|
self.loader.load(ref=ref)
|
|
resolver = self._resolver(ref, intent.key)
|
|
if pending_guard is not None:
|
|
# Refresh actual child permission at the final public authorization
|
|
# boundary, before SQL locking. Like root RBAC, this external check
|
|
# cannot fence revocations that happen after the RPC returns.
|
|
authority = validate_private_read(self.loader, ref)
|
|
if pending_guard.resolved.ref != ref or (
|
|
authority.app_id,
|
|
authority.workflow_id,
|
|
authority.graph_revision,
|
|
authority.generation,
|
|
) != (
|
|
pending_guard.invocation_app_id,
|
|
pending_guard.invocation_workflow_id,
|
|
pending_guard.invocation_revision,
|
|
pending_guard.resolved.generation,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_pending_conflict")
|
|
if decision not in {"approved", "denied"} or any(
|
|
type(v) is not int or v < 0 or v > 2**63 - 1 for v in (max_calls, max_dispatches, max_cost_units)
|
|
):
|
|
raise BuilderExecutionPolicyError("invalid_native_grant")
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
if expires_at.tzinfo is None or not self.clock() < expires_at <= scope.expires_at:
|
|
raise BuilderExecutionPolicyError("invalid_native_grant")
|
|
binding = self._binding(session, owned, intent, resolver=resolver)
|
|
digest = _scope_digest(intent, binding)
|
|
if pending_guard is not None:
|
|
from services.dify_builder.native_testing.agent_operations import AgentOperationBindingResolver
|
|
|
|
terms = pending_guard.terms
|
|
if (max_calls, max_dispatches, max_cost_units, expires_at) != (
|
|
terms.max_calls,
|
|
terms.max_dispatches,
|
|
terms.max_cost_units,
|
|
datetime.fromisoformat(terms.expires_at),
|
|
):
|
|
raise BuilderExecutionPolicyError("native_pending_conflict")
|
|
pending_guard.recheck(
|
|
session=session,
|
|
owned=owned,
|
|
intent=intent,
|
|
scope_digest=digest,
|
|
recovery_state=resolver.recovery if isinstance(resolver, AgentOperationBindingResolver) else None,
|
|
)
|
|
grant = session.scalar(
|
|
select(NativeEffectGrant).where(
|
|
NativeEffectGrant.tenant_id == owned.tenant_id,
|
|
NativeEffectGrant.request_id == ref.request_id,
|
|
NativeEffectGrant.scope_digest == digest,
|
|
)
|
|
)
|
|
fields = {
|
|
"decision": decision,
|
|
"max_calls": max_calls,
|
|
"max_dispatches": max_dispatches,
|
|
"max_cost_units": max_cost_units,
|
|
"expires_at": expires_at,
|
|
}
|
|
if grant is not None:
|
|
if (grant.decision, grant.max_calls, grant.max_dispatches, grant.max_cost_units, grant.expires_at) != (
|
|
decision,
|
|
max_calls,
|
|
max_dispatches,
|
|
max_cost_units,
|
|
expires_at,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_grant_conflict")
|
|
if pending_guard is not None:
|
|
return pending_guard.save(session=session, request_id=ref.request_id)
|
|
return grant.id
|
|
grant = NativeEffectGrant(
|
|
tenant_id=owned.tenant_id,
|
|
request_id=ref.request_id,
|
|
actor_id=owned.actor_id,
|
|
scope_digest=digest,
|
|
spent_calls=0,
|
|
spent_dispatches=0,
|
|
spent_cost_units=0,
|
|
**fields,
|
|
)
|
|
session.add(grant)
|
|
session.flush()
|
|
if pending_guard is not None:
|
|
return pending_guard.save(session=session, request_id=ref.request_id)
|
|
return grant.id
|
|
|
|
def _permit_ref(self, permit: DispatchPermit) -> NativeTestContextRef:
|
|
with self.loader.session_factory() as session:
|
|
row = session.execute(
|
|
select(NativeEffectAttempt, DifyBuilderExecutionRequest)
|
|
.join(DifyBuilderExecutionRequest, DifyBuilderExecutionRequest.id == NativeEffectAttempt.request_id)
|
|
.where(
|
|
NativeEffectAttempt.id == permit.attempt_id,
|
|
NativeEffectAttempt.operation_id == permit.operation_id,
|
|
NativeEffectAttempt.permit_digest == canonical_digest(permit.permit_id),
|
|
NativeEffectAttempt.tenant_id == self.loader.actor.tenant_id,
|
|
DifyBuilderExecutionRequest.tenant_id == self.loader.actor.tenant_id,
|
|
DifyBuilderExecutionRequest.actor_id == self.loader.actor.account_id,
|
|
)
|
|
).one_or_none()
|
|
if row is None:
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
attempt, request = row
|
|
if attempt.expires_at != datetime.fromisoformat(permit.expires_at):
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
return NativeTestContextRef(
|
|
request_id=request.id, invocation_id=attempt.invocation_id, context_digest=request.context_digest
|
|
)
|
|
|
|
def _attempt(self, session: Session, owned: OwnedNativeTest, permit: DispatchPermit) -> NativeEffectAttempt:
|
|
attempt = session.scalar(
|
|
_owned_query(NativeEffectAttempt, owned).where(
|
|
NativeEffectAttempt.id == permit.attempt_id,
|
|
NativeEffectAttempt.operation_id == permit.operation_id,
|
|
NativeEffectAttempt.permit_digest == canonical_digest(permit.permit_id),
|
|
)
|
|
)
|
|
if attempt is None:
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
return attempt
|
|
|
|
def validate_dispatch(self, *, ref: NativeTestContextRef, intent: EffectIntent, permit: DispatchPermit) -> None:
|
|
"""Revalidate a reserved permit without claiming or spending another one.
|
|
|
|
This is NOT a physical-dispatch fence. An authenticated endpoint must
|
|
also atomically consume its durable dispatch fence before performing I/O;
|
|
repeated validation cannot authorize repeating the transport operation.
|
|
"""
|
|
try:
|
|
ref = NativeTestContextRef.model_validate(ref.model_dump(mode="json"))
|
|
intent = EffectIntent.model_validate(intent.model_dump(mode="json"))
|
|
permit = DispatchPermit.model_validate(permit.model_dump(mode="json"))
|
|
except (ValidationError, AttributeError, TypeError):
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit") from None
|
|
if self._permit_ref(permit) != ref:
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
self.loader.load(ref=ref)
|
|
resolver = self._resolver(ref, intent.key)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
self._validate_dispatch_locked(
|
|
session=session, owned=owned, scope=scope, intent=intent, permit=permit, resolver=resolver
|
|
)
|
|
|
|
def validate_completed_config_claim(
|
|
self, *, ref: NativeTestContextRef, intent: EffectIntent, permit: DispatchPermit
|
|
) -> str:
|
|
"""Read-only ACK admission; never usable as dispatch or result-store permission.
|
|
|
|
The caller must additionally prove its immutable typed ACK and the same
|
|
live physical job, and repeat this check after that external read.
|
|
"""
|
|
ref = NativeTestContextRef.model_validate(ref.model_dump(mode="json"))
|
|
intent = EffectIntent.model_validate(intent.model_dump(mode="json"))
|
|
permit = DispatchPermit.model_validate(permit.model_dump(mode="json"))
|
|
if (
|
|
intent.kind != "shell"
|
|
or intent.key.occurrence_path[-2:-1] != ("shell_run",)
|
|
or self._permit_ref(permit) != ref
|
|
):
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
self.loader.load(ref=ref)
|
|
resolver = self._resolver(ref, intent.key)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
attempt = self._attempt(session, owned, permit)
|
|
binding = self._binding(session, owned, intent, resolver=resolver)
|
|
operation = session.scalar(
|
|
_owned_query(NativeEffectOperation, owned).where(NativeEffectOperation.id == permit.operation_id)
|
|
)
|
|
claim = session.scalar(
|
|
_owned_query(NativeEffectDispatchClaim, owned).where(
|
|
NativeEffectDispatchClaim.operation_id == permit.operation_id,
|
|
NativeEffectDispatchClaim.attempt_id == permit.attempt_id,
|
|
)
|
|
)
|
|
outcome = session.scalar(
|
|
_owned_query(NativeEffectOutcome, owned).where(
|
|
NativeEffectOutcome.operation_id == permit.operation_id,
|
|
NativeEffectOutcome.attempt_id == permit.attempt_id,
|
|
)
|
|
)
|
|
now = self.clock()
|
|
if (
|
|
operation is None
|
|
or operation.operation_digest != operation_digest(intent.key)
|
|
or operation.intent_digest != canonical_digest(intent.model_dump(mode="json"))
|
|
or operation.scope_digest != _scope_digest(intent, binding)
|
|
or operation.state != "completed"
|
|
or claim is None
|
|
or outcome is None
|
|
or outcome.outcome != "completed"
|
|
or outcome.result_ref is None
|
|
or outcome.result_ref != operation.result_ref
|
|
or attempt.expires_at != datetime.fromisoformat(permit.expires_at)
|
|
or now >= attempt.expires_at
|
|
or now >= scope.expires_at
|
|
):
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
require_ancestor_authority(session, ref, owned.tenant_id)
|
|
_completed_config_read_readiness(session, owned)
|
|
return outcome.result_ref
|
|
|
|
def claim_dispatch(self, *, ref: NativeTestContextRef, intent: EffectIntent, permit: DispatchPermit) -> None:
|
|
"""Consume a reserved permit once, durably, before the sole owner's I/O.
|
|
|
|
The actual owner must compare its requested resource, target, typed args
|
|
and phase with intent before calling this boundary. Success is returned
|
|
only after commit. A lost/uncertain response must never be retried and
|
|
followed by I/O: the committed claim cannot be reissued or refunded.
|
|
This method never spends another permit or performs external I/O under
|
|
the root request lock. It does not itself execute the physical effect.
|
|
"""
|
|
try:
|
|
ref = NativeTestContextRef.model_validate(ref.model_dump(mode="json"))
|
|
intent = EffectIntent.model_validate(intent.model_dump(mode="json"))
|
|
permit = DispatchPermit.model_validate(permit.model_dump(mode="json"))
|
|
except (BuilderExecutionPolicyError, ValidationError, AttributeError, TypeError):
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit") from None
|
|
if self._permit_ref(permit) != ref:
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
self.loader.load(ref=ref)
|
|
resolver = self._resolver(ref, intent.key)
|
|
# Child-app RBAC may perform RPC, so refresh before the writer lock.
|
|
# SQL provenance and state are checked again inside the transaction.
|
|
authority = validate_private_read(self.loader, ref)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
attempt = self._validate_dispatch_locked(
|
|
session=session, owned=owned, scope=scope, intent=intent, permit=permit, resolver=resolver
|
|
)
|
|
require_ancestor_authority(session, ref, owned.tenant_id)
|
|
state = session.scalar(_owned_query(NativeInvocationState, owned))
|
|
if (
|
|
state is None
|
|
or state.state not in {"running", "waiting", "queued"}
|
|
or (state.app_id, state.workflow_id, state.context_object_id, state.generation)
|
|
!= (authority.app_id, authority.workflow_id, authority.context_object_id, authority.generation)
|
|
):
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
workflow = validate_invocation_workflow(
|
|
session, tenant_id=owned.tenant_id, app_id=authority.app_id, workflow_id=authority.workflow_id
|
|
)
|
|
revision_context = authority.revision_context
|
|
revision = (
|
|
admitted_workflow_revision(workflow, revision_context)
|
|
if revision_context
|
|
else native_workflow_revision(workflow)
|
|
)
|
|
if revision != authority.graph_revision:
|
|
raise BuilderExecutionPolicyError("native_revision_drift")
|
|
existing = session.scalar(
|
|
_owned_query(NativeEffectDispatchClaim, owned).where(
|
|
NativeEffectDispatchClaim.operation_id == attempt.operation_id
|
|
)
|
|
)
|
|
if existing is not None:
|
|
raise BuilderExecutionPolicyError("dispatch_already_consumed")
|
|
session.add(
|
|
NativeEffectDispatchClaim(
|
|
**_owner(owned),
|
|
operation_id=attempt.operation_id,
|
|
attempt_id=attempt.id,
|
|
claimed_at=self.clock(),
|
|
)
|
|
)
|
|
session.flush()
|
|
# Leaving loader.locked commits before any caller can perform I/O.
|
|
|
|
def _validate_dispatch_locked(
|
|
self,
|
|
*,
|
|
session: Session,
|
|
owned: OwnedNativeTest,
|
|
scope: NativeTestScope,
|
|
intent: EffectIntent,
|
|
permit: DispatchPermit,
|
|
resolver=None,
|
|
) -> NativeEffectAttempt:
|
|
"""Shared local validation inside the caller's existing root transaction.
|
|
|
|
The durable transport consumer must call this while holding loader.locked
|
|
and insert its unique consumption fence before committing that same lock.
|
|
No I/O, independent transaction or quota mutation occurs in this helper.
|
|
"""
|
|
attempt = self._attempt(session, owned, permit)
|
|
operation = session.scalar(
|
|
_owned_query(NativeEffectOperation, owned).where(NativeEffectOperation.id == permit.operation_id)
|
|
)
|
|
binding = self._binding(session, owned, intent, resolver=resolver)
|
|
now = self.clock()
|
|
if (
|
|
operation is None
|
|
or operation.operation_digest != operation_digest(intent.key)
|
|
or operation.intent_digest != canonical_digest(intent.model_dump(mode="json"))
|
|
or operation.scope_digest != _scope_digest(intent, binding)
|
|
or operation.state != "claimed"
|
|
or attempt.expires_at != datetime.fromisoformat(permit.expires_at)
|
|
or now >= attempt.expires_at
|
|
or now >= scope.expires_at
|
|
):
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
require_ancestor_authority(session, owned.ref, owned.tenant_id)
|
|
dispatch_readiness(session, ref=owned.ref, tenant_id=owned.tenant_id, now=now)
|
|
return attempt
|
|
|
|
def record_outcome(self, *, permit: DispatchPermit, outcome: Outcome, result_ref: str | None) -> EffectReceipt:
|
|
receipt = EffectReceipt(
|
|
operation_id=permit.operation_id, attempt_id=permit.attempt_id, outcome=outcome, result_ref=result_ref
|
|
)
|
|
if outcome == "completed" and result_ref is None:
|
|
raise BuilderExecutionPolicyError("completed_result_required")
|
|
ref = self._permit_ref(permit)
|
|
with self.loader.locked(ref=ref, settlement=True) as (session, owned, _):
|
|
attempt = self._attempt(session, owned, permit)
|
|
operation = session.scalar(
|
|
_owned_query(NativeEffectOperation, owned).where(NativeEffectOperation.id == permit.operation_id)
|
|
)
|
|
if operation is None:
|
|
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
|
|
existing = session.scalar(
|
|
_owned_query(NativeEffectOutcome, owned).where(NativeEffectOutcome.attempt_id == attempt.id)
|
|
)
|
|
if existing is not None:
|
|
if existing.outcome != outcome or existing.result_ref != result_ref:
|
|
raise BuilderExecutionPolicyError("outcome_conflict")
|
|
return receipt
|
|
self._append_outcome(session, owned, operation, attempt, outcome, result_ref)
|
|
return receipt
|
|
|
|
@staticmethod
|
|
def _append_outcome(session, owned, operation, attempt, outcome, result_ref):
|
|
session.add(
|
|
NativeEffectOutcome(
|
|
**_owner(owned),
|
|
operation_id=operation.id,
|
|
attempt_id=attempt.id,
|
|
outcome=outcome,
|
|
result_ref=result_ref,
|
|
)
|
|
)
|
|
operation.state, operation.result_ref = outcome, result_ref
|
|
|
|
def resolve_budget_reservation(
|
|
self, *, permit: DispatchPermit, execution_id: str, invocation_digest: str
|
|
) -> BudgetReservation:
|
|
ref = self._permit_ref(permit)
|
|
self.loader.load(ref=ref)
|
|
with self.loader.locked(ref=ref) as (session, owned, _):
|
|
attempt = self._attempt(session, owned, permit)
|
|
return self._reservation(session, owned, attempt, execution_id, invocation_digest)
|
|
|
|
def resolve_budget_reference(
|
|
self, *, ref: NativeTestContextRef, budget_reservation_ref: str, execution_id: str, invocation_digest: str
|
|
) -> BudgetReservation:
|
|
"""CS6 authenticated server bridge; a supplied reference is never authority."""
|
|
self.loader.load(ref=ref)
|
|
with self.loader.locked(ref=ref) as (session, owned, _):
|
|
attempt = session.scalar(
|
|
_owned_query(NativeEffectAttempt, owned).where(
|
|
NativeEffectAttempt.budget_reservation_ref == budget_reservation_ref
|
|
)
|
|
)
|
|
if attempt is None:
|
|
raise BuilderExecutionPolicyError("invalid_budget_reservation")
|
|
return self._reservation(session, owned, attempt, execution_id, invocation_digest)
|
|
|
|
def _reservation(self, session, owned, attempt, execution_id, invocation_digest) -> BudgetReservation:
|
|
operation = session.scalar(
|
|
_owned_query(NativeEffectOperation, owned).where(NativeEffectOperation.id == attempt.operation_id)
|
|
)
|
|
if (
|
|
attempt.execution_id != execution_id
|
|
or attempt.invocation_digest != invocation_digest
|
|
or attempt.expires_at <= self.clock()
|
|
or operation is None
|
|
or operation.state != "claimed"
|
|
):
|
|
raise BuilderExecutionPolicyError("invalid_budget_reservation")
|
|
return BudgetReservation(
|
|
budget_reservation_ref=attempt.budget_reservation_ref,
|
|
execution_id=attempt.execution_id,
|
|
invocation_digest=attempt.invocation_digest,
|
|
expires_at=attempt.expires_at,
|
|
limits=dict(attempt.budget),
|
|
profile=dict(attempt.profile),
|
|
)
|