NNO Docs
Concepts

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

  1. Overview
  2. StackDefinition & StackManifest
  3. Shared Resource Model
  4. Stack Lifecycle
  5. Feature Resolution within a Stack
  6. Stack Permissions
  7. Multiple Stacks per Platform
  8. ShellContext.stack — Runtime Injection
  9. 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: [email protected] (from registry)
│   ├── SharedResources
│   │   ├── D1: k3m9p2xw7q-saas-starter-db-prod
│   │   └── KV: k3m9p2xw7q-saas-starter-kv-prod
│   ├── FeatureActivation: [email protected]
│   │   └── Worker: k3m9p2xw7q-prod-billing (→ STACK_DB, STACK_KV)
│   ├── FeatureActivation: [email protected]
│   │   └── (UI only, no Worker)
│   └── FeatureActivation: [email protected]
│       └── Worker: k3m9p2xw7q-prod-analytics (→ STACK_DB)

├── StackInstance: custom-ops (platform-local, no template)
│   ├── SharedResources
│   │   └── D1: k3m9p2xw7q-custom-ops-db-prod
│   └── FeatureActivation: [email protected]
│       └── (UI only, no Worker — routes through gateway)

└── StandaloneFeature: [email protected] (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.png

Key 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-sync

Per-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-prod

Cross-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:

BindingValueCondition
STACK_DBShared D1 bindingresources.sharedD1: true
STACK_STORAGEShared R2 bindingresources.sharedR2: true
STACK_KVShared KV bindingresources.sharedKV: true
STACK_IDStack instance ID stringAlways (when activated in a stack)
STACK_WORKERStack orchestration Worker bindingresources.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 typeProvisioning failure behaviour
required: trueAny failure aborts the entire PROVISION_STACK job; stack remains pending / enters failed state
required: falseFailure 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:

  1. zero:access (operator gate — Zero UI only)
  2. \{stack-id\}:access (stack gate — all stack routes)
  3. \{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: [email protected]
│   ├── D1:  k3m9p2xw7q-saas-starter-db-prod
│   ├── KV:  k3m9p2xw7q-saas-starter-kv-prod
│   ├── [email protected]   Worker → STACK_DB, STACK_KV
│   └── [email protected] Worker → STACK_DB

├── StackInstance B: [email protected]
│   ├── D1:  k3m9p2xw7q-analytics-pro-db-prod
│   ├── R2:  k3m9p2xw7q-analytics-pro-storage-prod
│   ├── KV:  k3m9p2xw7q-analytics-pro-kv-prod
│   ├── [email protected]     Worker → STACK_DB, STACK_STORAGE, STACK_KV
│   ├── [email protected]   Worker → STACK_DB, STACK_STORAGE
│   └── [email protected]    (UI only)

└── StandaloneFeature: [email protected]
    └── (UI only — no CF resources required)

Feature Active in Multiple Stacks

The same feature (e.g., [email protected]) 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 ShellContext is designed but not yet implemented in the SDK. The stack?: StackInstance field is not currently present on the live ShellContext interface. 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.stack is added to the SDK.

ShellContext.stack will be populated by the shell when:

  1. The current route belongs to a feature that is part of an active stack instance
  2. The shell's feature manifest includes stackInstanceId for the feature activation

ShellContext.stack will be undefined when:

  1. The feature was activated as a standalone (no stack)
  2. The feature is a core package that pre-dates the stack system (backward compat)

9. Phase 1 vs Phase 2 / Phase 3

AreaPhase 1 (Current)Phase 2 (Planned)
Stack templatesNNO operator creates via API (POST /api/v1/stacks)Zero UI stack template editor
Platform-local stacksDefined in code, CLI only (nno stack create)Zero UI drag-and-drop composer
Cross-stack dependenciesNot supportedStack can declare dependsOn: [stackId]
Stack upgradesManual: deactivate → activate new versionIn-place version upgrade via provisioning
Resource migrationNot supportedD1 migration on stack upgrade
Stack telemetryNot implementedPer-stack usage dashboards in Zero UI
Feature config overridesStored in stack definition; not yet passed to WorkersWorker env injection at activation time
ShellContext.stackNot yet implemented — designed but not present in SDK (see §8)Implement injection; shell wizard with resource preview on activation
Shared schema toolingPrefix convention documented; not enforced by toolingnno stack validate enforces prefix conventions
Stack deactivationDeactivates 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.app covers all stacks on the platform
  • Uses the literal string default in 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 literal default is 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

ContextValue usedExample
DNS hostnamedefault (keyword) or 10-char nano-idauth.svc.default.a1b2c3d4e5.nno.app
CF resource namedefault (keyword) or 10-char nano-ida1b2c3d4e5-default-auth
Registry databasereal nano-id (with is_default = 1 for default)r8n4t6y1z5
Human display nameany 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 auth

The 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

On this page