Build Risk-Based Release Gates for Mini Apps in TypeScript
Mini-app platforms can shorten the path from a business idea to a customer-facing service. That advantage disappears when every release enters the same manual approval queue. It also becomes dangerous when teams respond by bypassing the queue.
A better release system distinguishes routine changes from changes that alter the host application's risk boundary. Low-risk work can proceed through a self-service path after automated checks. A new service using approved capabilities may require a platform review. New payment, identity, data, or native-device access should reach security and risk specialists.
This article builds a small TypeScript policy evaluator for those three lanes. The rules are examples, not a compliance standard. The important design choices are:
- release requests declare their intent and evidence;
- certain conditions are categorical blockers;
- policy decisions are deterministic and auditable;
- a low score never cancels an unapproved capability;
- policy evaluation stays separate from deployment execution.
Model the release request
Start with explicit types. Free-form tickets are difficult to validate consistently, so the request should name the change types, data classes, host capabilities, evidence, and people involved.
type ChangeType =
| "content"
| "configuration"
| "new_service"
| "host_api"
| "payment"
| "identity";
type DataClass = "public" | "internal" | "personal" | "sensitive";
type Evidence =
| "owner_declared"
| "schema_valid"
| "tests_passed"
| "security_scan_passed"
| "artifact_signed"
| "rollback_ready"
| "monitoring_ready"
| "privacy_assessment";
type ReleaseLane =
| "self_service"
| "platform_review"
| "security_review";
interface ChangeRequest {
requestId: string;
serviceId: string;
requestedBy: string;
approvedBy?: string;
changeTypes: ChangeType[];
dataClasses: DataClass[];
requestedCapabilities: string[];
automatedEvidence: Evidence[];
templateId?: string;
}
interface PolicyContext {
approvedTemplates: Set<string>;
allowlistedCapabilities: Set<string>;
}
interface PolicyDecision {
requestId: string;
lane: ReleaseLane;
allowed: boolean;
reasons: string[];
missingEvidence: Evidence[];
evaluatedAt: string;
policyVersion: string;
}
The approvedBy field is optional because a self-service request may not need a human approver. When an approval is required, the evaluator will prevent the requester from approving their own release.
Define evidence by lane
Each lane requires a different evidence set. The standard path should still prove ownership, validity, testing, integrity, and recoverability.
const EVIDENCE_BY_LANE: Record<ReleaseLane, Evidence[]> = {
self_service: [
"owner_declared",
"schema_valid",
"tests_passed",
"artifact_signed",
"rollback_ready",
],
platform_review: [
"owner_declared",
"schema_valid",
"tests_passed",
"security_scan_passed",
"artifact_signed",
"rollback_ready",
"monitoring_ready",
],
security_review: [
"owner_declared",
"schema_valid",
"tests_passed",
"security_scan_passed",
"artifact_signed",
"rollback_ready",
"monitoring_ready",
"privacy_assessment",
],
};
In a real system, evidence should refer to immutable results: a build identifier, scan report digest, test run, signature, policy version, and deployment candidate. A boolean typed into a form is a declaration, not proof.
Classify boundary changes first
The evaluator should look for hard boundaries before considering convenience or speed. New payment and identity behavior always enter the security-review lane in this example. So do sensitive data and capabilities outside the host allowlist.
function includesAny<T>(items: T[], candidates: T[]): boolean {
return candidates.some((candidate) => items.includes(candidate));
}
function classifyLane(
request: ChangeRequest,
context: PolicyContext,
): { lane: ReleaseLane; reasons: string[] } {
const reasons: string[] = [];
const unapprovedCapabilities = request.requestedCapabilities.filter(
(capability) => !context.allowlistedCapabilities.has(capability),
);
if (unapprovedCapabilities.length > 0) {
reasons.push(
`Unapproved capabilities: ${unapprovedCapabilities.join(", ")}`,
);
}
if (includesAny(request.changeTypes, ["payment", "identity"])) {
reasons.push("Payment or identity boundary changes require security review");
}
if (request.dataClasses.includes("sensitive")) {
reasons.push("Sensitive data use requires security and privacy review");
}
if (reasons.length > 0) {
return { lane: "security_review", reasons };
}
const isTemplateApproved =
request.templateId !== undefined &&
context.approvedTemplates.has(request.templateId);
const onlyRoutineChanges = request.changeTypes.every((change) =>
["content", "configuration"].includes(change),
);
const dataStaysRoutine = request.dataClasses.every((dataClass) =>
["public", "internal"].includes(dataClass),
);
if (isTemplateApproved && onlyRoutineChanges && dataStaysRoutine) {
return {
lane: "self_service",
reasons: ["Change stays within an approved template and risk boundary"],
};
}
return {
lane: "platform_review",
reasons: ["New service or non-routine change within approved capabilities"],
};
}
This function deliberately avoids a numeric score. An unapproved camera API should not become acceptable because the change also has good test coverage. Tests and permissions answer different questions.
Evaluate evidence and separation of duties
Classification decides who needs to inspect a release. Authorization decides whether it may proceed now.
const POLICY_VERSION = "2026-08-28.1";
function missingEvidence(
lane: ReleaseLane,
supplied: Evidence[],
): Evidence[] {
return EVIDENCE_BY_LANE[lane].filter(
(required) => !supplied.includes(required),
);
}
export function evaluateRelease(
request: ChangeRequest,
context: PolicyContext,
now = new Date(),
): PolicyDecision {
const classification = classifyLane(request, context);
const missing = missingEvidence(
classification.lane,
request.automatedEvidence,
);
const reasons = [...classification.reasons];
if (missing.length > 0) {
reasons.push(`Missing evidence: ${missing.join(", ")}`);
}
const needsHumanApproval = classification.lane !== "self_service";
const hasApproval = request.approvedBy !== undefined;
const selfApproval =
hasApproval && request.approvedBy === request.requestedBy;
if (needsHumanApproval && !hasApproval) {
reasons.push("The selected lane requires a named approver");
}
if (selfApproval) {
reasons.push("Requester and approver must be different people");
}
const allowed =
missing.length === 0 &&
(!needsHumanApproval || hasApproval) &&
!selfApproval;
return {
requestId: request.requestId,
lane: classification.lane,
allowed,
reasons,
missingEvidence: missing,
evaluatedAt: now.toISOString(),
policyVersion: POLICY_VERSION,
};
}
The result explains the decision. Store it next to the artifact digest and deployment record. An auditor or incident responder should be able to reconstruct which policy ran, which evidence existed, and why the lane was selected.
Try three requests
Create a small policy context and a helper containing the evidence required by the standard path.
const context: PolicyContext = {
approvedTemplates: new Set(["campaign-v3", "service-card-v2"]),
allowlistedCapabilities: new Set([
"analytics.read",
"notifications.send",
"profile.basic.read",
]),
};
const standardEvidence: Evidence[] = [
"owner_declared",
"schema_valid",
"tests_passed",
"artifact_signed",
"rollback_ready",
];
const campaign: ChangeRequest = {
requestId: "rel-101",
serviceId: "summer-rewards",
requestedBy: "business:rewards-team",
changeTypes: ["content", "configuration"],
dataClasses: ["public"],
requestedCapabilities: ["analytics.read"],
automatedEvidence: standardEvidence,
templateId: "campaign-v3",
};
console.log(evaluateRelease(campaign, context));
// lane: self_service, allowed: true
A new service using approved capabilities enters platform review. It needs additional evidence and a separate approver.
const service: ChangeRequest = {
requestId: "rel-102",
serviceId: "appointment-booking",
requestedBy: "product:local-services",
approvedBy: "platform:on-call-owner",
changeTypes: ["new_service"],
dataClasses: ["personal"],
requestedCapabilities: ["profile.basic.read", "notifications.send"],
automatedEvidence: [
...standardEvidence,
"security_scan_passed",
"monitoring_ready",
],
};
console.log(evaluateRelease(service, context));
// lane: platform_review, allowed: true
An unapproved native capability moves directly to security review. Missing privacy evidence blocks it.
const identityService: ChangeRequest = {
requestId: "rel-103",
serviceId: "partner-identity-check",
requestedBy: "partner:onboarding-team",
approvedBy: "security:reviewer",
changeTypes: ["identity", "host_api"],
dataClasses: ["sensitive"],
requestedCapabilities: ["camera.native", "profile.basic.read"],
automatedEvidence: [
...standardEvidence,
"security_scan_passed",
"monitoring_ready",
],
};
console.log(evaluateRelease(identityService, context));
// lane: security_review, allowed: false
// missingEvidence includes privacy_assessment
Test the rules, including the uncomfortable cases
Policy code deserves tests that protect its safety properties. Vitest works well for this small example.
import { describe, expect, it } from "vitest";
import { evaluateRelease } from "./release-policy";
describe("release policy", () => {
it("allows a complete routine campaign on the self-service lane", () => {
const decision = evaluateRelease(campaign, context);
expect(decision.lane).toBe("self_service");
expect(decision.allowed).toBe(true);
});
it("routes an unapproved capability to security review", () => {
const decision = evaluateRelease(identityService, context);
expect(decision.lane).toBe("security_review");
expect(decision.allowed).toBe(false);
expect(decision.reasons.join(" ")).toContain("camera.native");
});
it("rejects self-approval on a reviewed lane", () => {
const request = {
...service,
approvedBy: service.requestedBy,
};
const decision = evaluateRelease(request, context);
expect(decision.allowed).toBe(false);
expect(decision.reasons).toContain(
"Requester and approver must be different people",
);
});
it("records the policy version for audit", () => {
const decision = evaluateRelease(campaign, context);
expect(decision.policyVersion).toBe("2026-08-28.1");
});
});
Add tests for empty fields, unknown change types at the API boundary, policy-version migrations, unavailable evidence providers, expired approvals, and emergency rollback. Treat an evaluation error as a blocked release. A policy service that fails open can turn an operational incident into a security incident.

