Skip to content
Open
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
56 changes: 54 additions & 2 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,55 @@ truncated payload; a mismatch means content was delivered whose bytes are not th
LaunchDarkly hashed, which is a possible **active-tampering** signal. Alert on it, and
treat `expected_hash` / `observed_hash` as the evidence pair.

#### Failing closed on tampering

The log record above is the operator's surface. `get_skill_result` is the application's:
same retrieval, same verification, same telemetry as `get_skill`, and it reports which of
five outcomes happened instead of collapsing all of them to `None`.

```python
from launchdarkly_ai_server import get_skill_result

outcome = await get_skill_result("pdf-extraction")

if outcome.reason == "integrity_failure":
# Content was delivered and did not verify. Do not degrade quietly.
raise SystemExit(f"refusing to start: {outcome.detail}")

if outcome.reason == "store_unavailable":
# The store could not answer at all. Retry, alert, or carry on with what
# you already have — but this is an outage, not a revocation.
print(f"skill retrieval unavailable: {outcome.detail}")
elif outcome.reason in ("absent", "wrong_version"):
# Nothing was tampered with — this skill is simply not available to you.
print(f"continuing without a skill: {outcome.detail}")
elif outcome.skill is not None:
print(outcome.skill.content)
```

| `reason` | Meaning |
|---|---|
| `ok` | A verified skill was returned; `.skill` is set and `.detail` is `None`. |
| `absent` | The store answered, and does not hold that key. |
| `integrity_failure` | Content was delivered and failed verification, so it was withheld. **The one to fail closed on.** |
| `store_unavailable` | The store itself could not answer — it raised. An outage, not a deletion. |
| `wrong_version` | The store answered with a version other than the one asked for, so the answer was withheld. |

`.detail` is human-readable and safe to log or show an operator — it names the key and the
failure mode, and never carries skill content or a filesystem path. Branch on `.reason`,
not on `.detail`. `.skill` is populated only when `.reason == "ok"`. `SkillOutcome` is
frozen, like every other value type here.

**`get_skill` is unchanged.** It still returns `None` for all four failures and still never
raises for one, so no existing caller has to move. The two accessors run the same code path
and differ only in what they report — `get_skill_result` adds no second log record and no
second signal for a failure that already emitted one, so a caller can switch to it without
double-counting anything.

`get_skills` and `all_skills` have no reported form: they still omit entries that could not
be resolved, and a run that omitted anything logs a count at WARN. Retrieve individually
with `get_skill_result` when you need the reason per key.

**Versions are selected, not filtered.** A store may hold several versions of one key at
once, because a delivery payload does: the newest version of every skill, plus every
version a variation currently pins. `get_skill("k", version=1)` asks the store for version
Expand Down Expand Up @@ -387,6 +436,7 @@ Windows.
|---|---|
| `skill_refs(config)` | Project a config's `skills` array into `list[SkillReference]`. Pure — no client, store, or network needed. Returns `[]` when absent. |
| `get_skill(key, *, version=None)` | One verified skill, or `None`. `version=None` means newest available; a specific `version` matches exactly. Raises only when no store is configured. |
| `get_skill_result(key, *, version=None)` | The same retrieval, reporting **why**: a frozen `SkillOutcome` with `.skill`, `.reason` (`ok` / `absent` / `integrity_failure` / `store_unavailable` / `wrong_version`), and `.detail`. Use it to fail closed on tampering — see *Failing closed on tampering* above. Raises only when no store is configured. |
| `get_skills(refs)` | Batch form. Accepts `SkillReference` values and bare key strings (string = latest). Results follow input order; missing or unverifiable entries are omitted. |
| `all_skills()` | Every verified skill the store holds, one per key at its newest version. |
| `write_skills(skills, root, *, prune=True, timeout=10.0, on_unavailable="keep")` | Materialize skills under `root`, returning a `ReconcileReport`. `prune` removes formerly-managed skills no longer requested. `on_unavailable="raise"` raises instead of reporting when content cannot be retrieved. Raises `ValueError` for an unusable root, a negative `timeout`, or an unrecognised `on_unavailable`. **Performs synchronous filesystem I/O — see the note below.** |
Expand All @@ -406,9 +456,10 @@ manifest, for instance — carries the empty string as its `key`.

