DEV Community

Alex Zhu
Alex Zhu

Posted on

A Staging-Gate Playbook for AI Spikes on Shared Free Hosts

You join the afternoon standup and someone demos a chatbot that already talks to staging data. Token cost looked like zero, so the spike used a free remote model without named owners. You ask who pages if the endpoint starts leaking customer fields, and the room goes quiet. That silence is the real incident, because promotion happened without a written gate on the wiki.

The failure you are actually debugging

A free-tier spike fails in a quieter way than a crashed process or a red deploy. The model answers quickly, the shared host stays up, and reviewers assume someone else classified the data. You later find prompt logs in a world-readable volume, plus an API token inside a notebook. None of those mistakes required advanced attackers, because they only required a missing promotion owner on the wiki.

Name five roles before anyone shares a URL

You should refuse a public demo until these names exist in the wiki, even if the prototype still runs on a laptop. The names can be placeholders, but they cannot be Slack group mentions that nobody reads. If a role is vacant, the gate fails closed, and the spike must stay on a private branch. You are not blocking creativity; you are blocking an implicit production contract that nobody signed.

1. Prototype owner

The owner is the person who can delete the spike on the same day it misbehaves. They keep the repository path, the inference endpoint, and the data class in one wiki row. If they go on leave, they hand the row to a named deputy before the next working day. You should not accept the intern who started it unless that intern can actually take the page.

2. Data steward

The steward says whether prompts, tools, and logs may leave the laptop for a free remote host. They classify the payload as public, internal, or restricted, and they write the class beside the endpoint. Restricted data stops the gate even when the model quality looks excellent in a demo. You ask them to sign the row, not to bless a vendor in the abstract.

3. Inference operator

The operator records where tokens actually go, including a free shared server if that is the current backend. They document failover: local stub, queued jobs, or a hard error, never a silent model swap. They also record how secrets are injected, because notebook paste is not an injection strategy. You treat this role as production work even while the invoice is still zero.

4. Promotion reviewer

The reviewer is not the person who wrote the spike, and they do not rubber-stamp a video recording. They run the checker script in this article and they read the wiki row against the repository. If the checker fails, they comment on the pull request instead of debating model quality in chat. You want a reviewer who can say no without writing a long manifesto in the thread.

5. On-call delegate

Someone must receive the first human message when the free host stalls or the prompt cache starts echoing secrets. That person needs a rollback command they can run, not a link to a long design document. If you cannot name them, you do not have a service; you have a group chat with extra latency. Write their working hours beside the rollback command so overnight pages have a realistic chance.

Handoffs happen on the wiki row, not in a private message after the demo. When the prototype owner changes, they update the row in the same pull request that transfers repository permissions. If the on-call delegate is away, the promotion reviewer blocks staging rather than borrowing a random engineer from another team. You should treat a missing handoff as a failed gate, not as a calendar problem to ignore.

Run this seven-step gate in order

Do not skip ahead to hosting just because the demo impressed a stakeholder in the hallway. Walk the steps in order, and keep the evidence on the wiki row instead of in screenshots. If a step has no artifact, the promotion reviewer stops the gate and leaves the spike private. You can still finish a small spike in one afternoon when the fields are honest and complete.

Step 1: Freeze the spike identity

You create a wiki row with a short name, a repository URL, and a single owning team. You refuse duplicate names like new-bot-final-2, because later handoffs cannot search slang titles in the wiki. You add the date the spike first left the laptop, even if that date is this morning. The row is the object you will promote, reject, or delete; chat threads are not objects.

Step 2: Classify every payload that can leave the process

You list prompts, retrieved chunks, tool arguments, and traces as separate rows, not as one fuzzy user-data blob. The steward marks each row public, internal, or restricted before any remote call is enabled. Restricted rows block free remote hosts, including shared servers that feel isolated during a hallway demo. If classification is unknown, you keep inference on the laptop and you do not share URLs.

Step 3: Inventory the inference path

You write the provider, the access mode, and whether the host is shared, dedicated, or local. You do not paste marketing pages into the wiki and then call that a complete inventory. If the path uses free model access, you say so in plain language beside the owner. You also note that free paths can change without a procurement ticket, so failover cannot be hope.

Step 4: Scan secrets as if the notebook were already public

You run a secret scanner on the repo, the env files, and any exported notebooks before reviewers judge model quality. You rotate anything that appeared in a prompt, because model logs are not a vault. You store replacements in the team secret manager and you reference the path from the wiki row. A screenshot of a green demo does not replace this scan, no matter who attended the hallway review.

