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
19 changes: 14 additions & 5 deletions docs/effect-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Source spelling, receiver candidates, suffixes, bare method names, package metad

Equivalent YAML/JSON/TOML key and contract ordering produces the same semantic hashes. Formatting changes can change only the raw hash.

## Schema v1, v2, and v3
## Schema v1 through v4

```yaml
schema_version: 1
Expand Down Expand Up @@ -101,6 +101,14 @@ Schema v3 adds optional structured `http_method` metadata for exact
`PUT`, `PATCH`, `DELETE`, `HEAD`, and `OPTIONS` are accepted. A method is
contract semantics, not runtime observation or a fallback match key.

Schema v4 adds ordered `composite` resource selectors with two through four
ordinary selector components. Every component must resolve to finite evidence;
the bounded Cartesian product may contain at most eight identities. Each result
hash includes the ordered selector domains and component hashes, so `(Bucket,
Key)` cannot collide across buckets or with a reversed selector. Missing,
dynamic, path-based, or over-budget components make the complete resource
identity unavailable rather than partially matching it.

Selectors are deliberately bounded:

- `none`
Expand Down Expand Up @@ -154,10 +162,11 @@ mode-specific append classification, deferred cursors, Redis pipelines, and bare
method names are intentionally absent.

Each family has an independent identity and semantic hash. Filesystem receiver
origins and exact HTTP verb tables are version `2.0.0`; the other non-SQL
families remain `1.0.0`. HTTP contracts preserve `GET`, `POST`, `PUT`, `PATCH`,
`DELETE`, `HEAD`, or `OPTIONS` as structured contract semantics while finite
URLs remain hashed resource evidence. The v1 changelog and
origins, exact HTTP verb tables, and composite typed-S3 `(Bucket, Key)` identities
are version `2.0.0`; MongoDB and Redis remain `1.0.0`. HTTP contracts preserve
`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, or `OPTIONS` as structured
contract semantics while finite URLs remain hashed resource evidence. Typed S3
contracts fail closed unless both bucket and key are finite. The v1 changelog and
known exclusions are frozen in `benchmarks/results/effect-presets-v1/README.md`.
Multiple presets are not silently merged because the current provenance model has
one authoritative contract source per analysis.
Expand Down
61 changes: 58 additions & 3 deletions src/fastapi_endpoint_detector/analyzer/effect_contract_auditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

import hashlib
import json
from itertools import product
from pathlib import Path
from typing import TYPE_CHECKING, Any

from fastapi_endpoint_detector.models.effect_contract import (
CallResolutionStatus,
CompositeEffectSelector,
EffectContract,
EffectSelector,
FiniteValueStatus,
LoadedEffectContracts,
ResolvedCallSite,
Expand Down Expand Up @@ -130,11 +133,10 @@ def _call_payload(site: ResolvedCallSite, relative_path: str) -> dict[str, Any]:
}


