
Flagship
- 2 installs
- 1 repo stars
- Updated February 6, 2026
- twillai/flagship
Creates, analyzes, and iterates budget-bounded feature-flag experiments with cohorts, PostHog MCP, and GitHub Actions loops, enforcing a per-experiment budget hard stop.
About
Runs a feature-flag experiment lifecycle in create/analyze/iterate modes, keeping repo mutations PR-only and gating on budget and policy checks with PostHog as the results source of truth. A developer uses it to set up and operate budget-capped product experiments.
- Three modes: create, analyze, iterate with immutable core fields
- Budget hard stop, PR-only mutations, and PostHog-plus-manifest hybrid source of truth
Flagship by the numbers
- 2 all-time installs (skills.sh)
- Ranked #13,956 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/twillai/flagship --skill flagshipAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 1 |
| Last updated | February 6, 2026 |
| Repository | twillai/flagship ↗ |
What it does
Creates, analyzes, and iterates budget-bounded feature-flag experiments with cohorts, PostHog MCP, and GitHub Actions loops, enforcing a per-experiment budget hard stop.
Files
Flagship
Run one experiment lifecycle in three modes: create, analyze, iterate.
Core Rules
- Use one primary KPI and optional guardrails.
- Treat
objective,primary_kpi, andmax_budget_usdas immutable after creation. - Enforce a cumulative per-experiment budget hard stop.
- Keep repository mutations PR-only.
- Run deterministic policy gates after analysis. Override to
HOLDon any gate failure. - Use a hybrid source-of-truth model:
- PostHog experiment object is authoritative for exposure assignment and experiment results.
- Repository manifest/state is authoritative for budget, guardrails, rollout policy, and PR workflow.
Create Mode
Run a structured brainstorm, then write files:
- Manifest:
.flagship/experiments/<experiment_id>.yaml - State:
.flagship/state/<experiment_id>.yaml - Generated workflow:
.github/workflows/flagship-loop.yml
Capture at minimum:
- Objective
- Primary KPI
- Guardrails
- Max budget (default
1000) - Feature flag key with control/treatment variants
- PostHog project and cohort ids
Before finalizing manifest fields, determine feature-flag provider and MCP readiness.
Pre-Write Gate (Mandatory)
Do not write any files until required parameters are clarified and explicitly specified by the human.
Required human-confirmed fields before any write:
- Experiment definition (
experiment_id/title and objective) - Primary KPI
- Max budget (
max_budget_usd) - MCP readiness for the selected feature-flag provider
- GitHub Actions secret setup confirmation for MCP auth
Write-blocked files until gate passes:
.flagship/experiments/<experiment_id>.yaml.flagship/state/<experiment_id>.yaml.github/workflows/flagship-loop.yml
If any required field is missing or ambiguous:
- Continue brainstorming with targeted follow-up questions.
- Summarize which fields are still missing.
- Do not scaffold or update files yet.
After all required fields are explicit, restate final values and get a clear human go-ahead, then write files.
MCP Readiness + GitHub Secret Guidance (Required During Brainstorm)
During create, verify MCP readiness before writing files.
1. Check local MCP install for the selected provider. 2. If missing, provide setup commands and wait for human confirmation. 3. Provide GitHub Actions API key setup instructions with exact secret names. 4. Confirm completion before passing the pre-write gate.
For PostHog, use this minimum guidance:
- Local install check:
codex mcp listcodex mcp get posthog --json- If not installed:
- US cloud (default):
codex mcp add posthog --url https://mcp.posthog.com/mcp --bearer-token-env-var POSTHOG_API_KEY - EU cloud:
codex mcp add posthog --url https://mcp-eu.posthog.com/mcp --bearer-token-env-var POSTHOG_API_KEY - OAuth fallback:
codex mcp add posthog --url https://mcp.posthog.com/mcpthencodex mcp login posthog - Optional wizard bootstrap:
npx @posthog/wizard mcp add - Re-check:
codex mcp get posthog --json - GitHub Actions secrets:
- Add
POSTHOG_MCP_URL(PostHog MCP server URL) - Add
POSTHOG_API_KEY(PostHog personal API key with required experiment read scopes) - Ensure workflow environment/repo exposes those names unchanged.
Brainstorm Conversation Style
Use a collaborative conversation, not a rigid intake form.
- Ask one high-leverage question at a time.
- Start with product and user outcome questions before technical setup details.
- Reflect back what the user said in plain language before asking the next question.
- Offer 2 to 3 concrete experiment directions with tradeoffs, then recommend one.
- Avoid dumping a long required-field checklist in one message.
- Use defaults where reasonable and ask only for missing technical IDs at the end.
- Keep tone natural and concise; focus on decision quality, not template completion.
Suggested question flow:
1. Desired behavior change and target user segment. 2. One success KPI and one failure condition. 3. Smallest treatment change that can ship quickly. 4. Guardrail risk that should stop or pause rollout. 5. Technical IDs (PostHog project/cohorts/flag key/variants) only after direction is chosen.
Workflow Generation
Generate the GitHub Actions workflow from the skill template:
- Template source:
assets/flagship-loop.yml.tmpl - Target output:
.github/workflows/flagship-loop.yml
Rules:
1. If target workflow does not exist, create it from the template. 2. If target workflow exists, update it to preserve custom repository details while keeping the core Flagship loop behavior. 3. Do not treat the workflow file in the repository as static reference documentation; the agent should own generating/updating it.
Provider Detection and MCP Bootstrap
1. Detect current feature-flag system from repository code/config:
- Check dependencies and references for providers such as PostHog, LaunchDarkly, Statsig, Split, or homegrown flags.
2. If a provider is already in use:
- Reuse that provider for flag rollout in this experiment.
- Keep provider metadata in the manifest.
3. If no provider is clearly installed:
- Default to PostHog for MVP.
- Add a TODO/plan for product SDK instrumentation in app code if missing.
- Attempt PostHog MCP setup in developer environments with:
- US cloud (default):
codex mcp add posthog --url https://mcp.posthog.com/mcp --bearer-token-env-var POSTHOG_API_KEY - EU cloud:
codex mcp add posthog --url https://mcp-eu.posthog.com/mcp --bearer-token-env-var POSTHOG_API_KEY - Verify with:
codex mcp get posthog --json - Optional wizard bootstrap:
npx @posthog/wizard mcp add - For GitHub Actions, configure PostHog MCP in Codex
config.tomlwith: url = "${POSTHOG_MCP_URL}"headers = { Authorization = "Bearer ${POSTHOG_API_KEY}" }- Treat API key creation as manual setup owned by the user.
Hybrid Data Model Requirements
- Persist PostHog experiment identifiers in manifest metadata (for example
posthog.experiment_id) once created. - Persist feature-flag provider metadata (for example
feature_flag.provider). - On each analyze run:
- Read results from PostHog experiment APIs/tools.
- Compare critical settings between PostHog and manifest.
- If drift is detected, set final action to
HOLDand require review.
Use schema rules from references/experiment-schema.md.
Analyze Mode
Load the experiment manifest and read experiment metrics via PostHog MCP. Normalize metrics into one JSON document using scripts/fetch_metrics.sh. Generate an agent recommendation JSON containing:
agent_recommendationconfidencereasoning_summary
Run deterministic policy gates with scripts/evaluate_policy.sh. Never skip policy gates.
Iterate Mode
When final action is ITERATE, propose code changes for the treatment path. Prepare a PR-ready change summary with:
- Hypothesis and KPI expectation
- Files changed
- Guardrail impact risks
- Rollback note
Do not mutate core manifest fields. Update report and state only.
Expected Output Paths
- Manifest:
.flagship/experiments/<experiment_id>.yaml - State:
.flagship/state/<experiment_id>.yaml - Report:
.flagship/reports/<yyyy-mm-dd>/<experiment_id>.json - Ledger:
.flagship/ledger/<experiment_id>.jsonl
Decision Payload Schema
Return JSON with exactly these fields:
experiment_idwindow_start_utcwindow_end_utckpi_controlkpi_treatmentguardrail_deltasagent_recommendationpolicy_resultpolicy_fail_reasonsfinal_actionbudget_before_usdbudget_after_usdconfidence
Use references:
references/experiment-schema.mdreferences/posthog-mcp-queries.mdreferences/policy-gates.mdreferences/provider-and-hybrid.md
Experiment Schema
This reference defines the required YAML schema for Flagship experiments.
Manifest Path
.flagship/experiments/<experiment_id>.yaml
Required Manifest Fields
experiment_id: onboarding-copy-v1
title: Improve onboarding first-run activation
objective: Increase onboarding activation within 24 hours
primary_kpi: activation_24h_rate
guardrails:
- name: onboarding_completion_time_p95
direction: lower_is_better
max_degradation_pct: 2.0
- name: error_rate
direction: lower_is_better
max_degradation_pct: 1.0
max_budget_usd: 1000
feature_flag:
provider: posthog
key: onboarding.copy_variant
control_variant: control
treatment_variant: treatment
posthog:
project_id: "12345"
experiment_id: "9876"
cohorts:
control: "1122"
treatment: "3344"
status: active
created_at_utc: "2026-02-05T00:00:00Z"Status Values
draftactivepausedwinner_selectedcompletedstopped
Immutable Fields
After first commit, these fields must not change:
objectiveprimary_kpimax_budget_usd
Hybrid Linkage Fields
Use these fields to connect repository state with native PostHog experiment state:
feature_flag.provider(recommended, typicallyposthog)feature_flag.keyposthog.project_idposthog.experiment_id(recommended once created)
State Path
.flagship/state/<experiment_id>.yaml
Required State Fields
spent_usd_total: 0
budget_remaining_usd: 1000
last_run_at_utc: null
last_decision: HOLD
current_rollout_percent: 0
open_pr_number: nullReport Path
.flagship/reports/<yyyy-mm-dd>/<experiment_id>.json
Decision reports must follow the schema defined in SKILL.md.
Policy Gates
Deterministic policy gates run after agent analysis. They can override the recommendation.
Inputs
- Manifest YAML
- Normalized metrics JSON
- Budget snapshot JSON
- Agent recommendation JSON
Gates
1. Budget hard stop
- Fail if remaining budget is
<= 0. - Force final action to
STOP.
2. Minimum sample size
- Fail if either cohort sample is below threshold.
- Default threshold:
500. - Force final action to
HOLD.
3. Guardrail degradation
- Fail if any guardrail delta exceeds max degradation.
- Use per-guardrail
max_degradation_pctfrom manifest when present. - Compare degradation as percentage change from control baseline.
- Default fallback max degradation:
2.0(%). - Force final action to
HOLD.
4. PostHog/manifest drift
- Warn when critical metadata mismatches between PostHog experiment metadata and manifest metadata.
- Critical fields include:
posthog.experiment_idfeature_flag.providerfeature_flag.keyfeature_flag.control_variantfeature_flag.treatment_variant- Do not force final action by itself; review warnings in logs/report context.
5. Recommendation validity
- Fail if recommendation is not one of:
ITERATEKEEP_TREATMENTKEEP_CONTROLHOLDSTOP- Force final action to
HOLD.
Output
PASS|FAIL with final action and reason list.
Final Action Logic
- If budget gate fails:
STOP. - Else if any gate fails:
HOLD. - Else: use agent recommendation.
PostHog MCP Query Flow
Use the PostHog MCP server for cohort analysis in analyze mode.
Goal
Collect control/treatment metrics for:
- primary KPI value per cohort
- sample size per cohort
- guardrail values per cohort
Normalize these values via scripts/fetch_metrics.sh.
Recommended Sequence
1. Read experiment manifest. 2. Resolve:
posthog.project_idposthog.cohorts.controlposthog.cohorts.treatmentprimary_kpiguardrails[*].name
3. Query PostHog MCP for the target time window. 4. Save raw MCP response JSON to a file. 5. Run scripts/fetch_metrics.sh to normalize.
Raw JSON Shape Expected by fetch_metrics.sh
{
"window_start_utc": "2026-02-05T00:00:00Z",
"window_end_utc": "2026-02-06T00:00:00Z",
"experiment": {
"experiment_id": "9876",
"provider": "posthog",
"feature_flag_key": "onboarding.copy_variant",
"control_variant": "control",
"treatment_variant": "treatment"
},
"variants": {
"control": {
"kpi": 0.214,
"sample_size": 640,
"guardrails": {
"onboarding_completion_time_p95": 112.4,
"error_rate": 0.011
}
},
"treatment": {
"kpi": 0.239,
"sample_size": 618,
"guardrails": {
"onboarding_completion_time_p95": 114.2,
"error_rate": 0.012
}
}
}
}Notes
- Store API host and credentials in GitHub environment secrets.
- In GitHub Actions, configure Codex MCP via
config.toml. - Keep this flow read-only for analysis steps.
Provider Detection and Hybrid Source of Truth
Use this reference during create and analyze modes.
1) Feature-Flag Provider Detection
Inspect repository dependencies and config before assuming PostHog.
Common signals:
- PostHog:
posthog-js,posthog-node,posthog-ruby,posthog-python - LaunchDarkly:
launchdarkly,ldclient - Statsig:
statsig - Split:
splitio
If one provider is clearly present, reuse it for flag delivery.
If none is present, default to PostHog for Flagship MVP.
2) MCP Bootstrap
Local development
Preferred Codex setup:
codex mcp add posthog --url https://mcp.posthog.com/mcp --bearer-token-env-var POSTHOG_API_KEYEU cloud variant:
codex mcp add posthog --url https://mcp-eu.posthog.com/mcp --bearer-token-env-var POSTHOG_API_KEYOAuth fallback:
codex mcp add posthog --url https://mcp.posthog.com/mcp
codex mcp login posthogOptional wizard bootstrap:
npx @posthog/wizard mcp addGitHub Actions
Use API key auth in CODEX_HOME/config.toml:
[mcp_servers.posthog]
transport = "streamable_http"
url = "${POSTHOG_MCP_URL}"
headers = { Authorization = "Bearer ${POSTHOG_API_KEY}" }Manual step: user creates PostHog MCP-compatible personal API key.
Readiness Checklist (Use During Create Brainstorm)
Treat MCP readiness as a required checkpoint before writing manifest/state/workflow files.
Run:
codex mcp list
codex mcp get posthog --jsonIf PostHog MCP is missing, guide setup with one of:
codex mcp add posthog --url https://mcp.posthog.com/mcp --bearer-token-env-var POSTHOG_API_KEYor:
codex mcp add posthog --url https://mcp-eu.posthog.com/mcp --bearer-token-env-var POSTHOG_API_KEYThen verify:
codex mcp get posthog --jsonFor GitHub Actions, instruct user to add these secrets:
POSTHOG_MCP_URLPOSTHOG_API_KEY
Keep secret names aligned with workflow env/config references.
3) Hybrid Source of Truth Rules
Treat PostHog and repo metadata as complementary authorities:
- PostHog experiment object:
- experiment lifecycle in PostHog
- exposure assignment
- results computation
- Repo manifest/state:
- budget ceiling
- policy gates
- rollout workflow
- PR automation mapping
4) Required Linkage Metadata
When available, store:
feature_flag.providerfeature_flag.keyposthog.project_idposthog.experiment_id
If metadata drift is detected between PostHog and manifest on critical fields, force HOLD.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
evaluate_policy.sh \
--manifest <path> \
--metrics <path> \
--budget <path> \
--recommendation <path|literal> \
--output <path> \
[--min-sample <n>] \
[--guardrail-max-degradation <decimal>]
Description:
Evaluate deterministic policy gates and emit final Flagship decision payload.
EOF
}
manifest=""
metrics=""
budget=""
recommendation=""
output=""
min_sample="500"
guardrail_max_degradation="2.0"
while [[ $# -gt 0 ]]; do
case "$1" in
--manifest) manifest="$2"; shift 2 ;;
--metrics) metrics="$2"; shift 2 ;;
--budget) budget="$2"; shift 2 ;;
--recommendation) recommendation="$2"; shift 2 ;;
--output) output="$2"; shift 2 ;;
--min-sample) min_sample="$2"; shift 2 ;;
--guardrail-max-degradation) guardrail_max_degradation="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*)
echo "Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
if [[ -z "$manifest" || -z "$metrics" || -z "$budget" || -z "$recommendation" || -z "$output" ]]; then
echo "Missing required arguments." >&2
usage
exit 1
fi
python3 - "$manifest" "$metrics" "$budget" "$recommendation" "$output" "$min_sample" "$guardrail_max_degradation" <<'PY'
import json
import subprocess
import sys
from pathlib import Path
def load_yaml(path: Path):
try:
import yaml # type: ignore
return yaml.safe_load(path.read_text())
except ImportError:
try:
raw = subprocess.check_output(
[
"ruby",
"-ryaml",
"-rjson",
"-e",
"print JSON.dump(YAML.load_file(ARGV[0]))",
str(path),
],
text=True,
)
return json.loads(raw)
except Exception as exc:
print(
"Unable to parse YAML. Install pyyaml or ensure ruby with psych is available.",
file=sys.stderr,
)
raise exc
manifest_path = Path(sys.argv[1])
metrics_path = Path(sys.argv[2])
budget_path = Path(sys.argv[3])
recommendation_arg = sys.argv[4]
output_path = Path(sys.argv[5])
min_sample = int(sys.argv[6])
guardrail_max = float(sys.argv[7])
manifest = load_yaml(manifest_path) or {}
metrics = json.loads(metrics_path.read_text())
budget = json.loads(budget_path.read_text())
rec_path = Path(recommendation_arg)
if rec_path.exists():
recommendation_raw = json.loads(rec_path.read_text())
else:
recommendation_raw = {
"agent_recommendation": recommendation_arg,
"confidence": 0.5,
"reasoning_summary": "Literal recommendation input",
}
allowed_recommendations = {"ITERATE", "KEEP_TREATMENT", "KEEP_CONTROL", "HOLD", "STOP"}
fail_reasons = []
budget_before = float(
budget.get("budget_remaining_usd", budget.get("remaining_usd", budget.get("budget_before_usd", 0.0)))
)
if budget_before <= 0:
fail_reasons.append("Budget hard stop: remaining budget <= 0")
control_n = int(metrics.get("sample_size_control", 0))
treatment_n = int(metrics.get("sample_size_treatment", 0))
if control_n < min_sample or treatment_n < min_sample:
fail_reasons.append(
f"Minimum sample not met: control={control_n}, treatment={treatment_n}, required={min_sample}"
)
guardrail_config = {}
for entry in manifest.get("guardrails", []) or []:
if not isinstance(entry, dict):
continue
name = entry.get("name")
if not name:
continue
guardrail_config[name] = {
"direction": entry.get("direction", "lower_is_better"),
"max_degradation_pct": float(entry.get("max_degradation_pct", guardrail_max)),
}
raw_guardrails = metrics.get("raw_guardrails", {}) or {}
raw_control = raw_guardrails.get("control", {}) or {}
raw_treatment = raw_guardrails.get("treatment", {}) or {}
for key, delta in (metrics.get("guardrail_deltas", {}) or {}).items():
cfg = guardrail_config.get(
key,
{"direction": "lower_is_better", "max_degradation_pct": guardrail_max},
)
direction = cfg["direction"]
limit_pct = float(cfg["max_degradation_pct"])
c_val = raw_control.get(key)
t_val = raw_treatment.get(key)
if c_val is not None and t_val is not None:
c_val = float(c_val)
t_val = float(t_val)
signed_delta = t_val - c_val
pct_delta = abs((signed_delta / c_val) * 100.0) if c_val != 0 else (100.0 if signed_delta != 0 else 0.0)
if direction == "lower_is_better" and signed_delta > 0 and pct_delta > limit_pct:
fail_reasons.append(
f"Guardrail breach: {key} degraded by {pct_delta:.4f}% (limit={limit_pct:.4f}%)"
)
elif direction == "higher_is_better" and signed_delta < 0 and pct_delta > limit_pct:
fail_reasons.append(
f"Guardrail breach: {key} degraded by {pct_delta:.4f}% (limit={limit_pct:.4f}%)"
)
elif direction not in {"lower_is_better", "higher_is_better"} and pct_delta > limit_pct:
fail_reasons.append(
f"Guardrail breach: {key} moved by {pct_delta:.4f}% (limit={limit_pct:.4f}%)"
)
else:
if float(delta) > guardrail_max:
fail_reasons.append(
f"Guardrail breach: {key} delta={float(delta):.6f} exceeds fallback limit={guardrail_max:.6f}"
)
drift_reasons = metrics.get("manifest_posthog_drift_reasons", [])
if not isinstance(drift_reasons, list):
drift_reasons = [str(drift_reasons)]
drift_detected = bool(metrics.get("manifest_posthog_drift_detected", False)) or bool(drift_reasons)
if drift_detected:
# Keep drift visibility in logs but do not hard-block decisions.
if drift_reasons:
print(
"Manifest/PostHog drift warnings: " + "; ".join(str(reason) for reason in drift_reasons),
file=sys.stderr,
)
else:
print("Manifest/PostHog drift warning detected", file=sys.stderr)
agent_recommendation = str(recommendation_raw.get("agent_recommendation", "HOLD")).upper()
if agent_recommendation not in allowed_recommendations:
fail_reasons.append(f"Invalid recommendation: {agent_recommendation}")
agent_recommendation = "HOLD"
if budget_before <= 0:
final_action = "STOP"
elif fail_reasons:
final_action = "HOLD"
else:
final_action = agent_recommendation
policy_result = "PASS" if not fail_reasons else "FAIL"
payload = {
"experiment_id": manifest.get("experiment_id"),
"window_start_utc": metrics.get("window_start_utc"),
"window_end_utc": metrics.get("window_end_utc"),
"kpi_control": metrics.get("kpi_control"),
"kpi_treatment": metrics.get("kpi_treatment"),
"guardrail_deltas": metrics.get("guardrail_deltas", {}),
"agent_recommendation": agent_recommendation,
"policy_result": policy_result,
"policy_fail_reasons": fail_reasons,
"final_action": final_action,
"budget_before_usd": budget_before,
"budget_after_usd": budget_before,
"confidence": float(recommendation_raw.get("confidence", 0.5)),
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
print(str(output_path))
PY
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
fetch_metrics.sh --manifest <path> --mcp-output <path> --output <path> [--window-start <iso8601>] [--window-end <iso8601>]
Description:
Normalize raw PostHog MCP metric output into the Flagship metrics schema.
EOF
}
manifest=""
mcp_output=""
output=""
window_start=""
window_end=""
while [[ $# -gt 0 ]]; do
case "$1" in
--manifest) manifest="$2"; shift 2 ;;
--mcp-output) mcp_output="$2"; shift 2 ;;
--output) output="$2"; shift 2 ;;
--window-start) window_start="$2"; shift 2 ;;
--window-end) window_end="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*)
echo "Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
if [[ -z "$manifest" || -z "$mcp_output" || -z "$output" ]]; then
echo "Missing required arguments." >&2
usage
exit 1
fi
python3 - "$manifest" "$mcp_output" "$output" "$window_start" "$window_end" <<'PY'
import json
import subprocess
import sys
from pathlib import Path
def load_yaml(path: Path):
try:
import yaml # type: ignore
return yaml.safe_load(path.read_text())
except ImportError:
try:
raw = subprocess.check_output(
[
"ruby",
"-ryaml",
"-rjson",
"-e",
"print JSON.dump(YAML.load_file(ARGV[0]))",
str(path),
],
text=True,
)
return json.loads(raw)
except Exception as exc:
print(
"Unable to parse YAML. Install pyyaml or ensure ruby with psych is available.",
file=sys.stderr,
)
raise exc
manifest_path = Path(sys.argv[1])
mcp_path = Path(sys.argv[2])
output_path = Path(sys.argv[3])
window_start_arg = sys.argv[4]
window_end_arg = sys.argv[5]
manifest = load_yaml(manifest_path)
raw = json.loads(mcp_path.read_text())
manifest_feature_flag = manifest.get("feature_flag", {}) or {}
manifest_posthog = manifest.get("posthog", {}) or {}
raw_experiment = raw.get("experiment", {}) or raw.get("posthog_experiment", {}) or {}
def resolve_variant(container, names):
if not isinstance(container, dict):
return {}
for name in names:
if name and isinstance(container.get(name), dict):
return container[name]
normalized = {str(name).lower() for name in names if name}
for key, value in container.items():
if str(key).lower() in normalized and isinstance(value, dict):
return value
return {}
def resolve_variants(payload, control_name, treatment_name):
candidates = [
payload.get("variants"),
payload.get("cohorts"),
payload.get("results"),
(payload.get("metrics", {}) or {}).get("variants"),
]
control_names = [control_name, "control", "baseline", "a"]
treatment_names = [treatment_name, "treatment", "variant", "b"]
control_variant = {}
treatment_variant = {}
for container in candidates:
if not isinstance(container, dict):
continue
if not control_variant:
control_variant = resolve_variant(container, control_names)
if not treatment_variant:
treatment_variant = resolve_variant(container, treatment_names)
if control_variant and treatment_variant:
break
return control_variant, treatment_variant
control, treatment = resolve_variants(
raw,
manifest_feature_flag.get("control_variant"),
manifest_feature_flag.get("treatment_variant"),
)
def read_nested(data, dotted):
cur = data
for key in dotted.split("."):
if not isinstance(cur, dict) or key not in cur:
return None
cur = cur[key]
return cur
def read_raw_experiment(*candidates):
for source in [raw_experiment, raw]:
if not isinstance(source, dict):
continue
for key in candidates:
if "." in key:
value = read_nested(source, key)
else:
value = source.get(key)
if value is not None:
return value
return None
drift_reasons = []
def add_drift_reason(field_name, manifest_value, posthog_value):
if manifest_value is None or posthog_value is None:
return
if str(manifest_value) != str(posthog_value):
drift_reasons.append(
f"{field_name} mismatch (manifest={manifest_value!r}, posthog={posthog_value!r})"
)
add_drift_reason(
"posthog.experiment_id",
manifest_posthog.get("experiment_id"),
read_raw_experiment("experiment_id", "id"),
)
add_drift_reason(
"feature_flag.provider",
manifest_feature_flag.get("provider"),
read_raw_experiment("provider"),
)
add_drift_reason(
"feature_flag.key",
manifest_feature_flag.get("key"),
read_raw_experiment("feature_flag_key", "feature_flag.key", "flag.key"),
)
add_drift_reason(
"feature_flag.control_variant",
manifest_feature_flag.get("control_variant"),
read_raw_experiment("control_variant", "variants.control.name"),
)
add_drift_reason(
"feature_flag.treatment_variant",
manifest_feature_flag.get("treatment_variant"),
read_raw_experiment("treatment_variant", "variants.treatment.name"),
)
def read_number(variant, keys, default=0.0):
if not isinstance(variant, dict):
return float(default)
for key in keys:
value = variant.get(key)
if value is not None:
try:
return float(value)
except (TypeError, ValueError):
pass
nested_metrics = variant.get("metrics", {}) or {}
if isinstance(nested_metrics, dict):
for key in keys:
value = nested_metrics.get(key)
if value is not None:
try:
return float(value)
except (TypeError, ValueError):
pass
return float(default)
def read_int(variant, keys, default=0):
return int(read_number(variant, keys, default=default))
def read_guardrails(variant):
if not isinstance(variant, dict):
return {}
direct = variant.get("guardrails")
if isinstance(direct, dict):
return direct
nested_metrics = variant.get("metrics", {}) or {}
if isinstance(nested_metrics, dict):
nested_guardrails = nested_metrics.get("guardrails")
if isinstance(nested_guardrails, dict):
return nested_guardrails
return {}
control_guardrails = read_guardrails(control)
treatment_guardrails = read_guardrails(treatment)
guardrail_names = set(control_guardrails.keys()) | set(treatment_guardrails.keys())
guardrail_deltas = {}
for name in sorted(guardrail_names):
c_val = control_guardrails.get(name)
t_val = treatment_guardrails.get(name)
if c_val is None or t_val is None:
continue
guardrail_deltas[name] = float(t_val) - float(c_val)
out = {
"experiment_id": manifest.get("experiment_id"),
"window_start_utc": window_start_arg or raw.get("window_start_utc"),
"window_end_utc": window_end_arg or raw.get("window_end_utc"),
"kpi_control": read_number(control, ["kpi", "primary_kpi", "value", "metric", "conversion_rate"]),
"kpi_treatment": read_number(treatment, ["kpi", "primary_kpi", "value", "metric", "conversion_rate"]),
"sample_size_control": read_int(control, ["sample_size", "n", "count", "users"]),
"sample_size_treatment": read_int(treatment, ["sample_size", "n", "count", "users"]),
"guardrail_deltas": guardrail_deltas,
"raw_guardrails": {
"control": control_guardrails,
"treatment": treatment_guardrails,
},
"manifest_posthog_drift_detected": len(drift_reasons) > 0,
"manifest_posthog_drift_reasons": drift_reasons,
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(out, indent=2, sort_keys=True) + "\n")
print(str(output_path))
PY
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
validate_manifest.sh --manifest <path> [--previous <path>] [--output <path>]
Description:
Validate required experiment manifest fields and immutable field constraints.
EOF
}
manifest=""
previous=""
output=""
while [[ $# -gt 0 ]]; do
case "$1" in
--manifest) manifest="$2"; shift 2 ;;
--previous) previous="$2"; shift 2 ;;
--output) output="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*)
echo "Unknown argument: $1" >&2
usage
exit 1
;;
esac
done
if [[ -z "$manifest" ]]; then
echo "Missing required argument: --manifest" >&2
usage
exit 1
fi
python3 - "$manifest" "$previous" "$output" <<'PY'
import json
import subprocess
import sys
from pathlib import Path
def load_yaml(path: Path):
try:
import yaml # type: ignore
return yaml.safe_load(path.read_text())
except ImportError:
try:
raw = subprocess.check_output(
[
"ruby",
"-ryaml",
"-rjson",
"-e",
"print JSON.dump(YAML.load_file(ARGV[0]))",
str(path),
],
text=True,
)
return json.loads(raw)
except Exception as exc:
print(
"Unable to parse YAML. Install pyyaml or ensure ruby with psych is available.",
file=sys.stderr,
)
raise exc
manifest_path = Path(sys.argv[1])
previous_path = Path(sys.argv[2]) if sys.argv[2] else None
output_path = Path(sys.argv[3]) if sys.argv[3] else None
manifest = load_yaml(manifest_path) or {}
errors = []
required_root = [
"experiment_id",
"title",
"objective",
"primary_kpi",
"guardrails",
"max_budget_usd",
"posthog",
"feature_flag",
"status",
"created_at_utc",
]
for field in required_root:
if field not in manifest:
errors.append(f"Missing required field: {field}")
feature_flag = manifest.get("feature_flag", {}) or {}
for field in ["key", "control_variant", "treatment_variant"]:
if field not in feature_flag:
errors.append(f"Missing required field: feature_flag.{field}")
posthog = manifest.get("posthog", {}) or {}
if "project_id" not in posthog:
errors.append("Missing required field: posthog.project_id")
cohorts = posthog.get("cohorts", {}) or {}
for field in ["control", "treatment"]:
if field not in cohorts:
errors.append(f"Missing required field: posthog.cohorts.{field}")
allowed_status = {"draft", "active", "paused", "winner_selected", "completed", "stopped"}
status = manifest.get("status")
if status not in allowed_status:
errors.append(f"Invalid status: {status}. Allowed: {sorted(allowed_status)}")
budget = manifest.get("max_budget_usd")
if budget is None:
errors.append("max_budget_usd cannot be null")
else:
try:
if float(budget) <= 0:
errors.append("max_budget_usd must be greater than 0")
except (TypeError, ValueError):
errors.append("max_budget_usd must be numeric")
if previous_path:
prev = load_yaml(previous_path) or {}
for immutable_key in ["objective", "primary_kpi", "max_budget_usd"]:
if prev.get(immutable_key) != manifest.get(immutable_key):
errors.append(
f"Immutable field changed: {immutable_key} "
f"(old={prev.get(immutable_key)!r}, new={manifest.get(immutable_key)!r})"
)
result = {
"valid": len(errors) == 0,
"errors": errors,
"manifest_path": str(manifest_path),
}
payload = json.dumps(result, indent=2, sort_keys=True) + "\n"
if output_path:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(payload)
sys.stdout.write(payload)
sys.exit(0 if result["valid"] else 1)
PY