Step 5: Declare failover in one sentence you could read at 2 a.m.

You write what the product does if the free host is slow, full, or gone. Allowed answers are fail closed, serve a local stub, or queue work for a named operator. Forbidden answers include silently switching models, retrying customer data against a new host, or paging everyone. You test the chosen path once with a killed endpoint, then you record the command you used.

Step 6: Run the checker in CI

You add the script below as a proposed gate job that fails the pull request when required fields are empty. You keep the job boring: it does not call a model and it does not scrape vendor status pages. Reviewers should be able to reproduce a failure with one command on their own laptop after cloning. If the job is skipped, the promotion decision stays blocked even when stakeholders are waiting in the channel.

Step 7: Record sandbox, staging, or blocked

You write one of those three words on the wiki row, plus the reviewer name and the date. Sandbox means named testers only, no customer identifiers, and a deletion date no later than two weeks. Staging means the five roles are filled, secrets are rotated, and failover was exercised once. Blocked means the spike stays private; you do not negotiate that word in a hallway.

Paste this one-page run into the wiki

Copy the block into your team wiki and replace the placeholders before the next demo. Keep it on one page so a reviewer can finish it during a single pull request. If the page grows past a screen, you are hiding vacant roles behind prose. The checker reads a YAML twin of this page, so keep the field names stable.

# AI spike staging gate

- spike_id:
- repo_url:
- owning_team:
- first_left_laptop:
- data_class: public | internal | restricted
- inference_mode: free_remote | self_hosted | vendor_paid
- inference_provider:
- failover: fail_closed | local_stub | queue_for_operator
- secret_scan_at:
- secret_manager_path:
- named_testers:
- delete_after:
- vendor_review_url: (required when restricted + staging)
- decision: sandbox | staging | blocked
- reviewer:
- reviewed_at:

## Roles
- prototype_owner:
- data_steward:
- inference_operator:
- promotion_reviewer:
- oncall_delegate:
- oncall_hours:
- rollback_command:

## Handoff
- previous_owner:
- new_owner:
- permissions_pr:
- reason:

## Evidence
- payload inventory link:
- failover test command:
- checker log:
Enter fullscreen mode Exit fullscreen mode

Reproduce the checker on your laptop

The following checker is a proposed example that you should treat as unexecuted until you run it locally. It never calls a model, so a failure means the wiki row is incomplete rather than the vendor is down. You can drop the files into an empty directory and learn the gate without touching production data. If PyYAML is missing, install it in a virtualenv instead of adding it to a production image.

  1. Create a local virtualenv first so the checker dependency never lands in a production image.
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install pyyaml
Enter fullscreen mode Exit fullscreen mode
  1. Save the YAML map below as promotion.yaml beside the checker, using honest placeholders instead of empty strings.
spike_id: billing-faq-spike
repo_url: https://git.example.internal/app/billing-faq-spike
data_class: internal
inference_mode: free_remote
failover: fail_closed on timeout; local_stub for unauthenticated testers
secret_scan_at: "2026-09-07T10:00:00Z"
decision: sandbox
named_testers:
  - jordan
  - sam
delete_after: "2026-09-21"
roles:
  prototype_owner: jordan
  data_steward: data-governance
  inference_operator: platform-ai
  promotion_reviewer: sre-review
  oncall_delegate: sam
Enter fullscreen mode Exit fullscreen mode
  1. Run the checker once and confirm a passing row prints PROMOTION GATE PASSED with the spike identifier.
python3 check_promotion.py --file promotion.yaml
Enter fullscreen mode Exit fullscreen mode
  1. Delete the on-call name, run the same command again, and confirm the process exits nonzero.
  2. Add the command as a CI step that fails the pull request when the wiki twin is incomplete.