The fixed on-disk values are exported too, so you do not have to hardcode them:
`MANIFEST_FILENAME` (`.launchdarkly-skills.json`, handy for a `.gitignore`),
`SKILL_FILENAME`, and `MANIFEST_VERSION`. So are the two closed-set types, for annotating
`SKILL_FILENAME`, and `MANIFEST_VERSION`. So are the three closed-set types, for annotating
your own helpers: `ReconcileActionKind` (`written` / `updated` / `skipped_current` /
`removed` / `error`) and `OnUnavailable` (`keep` / `raise`).
`removed` / `error`), `OnUnavailable` (`keep` / `raise`), and `SkillOutcomeReason`
(`absent` / `integrity_failure` / `ok` / `store_unavailable` / `wrong_version`).

**`write_skills` blocks.** It is `async` for parity with the other accessors and with the
TypeScript SDK, but it awaits nothing: every read, write, `fsync` and rename runs inline,
Expand Down Expand Up @@ -460,5 +511,6 @@ All types are exported from this package. Handler packages import them from here
| `GraphTopology` | The parsed graph flag shape (`root` + `edges`) |
| `Skill` | A frozen skill document: `.key`, `.version`, `.content` (verified verbatim `bytes`), `.content_hash`, `.name?`, `.description?` |
| `SkillReference` | A frozen version-pinned pointer to a skill: `.key`, `.version` |
| `SkillOutcome` | A frozen retrieval outcome: `.skill`, `.reason` (`SkillOutcomeReason`), `.detail` |
| `ReconcileAction` | One `write_skills` outcome: `.key`, `.action`, `.version?`, `.path?`, `.error?` |
| `ReconcileReport` | The `write_skills` result: `.actions`, `.ok`, and `.errors` |
59 changes: 54 additions & 5 deletions packages/client/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ from launchdarkly_ai_server import config, graph, resolve_graph

