Skip to content
Closed
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
42 changes: 39 additions & 3 deletions docs/EFFECT_KIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ from the reference apps.
at-most-once counts, idempotency keys, and `{param: ...}` references that
bind to the run's governed parameters. Contracts are substrate-neutral.
2. **The deployment declares WHERE truth lives** — the `effects:` section of
`deployment.yaml` wires exactly one `EffectVerifier` (REST / GraphQL /
FHIR / SQL / file / email / document / document-hash, or a registered
plugin adapter) plus its secret-isolated auth.
`deployment.yaml` wires one `EffectVerifier` (REST / GraphQL / FHIR / SQL /
file / email / document / document-hash, or a registered plugin adapter)
plus its secret-isolated auth. When more than one reviewed read boundary is
available, `candidates:` selects the strongest evidence tier for each resolved
effect before input. It does not downgrade after input: an unavailable selected proof
halts or enters reconciliation.
3. **The runtime refuses to guess.** Every verdict is CONFIRMED / REFUTED /
INDETERMINATE; both non-confirmed verdicts HALT. A step that declares
effects with no verifier configured HALTs. An escalated failure emits a
Expand Down Expand Up @@ -123,6 +126,39 @@ Any kind additionally accepts the evidence-minimization fields
`evidence_redact_fields` / `evidence_keep_fields` (see "Evidence minimization"
below).

### Candidate selection when there is no database connection

A database connection is not required. Configure the strongest qualified
read boundary that the workflow has: REST/FHIR/GraphQL, read-only SQL, a file
or report export, a separately authenticated read-only session through a
plugin, or a persisted-state re-acquisition. Do not configure a same-surface
screen read-back as proof of a consequential write.

For more than one reviewed boundary, use `effects.candidates` instead of
`effects.kind`. Each candidate has the normal `EffectsConfig` fields. Flow
constructs every candidate before actuation, then selects the lowest numeric
`VerificationTier` for each resolved effect; declaration order resolves a tie.
This makes the choice deterministic and reviewable. A missing secret, an
invalid config, or an invalid plugin tier refuses the run before input. The
on-screen candidate is tier 3 only for that exact effect when its read-back
reopens persisted state through a different path. It is tier 4 for a
same-surface read-back. After the action, Flow does not fall back to a weaker
candidate if the selected verifier is unavailable. It records the unavailable
proof and halts or creates the normal reconciliation task.

```yaml
effects:
candidates:
- kind: document # independent export arrival (tier 1)
root: /secure/exports
file_pattern: "confirmation-*.json"
document_format: json
- kind: onscreen # lower-tier persisted-state read-back
```

The single `kind:` form remains the recommended configuration when one
qualified verifier exists and remains fully compatible with prior deployments.

The `sql` kind refuses to construct unless `sql_query` passes the read-only
statement filter (single statement, `SELECT`/`WITH` leading keyword, no
comments, no mutating/DDL/control keywords or known side-effecting functions,
Expand Down
90 changes: 85 additions & 5 deletions openadapt_flow/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,20 @@ class EffectsConfig(BaseModel):
#: LOWER-CONFIDENCE consistency tier, never independent proof.
kind: str = "none"

#: Reviewed alternatives for the same effect contract. This is useful
#: where the application exposes more than one read boundary (for example,
#: a file export and a separately authenticated read session) but no direct
#: database connection. The runtime builds every candidate before input,
#: then uses the candidate with the strongest declared evidence tier
#: (lowest numeric tier; declaration order breaks a tie). It never falls
#: back after actuation: an unavailable selected verifier is uncertain and
#: therefore halts or enters reconciliation.
#:
#: The legacy single ``kind`` form and this list are mutually exclusive.
#: Nested lists are refused to keep the selected verifier and its evidence
#: unambiguous.
candidates: list["EffectsConfig"] = Field(default_factory=list)

# -- onscreen (no-API screen read-back; auto-derived per-effect region) ---
#: Explicit read-back region ``(x, y, w, h)`` for a hand-configured
#: deployment. Normally left None — the compiler auto-derives a per-effect
Expand Down Expand Up @@ -307,6 +321,26 @@ def _coerce_value_exprs(cls, v: Any) -> Any:
}
return v

