NNO Stacks
Documentation for NNO Stacks
Date: 2026-03-30 Status: Detailed Design Parent: System Architecture — Section 3, Layer 0 (NNO Core)
Table of Contents
- Overview
- StackDefinition & StackManifest
- Shared Resource Model
- Stack Lifecycle
- Feature Resolution within a Stack
- Stack Permissions
- Multiple Stacks per Platform
- ShellContext.stack — Runtime Injection
- Phase 1 vs Phase 2
1. Overview [Phase 1]
A Stack is a named, versioned collection of NNO-built features that provision and operate together, sharing a common set of Cloudflare primitive resources (D1, Workers, R2, KV). Stacks are the primary deployment unit for multi-feature applications on NNO.
Why Stacks
Without stacks, each feature activates independently and provisions its own CF resources. This creates friction when multiple related features need to operate as a cohesive application — for example, a CRM requiring a shared database, a shared Worker, and a UI across three feature packages. Every feature ends up with its own isolated D1, creating data silos and requiring cross-service joins via HTTP.
Stacks solve this by declaring a single shared resource namespace for a group of features:
- One D1 (optional) — all features in the stack share one database, namespaced by table prefix
- One R2 bucket (optional) — all features share one bucket, namespaced by key prefix
- One KV namespace (optional) — all features share one KV, namespaced by key prefix
- Per-feature Workers — each feature still deploys its own Worker, but all Workers receive the same shared resource bindings at activation time
Two Creation Paths
Path A — NNO Stack Template (from Stack Registry):
NNO operators author and publish stack definitions to services/stack-registry. Platform admins activate a template; the provisioning service creates the stack instance and provisions shared resources once.
Path B — Platform-local Stack (custom composition):
Platform admins create a named stack in Zero UI or via CLI, selecting which NNO features to include. Stored directly in the registry as a stacks record with template_id left null (no registry template).
What a Stack is NOT
- A Stack does not contain feature code — all feature packages are NNO-built and exist in the monorepo
- A Stack is not a new deployment unit for the shell — the console shell still bundles features individually
- Stacks do not replace standalone feature activation — features remain independently activatable for backward compatibility
ASCII Diagram: Stack Instance Model
Platform (k3m9p2xw7q)
│
├── StackInstance: saas-starter@1.0.0 (from registry)
│ ├── SharedResources
│ │ ├── D1: k3m9p2xw7q-saas-starter-db-prod
│ │ └── KV: k3m9p2xw7q-saas-starter-kv-prod
│ ├── FeatureActivation: billing@1.0.0
│ │ └── Worker: k3m9p2xw7q-prod-billing (→ STACK_DB, STACK_KV)
│ ├── FeatureActivation: settings@1.0.0
│ │ └── (UI only, no Worker)
│ └── FeatureActivation: analytics@1.0.0
│ └── Worker: k3m9p2xw7q-prod-analytics (→ STACK_DB)
│
├── StackInstance: custom-ops (platform-local, no template)
│ ├── SharedResources
│ │ └── D1: k3m9p2xw7q-custom-ops-db-prod
│ └── FeatureActivation: zero@0.1.0
│ └── (UI only, no Worker — routes through gateway)
│
└── StandaloneFeature: billing@1.0.0 (backward compat, no stack)
└── D1: k3m9p2xw7q-prod-billing-db (per-feature, isolated)2. StackDefinition & StackManifest [Phase 1]
All Stack types are defined in packages/sdk and exported from @neutrino-io/sdk/types.
StackFeatureRef
// packages/sdk/src/types/stack.ts
export interface StackFeatureRef {
featureId: string; // NNO feature ID, e.g. 'billing', 'analytics'
required: boolean; // false = stack activates without this feature if unavailable
config?: Record<string, unknown>; // Feature-level overrides within stack context
}StackResourceRequirements
export interface StackResourceRequirements {
sharedD1?: boolean; // Provision one D1 shared across all stack features
sharedR2?: boolean; // Provision one R2 bucket shared across stack features
sharedKV?: boolean; // Provision one KV namespace shared across stack features
worker?: boolean; // Deploy a stack-level orchestration Worker
pages?: boolean; // CF Pages project for the stack's front-end
queue?: boolean; // Message queue for the stack
minimumPlan?: "starter" | "growth" | "scale";
}StackDefinition
The primary type used by NNO operators to define a stack template:
export interface StackDefinition {
id: string; // kebab-case, e.g. 'saas-starter'
version: string; // semver
displayName: string; // e.g. 'SaaS Starter Stack'
description: string;
icon?: string; // Lucide icon name
features: StackFeatureRef[]; // Ordered list of features in this stack
resources: StackResourceRequirements;
permissions: string[]; // Stack-level gates, e.g. ['saas-starter:access']
requiresService?: boolean; // Deploy a stack-level orchestration Worker
serviceEnvKey?: string; // Env var for the orchestration Worker URL
}StackManifest
The build-time discovery contract for stacks (used by the shell's Vite plugin):
export interface StackManifest {
id: string;
name: string; // Display name
version: string; // Semver
description: string;
icon?: string;
features: StackFeatureRef[];
resources: StackResourceRequirements;
domain: string; // Business domain grouping
type: "system" | "business";
group: string; // Sidebar group heading
}StackInstance
Runtime context (see §8 — designed but not yet injected into ShellContext):
export interface StackInstance {
id: string; // stack instance ID (from registry)
stackId: string; // template ID or 'local' for platform-local stacks
name: string;
version: string;
isLocal: boolean;
sharedResources: {
d1Id?: string;
r2Name?: string;
kvId?: string;
workerName?: string;
};
}Example: saas-starter Stack Template
// Published to services/stack-registry by NNO operators
const saasStarterStack: StackDefinition = {
id: "saas-starter",
version: "1.0.0",
displayName: "SaaS Starter Stack",
description: "Complete SaaS starter with billing, settings, and analytics.",
icon: "Layers",
features: [
{ featureId: "billing", required: true },
{ featureId: "settings", required: true },
{ featureId: "analytics", required: false }, // Optional — stack activates without it
],
resources: {
sharedD1: true,
sharedKV: true,
minimumPlan: "growth",
},
permissions: ["saas-starter:access"],
};Example: analytics-pro Stack Template
const analyticsProStack: StackDefinition = {
id: "analytics-pro",
version: "1.0.0",
displayName: "Analytics Pro Stack",
description:
"Advanced analytics suite with data warehouse and export features.",
icon: "BarChart3",
features: [
{ featureId: "analytics", required: true },
{ featureId: "data-export", required: true },
{ featureId: "dashboards", required: false },
],
resources: {
sharedD1: true,
sharedR2: true,
sharedKV: true,
minimumPlan: "growth",
},
permissions: ["analytics-pro:access"],
};3. Shared Resource Model [Phase 1]
Table Prefix Convention (sharedD1)
When resources.sharedD1: true, a single D1 database is provisioned for the entire stack. Each feature namespaces its tables using the prefix \{featureId\}__:
-- billing feature tables in the shared stack D1
CREATE TABLE billing__subscriptions ( ... );
CREATE TABLE billing__invoices ( ... );
CREATE TABLE billing__events ( ... );
-- analytics feature tables in the same D1
CREATE TABLE analytics__events ( ... );
CREATE TABLE analytics__sessions ( ... );
CREATE TABLE analytics__reports ( ... );This convention is enforced by the NNO schema tooling — migration files for stack-aware features must prefix all table names.
Path Prefix Convention (sharedR2)
When resources.sharedR2: true, all features in the stack share one R2 bucket. Features prefix their R2 paths with \{featureId\}/:
analytics/exports/2026-01/report.csv
analytics/exports/2026-02/report.csv
crm/uploads/contacts-import.csv
crm/media/avatar-abc123.pngKey Prefix Convention (sharedKV)
When resources.sharedKV: true, features prefix all KV keys with \{featureId\}::
billing:quota:k3m9p2xw7q
billing:stripe-webhook-state
analytics:cache:dashboard-k3m9p2xw7q
analytics:last-syncPer-Stack Resource Isolation
Each stack instance gets its own resource namespace. There is no cross-stack resource sharing — two stack instances on the same platform each get their own D1, R2, and KV:
Stack: saas-starter → D1: k3m9p2xw7q-saas-starter-db-prod
Stack: custom-ops → D1: k3m9p2xw7q-custom-ops-db-prodCross-stack data access must go through feature API calls, not direct DB access.
Worker Binding Injection
The provisioning service injects shared resource bindings into each feature Worker at activation time. Feature Workers receive:
| Binding | Value | Condition |
|---|---|---|
STACK_DB | Shared D1 binding | resources.sharedD1: true |
STACK_STORAGE | Shared R2 binding | resources.sharedR2: true |
STACK_KV | Shared KV binding | resources.sharedKV: true |
STACK_ID | Stack instance ID string | Always (when activated in a stack) |
STACK_WORKER | Stack orchestration Worker binding | resources.worker: true |
Standalone features (activated without a stack) receive none of these bindings — they provision and bind their own isolated resources as before.
4. Stack Lifecycle [Phase 1]
States
A stack instance progresses through these states:
┌──────────┐
│ pending │ ← stacks record created
└────┬─────┘
│ PROVISION_STACK job enqueued
│ (shared resources provisioned,
│ feature sub-jobs enqueued)
┌─────────┴──────────┐
│ │
┌────▼────┐ ┌─────▼──────┐
│ active │ │ failed │
└────┬────┘ └────────────┘
│
│ DEACTIVATE_STACK job
┌────▼──────────┐
│ deactivating │ ← planned; not yet enforced by schema
└────┬──────────┘
│
┌────▼──────────┐
│ deactivated │ ← planned; not yet enforced by schema
└───────────────┘Optional feature failures: When a required feature fails to enqueue during PROVISION_STACK, the job throws and the stack remains pending (or transitions to failed). When an optional feature fails, the executor logs a warning and continues — the stack still transitions to active. There is no degraded state in the current schema; partial activation is simply reported in the job steps log.
Deactivation states (deactivating, deactivated): The DEACTIVATE_STACK job type is defined in the provisioning service but the stacks table status column has no enum constraint. These state values are planned but not yet enforced by schema or executor.
Activation Trigger
Registry POST /platforms/:platformId/stacks
→ Validates StackDefinition (from Stack Registry or inline for local stacks)
→ Creates stacks record (status: pending)
→ Emits stack.activating event → PROVISION_QUEUE
→ Returns 202 { stackId, jobId }Deactivation Trigger
Registry DELETE /platforms/:platformId/stacks/:stackId
→ Validates no dependent stacks (Phase 2: cross-stack deps)
→ Updates stacks status to deactivating
→ Emits stack.deactivating event → PROVISION_QUEUE
→ Returns 202 { jobId }5. Feature Resolution within a Stack [Phase 1]
Required vs Optional Features
The required flag on StackFeatureRef controls stack activation behaviour:
| Feature type | Provisioning failure behaviour |
|---|---|
required: true | Any failure aborts the entire PROVISION_STACK job; stack remains pending / enters failed state |
required: false | Failure logs a warning and continues activating remaining features; stack still transitions to active (partial activation recorded in job steps) |
This allows stacks to activate partially and remain useful even when non-critical features are unavailable.
Feature Config Overrides
Each StackFeatureRef.config can override feature-level defaults within the stack context:
features: [
{
featureId: "analytics",
required: false,
config: {
retentionDays: 90, // Override analytics default (30)
enableRealtime: true, // Feature-specific flag
},
},
];Config overrides are passed to the feature Worker as Worker environment variables at activation time. Feature Workers should declare accepted config keys in their FeatureDefinition.
Feature Ordering
Features in StackDefinition.features[] are activated in order. This allows features that depend on shared schema changes from earlier features to activate after their dependencies. For example, a billing feature that creates billing__subscriptions should be listed before analytics if analytics queries that table.
6. Stack Permissions [Phase 1]
Stack-Level Access Gate
Each stack declares a permission in StackDefinition.permissions that controls platform-wide access to all features in the stack:
permissions: ["saas-starter:access"];A user without this permission cannot access any feature route that belongs to this stack instance, even if they hold individual feature permissions.
Feature-Level Permissions Still Enforced
Feature-level permissions (e.g., billing:manage, analytics:export) continue to be enforced within the stack context. Stack permissions are a coarse gate; feature permissions are fine-grained gates within the stack.
Permission evaluation order:
zero:access(operator gate — Zero UI only)\{stack-id\}:access(stack gate — all stack routes)\{feature-id\}:\{action\}(feature gate — individual routes and UI elements)
Stack Permission Assignment
Stack permissions are assigned through the same NNO IAM permission system as feature permissions. Platform admins can grant saas-starter:access to roles via the IAM API or via Zero UI.
7. Multiple Stacks per Platform [Phase 1]
A platform can have multiple stack instances active simultaneously. Each stack:
- Gets its own shared resource namespace (no cross-stack DB sharing)
- Has its own lifecycle (independent activation/deactivation)
- Has its own permission gate
ASCII Diagram: Platform with 2 Stacks
Platform: k3m9p2xw7q (AcmeCorp)
│
├── StackInstance A: saas-starter@1.0.0
│ ├── D1: k3m9p2xw7q-saas-starter-db-prod
│ ├── KV: k3m9p2xw7q-saas-starter-kv-prod
│ ├── billing@1.0.0 Worker → STACK_DB, STACK_KV
│ └── analytics@1.0.0 Worker → STACK_DB
│
├── StackInstance B: analytics-pro@1.0.0
│ ├── D1: k3m9p2xw7q-analytics-pro-db-prod
│ ├── R2: k3m9p2xw7q-analytics-pro-storage-prod
│ ├── KV: k3m9p2xw7q-analytics-pro-kv-prod
│ ├── analytics@1.0.0 Worker → STACK_DB, STACK_STORAGE, STACK_KV
│ ├── data-export@1.0.0 Worker → STACK_DB, STACK_STORAGE
│ └── dashboards@1.0.0 (UI only)
│
└── StandaloneFeature: settings@1.0.0
└── (UI only — no CF resources required)Feature Active in Multiple Stacks
The same feature (e.g., analytics@1.0.0) can be active in multiple stacks simultaneously. Each activation is tracked independently in feature_activations. Workers for each activation receive different STACK_DB, STACK_STORAGE, STACK_KV, and STACK_ID bindings. The feature code is the same; the data context is different.
Note: A feature can also be active as a standalone activation (no stack) simultaneously with its stack activations. These are fully isolated — different Worker instances, different resource bindings.
Stack Naming Conflict Prevention
The Registry enforces uniqueness on (platform_id, stack_name, environment) in the stacks table. A platform cannot have two active stack instances with the same name in the same environment.
8. ShellContext.stack — Runtime Injection [Designed, Not Yet Implemented]
Note: Stack context injection into
ShellContextis designed but not yet implemented in the SDK. Thestack?: StackInstancefield is not currently present on the liveShellContextinterface. The design below documents the intended future behaviour.
The console shell will pass StackInstance context to features that are activated within a stack. Features will be able to use useShell().stack to get their stack context and shared resource endpoint information.
Planned ShellContext Extension
// packages/sdk/src/feature/types.ts (target state — not yet implemented)
export interface ShellContext {
platform: { id: string; name: string };
tenant: { id: string; name: string; parentId: string | null };
user: { id: string; email: string; name: string; permissions: string[] };
features: { active: string[]; isActive: (id: string) => boolean };
navigate: (to: string) => void;
env: Record<string, string>;
// Stack context injection into ShellContext is designed but not yet implemented in the SDK.
// stack?: StackInstance;
}Usage in Feature Components
import { useShell } from '@neutrino-io/sdk/feature';
function BillingDashboard() {
const shell = useShell();
if (shell?.stack) {
// Feature is running in a stack context
console.log('Stack ID:', shell.stack.id);
console.log('Stack version:', shell.stack.version);
console.log('Shared D1:', shell.stack.sharedResources.d1Id);
} else {
// Feature is running as a standalone activation (no stack)
}
return <div>...</div>;
}When stack Will Be Present (Planned Behaviour)
Not yet implemented. The following describes the intended behaviour once
ShellContext.stackis added to the SDK.
ShellContext.stack will be populated by the shell when:
- The current route belongs to a feature that is part of an active stack instance
- The shell's feature manifest includes
stackInstanceIdfor the feature activation
ShellContext.stack will be undefined when:
- The feature was activated as a standalone (no stack)
- The feature is a core package that pre-dates the stack system (backward compat)
9. Phase 1 vs Phase 2 / Phase 3
| Area | Phase 1 (Current) | Phase 2 (Planned) |
|---|---|---|
| Stack templates | NNO operator creates via API (POST /api/v1/stacks) | Zero UI stack template editor |
| Platform-local stacks | Defined in code, CLI only (nno stack create) | Zero UI drag-and-drop composer |
| Cross-stack dependencies | Not supported | Stack can declare dependsOn: [stackId] |
| Stack upgrades | Manual: deactivate → activate new version | In-place version upgrade via provisioning |
| Resource migration | Not supported | D1 migration on stack upgrade |
| Stack telemetry | Not implemented | Per-stack usage dashboards in Zero UI |
| Feature config overrides | Stored in stack definition; not yet passed to Workers | Worker env injection at activation time |
ShellContext.stack | Not yet implemented — designed but not present in SDK (see §8) | Implement injection; shell wizard with resource preview on activation |
| Shared schema tooling | Prefix convention documented; not enforced by tooling | nno stack validate enforces prefix conventions |
| Stack deactivation | Deactivates all features; shared resources retained by default (see Provisioning §10) | deleteData: true deletes all shared CF resources (D1, R2, KV, Worker); per-feature D1s always preserved in Phase 1 |
10. Stack as a Monorepo Project [Updated for DNS architecture]
Stack = Monorepo Project
A Stack is not just a logical grouping of features — it is also the unit of code organisation. Each stack corresponds to a monorepo project scaffolded from the nno-stack-starter template. The template provides:
- A console shell app (
apps/console/) for any frontend apps in the stack - Service stubs (
services/) for backend Workers - Shared config (
src/config/features.config.ts,auth.config.ts,env.config.ts) - CI/CD via GitHub Actions →
wrangler pages deploy→ CF Pages
When the NNO CLI Service provisions a new stack for a platform, it creates a GitHub repo from this template and substitutes the stack-id and platform-id placeholders.
The default Stack
Every platform has a default stack created automatically at onboarding. The default stack:
- Always hosts the platform's auth service (
auth.svc.default.<pid>.nno.app) - Is the cookie anchor — the auth cookie domain
.<pid>.nno.appcovers all stacks on the platform - Uses the literal string
defaultin DNS hostnames and CF resource names (\{pid\}-default-auth) - In the registry database, the default stack has a real nano-id with
is_default = 1— the literaldefaultis only a DNS/resource-name alias
default is a reserved keyword for DNS and resource naming. Users cannot create a stack with the slug default. The isValidUserStackId('default') utility returns false.
Stack IDs in DNS vs. Registry
| Context | Value used | Example |
|---|---|---|
| DNS hostname | default (keyword) or 10-char nano-id | auth.svc.default.a1b2c3d4e5.nno.app |
| CF resource name | default (keyword) or 10-char nano-id | a1b2c3d4e5-default-auth |
| Registry database | real nano-id (with is_default = 1 for default) | r8n4t6y1z5 |
| Human display name | any string | "Marketing Stack" |
The registry resolves default to the actual nano-id when querying by stack name.
Entity Hierarchy
Platform (k3m9p2xw7q)
├── Tenant A (r8n4t6y1z5)
│ ├── Stack: default ← auto-created, hosts auth
│ │ └── auth.svc.default.k3m9p2xw7q.nno.app
│ └── Stack: x7y8z9w0q1 ← "Marketing"
│ ├── dashboard.app.x7y8z9w0q1.k3m9p2xw7q.nno.app
│ └── dashboard.svc.x7y8z9w0q1.k3m9p2xw7q.nno.app
└── Tenant B (w2q5m8n1p7)
└── Stack: default
└── auth.svc.default.k3m9p2xw7q.nno.app ← same platform authThe hierarchy is: Platform → Tenant → Stack → apps + services. Portal is no longer a special entity type — it is simply an app (type: app) deployed within a stack.
See dns-naming.md for the full DNS hostname convention.
Status: Detailed design — Stack architecture defined 2026-03-30
Implementation target: services/stack-registry/, services/registry/, services/provisioning/, packages/sdk/
Related: System Architecture · Feature Package SDK · Provisioning · Registry · Stack Registry