#!/usr/bin/env python3
"""Proposed promotion-gate checker. Treat as unexecuted until you run it locally."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

try:
    import yaml
except ImportError:
    print("Install PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
    sys.exit(2)

REQUIRED_ROLES = (
    "prototype_owner",
    "data_steward",
    "inference_operator",
    "promotion_reviewer",
    "oncall_delegate",
)
REQUIRED_FIELDS = (
    "spike_id",
    "repo_url",
    "data_class",
    "inference_mode",
    "failover",
    "secret_scan_at",
    "decision",
)
ALLOWED_DATA_CLASS = {"public", "internal", "restricted"}
ALLOWED_MODE = {"free_remote", "self_hosted", "vendor_paid"}
ALLOWED_DECISION = {"sandbox", "staging", "blocked"}
PLACEHOLDERS = {"tbd", "n/a", "ai", "intern-rotating", "todo"}


def load_row(path: Path) -> dict:
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError("promotion file must be a mapping")
    return data


def problems(row: dict) -> list[str]:
    issues: list[str] = []
    for key in REQUIRED_FIELDS:
        if not row.get(key):
            issues.append(f"missing field: {key}")

    roles = row.get("roles") or {}
    for key in REQUIRED_ROLES:
        value = roles.get(key) if isinstance(roles, dict) else None
        label = str(value or "").strip().lower()
        if not label or label in PLACEHOLDERS:
            issues.append(f"role vacant or placeholder: {key}")

    data_class = str(row.get("data_class", "")).lower()
    mode = str(row.get("inference_mode", ""))
    decision = str(row.get("decision", ""))

    if data_class and data_class not in ALLOWED_DATA_CLASS:
        issues.append("data_class must be public, internal, or restricted")
    if mode and mode not in ALLOWED_MODE:
        issues.append("inference_mode must be free_remote, self_hosted, or vendor_paid")
    if decision and decision not in ALLOWED_DECISION:
        issues.append("decision must be sandbox, staging, or blocked")
    if data_class == "restricted" and mode == "free_remote":
        issues.append("restricted data cannot use free_remote inference")
    if decision == "staging" and data_class == "restricted" and not row.get("vendor_review_url"):
        issues.append("missing field: vendor_review_url")

    failover = str(row.get("failover", "")).lower()
    forbidden = ("silent swap", "switch model", "page everyone", "hope")
    if any(token in failover for token in forbidden):
        issues.append("failover is too vague or forbidden")

    testers = row.get("named_testers") or []
    if decision == "sandbox" and not testers:
        issues.append("sandbox decision requires named_testers")
    if decision == "sandbox" and not row.get("delete_after"):
        issues.append("sandbox decision requires delete_after")
    return issues


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Fail closed when promotion wiki fields are empty"
    )
    parser.add_argument("--file", default="promotion.yaml")
    args = parser.parse_args()
    path = Path(args.file)
    if not path.exists():
        print(f"missing {path}", file=sys.stderr)
        return 1
    try:
        row = load_row(path)
    except Exception as exc:
        print(f"invalid yaml: {exc}", file=sys.stderr)
        return 1
    issues = problems(row)
    if issues:
        print("PROMOTION GATE FAILED")
        for item in issues:
            print(f"- {item}")
        return 1
    print("PROMOTION GATE PASSED")
    print(f"decision={row.get('decision')} spike_id={row.get('spike_id')}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

A passing run should print the decision and spike id, then exit zero. A missing on-call name should print PROMOTION GATE FAILED and return status 1. You can wire that status into GitHub Actions, GitLab CI, or a pre-push hook without teaching the job anything about models. Keep the YAML in the same pull request as the spike so reviewers never hunt through chat for owners.

Where a free model host fits this gate

Some teams run the sandbox step against MonkeyCode, which offers free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Treat that pair as a convenience for spikes, not as a silent production backend with implied uptime. If your sandbox already points at that free server, fill the wiki row first, then decide whether the spike still deserves a URL.

Limitations

This playbook will not make a free shared host into a regulated production region with an implicit uptime contract. It will not rank models, estimate token burn, or prove that a complimentary server will still exist next quarter. You should not send health data, payment data, or government identifiers through any free remote path just because the wiki row is complete. The gate only records decisions; it does not encrypt payloads or replace a real vendor review.

Who should skip this playbook

Skip this SOP if you are a solo developer whose process never leaves localhost and never touches shared credentials. Skip it if your platform team already blocks unknown inference hosts in CI with a stronger policy engine. Skip it if you hoped a free server would become the production backend without a procurement conversation. In those cases the wiki page becomes theater, and theater is usually how shadow production starts.

You do not need a larger platform team to stop free-tier spikes from becoming shadow production. You need five names, one wiki row, and a checker job that fails closed on missing fields. If a demo cannot survive that gate, it was not ready for staging data, however fluent the model sounded. Keep the page short enough that a tired reviewer will actually read it before the next standup.

Top comments (0)