There is no single unified draft → ... → active enum anywhere in the code. A client’s
model moves through four independent state machines that compose together. Documenting
them as one linear chain would misrepresent the system — this page documents the real,
separate machines, verified against the schema and service code on this branch.
The four state machines
1. client_profiles.status — the portal-visible stage
CHECK (status IN ('draft','data_validated','training','trained','hosting','live','archived'))
(db/migrations/0060_provisioning_domain.sql)
Legal transitions, enforced by assert_valid_status_transition (app/store/provisioning_domain.py,
409 invalid_transition otherwise):
draft -> data_validated
data_validated -> data_validated (re-validate) | training
training -> trained
trained -> training | data_validated (D5 reject, initial-kind) | hosting
hosting -> live | trained | training
live -> trained | training
hosting/live re-entering training directly is deliberate: retraining an already-hosted
client is a normal, supported flow. archived is a declared CHECK value with no code path
that ever sets it — it exists in the schema but nothing in the current codebase transitions a
client there. Document it as defined-but-unused, not as a reachable end state.
2. eval_runs.status — the evaluation execution
status text NOT NULL DEFAULT 'running' CHECK (status IN ('running','passed','failed','error'))
(db/migrations/0063_lifecycle_eval.sql) Exactly four values — “approved” / “overridden” /
“rejected” are not separate status values; they live one level down, in
eval_runs.report.decision.kind (a free-form jsonb field), set by the D3/D4/D5 endpoints.
3. workspace_models.status — the registry row
The column comment in db/schema.sql says -- 'active' | 'superseded' | 'failed', but this is
stale: the actual code also writes candidate:
registry_row = {
...
"status": "candidate" if portal_managed else "active",
...
}
(app/services/model_lifecycle.py) There is no DB CHECK constraint on this column, so
candidate is a real, uncontradicted value. A portal-managed training run (trigger= "model_train", the only trigger the portal’s C2/F1 endpoints ever use) registers as
candidate and never auto-activates — only promote() (below) or hosting’s activate()
can move a portal-managed version to active. This closes a real gap: before this behavior
existed, a never-evaluated candidate could start serving production traffic (via the in-process
inline inference path) the instant training finished.
phase text NOT NULL CHECK (phase IN ('shadow','canary','active','rolled_back'))
(db/migrations/0063_lifecycle_eval.sql), driven by app/services/promotion.py:
start_shadow — legal only when no open (shadow/canary) or active promotion exists
for that (workspace, version). Writes only a model_promotions row —
zero other side effects, zero traffic change. Shadow-phase assignment records land in the
separate, throwaway shadow_assignments table (14-day TTL sweep), never touching the
registry or live routing.
start_canary — legal only from shadow or canary (covers both first entry and a
percentage adjustment). Canary arm routing is deterministic:
sha256(content_hash) mod 100 < canary_pct — never a salted hash, so the same issue always
routes the same way for a given candidate.
promote — legal only from shadow or canary. Atomically flips the
workspace_models registry (candidate → active, prior active → superseded). This is
distinct from activate (hosting’s E5, service go-live): promote flips which model
version is the workspace’s registered active model; activate flips which service receives
traffic. They are two different mutations on two different tables.
rollback — legal only when the latest promotion for that version is phase='active'
and has a recorded incumbent_version; re-activates the incumbent atomically and freezes the
rolled-back version’s final stats.
compute_promotion_stats — zero matched (candidate, incumbent) sample pairs returns
sample_count: 0 and every rate field null, never a fabricated 0.0 or percentage.
latency_p95_ms is always null — there is no timestamp-delta column and no real hosted
round-trip measured against it; this is a documented gap, not a stub pretending to work.
How the stages compose
Putting the four machines together (not a literal enum — a narrative across all four):
client_profiles.status: draft → data_validated → training → trained ────────────────► hosting → live
│
eval_runs.status: (created) running → passed/failed/error
│
eval_runs.report.decision.kind: approved / overridden / rejected
│
workspace_models.status: candidate ─────────► active (at promote) → superseded (later)
│
model_promotions.phase: shadow → canary → active / rolled_back
A client can sit at client_profiles.status=trained for the entire evaluate→approve→promote
window — that whole window has no dedicated client status of its own.
R-L1 — evaluation is mandatory
There is no code path that lets a training run reach approved without a real evaluation
having genuinely passed:
def approve_eval_run(repo, training_run_id: str, *, actor_user_id: str) -> Dict:
"""D3 — staff:ml_eng confirms a PASSED evaluation. 409 unless the
latest eval_run's status is 'passed'."""
existing = repo.list_eval_runs_for_training_run(training_run_id)
if not existing:
raise ApiError(404, "not_found", "no evaluation exists...")
latest = existing[0]
if latest["status"] != "passed":
raise ApiError(409, "invalid_request",
f"cannot approve — latest evaluation status is {latest['status']!r}, not 'passed'")
(app/services/model_eval.py) passed can only be set by run_and_persist_gates — the real
gate-evaluation harness (see Evaluation Gates). This is
enforced structurally, not just by convention.
Hosting itself carries a second, independent enforcement point — EVAL_REQUIRED (default
True):
def assert_eval_required_satisfied(repo, training_run_id: str) -> None:
"""Raises ApiError(409) unless the training run's evaluation genuinely
PASSED or was explicitly OVERRIDDEN (D4)."""
(app/services/model_eval.py, called from hosting.create_service, E1) — a merely-trained,
never-evaluated, or failed-and-not-overridden run is refused with 409 eval_required. Setting
EVAL_REQUIRED=false disables this specific check only; it does not disable R-L1’s approval
gate above.
R-L2 — override requires staff:admin, a reason, and the artifact is flagged forever
POST /v1/provisioning/training-runs/{run_id}/override
Authorization: Bearer <staff:admin JWT>
{"reason": "Corpus is a known-noisy fixture; SRE reviewed manually and signed off."}
Guarded by both require_staff("admin") and require_feature("internal.eval_override") —
staff:admin is the only role with that feature key. The reason is enforced twice — once by
the RBAC/feature gate implicitly restricting who can even call this, and once explicitly in the
service:
if not reason or not reason.strip():
raise ApiError(422, "invalid_request", "override reason is required")
(app/services/model_eval.py) The override is not just a database flag — it physically
rewrites the stored manifest.json in the model store:
eval_block = dict(manifest.get("eval") or {})
eval_block.update({
"overridden": True, "overridden_by": actor_user_id, "overridden_at": _now_iso(),
"overridden_reason": reason,
})
manifest["eval"] = eval_block
store.put(workspace_id, candidate_version, "manifest.json", ...)
This is deliberate — “surfaced on the artifact forever,” per the spec’s own framing. The
workspace_models.manifest DB row (a denormalized copy the go-live summary reads) is
separately synced via repo.update_workspace_model_manifest(...) in the same call, so the
override shows up everywhere the artifact’s manifest is read from — not just the physical
store. Every override is also audited (provisioning.eval_override, with before/after
snapshots) and emits a model.lifecycle event (eval_failed → approved_via_override).
content_hash and determinism_hash are frozen at train time, before any evaluation ever
runs. An override never touches either hash — the manifest’s integrity fields and its
eval-decision fields are deliberately independent, so a future integrity check never mistakes
an honest override for tampering.
Related pages