# Agent Skills
from launchdarkly_ai_server import (
skill_refs, get_skill, get_skills, all_skills, write_skills,
SkillStore, InMemorySkillStore,
skill_refs, get_skill, get_skill_result, get_skills, all_skills, write_skills,
SkillStore, InMemorySkillStore, SkillOutcome,
SKILL_FILENAME, MANIFEST_FILENAME, MANIFEST_VERSION,
ReconcileActionKind, OnUnavailable, # the two closed-set unions
ReconcileActionKind, OnUnavailable, SkillOutcomeReason, # the three closed-set unions
)
```

Expand Down Expand Up @@ -188,8 +188,8 @@ Three layers, in increasing order of blast radius:
typed `SkillReference` values. Pure: no network, no client, no store, no telemetry.
Validation of the array itself lives in `parse_ai_config` and is **fail closed** — one
malformed reference fails the whole config parse.
2. **Content accessors** — `get_skill`, `get_skills`, `all_skills` read through the
`SkillStore` seam. Configure a store with
2. **Content accessors** — `get_skill`, `get_skill_result`, `get_skills`, `all_skills` read
through the `SkillStore` seam. Configure a store with
`init_client(options={"skillStore": store})`; with none configured the accessors raise
an actionable `RuntimeError`. A delivery transport can be added behind the seam
without touching the public API.
Expand Down Expand Up @@ -217,6 +217,55 @@ one place that collapses the result to one object per key, because both whole-st
consumers need it — `all_skills`, since a list holding two versions of one key is not a set
of skills, and the `"*"` reconcile, since `<root>/<key>/SKILL.md` is a single path.

### The reported outcome vocabulary, and the `Resolution` mapping

`get_skill` returns `Skill | None`; `get_skill_result` returns a frozen `SkillOutcome`
(`skill`, `reason`, `detail`) naming *which* outcome happened. Both are
`resolve_from_store` — one retrieval, one verification, one telemetry pass — and they differ
only in what they report. `get_skill`'s contract is load-bearing and **frozen**: `None` for
every failure, never raises for one, documented in its docstring and in the README. Change
it and every caller that treats `None` as "no skill" breaks silently.

`SkillOutcomeReason` is five tokens, listed alphabetically for the same reason
`IntegrityReasonCode` is — so the vocabulary reads identically in the Python and TypeScript
SDKs, where the type name, the accessor name, and the tokens are all deliberately the same.
Do not rename one on one side.

Internal `Resolution.reason` maps 1:1 onto it, set explicitly at every construction site:

| `resolve_from_store` outcome | `reason` |
|---|---|
| the store raised (`unavailable=True`) | `store_unavailable` |
| `raw` is not a dict | `absent` |
| `verify_raw_skill` returned `None` | `integrity_failure` |
| `skill.version != wanted_version` | `wrong_version` |
| success | `ok` |

**Adding a sixth internal outcome means choosing which public token it maps to.**
`Resolution.reason` has no default, so the compiler asks the question; answer it rather than
defaulting to `absent`, which claims the store does not hold the skill. If the new outcome
is genuinely neither of the five, the token set grows — on both sides, in the same commit.

Two things the reason is deliberately *not*:

- **Not derived from `Resolution.error`.** That string is prose for a human; recovering a
decision a caller fails closed on by matching it is the fragility the typed token exists
to remove. `detail` *is* that string, passed straight through — safe to surface (key and
failure mode only, never content, never a path), and not for matching on.
- **Not `Resolution.unavailable`.** The flag answers "may prune run?" and the token answers
"what does the caller learn?". They agree by construction — `unavailable` is `True` in
exactly the `store_unavailable` case — and both exist because `store_unavailable` must
stay distinct from `absent`: only a raising store suppresses pruning, since deleting
managed files after a failed lookup turns an outage into data loss.

`get_skill_result` emits nothing of its own. The integrity log record and signal already
fired inside verification before `resolve_from_store` returned; recording anything here
would double-count one failure in a SIEM and in the product counter.

There is no `get_skills_result` or `all_skills_result`. The batch accessors keep omitting
unresolved entries and keep logging the run-level WARN count, and a second accessor per
batch form would double the surface for a case nobody has asked for.

### Security posture — do not relax any of this

Store data is **untrusted input**; the transport is not part of the trust boundary.
Expand Down
8 changes: 7 additions & 1 deletion packages/client/src/launchdarkly_ai_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
InMemorySkillStore,
all_skills,
get_skill,
get_skill_result,
get_skills,
skill_refs,
)
Expand Down Expand Up @@ -85,6 +86,8 @@
ReconcileActionKind,
ReconcileReport,
Skill,
SkillOutcome,
SkillOutcomeReason,
SkillReference,
StreamChunkEvent,
StreamDoneEvent,
Expand Down Expand Up @@ -150,6 +153,7 @@
"ReconcileActionKind",
"ReconcileReport",
"Skill",
"SkillOutcome",
"SkillReference",
"StreamChunkEvent",
"StreamDoneEvent",
Expand Down Expand Up @@ -227,14 +231,16 @@
# skills
"skill_refs",
"get_skill",
"get_skill_result",
"get_skills",
"all_skills",
"write_skills",
"SkillStore",
"InMemorySkillStore",
# skills — the two closed-set unions a typed consumer needs to name
# skills — the three closed-set unions a typed consumer needs to name
"ReconcileActionKind",
"OnUnavailable",
"SkillOutcomeReason",
# skills — on-disk constants, identical across languages
"SKILL_FILENAME",
"MANIFEST_FILENAME",
Expand Down
36 changes: 35 additions & 1 deletion packages/client/src/launchdarkly_ai_server/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
resolve_from_store,
verify_raw_skill,
)
from .types import AiConfigRep, Skill, SkillReference
from .types import AiConfigRep, Skill, SkillOutcome, SkillReference
from .types_validation import (
is_valid_skill_key,
is_valid_skill_version,
Expand Down Expand Up @@ -247,6 +247,40 @@ async def get_skill(key: str, *, version: int | None = None) -> Skill | None:
return resolve_from_store(require_store(), key, version).skill


async def get_skill_result(key: str, *, version: int | None = None) -> SkillOutcome:
"""
Retrieves one verified skill, reporting *why* when there is none.

Same retrieval, same verification, same telemetry as ``get_skill`` — the two
differ only in what they report. ``get_skill`` collapses "no such skill",
"the store raised", "that is not the version held", and "the content failed
integrity verification" to one ``None``; this returns a ``SkillOutcome``
whose ``reason`` names which of them happened, so a caller can fail closed on
suspected tampering while tolerating a merely-absent skill:

```python
outcome = await get_skill_result("pdf-extraction")
if outcome.reason == "integrity_failure":
raise SystemExit(f"refusing to run: {outcome.detail}")
if outcome.skill is not None:
print(outcome.skill.content)
```

``detail`` is human-readable and safe to surface — it names the key and the
failure mode, never any skill content or filesystem path. Branch on
``reason``, not on ``detail``.

Emits nothing of its own: an integrity failure has already recorded its log
record and its signal inside verification, and recording a second here would
double-count one failure. Raises ``RuntimeError`` only when no skill store is
configured, exactly as ``get_skill`` does.
"""
resolved = resolve_from_store(require_store(), key, version)
return SkillOutcome(
skill=resolved.skill, reason=resolved.reason, detail=resolved.error
)


async def get_skills(refs: Sequence[SkillReference | str]) -> list[Skill]:
"""
Retrieves a batch of verified skills.
Expand Down
39 changes: 33 additions & 6 deletions packages/client/src/launchdarkly_ai_server/skills_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from dataclasses import dataclass
from typing import Any, Literal, Protocol, get_args

from .types import Skill, SkillReference
from .types import Skill, SkillOutcomeReason, SkillReference
from .types_validation import is_valid_skill_key, is_valid_skill_version

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -672,6 +672,26 @@ def newest_by_key(objects: dict[str, dict[str, Any]]) -> list[tuple[str, Any]]:
class Resolution:
"""One key resolved against a store: the skill, or why there is none."""

reason: SkillOutcomeReason
"""
Which of the five public outcomes this resolution is.

Declared first and **without a default**, so every construction site has to
state it. A default would be the wrong shape twice over: a contributor
adding a sixth internal outcome would inherit whichever token happened to be
the default rather than deciding which public token it maps to, and if that
default were ``"ok"`` a failure would publish ``ok`` with no skill attached.

Carried as a token rather than derived from ``error`` on the way out:
``get_skill_result`` publishes this value, and pattern-matching prose to
recover a decision a caller fails closed on is exactly the fragility the
typed outcome exists to remove. A reviewer can read the mapping here.

Distinct from ``unavailable`` on purpose — that flag answers one question
(may prune run?) and this token answers a different one (what does the
caller learn?) — but the two can only disagree by a bug: ``unavailable`` is
``True`` in exactly the ``store_unavailable`` case.
"""
skill: Skill | None = None
error: str | None = None
unavailable: bool = False
Expand Down Expand Up @@ -702,26 +722,33 @@ def resolve_from_store(
raw = store.get_object(SKILL_OBJECT_KIND, key, wanted_version)
except Exception as exc:
logger.error("Skill store raised while retrieving '%s'", key, exc_info=True)
return Resolution(error=store_raised(exc), unavailable=True)
return Resolution(
reason="store_unavailable",
error=store_raised(exc),
unavailable=True,
)

if not isinstance(raw, dict):
return Resolution(
error=f"skill '{key}' is not available from the configured skill store"
reason="absent",
error=f"skill '{key}' is not available from the configured skill store",
)

skill = verify_raw_skill(raw)
if skill is None:
return Resolution(
error=f"skill '{key}' failed integrity verification and was withheld"
reason="integrity_failure",
error=f"skill '{key}' failed integrity verification and was withheld",
)
if wanted_version is not None and skill.version != wanted_version:
return Resolution(
reason="wrong_version",
error=(
f"skill '{key}' version {wanted_version} is not available "
f"(the store holds version {skill.version})"
)
),
)
return Resolution(skill=skill)
return Resolution(reason="ok", skill=skill)


def reference_target(item: SkillReference | str) -> tuple[str, int | None]:
Expand Down
10 changes: 8 additions & 2 deletions packages/client/src/launchdarkly_ai_server/skills_fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,17 @@ def _resolve_reference(
"""
store = _available_store(deadline, f"'{key}'")
if isinstance(store, _RetrievalBlocked):
return Resolution(error=store.reason, unavailable=True)
return Resolution(
reason="store_unavailable", error=store.reason, unavailable=True
)

resolved = resolve_from_store(store, key, wanted_version)
if resolved.unavailable and resolved.error is not None:
return Resolution(error=_unavailable(resolved.error), unavailable=True)
return Resolution(
reason="store_unavailable",
error=_unavailable(resolved.error),
unavailable=True,
)
return resolved


Expand Down
Loading
Loading