Skip to content
This repository was archived by the owner on Feb 24, 2026. It is now read-only.

Commit 5e08fff

Browse files
committed
feat: split end-user exposure from live CI execution policy
1 parent 2e7d418 commit 5e08fff

File tree

7 files changed

+238
-29
lines changed

7 files changed

+238
-29
lines changed

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ High-level commands in this section are SDK-driven. `call` is the only raw trans
3333

3434
`public_ops_manifest.json` is MCP-first and policy-lean:
3535

36-
- 26 operations total
37-
- 18 `supported-executed` (safe/read/query/public wrapper)
38-
- 8 `supported-blocked-policy` (known side-effectful or risky flows kept for intent visibility only)
36+
- 33 operations total
37+
- `support_scope` is user-facing support intent for ranking/discovery.
38+
- `exposed_to_end_user` controls CLI catalog exposure.
39+
- `ci_live_execute` controls whether live release coverage executes the operation.
40+
- CI blocking does not imply end-user CLI blocking.
3941

4042
- Supported by snapshot-backed commands:
4143
- `catalog export --public-only --json`
@@ -47,10 +49,10 @@ High-level commands in this section are SDK-driven. `call` is the only raw trans
4749
- `workflow validate --body '{\"nodes\":[]}' --dry-run`
4850
- `workflow run-status --workflow-run-id <id> --dry-run`
4951
- `agent get --agent-id <id> --dry-run`
50-
- `node-types list --project-id <project_id> --dry-run`
52+
- `node-types list --dry-run`
5153
- `connections list --workspace-id <workspace_id> --project-id <project_id> --dry-run`
5254
- `get_nodetype_models_v1_node_types__get` and `get_anonymous_messages_v1_agent_threads_anonymous__thread_id__messages_get` are available as anonymous MCP discovery/ops through `call`.
53-
- `node-types dynamic-options` and workflow `create/run` are intentionally in `supported-blocked-policy` and must be blocked in automated coverage by default.
55+
- Side-effectful operations can still be exposed to end users while remaining `ci_live_execute=false` for safe release gating.
5456

5557
Admin/internal endpoints are intentionally not included in the bundled snapshot.
5658

@@ -134,7 +136,7 @@ bash scripts/release_readiness.sh
134136

135137
This validates operation-id mappings, runs unit tests, executes CLI dry-run smoke checks, and verifies the Node wrapper.
136138

137-
Optional live API coverage gate (26-op MCP-first public scope) with real key:
139+
Optional live API coverage gate (manifest-scoped public surface) with real key:
138140