@model_validator(mode="after")
def _validate_candidates(self) -> "EffectsConfig":
if not self.candidates:
return self
if (self.kind or "none").strip().lower() not in ("", "none"):
raise ValueError(
"effects.kind and effects.candidates are mutually exclusive"
)
for candidate in self.candidates:
if candidate.candidates:
raise ValueError("effects.candidates cannot contain nested candidates")
if (candidate.kind or "none").strip().lower() in ("", "none"):
raise ValueError(
"every effects.candidates entry must configure a verifier kind"
)
return self


EffectsConfig.model_rebuild()


class ActuationConfig(BaseModel):
"""The API/tool actuation tier (top of the capability ladder).
Expand Down Expand Up @@ -905,15 +939,56 @@ def build_effect_verifier(

Resolution order for ``kind``: built-in adapters, then plugin factories registered under the
``openadapt_flow.effect_verifiers`` entry-point group or via
``register_verifier_factory`` (the customer adapter SDK seam). When the
config sets ``evidence_redact_fields`` / ``evidence_keep_fields``, the
built verifier is wrapped so every verdict's evidence is minimized.
``register_verifier_factory`` (the customer adapter SDK seam). ``candidates``
builds every reviewed alternative and selects the strongest declared tier;
it does not create a post-actuation fallback. When the config sets
``evidence_redact_fields`` / ``evidence_keep_fields``, the built verifier
is wrapped so every verdict's evidence is minimized.

Raises:
ValueError: on an unknown ``kind``, a missing required field, an
unresolved ``{param: ...}`` reference, or a missing secret env var
(fail loud rather than wire a broken verifier).
"""
if cfg.candidates:
built = [
build_effect_verifier(candidate, params) for candidate in cfg.candidates
]
for candidate in built:
if candidate is None: # guarded by EffectsConfig, retained fail-closed
raise ValueError("effects.candidates entry did not build a verifier")
missing = [
name
for name in ("capture_pre_state", "verify")
if not callable(getattr(candidate, name, None))
]
if missing:
raise ValueError(
"effects.candidates entry is not an EffectVerifier; missing "
+ ", ".join(missing)
)
tier = getattr(candidate, "verification_tier", None)
tier_for = getattr(candidate, "verification_tier_for", None)
if isinstance(tier, bool) or (tier is None and not callable(tier_for)):
raise ValueError(
"effects.candidates entry has no valid verification tier"
)
from openadapt_flow.runtime.effects.adapter import CandidateEffectVerifier

candidate_selector = CandidateEffectVerifier(built)
if cfg.evidence_redact_fields or cfg.evidence_keep_fields is not None:
from openadapt_flow.runtime.effects.adapter import (
RedactingVerifier,
RedactionPolicy,
)

policy = RedactionPolicy(
redact_fields=list(cfg.evidence_redact_fields),
keep_fields=cfg.evidence_keep_fields,
)
return RedactingVerifier(candidate_selector, policy)
return candidate_selector

verifier = _build_effect_verifier_unredacted(cfg, params)
if verifier is None:
return None
Expand Down Expand Up @@ -1132,11 +1207,16 @@ def _build_effect_verifier_unredacted(

# Plugin seam: a customer adapter registered programmatically or under the
# 'openadapt_flow.effect_verifiers' entry-point group serves its own kind.
from openadapt_flow.runtime.effects.adapter import resolve_verifier_factory
from openadapt_flow.runtime.effects.adapter import (
resolve_verifier_factory,
validate_verifier_adapter,
)

factory = resolve_verifier_factory(kind)
if factory is not None:
return factory(cfg, params)
verifier = factory(cfg, params)
validate_verifier_adapter(verifier)
return verifier

raise ValueError(
f"unknown effects.kind {cfg.kind!r} "
Expand Down
6 changes: 6 additions & 0 deletions openadapt_flow/runtime/effects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
from openadapt_flow.runtime.effects.adapter import ( # noqa: F401
ENTRY_POINT_GROUP,
AdapterResult,
CandidateEffectState,
CandidateEffectVerifier,
CandidatePreState,
CollateralHook,
ConnectionProbe,
RedactingVerifier,
Expand Down Expand Up @@ -127,6 +130,9 @@
# adapter platform
"ENTRY_POINT_GROUP",
"AdapterResult",
"CandidateEffectState",
"CandidateEffectVerifier",
"CandidatePreState",
"CollateralHook",
"ConnectionProbe",
"RedactingVerifier",
Expand Down
Loading
Loading