API — truststack-shipped-or-not¶
Import name: shipped_or_not. Verify deployment claims with evidence; a claim is
SHIPPED only when every applicable check passes, otherwise UNVERIFIED
(fail toward distrust).
All public models are Pydantic v2 and frozen (immutable, hashable). Every
module uses from __future__ import annotations and full type hints satisfying
mypy --strict.
Public surface¶
Exported from shipped_or_not:
| Symbol | Kind |
|---|---|
DeploymentStatus |
enum |
CheckResult |
model |
RetryPolicy |
model |
TlsInfo |
model |
VerificationResult |
model |
AuditStore |
Protocol |
InMemoryAuditStore |
store |
SqliteAuditStore |
store |
DeploymentVerifier |
component |
OnChange |
type alias |
Notify |
type alias |
__version__ |
str |
Enums¶
DeploymentStatus(StrEnum)¶
| Member | Value |
|---|---|
SHIPPED |
"shipped" |
UNVERIFIED |
"unverified" |
SHIPPED is awarded only when every applicable check passes. Any error,
timeout, invalid certificate, or unexpected status collapses to UNVERIFIED.
Type aliases¶
| Alias | Definition | Used by |
|---|---|---|
OnChange |
Callable[[VerificationResult], Awaitable[None]] |
monitor(on_change=...) |
Notify |
Callable[[VerificationResult], Awaitable[None]] |
monitor(notify=...) |
Models (Pydantic v2, frozen)¶
CheckResult¶
The outcome of a single verification check.
| Field | Type | Notes |
|---|---|---|
name |
str |
check identifier — one of dns, ssl, http_status, health, http, error |
passed |
bool |
|
detail |
str \| None |
human-readable explanation |
to_report() -> dict[str, Any]—{"name", "passed", "detail"}.
The set of checks present on a result depends on the path taken:
| Check | Emitted when |
|---|---|
dns |
a response was received (passed=True) or a non-TLS connect error occurred (passed=False) |
ssl |
the URL is https — passed=True on a successful handshake, passed=False on a TLS/cert connect error |
http_status |
a response was received; passed iff status == expect_status |
health |
health_path was provided; passed iff the endpoint returns 200 |
http |
a non-connect httpx.HTTPError (e.g. a protocol/timeout error) was raised |
error |
any other unexpected exception was raised |
RetryPolicy¶
Exponential-backoff retry configuration for transient transport errors.
| Field | Type | Default | Constraint |
|---|---|---|---|
attempts |
int |
3 |
1 <= n <= 20 |
backoff_seconds |
float |
0.5 |
> 0 |
max_backoff |
float |
8.0 |
> 0, >= backoff_seconds |
jitter |
float |
0.0 |
0 <= j <= 1 |
Constructing a policy where max_backoff < backoff_seconds raises a Pydantic
ValidationError ("max_backoff must be >= backoff_seconds").
Methods
delay_for(attempt: int) -> float— backoff to wait before a 1-indexedattempt. Base delay ismin(backoff_seconds * 2 ** (attempt - 2), max_backoff); the first attempt (and anyattempt <= 1) is0.0. Whenjitter > 0the base delay is multiplied by1 + jitter * (2 * draw - 1)(a factor in[1 - jitter, 1 + jitter]) and clamped to[0, max_backoff].with_rng(rng: Callable[[], float] | None) -> RetryPolicy— returns a copy of the policy bound to a deterministic random source (a 0-argument callable returning a float in[0, 1), same contract asrandom.random) so jittered delays are reproducible in tests. The boundrngis aPrivateAttrand is excluded from (de)serialization and equality.
Example (deterministic jitter)
draws = iter([0.0, 1.0])
policy = RetryPolicy(backoff_seconds=0.5, jitter=0.2).with_rng(lambda: next(draws))
policy.delay_for(2) # 0.5 * (1 + 0.2*(2*0.0 - 1)) = 0.40
policy.delay_for(3) # base = 1.0; 1.0 * (1 + 0.2*(2*1.0 - 1)) = 1.20
TlsInfo¶
Best-effort subset of the peer certificate, captured from the live connection.
All fields optional (absent for http URLs, mocked transports, or any parse
failure — degradation never corrupts the verdict).
| Field | Type |
|---|---|
issuer |
str \| None |
subject |
str \| None |
not_after |
datetime \| None |
not_before |
datetime \| None |
to_report() -> dict[str, Any]— datetimes rendered as ISO-8601 strings (orNone).
issuer / subject are flattened from the ssl.getpeercert() RDN sequence
into a comma-joined key=value string (e.g. "C=US, O=Let's Encrypt, CN=R3").
not_after / not_before are parsed from the OpenSSL timestamp format and
stamped UTC.
VerificationResult¶
Evidence-bearing result of verifying a single deployment claim.
| Field | Type | Notes |
|---|---|---|
status |
DeploymentStatus |
the verdict |
url |
str |
the URL that was claimed live |
response_code |
int \| None |
root-URL HTTP status, None if no response |
verified_at |
datetime (UTC) |
defaults to now |
ssl_valid |
bool \| None |
True/False for https; None for http |
health_passed |
bool \| None |
None when health_path was not provided |
checks |
list[CheckResult] |
per-check evidence |
detail |
str \| None |
failure summary when not shipped |
final_url |
str \| None |
URL after following redirects |
elapsed_ms |
float \| None |
wall-clock time for the exchange (ms, rounded to 3dp) |
headers |
dict[str, str] |
captured subset of response headers (lower-cased keys) |
tls |
TlsInfo \| None |
best-effort TLS facts |
Captured headers (when present): server, content-type, content-length,
date, cache-control, x-powered-by, via, strict-transport-security,
location.
shipped(property) ->bool—Trueiffstatus is DeploymentStatus.SHIPPED.to_report() -> dict[str, Any]— flat, JSON-serializable audit report (verified_atand alltlstimestamps rendered as ISO-8601). Round-trips losslessly viaVerificationResult.model_validate(report).
Audit stores¶
AuditStore (Protocol, runtime-checkable)¶
Persistence contract for verification results. Any object implementing these
four coroutines satisfies the protocol (and passes isinstance(obj, AuditStore)).
async record(result: VerificationResult) -> None— persist a single result.async history(url: str) -> list[VerificationResult]— all results forurl, oldest first.async all() -> list[VerificationResult]— every result across all URLs, oldest first.async close() -> None— release any held resources.
InMemoryAuditStore¶
Process-local, thread-safe list-backed store. Insertion order is chronological
(each result carries its own verified_at and is appended on arrival). Ideal
for tests and ephemeral runs. close() is a no-op.
SqliteAuditStore¶
Durable store over stdlib sqlite3. The full to_report() JSON is stored in a
payload column with url / verified_at denormalized into indexed columns.
All blocking sqlite3 calls run via asyncio.to_thread, keeping the async API
non-blocking. A single connection (check_same_thread=False) is reused under a
lock. Schema: see docs/schemas/truststack-shipped-or-not.sql (mirrors
shipped_or_not.audit._SCHEMA).
SqliteAuditStore(path: str | Path = ":memory:") # build directly
await store.initialize() # idempotent schema creation
await SqliteAuditStore.connect(path=":memory:") # build + create schema (classmethod)
| Method | Behaviour |
|---|---|
connect(path=":memory:") (classmethod) |
construct and initialize() |
initialize() |
create the table + index IF NOT EXISTS (idempotent) |
record(result) |
INSERT one row off-thread, then commit |
history(url) |
SELECT ... WHERE url = ? ORDER BY id ASC |
all() |
SELECT ... ORDER BY id ASC |
close() |
close the underlying connection |
history / all rehydrate each row by validating the stored JSON back into a
VerificationResult, so persisted results round-trip exactly.
Component¶
DeploymentVerifier(BaseTrustComponent)¶
DeploymentVerifier(
retry: RetryPolicy | None = None, # default RetryPolicy()
timeout: float = 10.0, # per-request timeout (seconds)
event_bus: EventBus | None = None, # optional TrustEvent sink
audit_store: AuditStore | None = None, # optional durable persistence
)
Class attributes: component_name = "shipped-or-not", component_version = "0.1.0".
Methods
async verify(url: str, health_path: str | None = None, expect_status: int = 200) -> VerificationResult— verify a deployment claim and return evidence. Wrapped with@traced("shipped_or_not.verify").async history(url: str) -> list[VerificationResult]— replay persisted results (oldest first). Requiresaudit_store; raisesRuntimeError("history requires an audit_store to be configured") otherwise.async monitor(url, interval, on_change, *, iterations=None, health_path=None, expect_status=200, notify=None) -> VerificationResult | None— re-verify on an interval; see below.
Verdict rule. SHIPPED iff: host resolves, HTTP status == expect_status,
TLS certificate valid (for https), and — if health_path is given — the
health endpoint returns 200. Any failed check, error, timeout, or invalid
certificate yields UNVERIFIED, with detail summarising the cause:
| Situation | status |
detail shape |
ssl_valid |
|---|---|---|---|
| all checks pass | SHIPPED |
None |
True (https) / None (http) |
| one or more checks fail | UNVERIFIED |
"failed checks: http_status, health" |
check-dependent |
TLS/cert connect error (https) |
UNVERIFIED |
"SSL certificate invalid: ..." |
False |
| non-TLS connect error (DNS/refused) | UNVERIFIED |
"connection failed: ..." |
unchanged |
other httpx.HTTPError (e.g. timeout) |
UNVERIFIED |
"request failed: ..." |
unchanged |
| unexpected exception | UNVERIFIED |
"unexpected error: ..." |
unchanged |
A health_path request that itself raises httpx.HTTPError records a failed
health check rather than aborting the whole verification.
Retry. Only transport-level failures (httpx.ConnectError,
httpx.ConnectTimeout, httpx.TransportError) are retried with exponential
backoff per RetryPolicy. An HTTP response — even a 500 — is a verifiable
verdict and is not retried. verify_retry is logged on each retry.
Persistence. When an audit_store is configured, every result is recorded
in _finalize. Audit failures are logged (audit_persist_failed) and swallowed
— a broken audit backend never fabricates or suppresses a verdict.
Monitoring (monitor).
- Awaits
on_change(result)wheneverstatusdiffers from the previously observed status, including the first observation (baseline). - When provided,
notify(result)is awaited on the same change edges, afteron_change, and is wrapped so a failing webhook can never break the loop (monitor_notify_failedis logged on error). - Runs
iterationstimes when given (sleepingintervalseconds between checks, not after the last), otherwise loops until the surrounding task is cancelled.asyncio.CancelledErroris propagated cleanly. - Returns the last
VerificationResult, orNoneif zero iterations ran. monitor_changeis logged on each verdict flip.
Observability.
- Metrics on
self.registry:verifications(every call),shipped,unverified. - Events (if
event_busprovided):deployment.shipped/deployment.unverified; payload isVerificationResult.to_report(). verifyis wrapped with@traced("shipped_or_not.verify")._check_health()(the component's own health) always reportsHEALTHY.- Inherits
version(),health_check(),metrics()fromBaseTrustComponent.
Example (full)
import asyncio
from shipped_or_not import DeploymentVerifier, RetryPolicy, SqliteAuditStore
from truststack.events import EventBus
async def main() -> None:
store = await SqliteAuditStore.connect("audit.db")
verifier = DeploymentVerifier(
retry=RetryPolicy(attempts=5, jitter=0.2),
timeout=5.0,
event_bus=EventBus(),
audit_store=store,
)
result = await verifier.verify("https://example.com", health_path="/healthz")
if not result.shipped:
print("NOT shipped:", result.detail)
history = await verifier.history("https://example.com")
print(f"{len(history)} prior verdicts on record")
await store.close()
asyncio.run(main())
CLI¶
| Flag | Default | Meaning |
|---|---|---|
--health PATH |
None |
health endpoint that must return 200 |
--expect-status N |
200 |
expected root-URL HTTP status |
--timeout S |
10.0 |
per-request timeout in seconds |
--json |
off | emit the full to_report() as indented JSON |
Exit code: 0 if SHIPPED, 1 otherwise — a drop-in trust gate for CI
pipelines and agent tool calls.