139141
```bash
140142
bash scripts/release_readiness.sh --live-ops-gate --env-file /path/to/.env

scripts/ops_coverage_harness.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ def _collect_declared_operations(
318318
declared: dict[str, dict[str, Any]] = {}
319319
for record in manifest_records:
320320
op_id = record["operation_id"]
321-
support_scope = _normalize_support_scope(record.get("support_scope"))
321+
support_scope = _derive_manifest_support_scope(record)
322322
support_rationale = str(
323323
record.get("support_rationale", "No support rationale declared in manifest.")
324324
)
@@ -346,6 +346,38 @@ def _normalize_support_scope(value: Any) -> str:
346346
return SUPPORT_SCOPE_ALIASES.get(normalized, SUPPORT_SCOPE_OUT_OF_SCOPE)
347347

348348

349+
def _coerce_manifest_bool(value: Any, *, default: bool) -> bool:
350+
if isinstance(value, bool):
351+
return value
352+
if isinstance(value, str):
353+
normalized = value.strip().lower()
354+
if normalized in {"true", "1", "yes", "y", "on"}:
355+
return True
356+
if normalized in {"false", "0", "no", "n", "off"}:
357+
return False
358+
return default
359+
360+
361+
def _derive_manifest_support_scope(record: Mapping[str, Any]) -> str:
362+
# `support_scope` remains supported for backward compatibility, while
363+
# `exposed_to_end_user` + `ci_live_execute` define policy intent.
364+
legacy_scope = _normalize_support_scope(record.get("support_scope"))
365+
exposed_to_end_user = _coerce_manifest_bool(
366+
record.get("exposed_to_end_user"),
367+
default=legacy_scope != SUPPORT_SCOPE_UNSUPPORTED,
368+
)
369+
ci_live_execute = _coerce_manifest_bool(
370+
record.get("ci_live_execute"),
371+
default=legacy_scope == SUPPORT_SCOPE_EXECUTED,
372+
)
373+
374+
if not exposed_to_end_user:
375+
return SUPPORT_SCOPE_UNSUPPORTED
376+
if ci_live_execute:
377+
return SUPPORT_SCOPE_EXECUTED
378+
return SUPPORT_SCOPE_BLOCKED
379+
380+
349381
def _first_non_empty(values: Mapping[str, str] | None, *keys: str) -> str | None:
350382
if values is None:
351383
return None

scripts/ops_release_gate.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ def _parse_args() -> argparse.Namespace:
5151
parser = argparse.ArgumentParser(
5252
description=(
5353
"Validate scripts/ops_coverage_harness.py JSON report against release "
54-
"expectations derived from the public ops manifest by default."
54+
"expectations derived from the public ops manifest "
55+
"(prefer `exposed_to_end_user` + `ci_live_execute` when present)."
5556
)
5657
)
5758
parser.add_argument(
@@ -110,6 +111,38 @@ def _normalize_support_scope(value: Any) -> str:
110111
return SUPPORT_SCOPE_ALIASES.get(normalized, SUPPORT_SCOPE_UNSUPPORTED)
111112

112113

114+
def _coerce_manifest_bool(value: Any, *, default: bool) -> bool:
115+
if isinstance(value, bool):
116+
return value
117+
if isinstance(value, str):
118+
normalized = value.strip().lower()
119+
if normalized in {"true", "1", "yes", "y", "on"}:
120+
return True
121+
if normalized in {"false", "0", "no", "n", "off"}:
122+
return False
123+
return default
124+
125+
126+
def _derive_manifest_support_scope(record: Mapping[str, Any]) -> str:
127+
# Backward compatibility: if boolean fields are absent, derive from legacy
128+
# support_scope aliases.
129+
legacy_scope = _normalize_support_scope(record.get("support_scope"))
130+
exposed_to_end_user = _coerce_manifest_bool(
131+
record.get("exposed_to_end_user"),
132+
default=legacy_scope != SUPPORT_SCOPE_UNSUPPORTED,
133+
)
134+
ci_live_execute = _coerce_manifest_bool(
135+
record.get("ci_live_execute"),
136+
default=legacy_scope == SUPPORT_SCOPE_EXECUTED,
137+
)
138+
139+
if not exposed_to_end_user:
140+
return SUPPORT_SCOPE_UNSUPPORTED
141+
if ci_live_execute:
142+
return SUPPORT_SCOPE_EXECUTED
143+
return SUPPORT_SCOPE_BLOCKED
144+
145+
113146
def _load_manifest(path: Path) -> list[dict[str, Any]]:
114147
payload = json.loads(path.read_text(encoding="utf-8"))
115148
if not isinstance(payload, list):
@@ -125,7 +158,7 @@ def _derive_expected_counts_from_manifest(path: Path) -> dict[str, int]:
125158
records = _load_manifest(path)
126159
scope_counts: Counter[str] = Counter()
127160
for record in records:
128-
scope_counts[_normalize_support_scope(record.get("support_scope"))] += 1
161+
scope_counts[_derive_manifest_support_scope(record)] += 1
129162
return {
130163
"total": len(records),
131164
"executed": scope_counts[SUPPORT_SCOPE_EXECUTED],

src/agenticflow_cli/main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,13 @@ def _apply_curated_manifest_filter(
697697
manifest_metadata = _manifest_metadata_by_operation_id()
698698
if not manifest_metadata:
699699
return operations
700-
allowed_operation_ids = set(manifest_metadata)
700+
allowed_operation_ids = {
701+
operation_id
702+
for operation_id, metadata in manifest_metadata.items()
703+
if not isinstance(metadata, Mapping)
704+
or not isinstance(metadata.get("exposed_to_end_user"), bool)
705+
or bool(metadata.get("exposed_to_end_user"))
706+
}
701707
return [
702708
operation
703709
for operation in operations

0 commit comments

Comments
 (0)