Keep evaluation separate from deployment
The evaluator should not deploy the mini app. Instead, return a signed or otherwise tamper-evident decision that the deployment controller verifies. This separation makes it easier to test the policy and restrict deployment credentials.
A production flow might look like this:
- The build pipeline produces a signed artifact and evidence references.
- The requester submits a structured change manifest.
- The policy service selects a lane and reports missing requirements.
- Human approval is added only when the lane requires it.
- The policy is evaluated again against the immutable artifact.
- The deployment controller verifies the decision and promotes the artifact.
- Runtime monitoring records health, version, audience, and capability use.
- Rollback or remote suspension remains available to the host operator.
Re-evaluation is important. An approval for artifact A must not authorize artifact B, even if the service ID is unchanged.
Treat capabilities as contracts, not strings
The example uses a Set<string> to keep the code readable. A production capability registry should carry enough metadata for a meaningful decision. Each capability can declare its owner, supported versions, data classification, permitted service types, consent requirement, rate limit, and default review lane.
For example, profile.basic.read might expose only a stable customer identifier and display name. profile.financial.read would represent a different data boundary and should not inherit the same policy merely because both names begin with profile.
Version the capability contract as well as the policy. If a host API starts returning an additional field, existing mini apps should not receive it silently. The platform can keep the old contract available, require explicit adoption of the new version, and run the release evaluator again. That turns capability access into a reviewable dependency rather than an ambient privilege.
The registry should also reject unknown capabilities by default. Avoid wildcard permissions such as device.* or profile.* unless the runtime can expand and record the exact operations used. Broad scopes make the initial integration convenient but weaken both audit evidence and incident containment.
Make evidence verifiable and short-lived
Evidence has a lifecycle. A security scan from last month may say little about an artifact built today. A privacy assessment may cover one data flow but not a newly added partner destination. An approval can also expire when the service owner, policy, artifact, or capability contract changes.
Instead of storing only evidence names, use references with at least these fields:
- evidence type and producer;
- artifact or source revision covered;
- result and relevant findings;
- creation and expiry timestamps;
- integrity digest or signature;
- policy version that requested it.
The evaluator can then require fresh, matching evidence. This also prevents a subtle failure mode in which a team attaches a successful test result from a different build.
Keep policy results immutable. If a reviewer grants an exception, record the exact blocker, compensating control, owner, expiration date, and affected artifact. Future policy runs can recognize the exception without erasing the reason it existed. An expired exception should fail closed and return to its normal lane.
Design the runtime response before enabling self-service
Release authorization is only one moment in the service lifecycle. The host application remains responsible for what runs inside it, so the platform needs post-release controls that operate at mini-app speed.
At minimum, record the active service version, audience, requested capabilities, health state, and current owner. Support progressive exposure through employee, pilot, percentage, and general-availability stages. Connect health thresholds to an automatic pause or rollback where the failure is unambiguous, and provide a human-operated suspension control for cases involving fraud, privacy, harmful content, or partner failure.
Test that control regularly. A kill switch that depends on an unavailable admin service or an outdated service identifier offers false reassurance. Recovery exercises should verify that the platform can stop new sessions, handle active sessions safely, preserve evidence, notify owners, and restore a known-good version.
Emergency changes need their own explicit path. They may relax normal timing or approval requirements, but they should tighten observability, scope, expiry, and retrospective review. Hiding emergency behavior inside the standard lane corrupts the data used to improve the policy.
Protect the policy service itself
Once deployments depend on this evaluator, its repository and runtime become high-value control points. Require reviewed changes to policy code, signed releases, least-privilege credentials, and an append-only decision log. Separate the people who change policy from the people who deploy services where regulation or internal policy requires it.
Monitor the evaluator for unavailable evidence providers, unusual approval patterns, repeated exception requests, and sudden shifts between lanes. A surge in security-review classifications may indicate a real product change, an overly broad new capability, or a broken registry. Policy metrics should start an investigation, not automatically loosen the boundary.
Evolve the policy from real release history
Do not begin with hundreds of rules. Review recent releases and find a frequent, low-risk pattern with repeated checks. A seasonal campaign or simple service page can be a good first candidate. Encode the smallest useful standard path, observe failures and exceptions, then expand it.
Measure throughput and stability together. Lead time and approval waiting time show whether the path is faster. Change failure, rollback, recovery time, and rework show whether it remains safe. Also track the share of requests using each lane and the reasons for escalation. If most “standard” releases require exceptions, the path may not reflect actual work.
Risk-based gates make routine governance decisions repeatable and reserve specialist attention for boundary changes. For a mini-app platform, that is the foundation of useful self-service: teams can move quickly because the host already knows the limits, captures the evidence, and can recover when a release behaves unexpectedly.
Top comments (0)