def _resource_identity(
contract: EffectContract,
def _selector_identity(
selector: EffectSelector,
site_payload: dict[str, Any],
) -> ResourceIdentityEvidence:
selector = contract.resource
if selector.path:
return ResourceIdentityEvidence(
status=FiniteValueStatus.UNAVAILABLE,
Expand Down Expand Up @@ -176,6 +178,59 @@ def _resource_identity(
)


def _resource_identity(
contract: EffectContract,
site_payload: dict[str, Any],
) -> ResourceIdentityEvidence:
selector = contract.resource
if not isinstance(selector, CompositeEffectSelector):
return _selector_identity(selector, site_payload)

component_evidence = [
_selector_identity(component, site_payload) for component in selector.components
]
if any(item.status == FiniteValueStatus.UNAVAILABLE for item in component_evidence):
return ResourceIdentityEvidence(
status=FiniteValueStatus.UNAVAILABLE,
reason_code="composite_component_unavailable",
)
cardinality = 1
for item in component_evidence:
cardinality *= len(item.value_hashes)
if cardinality > 8:
return ResourceIdentityEvidence(
status=FiniteValueStatus.UNAVAILABLE,
reason_code="composite_resource_limit_exceeded",
)

component_domains = [
component.model_dump(mode="json", exclude_none=True) for component in selector.components
]
value_hashes = tuple(
sorted(
{
_semantic_hash(
{
"schema_version": 1,
"kind": "composite_resource_identity",
"components": [
{"selector": domain, "value_hash": value_hash}
for domain, value_hash in zip(
component_domains, combination, strict=True
)
],
}
)
for combination in product(*(item.value_hashes for item in component_evidence))
}
)
)
return ResourceIdentityEvidence(
status=(FiniteValueStatus.EXACT if len(value_hashes) == 1 else FiniteValueStatus.FINITE),
value_hashes=value_hashes,
)


def audit_effect_contracts( # noqa: PLR0912, PLR0915
loaded: LoadedEffectContracts,
*,
Expand Down
33 changes: 29 additions & 4 deletions src/fastapi_endpoint_detector/models/effect_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,22 @@ def validate_shape(self) -> EffectSelector:
return self


class CompositeEffectSelector(_StrictModel):
"""Ordered, domain-separated resource identity components."""

kind: Literal["composite"]
components: tuple[EffectSelector, ...] = Field(min_length=2, max_length=4)

@model_validator(mode="after")
def validate_components(self) -> CompositeEffectSelector:
if any(component.kind == SelectorKind.NONE for component in self.components):
raise ValueError("composite resource components cannot be none selectors")
return self


EffectResourceSelector = EffectSelector | CompositeEffectSelector


class EffectBehavior(_StrictModel):
"""Declared call timing without implied control-flow proof."""

Expand All @@ -226,7 +242,7 @@ class EffectContract(_StrictModel):
invocation: InvocationKind
operation: EffectOperation
channel: EffectChannel
resource: EffectSelector = Field(default_factory=EffectSelector)
resource: EffectResourceSelector = Field(default_factory=EffectSelector)
value: EffectSelector | None = None
behavior: EffectBehavior = Field(default_factory=EffectBehavior)
package: PackageApplicability | None = None
Expand Down Expand Up @@ -276,7 +292,12 @@ def validate_invocation(self) -> EffectContract:
self.channel != EffectChannel.OUTBOUND_HTTP or self.operation != EffectOperation.REQUEST
):
raise ValueError("HTTP methods require an outbound_http request contract")
selectors = (self.resource, self.value)
resource_selectors = (
self.resource.components
if isinstance(self.resource, CompositeEffectSelector)
else (self.resource,)
)
selectors = (*resource_selectors, self.value)
if self.invocation in {InvocationKind.FUNCTION, InvocationKind.CONSTRUCTOR} and any(
selector is not None and selector.kind == SelectorKind.RECEIVER
for selector in selectors
Expand All @@ -288,15 +309,15 @@ def validate_invocation(self) -> EffectContract:
class EffectContractDocument(_StrictModel):
"""Versioned root document for a deterministic contract set."""

schema_version: Literal[1, 2, 3] = 1
schema_version: Literal[1, 2, 3, 4] = 1
preset: PresetMetadata
contracts: tuple[EffectContract, ...] = Field(min_length=1)

@field_validator("schema_version", mode="before")
@classmethod
def validate_schema_version_type(cls, value: object) -> object:
if type(value) is not int: # bool is intentionally excluded
raise ValueError("schema_version must be the integer 1, 2, or 3")
raise ValueError("schema_version must be the integer 1, 2, 3, or 4")
return value

@model_validator(mode="after")
Expand All @@ -311,6 +332,10 @@ def validate_contract_keys(self) -> EffectContractDocument:
contract.http_method is not None for contract in self.contracts
):
raise ValueError("structured HTTP methods require schema_version 3")
if self.schema_version < 4 and any(
isinstance(contract.resource, CompositeEffectSelector) for contract in self.contracts
):
raise ValueError("composite resource selectors require schema_version 4")
ids: set[str] = set()
keys: dict[tuple[str, InvocationKind], EffectContract] = {}
for contract in self.contracts:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,43 @@
schema_version: 1
schema_version: 4
preset:
id: typed-s3-effects
version: 1.0.0
version: 2.0.0
provenance:
kind: preset
source: fastapi-endpoint-detector/effects_object_storage_v1.yaml
revision: "1"
revision: "2"
contracts:
- id: typed-s3-get-object
symbol: mypy_boto3_s3.client.S3Client.get_object
invocation: instance_method
operation: read
channel: object_storage
resource: {kind: keyword, name: Key}
resource:
kind: composite
components:
- {kind: keyword, name: Bucket}
- {kind: keyword, name: Key}
package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"}
- id: typed-s3-put-object
symbol: mypy_boto3_s3.client.S3Client.put_object
invocation: instance_method
operation: write
channel: object_storage
resource: {kind: keyword, name: Key}
resource:
kind: composite
components:
- {kind: keyword, name: Bucket}
- {kind: keyword, name: Key}
value: {kind: keyword, name: Body}
package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"}
- id: typed-s3-delete-object
symbol: mypy_boto3_s3.client.S3Client.delete_object
invocation: instance_method
operation: delete
channel: object_storage
resource: {kind: keyword, name: Key}
resource:
kind: composite
components:
- {kind: keyword, name: Bucket}
- {kind: keyword, name: Key}
package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"}
Loading
Loading