Initial Sky Lattice scaffold: blueprint, platformctl, and docs.

Encode intent/decision/plan workflow for Snowflake platform delivery so engagements share a durable recipe instead of one-off LLM chats.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
VG 2026-07-15 01:48:36 -04:00
commit 611ad214fe
100 changed files with 4138 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
dist/
build/
.venv/
.env
*.tfstate
*.tfstate.*
.terraform/
customers/*/plans/
customers/*/observed/discover-latest.yaml
.DS_Store
.pytest_cache/

6
CHANGELOG.md Normal file
View File

@ -0,0 +1,6 @@
# Changelog
## 0.1.0 — 2026-07-15
- Initial Sky Lattice scaffold: blueprint, platformctl CLI, wizard catalogs, policies, Cursor skill, docs/decisions, customer landing guide.
- v0 planner emits deterministic object graphs; Terraform modules are stub-ready for snowflakedb provider.

1034
PLAN.md Normal file

File diff suppressed because it is too large Load Diff

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# Sky Lattice
Internal toolkit for standing up and evolving **Snowflake data platforms** across customers: durable **intent** + **decisions**, a versioned **blueprint**, deterministic **plan/apply**, and an optional **Cursor skill** front door.
Terraform remains the executor. Sky Lattice is the intent/decision/policy layer on top.
## Install
```bash
cd /path/to/sky-lattice
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
platformctl --version
platformctl doctor
```
## 5 commands to remember
| Command | Purpose |
|---|---|
| `platformctl wizard` | Guided setup / resume |
| `platformctl add domain …` | Incremental change |
| `platformctl plan` | Show what will be created |
| `platformctl apply` | Record/apply plan |
| `platformctl drift` | Compare inventory vs intent |
Also: `init`, `validate`, `discover`, `decision add|list`, `explain`, `destroy --env`, `skills install`, `doctor`.
## Quick start (greenfield demo)
```bash
platformctl init acme --mode greenfield
platformctl wizard --path customers/acme --answers examples/answers/acme.greenfield.yaml
platformctl validate --path customers/acme
platformctl plan --path customers/acme
platformctl apply --path customers/acme --yes
platformctl doctor --path customers/acme
```
Open `customers/acme` in Cursor — the `platform-ops` skill is already in `.cursor/skills/`.
## Brownfield
```bash
platformctl init globex --mode brownfield
# provide observed/inventory.yaml then:
platformctl discover --path customers/globex --inventory examples/answers/globex.inventory.yaml
platformctl wizard --path customers/globex --mode brownfield --answers examples/answers/globex.brownfield.yaml
platformctl plan --path customers/globex
```
## Layout
```text
blueprint/ # versioned IP: modules, schemas, wizard, policies, skills
src/skylattice/ # platformctl (Python)
customers/ # generated customer projects (demo)
docs/ # landing, recipes, product decisions
PLAN.md # design plan (reference)
```
## Customer environment landing
See [docs/customer-landing/README.md](docs/customer-landing/README.md). Default: customer gets git repo + CI + secrets + Snowflake automation user; blueprint stays pinned/versioned.
## LLM / skills
Skills drive `platformctl` and customer files — they do not own Snowflake. See skill at `blueprint/skills/platform-ops/`.
## License
Internal use — adapt as needed for your organization.

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.1.0

5
blueprint/CHANGELOG.md Normal file
View File

@ -0,0 +1,5 @@
# Blueprint changelog
## 0.1.0
Initial blueprint: environment, database_zones, rbac, warehouses, service_principals, monitoring modules; intent/decision/discover schemas; greenfield+brownfield wizard catalogs; policy rules; platform-ops skill; customer-repo template.

1
blueprint/VERSION Normal file
View File

@ -0,0 +1 @@
0.1.0

View File

@ -0,0 +1,12 @@
standard:
description: Typical domain access — analysts read curated/marts; loaders write landing/raw.
analyst_zones_read: [curated, marts]
loader_zones_write: [landing, raw]
engineer_zones: [landing, raw, curated, marts]
restricted:
description: Tighter domain — prod curated read requires explicit override + decision.
analyst_zones_read: [marts]
loader_zones_write: [landing, raw]
engineer_zones: [landing, raw, curated]
require_decision_for:
- prod_curated_read

View File

@ -0,0 +1,19 @@
customer: example
blueprint: "0.1.0"
mode: brownfield
env_strategy: database_per_env
environments:
- prod
domains:
- name: legacy
access_profile: standard
zones: [raw, curated]
warehouses:
profile: standard_cost_saver
identity:
sso: planned
service_users: true
overrides: []
unmanaged: []
wizard:
answered: []

View File

@ -0,0 +1,21 @@
customer: example
blueprint: "0.1.0"
mode: greenfield
env_strategy: database_per_env
environments:
- dev
- test
- prod
domains:
- name: sales
access_profile: standard
zones: [landing, raw, curated, marts]
warehouses:
profile: standard_cost_saver
identity:
sso: planned
service_users: true
overrides: []
unmanaged: []
wizard:
answered: []

View File

@ -0,0 +1,10 @@
standard_cost_saver:
size: X-SMALL
auto_suspend: 60
auto_resume: true
initially_suspended: true
performance:
size: MEDIUM
auto_suspend: 120
auto_resume: true
initially_suspended: false

View File

@ -0,0 +1,35 @@
variable "prefix" {
type = string
}
variable "domain" {
type = string
}
variable "zones" {
type = list(string)
default = ["landing", "raw", "curated", "marts"]
}
# Placeholder resources replace with snowflakedb/snowflake provider resources when applying to a real account.
# Sky Lattice renders these as documentation + planned object graph for v0.
locals {
database_name = "${var.prefix}_${upper(var.domain)}"
schema_names = { for z in var.zones : z => upper(z) }
}
output "database_name" {
value = local.database_name
}
output "schemas" {
value = local.schema_names
}
output "planned_objects" {
value = concat(
["database:${local.database_name}"],
[for z, s in local.schema_names : "schema:${local.database_name}.${s}"]
)
}

View File

@ -0,0 +1,24 @@
variable "customer" {
type = string
}
variable "environment" {
type = string
}
variable "env_strategy" {
type = string
default = "database_per_env"
}
locals {
prefix = var.env_strategy == "database_per_env" ? "${upper(var.customer)}_${upper(var.environment)}" : upper(var.customer)
}
output "prefix" {
value = local.prefix
}
output "environment" {
value = var.environment
}

View File

@ -0,0 +1,20 @@
variable "prefix" {
type = string
}
variable "credit_quota" {
type = number
default = 100
}
locals {
monitor_name = "${var.prefix}_MONITOR"
}
output "resource_monitor" {
value = local.monitor_name
}
output "planned_objects" {
value = ["resource_monitor:${local.monitor_name}"]
}

View File

@ -0,0 +1,36 @@
variable "prefix" {
type = string
}
variable "domain" {
type = string
}
variable "access_profile" {
type = string
default = "standard"
}
variable "environments" {
type = list(string)
}
locals {
roles = {
analyst = "${var.prefix}_${upper(var.domain)}_ANALYST"
loader = "${var.prefix}_${upper(var.domain)}_LOADER"
engineer = "${var.prefix}_${upper(var.domain)}_ENGINEER"
}
}
output "roles" {
value = local.roles
}
output "access_profile" {
value = var.access_profile
}
output "planned_objects" {
value = [for k, v in local.roles : "role:${v}"]
}

View File

@ -0,0 +1,23 @@
variable "prefix" {
type = string
}
variable "enabled" {
type = bool
default = true
}
locals {
users = var.enabled ? {
terraform = "${var.prefix}_TF_SVC"
loader = "${var.prefix}_LOADER_SVC"
} : {}
}
output "service_users" {
value = local.users
}
output "planned_objects" {
value = [for k, v in local.users : "user:${v}"]
}

View File

@ -0,0 +1,33 @@
variable "prefix" {
type = string
}
variable "profile" {
type = string
default = "standard_cost_saver"
}
variable "profile_config" {
type = object({
size = string
auto_suspend = number
auto_resume = bool
initially_suspended = bool
})
}
locals {
warehouse_name = "${var.prefix}_WH"
}
output "warehouse_name" {
value = local.warehouse_name
}
output "config" {
value = var.profile_config
}
output "planned_objects" {
value = ["warehouse:${local.warehouse_name}"]
}

View File

@ -0,0 +1,29 @@
# Sky Lattice policy pack (v0): evaluated by platformctl validate.
# Format: simple YAML rules (OPA/Conftest can wrap these later).
version: 1
rules:
- id: override_requires_decision
description: Every intent override must reference an active decision_id.
severity: error
check: overrides_have_decisions
- id: unmanaged_requires_decision
description: Unmanaged live objects should reference a decision when intentionally deferred.
severity: warning
check: unmanaged_have_decisions
- id: restricted_prod_curated_read
description: Restricted domains cannot enable prod curated read without an override + decision.
severity: error
check: restricted_prod_curated_guard
- id: no_empty_domains
description: At least one domain is required.
severity: error
check: domains_non_empty
- id: blueprint_pin_present
description: Intent must pin a blueprint version.
severity: error
check: blueprint_pinned

View File

@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://skylattice.dev/schemas/decision.schema.json",
"title": "SkyLatticeDecision",
"type": "object",
"required": ["id", "applies_to", "rationale", "status", "created_at"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Stable decision id, e.g. 2026-07-15-finance-prod-read"
},
"applies_to": {
"type": "string",
"description": "Intent path or TF address this decision covers."
},
"rationale": { "type": "string", "minLength": 1 },
"alternatives_rejected": {
"type": "array",
"items": { "type": "string" },
"default": []
},
"status": {
"type": "string",
"enum": ["active", "superseded", "expired"],
"default": "active"
},
"expires_on": {
"type": ["string", "null"],
"description": "ISO date when this exception should be revisited."
},
"client_constraint": { "type": ["string", "null"] },
"created_at": { "type": "string" },
"created_by": { "type": ["string", "null"] },
"superseded_by": { "type": ["string", "null"] }
}
}

View File

@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://skylattice.dev/schemas/discover-report.schema.json",
"title": "SkyLatticeDiscoverReport",
"type": "object",
"required": ["customer", "generated_at", "objects", "summary"],
"properties": {
"customer": { "type": "string" },
"generated_at": { "type": "string" },
"source": {
"type": "string",
"enum": ["live", "inventory_file", "terraform_state", "merged"],
"default": "inventory_file"
},
"objects": {
"type": "array",
"items": {
"type": "object",
"required": ["kind", "name", "classification"],
"properties": {
"kind": {
"type": "string",
"enum": ["database", "schema", "role", "warehouse", "user", "grant"]
},
"name": { "type": "string" },
"classification": {
"type": "string",
"enum": ["in_sync", "drifted_managed", "unmanaged_live", "in_state_missing_live", "blueprint_match", "conflict"]
},
"tf_address": { "type": ["string", "null"] },
"notes": { "type": "string" }
}
}
},
"summary": {
"type": "object",
"properties": {
"total": { "type": "integer" },
"matched_pct": { "type": "number" },
"unmanaged": { "type": "integer" },
"conflicts": { "type": "integer" },
"drifted": { "type": "integer" }
}
}
}
}

View File

@ -0,0 +1,131 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://skylattice.dev/schemas/intent.schema.json",
"title": "SkyLatticeCustomerIntent",
"type": "object",
"required": ["customer", "blueprint", "environments", "domains"],
"additionalProperties": false,
"properties": {
"customer": {
"type": "string",
"minLength": 1,
"description": "Customer slug (lowercase, hyphen-safe)."
},
"blueprint": {
"type": "string",
"description": "Pinned blueprint version, e.g. 0.1.0"
},
"mode": {
"type": "string",
"enum": ["greenfield", "brownfield"],
"default": "greenfield"
},
"env_strategy": {
"type": "string",
"enum": ["database_per_env", "account_per_env"],
"default": "database_per_env",
"description": "How environments are isolated."
},
"environments": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": ["dev", "test", "prod", "sandbox"]
},
"uniqueItems": true
},
"domains": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": { "type": "string", "minLength": 1 },
"access_profile": {
"type": "string",
"enum": ["standard", "restricted"],
"default": "standard"
},
"zones": {
"type": "array",
"items": {
"type": "string",
"enum": ["landing", "raw", "curated", "marts", "shared"]
},
"default": ["landing", "raw", "curated", "marts"]
}
}
}
},
"warehouses": {
"type": "object",
"additionalProperties": false,
"properties": {
"profile": {
"type": "string",
"enum": ["standard_cost_saver", "performance"],
"default": "standard_cost_saver"
}
}
},
"identity": {
"type": "object",
"additionalProperties": false,
"properties": {
"sso": {
"type": "string",
"enum": ["none", "planned", "okta", "azure_ad", "other"],
"default": "planned"
},
"service_users": {
"type": "boolean",
"default": true
}
}
},
"overrides": {
"type": "array",
"description": "Structured deviations from blueprint defaults; each should link to a decision id.",
"items": {
"type": "object",
"required": ["path", "value", "decision_id"],
"additionalProperties": false,
"properties": {
"path": { "type": "string" },
"value": {},
"decision_id": { "type": "string" }
}
},
"default": []
},
"unmanaged": {
"type": "array",
"description": "Live objects discovered but not managed by Sky Lattice.",
"items": {
"type": "object",
"required": ["kind", "name"],
"properties": {
"kind": { "type": "string" },
"name": { "type": "string" },
"reason": { "type": "string" },
"decision_id": { "type": "string" }
}
},
"default": []
},
"wizard": {
"type": "object",
"description": "Wizard progress metadata for resume.",
"properties": {
"answered": {
"type": "array",
"items": { "type": "string" }
},
"last_run": { "type": "string" }
}
}
}
}

View File

@ -0,0 +1,48 @@
---
name: platform-ops
description: >-
Operate Sky Lattice customer platforms. Use when adding domains, changing RBAC,
running wizard/plan/apply/drift, recording decisions, or brownfield adopt for
Snowflake platform setup with intent.yaml.
---
# Sky Lattice Platform Ops
You are driving **Sky Lattice** for a customer project. You do **not** invent ad-hoc Snowflake SQL as the system of record.
## Required context
1. Open the **customer repo** as the workspace (contains `intent.yaml`, `decisions/`, `terraform/`).
2. Read `intent.yaml` and active files under `decisions/`.
3. Prefer invoking `platformctl` over hand-writing HCL/SQL.
## Workflow
1. Understand the request in Snowflake terms (domain, env, warehouse, who can read curated).
2. If it breaks a blueprint default → create/update a **decision** (`platformctl decision add`).
3. Patch `intent.yaml` (or run `platformctl wizard` / `platformctl add`).
4. Run `platformctl validate` then `platformctl plan`.
5. Show the human summary + plan; **do not apply** until the user explicitly approves.
6. On approval: `platformctl apply` or open a PR for customer CI.
## Forbidden
- Emitting unaudited `GRANT` / `DROP` scripts as the final artifact
- Re-running greenfield `init` on an existing customer to pick up late requirements
- Skipping decisions when adding overrides or unmanaged exceptions
## Commands cheat sheet
```bash
platformctl doctor
platformctl wizard --mode greenfield|brownfield|--resume
platformctl add domain <name>
platformctl validate
platformctl plan
platformctl apply
platformctl drift
platformctl discover
platformctl decision add --applies-to PATH --rationale "..."
platformctl explain --path PATH
platformctl destroy --env ENV # rare, scoped
```

View File

@ -0,0 +1,18 @@
# Examples
## Add a domain
User: "Add a finance domain with restricted prod curated read for month-end; revisit after SSO."
Actions:
1. `platformctl decision add --applies-to domains.finance --rationale "Month-end needs prod curated read; revisit after SSO" --expires 2026-10-01`
2. Add domain to `intent.yaml` with `access_profile: restricted` and override linked to that decision id.
3. `platformctl validate && platformctl plan`
4. Wait for approval → `platformctl apply`
## Resume partial intake
User: "SSO is ready with Okta now."
Actions: set `identity.sso: okta` via wizard resume or direct intent edit → validate → plan → apply.

View File

@ -0,0 +1,8 @@
# RBAC best practices (Sky Lattice blueprint)
- Prefer functional roles (analyst, loader, engineer) composed from access roles per zone.
- Never use `ACCOUNTADMIN` for day-to-day service automation after bootstrap.
- Restricted domains: analysts read `marts` by default; `curated` in prod needs override + decision.
- Environment isolation: default `database_per_env` unless the customer requires account-per-env (record a decision).
- Temporary exceptions must have `expires_on` when possible.
- Brownfield: leave legacy roles in `unmanaged` with a decision rather than silently rewriting them on day one.

View File

@ -0,0 +1,48 @@
---
name: platform-ops
description: >-
Operate Sky Lattice customer platforms. Use when adding domains, changing RBAC,
running wizard/plan/apply/drift, recording decisions, or brownfield adopt for
Snowflake platform setup with intent.yaml.
---
# Sky Lattice Platform Ops
You are driving **Sky Lattice** for a customer project. You do **not** invent ad-hoc Snowflake SQL as the system of record.
## Required context
1. Open the **customer repo** as the workspace (contains `intent.yaml`, `decisions/`, `terraform/`).
2. Read `intent.yaml` and active files under `decisions/`.
3. Prefer invoking `platformctl` over hand-writing HCL/SQL.
## Workflow
1. Understand the request in Snowflake terms (domain, env, warehouse, who can read curated).
2. If it breaks a blueprint default → create/update a **decision** (`platformctl decision add`).
3. Patch `intent.yaml` (or run `platformctl wizard` / `platformctl add`).
4. Run `platformctl validate` then `platformctl plan`.
5. Show the human summary + plan; **do not apply** until the user explicitly approves.
6. On approval: `platformctl apply` or open a PR for customer CI.
## Forbidden
- Emitting unaudited `GRANT` / `DROP` scripts as the final artifact
- Re-running greenfield `init` on an existing customer to pick up late requirements
- Skipping decisions when adding overrides or unmanaged exceptions
## Commands cheat sheet
```bash
platformctl doctor
platformctl wizard --mode greenfield|brownfield|--resume
platformctl add domain <name>
platformctl validate
platformctl plan
platformctl apply
platformctl drift
platformctl discover
platformctl decision add --applies-to PATH --rationale "..."
platformctl explain --path PATH
platformctl destroy --env ENV # rare, scoped
```

View File

@ -0,0 +1,18 @@
# Examples
## Add a domain
User: "Add a finance domain with restricted prod curated read for month-end; revisit after SSO."
Actions:
1. `platformctl decision add --applies-to domains.finance --rationale "Month-end needs prod curated read; revisit after SSO" --expires 2026-10-01`
2. Add domain to `intent.yaml` with `access_profile: restricted` and override linked to that decision id.
3. `platformctl validate && platformctl plan`
4. Wait for approval → `platformctl apply`
## Resume partial intake
User: "SSO is ready with Okta now."
Actions: set `identity.sso: okta` via wizard resume or direct intent edit → validate → plan → apply.

View File

@ -0,0 +1,8 @@
# RBAC best practices (Sky Lattice blueprint)
- Prefer functional roles (analyst, loader, engineer) composed from access roles per zone.
- Never use `ACCOUNTADMIN` for day-to-day service automation after bootstrap.
- Restricted domains: analysts read `marts` by default; `curated` in prod needs override + decision.
- Environment isolation: default `database_per_env` unless the customer requires account-per-env (record a decision).
- Temporary exceptions must have `expires_on` when possible.
- Brownfield: leave legacy roles in `unmanaged` with a decision rather than silently rewriting them on day one.

View File

@ -0,0 +1,26 @@
name: sky-lattice-plan-apply
on:
pull_request:
push:
branches: [main, master]
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform fmt
run: terraform fmt -check -recursive || true
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate || true
- name: Reminder
run: |
echo "Wire Snowflake credentials and a remote backend before real apply."
echo "platformctl plan generates planned object graph; terraform apply needs provider config."

View File

@ -0,0 +1,17 @@
# Customer project — managed by Sky Lattice
This repository is the durable memory for this customer's Snowflake platform:
- `intent.yaml` — what we mean to manage
- `decisions/` — why deviations exist
- `terraform/` — executor (compiled from intent)
- `.cursor/skills/platform-ops/` — LLM front door for Cursor
## Next steps
1. Fill or resume the wizard: `platformctl wizard --resume`
2. Validate: `platformctl validate`
3. Plan: `platformctl plan`
4. Apply via CI or `platformctl apply` (with credentials)
See `docs/` in the Sky Lattice product repo for customer landing checklist.

View File

@ -0,0 +1 @@
# Keep decisions here as YAML files: <id>.yaml

View File

@ -0,0 +1,21 @@
terraform {
required_version = ">= 1.5.0"
# Configure a remote backend in customer CI (S3/Azure/GCS). Local is fine for demos.
}
# Generated and updated by `platformctl plan`.
# Pin blueprint modules via relative path in monorepo demos, or a module registry in production.
locals {
intent = yamldecode(file("${path.module}/../intent.yaml"))
}
module "sky_lattice" {
source = "../../../blueprint/modules/environment"
customer = local.intent.customer
environment = try(local.intent.environments[0], "dev")
env_strategy = try(local.intent.env_strategy, "database_per_env")
}
# Per-domain and warehouse modules are rendered into planned_objects.tf.json by platformctl.

View File

@ -0,0 +1,18 @@
customer: reference
blueprint: "0.1.0"
mode: greenfield
env_strategy: database_per_env
environments: [dev, prod]
domains:
- name: sales
access_profile: standard
zones: [landing, raw, curated, marts]
warehouses:
profile: standard_cost_saver
identity:
sso: planned
service_users: true
overrides: []
unmanaged: []
wizard:
answered: [customer, environments, domains]

View File

@ -0,0 +1,69 @@
# Brownfield wizard — discover first, then adoption choices.
version: 1
mode: brownfield
prerequisites:
- discover
questions:
- id: customer
prompt: "What is the customer slug?"
intent_path: customer
type: string
required: true
- id: environments
prompt: "Which environments will Sky Lattice manage going forward? (comma-separated)"
intent_path: environments
type: list
default: [prod]
item_enum: [dev, test, prod, sandbox]
- id: adopt_domains
prompt: "Which domains should we manage now? (comma-separated; others stay unmanaged)"
intent_path: domains
type: domain_list
required: true
default_access_profile: standard
- id: keep_legacy_roles
prompt: "Legacy roles to leave unmanaged for now? (comma-separated names, or none)"
type: unmanaged_list
kind: role
decision_required: true
decision_prompt: "Why keep legacy role {name} unmanaged?"
default: none
- id: naming_conflicts
prompt: "Any naming conflicts to keep as-is until migration? (comma-separated object names, or none)"
type: unmanaged_list
kind: database
decision_required: true
decision_prompt: "Why leave {name} unmanaged / conflict deferred?"
default: none
- id: warehouse_profile
prompt: "Warehouse profile for newly managed warehouses?"
intent_path: warehouses.profile
type: choice
choices:
- value: standard_cost_saver
label: "Cost saver"
- value: performance
label: "Performance"
default: standard_cost_saver
- id: identity_sso
prompt: "SSO status?"
intent_path: identity.sso
type: choice
choices:
- value: none
label: "No SSO"
- value: planned
label: "SSO planned"
- value: okta
label: "Okta"
- value: azure_ad
label: "Azure AD"
- value: other
label: "Other"
default: planned

View File

@ -0,0 +1,76 @@
# Greenfield wizard — Snowflake vocabulary questions mapped to intent paths.
version: 1
mode: greenfield
questions:
- id: customer
prompt: "What is the customer slug? (lowercase, e.g. acme)"
intent_path: customer
type: string
required: true
- id: env_strategy
prompt: "How should environments be isolated?"
intent_path: env_strategy
type: choice
choices:
- value: database_per_env
label: "Databases per environment in one account (default)"
- value: account_per_env
label: "Separate Snowflake account per environment"
default: database_per_env
- id: environments
prompt: "Which environments do you need? (comma-separated: dev,test,prod)"
intent_path: environments
type: list
default: [dev, test, prod]
item_enum: [dev, test, prod, sandbox]
- id: domains
prompt: "List business domains / data products (comma-separated, e.g. sales,finance,ops)"
intent_path: domains
type: domain_list
required: true
default_access_profile: standard
- id: domain_access
prompt: "Any domain that needs restricted access? (comma-separated names, or none)"
type: restricted_domains
default: none
maps_to: domains[].access_profile
- id: warehouse_profile
prompt: "Warehouse profile?"
intent_path: warehouses.profile
type: choice
choices:
- value: standard_cost_saver
label: "Cost saver (X-SMALL, aggressive auto-suspend)"
- value: performance
label: "Performance (MEDIUM)"
default: standard_cost_saver
- id: identity_sso
prompt: "SSO status?"
intent_path: identity.sso
type: choice
choices:
- value: none
label: "No SSO (service users / local only)"
- value: planned
label: "SSO planned later"
- value: okta
label: "Okta now"
- value: azure_ad
label: "Azure AD now"
- value: other
label: "Other IdP now"
default: planned
- id: prod_curated_exceptions
prompt: "Any domain that needs prod curated read early (against restricted defaults)? (comma-separated or none)"
type: override_with_decision
override_path_template: "domains.{name}.prod_curated_read"
decision_required: true
decision_prompt: "Why is prod curated read allowed early for {name}? (stored as a decision)"
default: none

View File

@ -0,0 +1,18 @@
# Maps wizard answer ids to intent mutation helpers used by platformctl.
version: 1
helpers:
string: set_path
choice: set_path
list: set_list_path
domain_list: set_domains
restricted_domains: mark_restricted_domains
override_with_decision: add_override_and_decision
unmanaged_list: add_unmanaged_with_decision
policy_hooks:
- when: override_with_decision
require: decision
- when: unmanaged_list
require: decision
- when: access_profile == restricted
note: "prod curated read requires override + decision"

0
customers/.gitkeep Normal file
View File

View File

@ -0,0 +1,48 @@
---
name: platform-ops
description: >-
Operate Sky Lattice customer platforms. Use when adding domains, changing RBAC,
running wizard/plan/apply/drift, recording decisions, or brownfield adopt for
Snowflake platform setup with intent.yaml.
---
# Sky Lattice Platform Ops
You are driving **Sky Lattice** for a customer project. You do **not** invent ad-hoc Snowflake SQL as the system of record.
## Required context
1. Open the **customer repo** as the workspace (contains `intent.yaml`, `decisions/`, `terraform/`).
2. Read `intent.yaml` and active files under `decisions/`.
3. Prefer invoking `platformctl` over hand-writing HCL/SQL.
## Workflow
1. Understand the request in Snowflake terms (domain, env, warehouse, who can read curated).
2. If it breaks a blueprint default → create/update a **decision** (`platformctl decision add`).
3. Patch `intent.yaml` (or run `platformctl wizard` / `platformctl add`).
4. Run `platformctl validate` then `platformctl plan`.
5. Show the human summary + plan; **do not apply** until the user explicitly approves.
6. On approval: `platformctl apply` or open a PR for customer CI.
## Forbidden
- Emitting unaudited `GRANT` / `DROP` scripts as the final artifact
- Re-running greenfield `init` on an existing customer to pick up late requirements
- Skipping decisions when adding overrides or unmanaged exceptions
## Commands cheat sheet
```bash
platformctl doctor
platformctl wizard --mode greenfield|brownfield|--resume
platformctl add domain <name>
platformctl validate
platformctl plan
platformctl apply
platformctl drift
platformctl discover
platformctl decision add --applies-to PATH --rationale "..."
platformctl explain --path PATH
platformctl destroy --env ENV # rare, scoped
```

View File

@ -0,0 +1,18 @@
# Examples
## Add a domain
User: "Add a finance domain with restricted prod curated read for month-end; revisit after SSO."
Actions:
1. `platformctl decision add --applies-to domains.finance --rationale "Month-end needs prod curated read; revisit after SSO" --expires 2026-10-01`
2. Add domain to `intent.yaml` with `access_profile: restricted` and override linked to that decision id.
3. `platformctl validate && platformctl plan`
4. Wait for approval → `platformctl apply`
## Resume partial intake
User: "SSO is ready with Okta now."
Actions: set `identity.sso: okta` via wizard resume or direct intent edit → validate → plan → apply.

View File

@ -0,0 +1,8 @@
# RBAC best practices (Sky Lattice blueprint)
- Prefer functional roles (analyst, loader, engineer) composed from access roles per zone.
- Never use `ACCOUNTADMIN` for day-to-day service automation after bootstrap.
- Restricted domains: analysts read `marts` by default; `curated` in prod needs override + decision.
- Environment isolation: default `database_per_env` unless the customer requires account-per-env (record a decision).
- Temporary exceptions must have `expires_on` when possible.
- Brownfield: leave legacy roles in `unmanaged` with a decision rather than silently rewriting them on day one.

View File

@ -0,0 +1,26 @@
name: sky-lattice-plan-apply
on:
pull_request:
push:
branches: [main, master]
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform fmt
run: terraform fmt -check -recursive || true
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate || true
- name: Reminder
run: |
echo "Wire Snowflake credentials and a remote backend before real apply."
echo "platformctl plan generates planned object graph; terraform apply needs provider config."

21
customers/acme/README.md Normal file
View File

@ -0,0 +1,21 @@
# acme — Sky Lattice customer project
Blueprint pin: `0.1.0`
# Customer project — managed by Sky Lattice
This repository is the durable memory for this customer's Snowflake platform:
- `intent.yaml` — what we mean to manage
- `decisions/` — why deviations exist
- `terraform/` — executor (compiled from intent)
- `.cursor/skills/platform-ops/` — LLM front door for Cursor
## Next steps
1. Fill or resume the wizard: `platformctl wizard --resume`
2. Validate: `platformctl validate`
3. Plan: `platformctl plan`
4. Apply via CI or `platformctl apply` (with credentials)
See `docs/` in the Sky Lattice product repo for customer landing checklist.

View File

@ -0,0 +1 @@
# Keep decisions here as YAML files: <id>.yaml

View File

@ -0,0 +1,10 @@
id: 2026-07-15-domains-finance
applies_to: domains.finance
rationale: demo decision
alternatives_rejected: []
status: active
expires_on: '2026-10-01'
client_constraint: null
created_at: '2026-07-15T05:34:25.700769+00:00'
created_by: null
superseded_by: null

View File

@ -0,0 +1,54 @@
customer: acme
blueprint: 0.1.0
mode: greenfield
env_strategy: database_per_env
environments:
- dev
- prod
domains:
- name: sales
access_profile: standard
zones:
- landing
- raw
- curated
- marts
- name: finance
access_profile: restricted
zones:
- landing
- raw
- curated
- marts
- name: ops
access_profile: standard
zones:
- landing
- raw
- curated
- marts
- name: marketing
access_profile: standard
zones:
- landing
- raw
- curated
- marts
warehouses:
profile: standard_cost_saver
identity:
sso: planned
service_users: true
overrides: []
unmanaged: []
wizard:
answered:
- customer
- env_strategy
- environments
- domains
- domain_access
- warehouse_profile
- identity_sso
- prod_curated_exceptions
last_run: '2026-07-15T05:34:23.899022+00:00'

View File

@ -0,0 +1,21 @@
terraform {
required_version = ">= 1.5.0"
# Configure a remote backend in customer CI (S3/Azure/GCS). Local is fine for demos.
}
# Generated and updated by `platformctl plan`.
# Pin blueprint modules via relative path in monorepo demos, or a module registry in production.
locals {
intent = yamldecode(file("${path.module}/../intent.yaml"))
}
module "sky_lattice" {
source = "../../../blueprint/modules/environment"
customer = local.intent.customer
environment = try(local.intent.environments[0], "dev")
env_strategy = try(local.intent.env_strategy, "database_per_env")
}
# Per-domain and warehouse modules are rendered into planned_objects.tf.json by platformctl.

View File

@ -0,0 +1,19 @@
planned_objects: []
summary:
- '[dev] domain=sales profile=standard: db=ACME_DEV_SALES, zones=[''landing'', ''raw'',
''curated'', ''marts''], analyst_read=[''curated'', ''marts'']'
- '[dev] domain=finance profile=restricted: db=ACME_DEV_FINANCE, zones=[''landing'',
''raw'', ''curated'', ''marts''], analyst_read=[''marts'']'
- '[dev] domain=ops profile=standard: db=ACME_DEV_OPS, zones=[''landing'', ''raw'',
''curated'', ''marts''], analyst_read=[''curated'', ''marts'']'
- '[dev] domain=marketing profile=standard: db=ACME_DEV_MARKETING, zones=[''landing'',
''raw'', ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']'
- '[prod] domain=sales profile=standard: db=ACME_PROD_SALES, zones=[''landing'', ''raw'',
''curated'', ''marts''], analyst_read=[''curated'', ''marts'']'
- '[prod] domain=finance profile=restricted: db=ACME_PROD_FINANCE, zones=[''landing'',
''raw'', ''curated'', ''marts''], analyst_read=[''marts'']'
- '[prod] domain=ops profile=standard: db=ACME_PROD_OPS, zones=[''landing'', ''raw'',
''curated'', ''marts''], analyst_read=[''curated'', ''marts'']'
- '[prod] domain=marketing profile=standard: db=ACME_PROD_MARKETING, zones=[''landing'',
''raw'', ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']'
object_count: 72

View File

@ -0,0 +1,48 @@
---
name: platform-ops
description: >-
Operate Sky Lattice customer platforms. Use when adding domains, changing RBAC,
running wizard/plan/apply/drift, recording decisions, or brownfield adopt for
Snowflake platform setup with intent.yaml.
---
# Sky Lattice Platform Ops
You are driving **Sky Lattice** for a customer project. You do **not** invent ad-hoc Snowflake SQL as the system of record.
## Required context
1. Open the **customer repo** as the workspace (contains `intent.yaml`, `decisions/`, `terraform/`).
2. Read `intent.yaml` and active files under `decisions/`.
3. Prefer invoking `platformctl` over hand-writing HCL/SQL.
## Workflow
1. Understand the request in Snowflake terms (domain, env, warehouse, who can read curated).
2. If it breaks a blueprint default → create/update a **decision** (`platformctl decision add`).
3. Patch `intent.yaml` (or run `platformctl wizard` / `platformctl add`).
4. Run `platformctl validate` then `platformctl plan`.
5. Show the human summary + plan; **do not apply** until the user explicitly approves.
6. On approval: `platformctl apply` or open a PR for customer CI.
## Forbidden
- Emitting unaudited `GRANT` / `DROP` scripts as the final artifact
- Re-running greenfield `init` on an existing customer to pick up late requirements
- Skipping decisions when adding overrides or unmanaged exceptions
## Commands cheat sheet
```bash
platformctl doctor
platformctl wizard --mode greenfield|brownfield|--resume
platformctl add domain <name>
platformctl validate
platformctl plan
platformctl apply
platformctl drift
platformctl discover
platformctl decision add --applies-to PATH --rationale "..."
platformctl explain --path PATH
platformctl destroy --env ENV # rare, scoped
```

View File

@ -0,0 +1,18 @@
# Examples
## Add a domain
User: "Add a finance domain with restricted prod curated read for month-end; revisit after SSO."
Actions:
1. `platformctl decision add --applies-to domains.finance --rationale "Month-end needs prod curated read; revisit after SSO" --expires 2026-10-01`
2. Add domain to `intent.yaml` with `access_profile: restricted` and override linked to that decision id.
3. `platformctl validate && platformctl plan`
4. Wait for approval → `platformctl apply`
## Resume partial intake
User: "SSO is ready with Okta now."
Actions: set `identity.sso: okta` via wizard resume or direct intent edit → validate → plan → apply.

View File

@ -0,0 +1,8 @@
# RBAC best practices (Sky Lattice blueprint)
- Prefer functional roles (analyst, loader, engineer) composed from access roles per zone.
- Never use `ACCOUNTADMIN` for day-to-day service automation after bootstrap.
- Restricted domains: analysts read `marts` by default; `curated` in prod needs override + decision.
- Environment isolation: default `database_per_env` unless the customer requires account-per-env (record a decision).
- Temporary exceptions must have `expires_on` when possible.
- Brownfield: leave legacy roles in `unmanaged` with a decision rather than silently rewriting them on day one.

View File

@ -0,0 +1,26 @@
name: sky-lattice-plan-apply
on:
pull_request:
push:
branches: [main, master]
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- name: Terraform fmt
run: terraform fmt -check -recursive || true
- name: Terraform init
run: terraform init -backend=false
- name: Terraform validate
run: terraform validate || true
- name: Reminder
run: |
echo "Wire Snowflake credentials and a remote backend before real apply."
echo "platformctl plan generates planned object graph; terraform apply needs provider config."

View File

@ -0,0 +1,21 @@
# globex — Sky Lattice customer project
Blueprint pin: `0.1.0`
# Customer project — managed by Sky Lattice
This repository is the durable memory for this customer's Snowflake platform:
- `intent.yaml` — what we mean to manage
- `decisions/` — why deviations exist
- `terraform/` — executor (compiled from intent)
- `.cursor/skills/platform-ops/` — LLM front door for Cursor
## Next steps
1. Fill or resume the wizard: `platformctl wizard --resume`
2. Validate: `platformctl validate`
3. Plan: `platformctl plan`
4. Apply via CI or `platformctl apply` (with credentials)
See `docs/` in the Sky Lattice product repo for customer landing checklist.

View File

@ -0,0 +1 @@
# Keep decisions here as YAML files: <id>.yaml

View File

@ -0,0 +1,10 @@
id: 2026-07-15-unmanaged-finance-old
applies_to: unmanaged.role.FINANCE_OLD
rationale: Legacy finance role retained until SCIM cutover
alternatives_rejected: []
status: active
expires_on: null
client_constraint: null
created_at: '2026-07-15T05:35:27.959133+00:00'
created_by: null
superseded_by: null

View File

@ -0,0 +1,42 @@
customer: globex
blueprint: 0.1.0
mode: brownfield
env_strategy: database_per_env
environments:
- prod
domains:
- name: sales
access_profile: standard
zones:
- landing
- raw
- curated
- marts
- name: finance
access_profile: standard
zones:
- landing
- raw
- curated
- marts
warehouses:
profile: standard_cost_saver
identity:
sso: planned
service_users: true
overrides: []
unmanaged:
- kind: role
name: FINANCE_OLD
reason: Legacy finance role retained until SCIM cutover
decision_id: 2026-07-15-unmanaged-finance-old
wizard:
answered:
- customer
- environments
- adopt_domains
- keep_legacy_roles
- naming_conflicts
- warehouse_profile
- identity_sso
last_run: '2026-07-15T05:35:27.962263+00:00'

View File

@ -0,0 +1,9 @@
objects:
- kind: database
name: GLOBEX_PROD_SALES
- kind: role
name: FINANCE_OLD
- kind: warehouse
name: GLOBEX_PROD_WH
- kind: database
name: SOME_LEGACY_DB

View File

@ -0,0 +1,21 @@
terraform {
required_version = ">= 1.5.0"
# Configure a remote backend in customer CI (S3/Azure/GCS). Local is fine for demos.
}
# Generated and updated by `platformctl plan`.
# Pin blueprint modules via relative path in monorepo demos, or a module registry in production.
locals {
intent = yamldecode(file("${path.module}/../intent.yaml"))
}
module "sky_lattice" {
source = "../../../blueprint/modules/environment"
customer = local.intent.customer
environment = try(local.intent.environments[0], "dev")
env_strategy = try(local.intent.env_strategy, "database_per_env")
}
# Per-domain and warehouse modules are rendered into planned_objects.tf.json by platformctl.

View File

@ -0,0 +1,28 @@
planned_objects:
- warehouse:GLOBEX_PROD_WH
- resource_monitor:GLOBEX_PROD_MONITOR
- user:GLOBEX_PROD_TF_SVC
- user:GLOBEX_PROD_LOADER_SVC
- database:GLOBEX_PROD_SALES
- schema:GLOBEX_PROD_SALES.LANDING
- schema:GLOBEX_PROD_SALES.RAW
- schema:GLOBEX_PROD_SALES.CURATED
- schema:GLOBEX_PROD_SALES.MARTS
- role:GLOBEX_PROD_SALES_ANALYST
- role:GLOBEX_PROD_SALES_LOADER
- role:GLOBEX_PROD_SALES_ENGINEER
- database:GLOBEX_PROD_FINANCE
- schema:GLOBEX_PROD_FINANCE.LANDING
- schema:GLOBEX_PROD_FINANCE.RAW
- schema:GLOBEX_PROD_FINANCE.CURATED
- schema:GLOBEX_PROD_FINANCE.MARTS
- role:GLOBEX_PROD_FINANCE_ANALYST
- role:GLOBEX_PROD_FINANCE_LOADER
- role:GLOBEX_PROD_FINANCE_ENGINEER
summary:
- '[prod] domain=sales profile=standard: db=GLOBEX_PROD_SALES, zones=[''landing'',
''raw'', ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']'
- '[prod] domain=finance profile=standard: db=GLOBEX_PROD_FINANCE, zones=[''landing'',
''raw'', ''curated'', ''marts''], analyst_read=[''curated'', ''marts'']'
- unmanaged role:FINANCE_OLD (Legacy finance role retained until SCIM cutover)
object_count: 20

View File

@ -0,0 +1,11 @@
# Customer environment bring-up checklist
- [ ] Snowflake automation user + key pair created
- [ ] Secrets installed in CI / secret store
- [ ] TF state backend ready (customer-controlled preferred)
- [ ] Customer repo pushed with blueprint pin in `intent.yaml`
- [ ] `.cursor/skills/platform-ops` present (or `platformctl skills install`)
- [ ] First `platformctl validate` + `plan` succeed
- [ ] First apply path agreed (CI vs operator)
- [ ] Drift process documented (`platformctl drift` + inventory source)
- [ ] Runbook: add domain / decision / blueprint upgrade

View File

@ -0,0 +1,57 @@
# How To Land Sky Lattice in a Customer Environment
This guide is for operators bringing Sky Lattice to a new customer Snowflake account.
## Prerequisites
- Customer Snowflake account (or ability to create one)
- Customer git host (GitHub/GitLab) or agreement that you host the customer project
- Place to store secrets (CI secrets / Vault)
- Optional: Cursor for skill-based UX
## Steps
1. **Bootstrap automation identity (one-time, manual)**
As `ACCOUNTADMIN`, create a Terraform/service user with key-pair auth and roles sufficient for platform objects (typically a dedicated automation role; avoid day-to-day ACCOUNTADMIN after bootstrap).
2. **Store secrets**
Put the private key and account identifiers in the customers secret store / CI secrets. Never commit keys.
3. **Create the customer project**
On your operator machine:
```bash
platformctl init <customer> --mode greenfield # or brownfield
platformctl wizard --path customers/<customer>
platformctl validate --path customers/<customer>
platformctl plan --path customers/<customer>
```
4. **Push to customer git**
Push `customers/<customer>/` to their repository (or your managed repo per contract).
5. **Wire CI**
The template workflow under `.github/workflows/terraform.yml` is a starting point. Add credentials, remote TF state backend, and real `terraform plan/apply` gates.
6. **First apply**
Run through CI or `platformctl apply` once credentials and provider resources are wired.
7. **Hand off**
Point customer admins at recipes: add domain, grant patterns, drift. Approvers only need to read Terraform/plan summaries.
## Expected results
- Customer repo holds intent + decisions + terraform
- CI can plan changes via PRs
- Blueprint remains pinned by version; full blueprint source stays with you unless air-gapped vendoring is required
## Troubleshooting
- **No intent.yaml** — run from the customer project or pass `--path`
- **Policy errors on overrides** — add `platformctl decision add` and link `decision_id`
- **terraform missing** — fine for v0 graph apply; install Terraform when enabling live provider apply
## Related
- [Bring-up checklist](CHECKLIST.md)
- Recipes under `docs/recipes/`

View File

@ -0,0 +1,18 @@
# D001 — Product name and scope
- **Status:** active
- **Date:** 2026-07-15
## Decision
Call the product **Sky Lattice**. Scope is an internal platform toolkit: versioned Snowflake **blueprint** + per-customer **intent/decisions** + deterministic **plan/apply** on top of Terraform — not a Marketplace Native App and not “an AI that sets up Snowflake.”
## Why
Snowflake is a blank slate; repeatability comes from codifying our recommended platform and tweaking per client. Existing tools (Terraform provider, SnowDDL, etc.) are engines, not our delivery recipe.
## Alternatives rejected
- Pure LLM chat each engagement with only TF state as memory
- Replacing Terraform entirely in v0
- Building a full SaaS control plane before a CLI+repo convention

View File

@ -0,0 +1,17 @@
# D002 — LLM as front door only
- **Status:** active
- **Date:** 2026-07-15
## Decision
Cursor/Claude Code **skills** are an optional UX that load customer `intent.yaml` + `decisions/` and invoke `platformctl`. The LLM must not emit unaudited GRANT/SQL as the system of record.
## Why
Session N+1 with TF state recovers *what* exists, not *why*. Skills + decision files fix amnesia without putting the model in the apply path.
## Alternatives rejected
- Free-form chat regenerating the whole platform each time
- Requiring every customer engineer to use an agent IDE (CLI-only path remains first-class)

View File

@ -0,0 +1,17 @@
# D003 — Blueprint vs customer repo
- **Status:** active
- **Date:** 2026-07-15
## Decision
Keep **blueprint** (modules, schemas, wizard catalogs, policies, skills) as versioned product IP. Each customer gets a **project repo** with intent, decisions, terraform root, and a copied skill pin.
## Why
Separates reusable best practices from client-specific answers and exceptions; enables blueprint upgrades via version pin.
## Alternatives rejected
- Forking the whole monorepo into every customer
- Storing only Terraform without an intent layer

View File

@ -0,0 +1,17 @@
# D004 — Packaging as Python platformctl
- **Status:** active
- **Date:** 2026-07-15
## Decision
Distribute the CLI as a **Python package** (`platformctl` / `skylattice`) installable with pip or uv/pipx. The CLI operates on a local customer project and pinned blueprint content inside the product repo (or published artifact later). It must **not** require reaching into an operators private live working tree from the customer network.
## Why
Fits Snowflake/Python ecosystem; private publish is easy; pipx/uv gives an isolated CLI.
## Alternatives rejected
- npm as primary distribution
- Requiring customers to clone the operator blueprint monorepo for day-to-day apply (CI can run Terraform only)

View File

@ -0,0 +1,17 @@
# D005 — Post-apply edit, not teardown
- **Status:** active
- **Date:** 2026-07-15
## Decision
After apply, the default recourse for late/changed requirements is **edit intent/decisions → plan → apply**. Teardown is **scoped** (`destroy --env`) and rare.
## Why
Intake is never complete on day one; wiping accounts to fix naming or add a domain is unsafe and slow.
## Alternatives rejected
- Re-running greenfield `init` on an existing customer
- Ad-hoc DROP scripts outside the intent loop

View File

@ -0,0 +1,17 @@
# D006 — Customer delivery mode A
- **Status:** active
- **Date:** 2026-07-15
## Decision
Default landing: customer hosts **git + CI + secrets + TF state backend + Snowflake automation user**. Operator keeps blueprint; customer repo pins blueprint version. Optional modes: operator-run apply (retainer), or vendored blueprint for air-gap.
## Why
Matches consulting delivery; minimizes IP leakage; uses familiar Terraform PR workflows for customer DevOps.
## Alternatives rejected
- Shipping full blueprint source into every customer by default
- Snowflake Native App as v0 packaging

View File

@ -0,0 +1,16 @@
# D007 — Wizard is re-entrant
- **Status:** active
- **Date:** 2026-07-15
## Decision
The wizard is a **resumable question catalog** (greenfield/brownfield) that patches intent and forces decisions on overrides — not a one-shot form.
## Why
Details arrive over weeks; the same guided path must support `add domain`, SSO later, and brownfield adopt.
## Alternatives rejected
- Single questionnaire then abandon the tool for hand-edited HCL only

View File

@ -0,0 +1,17 @@
# D008 — v0 planner emits object graph
- **Status:** active
- **Date:** 2026-07-15
## Decision
v0 `platformctl plan/apply` builds a **deterministic planned object graph** from intent (databases, schemas, roles, warehouses, service users) and writes `plans/latest.yaml`. Terraform modules are stubs/outputs suitable for extension with the official `snowflakedb/snowflake` provider. Live account apply via provider credentials is a follow-on.
## Why
Unlocks end-to-end intent→decision→plan workflow and dogfooding without blocking on full provider resource coverage on day one.
## Alternatives rejected
- Delaying any CLI until every Snowflake resource is fully Terraform-managed
- Making LLM-generated SQL the apply path in v0

14
docs/decisions/README.md Normal file
View File

@ -0,0 +1,14 @@
# Product decisions (Sky Lattice)
Durable rationale for choices made while designing this product. These are **meta-decisions** about the tool itself (not customer Snowflake exceptions).
| ID | Decision |
|---|---|
| [D001](D001-product-name-and-scope.md) | Name Sky Lattice; scope = intent/decision/policy over Terraform |
| [D002](D002-llm-as-front-door.md) | LLM/skills drive files+CLI; never system of record for grants |
| [D003](D003-blueprint-vs-customer-repo.md) | Split blueprint IP vs per-customer durable repo |
| [D004](D004-packaging-python.md) | Ship as Python `platformctl` via pip/uv; no live clone of operator monorepo from customer env |
| [D005](D005-post-apply-edit-not-teardown.md) | Default lifecycle is edit→plan→apply; scoped destroy is exception |
| [D006](D006-delivery-mode-a.md) | Default customer landing = their git/CI/secrets; you keep blueprint |
| [D007](D007-wizard-reentrant.md) | Wizard is resumable guided editor, not one-shot intake |
| [D008](D008-v0-planner-graph.md) | v0 planner emits object graph + TF stubs; live snowflakedb provider wired later |

View File

@ -0,0 +1,34 @@
# How To Add a Domain
Add a business domain to an existing Sky Lattice customer project after the platform is already applied.
## Prerequisites
- Customer project with `intent.yaml`
- `platformctl` installed
## Steps
1. Open the customer project directory.
2. Run:
```bash
platformctl add domain finance --access-profile restricted
```
3. If this breaks a default (e.g. early prod curated read), record a decision:
```bash
platformctl decision add \
--applies-to domains.finance \
--rationale "Month-end needs prod curated read; revisit after SSO" \
--expires 2026-10-01
```
4. Link an override in `intent.yaml` if needed (`overrides` with that `decision_id`).
5. Run `platformctl validate` then `platformctl plan`.
6. Review the plan summary and apply via `platformctl apply` or a PR.
## Expected results
Plan shows new database/schemas/roles for the domain across configured environments.

20
docs/recipes/fix-drift.md Normal file
View File

@ -0,0 +1,20 @@
# How To Fix Drift
Compare live inventory to desired intent and decide what to adopt or leave unmanaged.
## Steps
1. Export or maintain `observed/inventory.yaml` with objects `{kind, name}`.
2. Run `platformctl drift --inventory observed/inventory.yaml`.
3. For leftovers you will not manage yet:
```bash
platformctl decision add --applies-to unmanaged.role.LEGACY_ANALYST --rationale "..."
```
Add them under `intent.unmanaged` with that `decision_id`.
4. For gaps that should exist, `platformctl plan` and apply.
## Expected results
Discover report in `observed/discover-latest.yaml` with classifications.

View File

@ -0,0 +1,13 @@
# How To Resume the Wizard Later
Intake is incomplete — continue without re-init.
## Steps
1. `platformctl wizard --resume --path customers/<customer>`
2. Answer only remaining questions (or pass `--answers`).
3. `platformctl validate && platformctl plan`
## Expected results
`intent.wizard.answered` grows; prior domains/decisions remain intact.

View File

@ -0,0 +1,33 @@
# Wizard catalog ↔ intent schema (deep dive)
This documents the mapping locked for blueprint `0.1.0`.
## Intent fields covered by greenfield catalog
| Question id | Intent path / effect | Schema field |
|---|---|---|
| customer | `customer` | required string |
| env_strategy | `env_strategy` | enum |
| environments | `environments` | array of env enums |
| domains | `domains[]` | array of domain objects |
| domain_access | sets `domains[].access_profile` | enum standard/restricted |
| warehouse_profile | `warehouses.profile` | enum |
| identity_sso | `identity.sso` | enum |
| prod_curated_exceptions | `overrides[]` + decision file | overrides require decision_id |
## Brownfield extras
| Question id | Effect |
|---|---|
| adopt_domains | `domains[]` to manage now |
| keep_legacy_roles | `unmanaged[]` kind=role + decision |
| naming_conflicts | `unmanaged[]` kind=database + decision |
## Gaps / follow-ups
- Per-zone customization beyond default zone list
- Account-per-env provider alias wiring in Terraform
- Live Snowflake INFORMATION_SCHEMA discover connector
- JSON Schema `default` on nested domain zones not enforced by jsonschema without extending validator
Wizard `mapping.yaml` helpers mirror these mutations in `wizard_cmd.py`.

View File

@ -0,0 +1,8 @@
customer: acme
env_strategy: database_per_env
environments: [dev, test, prod]
domains: [sales, finance, ops]
domain_access: finance
warehouse_profile: standard_cost_saver
identity_sso: planned
prod_curated_exceptions: none

View File

@ -0,0 +1,8 @@
customer: globex
environments: [prod]
adopt_domains: [sales, finance]
keep_legacy_roles: FINANCE_OLD
keep_legacy_roles_rationale: "Legacy finance role retained until SCIM cutover"
naming_conflicts: none
warehouse_profile: standard_cost_saver
identity_sso: planned

View File

@ -0,0 +1,9 @@
objects:
- kind: database
name: GLOBEX_PROD_SALES
- kind: role
name: FINANCE_OLD
- kind: warehouse
name: GLOBEX_PROD_WH
- kind: database
name: SOME_LEGACY_DB

31
pyproject.toml Normal file
View File

@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "skylattice"
version = "0.1.0"
description = "Sky Lattice — intent + decisions + Terraform blueprint toolkit for Snowflake platforms"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Sky Lattice" }]
dependencies = [
"typer>=0.12",
"rich>=13.0",
"pyyaml>=6.0",
"jsonschema>=4.20",
"python-dateutil>=2.8",
]
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[project.scripts]
platformctl = "skylattice.cli:app"
skylattice = "skylattice.cli:app"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
skylattice = ["py.typed"]

View File

@ -0,0 +1,5 @@
"""Sky Lattice — platformctl CLI."""
from __future__ import annotations
__version__ = "0.1.0"

66
src/skylattice/cli.py Normal file
View File

@ -0,0 +1,66 @@
from __future__ import annotations
import typer
from rich.console import Console
from skylattice import __version__
from skylattice.commands import (
add_cmd,
apply_cmd,
decision_cmd,
destroy_cmd,
discover_cmd,
doctor_cmd,
drift_cmd,
explain_cmd,
init_cmd,
plan_cmd,
skills_cmd,
validate_cmd,
wizard_cmd,
)
app = typer.Typer(
name="platformctl",
help="Sky Lattice: intent + decisions + Terraform for Snowflake platforms.",
no_args_is_help=True,
add_completion=False,
)
console = Console()
app.command("init")(init_cmd.run)
app.command("wizard")(wizard_cmd.run)
app.command("add")(add_cmd.run)
app.command("validate")(validate_cmd.run)
app.command("plan")(plan_cmd.run)
app.command("apply")(apply_cmd.run)
app.command("discover")(discover_cmd.run)
app.command("drift")(drift_cmd.run)
app.add_typer(decision_cmd.app, name="decision")
app.command("doctor")(doctor_cmd.run)
app.command("explain")(explain_cmd.run)
app.command("destroy")(destroy_cmd.run)
app.add_typer(skills_cmd.app, name="skills")
def _version_callback(value: bool) -> None:
if value:
console.print(f"Sky Lattice platformctl {__version__}")
raise typer.Exit()
@app.callback()
def main(
version: bool = typer.Option(
False,
"--version",
help="Show version and exit.",
callback=_version_callback,
is_eager=True,
),
) -> None:
"""Sky Lattice platformctl."""
if __name__ == "__main__":
app()

View File

@ -0,0 +1 @@
"""platformctl command modules."""

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from skylattice.core.intent import load_intent, save_intent
from skylattice.core.paths import find_customer_root
console = Console()
def run(
what: str = typer.Argument(..., help="What to add: domain"),
name: str = typer.Argument(..., help="Name of the domain (or entity)"),
access_profile: str = typer.Option("standard", "--access-profile"),
path: Path | None = typer.Option(None, "--path"),
) -> None:
"""Incremental edit helper (lifecycle: edit → plan → apply)."""
customer_root = path.resolve() if path else find_customer_root()
intent = load_intent(customer_root)
if what != "domain":
console.print("[red]Only 'domain' is supported in v0.[/red]")
raise typer.Exit(1)
domains = list(intent.get("domains") or [])
if any(d.get("name") == name for d in domains):
console.print(f"[yellow]Domain already present:[/yellow] {name}")
raise typer.Exit(0)
domains.append(
{
"name": name,
"access_profile": access_profile,
"zones": ["landing", "raw", "curated", "marts"],
}
)
intent["domains"] = domains
save_intent(customer_root, intent)
console.print(f"[green]Added domain[/green] {name} ({access_profile})")
console.print("Next: platformctl validate && platformctl plan")

View File

@ -0,0 +1,61 @@
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import typer
from rich.console import Console
from skylattice.core.intent import load_intent
from skylattice.core.paths import find_customer_root
from skylattice.core.planner import build_plan, write_plan_artifacts
from skylattice.core.policy import run_policies
console = Console()
def run(
path: Path | None = typer.Option(None, "--path"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
terraform: bool = typer.Option(False, "--terraform", help="Also run terraform apply if available"),
) -> None:
"""Apply planned intent. Default records apply marker; optional terraform wrapper."""
customer_root = path.resolve() if path else find_customer_root()
intent = load_intent(customer_root)
policy = run_policies(customer_root, intent)
if not policy.ok:
for e in policy.errors:
console.print(f"[red]ERROR[/red] {e}")
raise typer.Exit(1)
plan = build_plan(intent)
write_plan_artifacts(customer_root, plan)
console.print(f"About to apply [bold]{plan['object_count']}[/bold] planned objects for {plan['customer']}.")
if not yes and not typer.confirm("Continue?"):
raise typer.Exit(0)
marker = customer_root / "plans" / "last-apply.yaml"
from skylattice.core.paths import dump_yaml
from datetime import datetime, timezone
dump_yaml(
marker,
{
"applied_at": datetime.now(timezone.utc).isoformat(),
"object_count": plan["object_count"],
"creates": plan["creates"],
"note": "v0 apply records desired graph; wire snowflakedb provider for live apply.",
},
)
console.print(f"[green]Apply recorded[/green] → {marker}")
if terraform:
tf = shutil.which("terraform")
if not tf:
console.print("[yellow]terraform not on PATH; skipped[/yellow]")
return
tf_dir = customer_root / "terraform"
subprocess.run([tf, "init", "-backend=false"], cwd=tf_dir, check=False)
subprocess.run([tf, "apply", "-auto-approve"], cwd=tf_dir, check=False)

View File

@ -0,0 +1,57 @@
from __future__ import annotations
import re
from datetime import datetime, timezone
from pathlib import Path
import typer
from rich.console import Console
from skylattice.core.decisions import create_decision, list_decisions
from skylattice.core.paths import find_customer_root
console = Console()
app = typer.Typer(help="Manage decision records (why deviations exist).", no_args_is_help=True)
def _default_id(applies_to: str) -> str:
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
safe = re.sub(r"[^a-z0-9]+", "-", applies_to.lower()).strip("-")[:40]
return f"{day}-{safe}"
@app.command("add")
def add(
applies_to: str = typer.Option(..., "--applies-to"),
rationale: str = typer.Option(..., "--rationale"),
expires: str | None = typer.Option(None, "--expires", help="YYYY-MM-DD"),
decision_id: str | None = typer.Option(None, "--id"),
path: Path | None = typer.Option(None, "--path"),
) -> None:
"""Record a durable rationale linked to an intent path."""
customer_root = path.resolve() if path else find_customer_root()
did = decision_id or _default_id(applies_to)
create_decision(
customer_root,
decision_id=did,
applies_to=applies_to,
rationale=rationale,
expires_on=expires,
)
console.print(f"[green]Decision recorded[/green] {did}{customer_root / 'decisions' / (did + '.yaml')}")
@app.command("list")
def list_all(
path: Path | None = typer.Option(None, "--path"),
) -> None:
"""List decision files for this customer."""
customer_root = path.resolve() if path else find_customer_root()
decisions = list_decisions(customer_root)
if not decisions:
console.print("[dim]No decisions yet.[/dim]")
return
for d in decisions:
console.print(f"{d.get('id')} [{d.get('status')}] → {d.get('applies_to')}")
console.print(f" {d.get('rationale')}")

View File

@ -0,0 +1,46 @@
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from skylattice.core.intent import load_intent, save_intent
from skylattice.core.paths import find_customer_root
from skylattice.core.planner import build_plan, write_plan_artifacts
console = Console()
def run(
env: str = typer.Option(..., "--env", help="Environment to remove from intent (scoped teardown)"),
path: Path | None = typer.Option(None, "--path"),
yes: bool = typer.Option(False, "--yes", "-y"),
) -> None:
"""Scoped teardown: remove an environment from intent and re-plan destroys."""
customer_root = path.resolve() if path else find_customer_root()
intent = load_intent(customer_root)
envs = list(intent.get("environments") or [])
if env not in envs:
console.print(f"[yellow]Env not in intent:[/yellow] {env}")
raise typer.Exit(1)
before = build_plan(intent)
if not yes and not typer.confirm(f"Remove env '{env}' from intent and plan destroys?"):
raise typer.Exit(0)
intent["environments"] = [e for e in envs if e != env]
save_intent(customer_root, intent)
after = build_plan(intent)
before_set = set(before["creates"])
after_set = set(after["creates"])
destroys = sorted(before_set - after_set)
after["destroys"] = destroys
after["creates"] = sorted(after_set - before_set)
after["updates"] = []
write_plan_artifacts(customer_root, after)
console.print(f"[green]Removed env[/green] {env}. Planned destroys: {len(destroys)}")
for d in destroys[:30]:
console.print(f" - {d}")
console.print("Review plans/latest.yaml then platformctl apply (or terraform destroy for those addresses).")

View File

@ -0,0 +1,47 @@
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from skylattice.core.discover import classify, load_inventory, write_discover_report
from skylattice.core.intent import load_intent
from skylattice.core.paths import find_customer_root
console = Console()
def run(
path: Path | None = typer.Option(None, "--path"),
inventory: Path | None = typer.Option(
None,
"--inventory",
help="YAML inventory of live objects (kind/name). Optional demo file.",
),
) -> None:
"""Discover/classify live inventory vs desired intent (observe layer)."""
customer_root = path.resolve() if path else find_customer_root()
intent = load_intent(customer_root)
inv_path = inventory
if inv_path is None:
candidate = customer_root / "observed" / "inventory.yaml"
inv_path = candidate if candidate.exists() else None
inventory_objs = load_inventory(inv_path) if inv_path else []
if not inventory_objs:
console.print(
"[yellow]No inventory provided.[/yellow] "
"Pass --inventory or create observed/inventory.yaml. "
"Live Snowflake connector can plug in here later."
)
report = classify(intent, inventory_objs)
out = write_discover_report(customer_root, report)
s = report["summary"]
console.print(
f"Discover: total={s['total']} matched={s['matched_pct']}% "
f"unmanaged={s['unmanaged']} conflicts={s['conflicts']} drifted={s['drifted']}"
)
console.print(f"[dim]Wrote {out}[/dim]")

View File

@ -0,0 +1,53 @@
from __future__ import annotations
import shutil
from pathlib import Path
import typer
from rich.console import Console
from skylattice import __version__
from skylattice.core.paths import blueprint_version, find_customer_root, repo_root
console = Console()
def run(
path: Path | None = typer.Option(None, "--path"),
) -> None:
"""Health check and next-step hints (low-friction onboarding)."""
console.print(f"[bold]Sky Lattice doctor[/bold] platformctl={__version__} blueprint={blueprint_version()}")
try:
root = repo_root()
console.print(f"[green]OK[/green] product root: {root}")
except FileNotFoundError as e:
console.print(f"[red]FAIL[/red] {e}")
raise typer.Exit(1)
for tool in ("terraform",):
loc = shutil.which(tool)
if loc:
console.print(f"[green]OK[/green] {tool}: {loc}")
else:
console.print(f"[yellow]MISS[/yellow] {tool} not on PATH (optional for v0 graph apply)")
try:
customer_root = path.resolve() if path else find_customer_root()
except FileNotFoundError:
console.print("[yellow]No customer intent.yaml in cwd.[/yellow] Next: platformctl init <customer>")
return
console.print(f"[green]OK[/green] customer project: {customer_root}")
intent = customer_root / "intent.yaml"
decisions = customer_root / "decisions"
skill = customer_root / ".cursor" / "skills" / "platform-ops" / "SKILL.md"
console.print(f" intent: {'yes' if intent.exists() else 'missing'}")
console.print(f" decisions dir: {'yes' if decisions.exists() else 'missing'}")
console.print(f" cursor skill: {'yes' if skill.exists() else 'missing — run platformctl skills install'}")
plan = customer_root / "plans" / "latest.yaml"
if plan.exists():
console.print("Next: review plans/latest.yaml or platformctl apply")
else:
console.print("Next: platformctl wizard --resume && platformctl validate && platformctl plan")

View File

@ -0,0 +1,33 @@
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from skylattice.commands import discover_cmd
from skylattice.core.paths import find_customer_root, load_yaml
console = Console()
def run(
path: Path | None = typer.Option(None, "--path"),
inventory: Path | None = typer.Option(None, "--inventory"),
) -> None:
"""Re-discover and highlight drift vs last apply / intent."""
customer_root = path.resolve() if path else find_customer_root()
discover_cmd.run(path=customer_root, inventory=inventory)
report_path = customer_root / "observed" / "discover-latest.yaml"
if not report_path.exists():
raise typer.Exit(1)
report = load_yaml(report_path)
drifted = [o for o in report.get("objects") or [] if o.get("classification") in ("drifted_managed", "unmanaged_live")]
missing = [o for o in report.get("objects") or [] if o.get("classification") == "blueprint_match"]
console.print(f"[bold]Drift summary[/bold]: unmanaged_or_drifted={len(drifted)} desired_missing_live={len(missing)}")
for o in drifted[:20]:
console.print(f" ! {o.get('classification')} {o.get('kind')}:{o.get('name')}")
for o in missing[:20]:
console.print(f" + desired not in inventory: {o.get('kind')}:{o.get('name')}")

View File

@ -0,0 +1,44 @@
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from skylattice.core.decisions import explain_path
from skylattice.core.intent import load_intent
from skylattice.core.paths import find_customer_root, get_by_path
console = Console()
def run(
path_key: str = typer.Option(..., "--path", help="Intent path, e.g. domains.finance"),
project: Path | None = typer.Option(None, "--project", help="Customer project directory"),
) -> None:
"""Explain why a path looks the way it does (intent value + decisions)."""
customer_root = project.resolve() if project else find_customer_root()
intent = load_intent(customer_root)
parts = path_key.split(".")
value = get_by_path(intent, path_key)
if value is None and parts and parts[0] == "domains" and len(parts) >= 2:
for d in intent.get("domains") or []:
if d.get("name") == parts[1]:
value = d if len(parts) == 2 else d.get(parts[2])
break
console.print(f"[bold]{path_key}[/bold] = {value!r}")
if parts and parts[0] == "domains" and len(parts) >= 2:
for d in intent.get("domains") or []:
if d.get("name") == parts[1]:
console.print(f"domain record: {d}")
matches = explain_path(customer_root, path_key)
if not matches:
console.print("[dim]No linked decisions.[/dim]")
return
for d in matches:
console.print(f"[green]decision[/green] {d.get('id')} [{d.get('status')}] expires={d.get('expires_on')}")
console.print(f" {d.get('rationale')}")

View File

@ -0,0 +1,55 @@
from __future__ import annotations
import shutil
from pathlib import Path
import typer
from rich.console import Console
from skylattice.core.intent import default_intent, save_intent
from skylattice.core.paths import blueprint_dir, blueprint_version, repo_root
console = Console()
def run(
customer: str = typer.Argument(..., help="Customer slug, e.g. acme"),
mode: str = typer.Option("greenfield", "--mode", help="greenfield|brownfield"),
path: Path | None = typer.Option(None, "--path", help="Parent dir for customer project"),
) -> None:
"""Create a customer project from the blueprint template."""
if mode not in ("greenfield", "brownfield"):
raise typer.BadParameter("mode must be greenfield or brownfield")
parent = path or (repo_root() / "customers")
parent.mkdir(parents=True, exist_ok=True)
dest = parent / customer
if dest.exists():
console.print(f"[red]Customer project already exists:[/red] {dest}")
raise typer.Exit(code=1)
template = blueprint_dir() / "templates" / "customer-repo"
shutil.copytree(template, dest)
intent = default_intent(mode, customer)
save_intent(dest, intent)
# Fix terraform module path relative to monorepo layout
tf_main = dest / "terraform" / "main.tf"
if tf_main.exists():
# customers/<name>/terraform -> ../../../blueprint/modules/environment
content = tf_main.read_text(encoding="utf-8")
content = content.replace(
'source = "../../../blueprint/modules/environment"',
'source = "../../../blueprint/modules/environment"',
)
tf_main.write_text(content, encoding="utf-8")
readme = dest / "README.md"
if readme.exists():
text = readme.read_text(encoding="utf-8")
text = f"# {customer} — Sky Lattice customer project\n\nBlueprint pin: `{blueprint_version()}`\n\n" + text
readme.write_text(text, encoding="utf-8")
console.print(f"[green]Created[/green] {dest}")
console.print("Next: [bold]platformctl wizard --path %s[/bold]" % dest)

View File

@ -0,0 +1,46 @@
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from rich.table import Table
from skylattice.core.intent import load_intent
from skylattice.core.paths import find_customer_root
from skylattice.core.planner import build_plan, write_plan_artifacts
from skylattice.core.policy import run_policies
console = Console()
def run(
path: Path | None = typer.Option(None, "--path"),
) -> None:
"""Compile intent → planned object graph (deterministic)."""
customer_root = path.resolve() if path else find_customer_root()
intent = load_intent(customer_root)
policy = run_policies(customer_root, intent)
if not policy.ok:
for e in policy.errors:
console.print(f"[red]ERROR[/red] {e}")
console.print("[red]Fix validation errors before planning.[/red]")
raise typer.Exit(1)
plan = build_plan(intent)
write_plan_artifacts(customer_root, plan)
console.print(f"[bold]Plan for {plan['customer']}[/bold] (blueprint {plan['blueprint']})")
console.print(f"Objects: {plan['object_count']}")
for line in plan["summary"]:
console.print(f"{line}")
table = Table(title="Creates")
table.add_column("Object")
for c in plan["creates"][:40]:
table.add_row(c)
if len(plan["creates"]) > 40:
table.add_row(f"... +{len(plan['creates']) - 40} more")
console.print(table)
console.print(f"[dim]Wrote {customer_root / 'plans' / 'latest.yaml'}[/dim]")
console.print("[dim]Terraform apply requires provider credentials; see docs/customer-landing.[/dim]")

View File

@ -0,0 +1,28 @@
from __future__ import annotations
import shutil
from pathlib import Path
import typer
from rich.console import Console
from skylattice.core.paths import blueprint_dir, find_customer_root
console = Console()
app = typer.Typer(help="Install Cursor skills into a customer project.", no_args_is_help=True)
@app.command("install")
def install(
path: Path | None = typer.Option(None, "--path"),
) -> None:
"""Copy blueprint platform-ops skill into .cursor/skills/."""
customer_root = path.resolve() if path else find_customer_root()
src = blueprint_dir() / "skills" / "platform-ops"
dest = customer_root / ".cursor" / "skills" / "platform-ops"
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest)
console.print(f"[green]Installed skill[/green] → {dest}")
console.print("Open this customer repo in Cursor and ask in plain language.")

View File

@ -0,0 +1,31 @@
from __future__ import annotations
from pathlib import Path
import typer
from rich.console import Console
from skylattice.core.intent import load_intent
from skylattice.core.paths import find_customer_root
from skylattice.core.policy import run_policies
console = Console()
def run(
path: Path | None = typer.Option(None, "--path"),
) -> None:
"""Validate intent schema + policy pack."""
customer_root = path.resolve() if path else find_customer_root()
intent = load_intent(customer_root)
result = run_policies(customer_root, intent)
for w in result.warnings:
console.print(f"[yellow]WARN[/yellow] {w}")
for e in result.errors:
console.print(f"[red]ERROR[/red] {e}")
if result.ok:
console.print("[green]Valid[/green]")
else:
raise typer.Exit(1)

View File

@ -0,0 +1,164 @@
from __future__ import annotations
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import typer
from rich.console import Console
from rich.prompt import Prompt
from skylattice.core.decisions import create_decision
from skylattice.core.intent import load_intent, mark_wizard_answered, save_intent
from skylattice.core.paths import blueprint_dir, find_customer_root, load_yaml, set_by_path
console = Console()
def _parse_list(raw: str) -> list[str]:
if not raw or raw.strip().lower() == "none":
return []
return [p.strip() for p in raw.split(",") if p.strip()]
def _slug_decision(prefix: str, name: str) -> str:
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
safe = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return f"{day}-{prefix}-{safe}"
def _apply_answer(
intent: dict[str, Any],
customer_root: Path,
q: dict[str, Any],
raw: str,
*,
answer_map: dict[str, Any] | None = None,
) -> None:
qtype = q.get("type")
qid = q["id"]
answer_map = answer_map or {}
def _rationale_for(name: str) -> str:
key = f"{qid}_rationale"
if key in answer_map:
return str(answer_map[key]).format(name=name)
per_name = answer_map.get(f"{qid}_rationales") or {}
if isinstance(per_name, dict) and name in per_name:
return str(per_name[name])
if answer_map:
# Non-interactive answers file: do not block on Prompt
return str(
answer_map.get("default_rationale")
or f"Deferred via wizard answers for {name}"
)
return Prompt.ask(q.get("decision_prompt", "Rationale?").format(name=name))
if qtype in ("string",) and q.get("intent_path"):
set_by_path(intent, q["intent_path"], raw.strip())
elif qtype == "choice" and q.get("intent_path"):
set_by_path(intent, q["intent_path"], raw.strip())
elif qtype == "list" and q.get("intent_path"):
set_by_path(intent, q["intent_path"], _parse_list(raw) or q.get("default"))
elif qtype == "domain_list" and q.get("intent_path"):
names = _parse_list(raw)
profile = q.get("default_access_profile") or "standard"
intent["domains"] = [
{"name": n, "access_profile": profile, "zones": ["landing", "raw", "curated", "marts"]}
for n in names
]
elif qtype == "restricted_domains":
names = _parse_list(raw)
for d in intent.get("domains") or []:
if d["name"] in names:
d["access_profile"] = "restricted"
elif qtype == "override_with_decision":
names = _parse_list(raw)
overrides = list(intent.get("overrides") or [])
for name in names:
rationale = _rationale_for(name)
did = _slug_decision("override", name)
create_decision(
customer_root,
decision_id=did,
applies_to=q.get("override_path_template", "domains.{name}").format(name=name),
rationale=rationale,
)
overrides.append(
{
"path": q.get("override_path_template", "domains.{name}.prod_curated_read").format(name=name),
"value": True,
"decision_id": did,
}
)
intent["overrides"] = overrides
elif qtype == "unmanaged_list":
names = _parse_list(raw)
unmanaged = list(intent.get("unmanaged") or [])
kind = q.get("kind") or "role"
for name in names:
rationale = _rationale_for(name)
did = _slug_decision("unmanaged", name)
create_decision(
customer_root,
decision_id=did,
applies_to=f"unmanaged.{kind}.{name}",
rationale=rationale,
)
unmanaged.append({"kind": kind, "name": name, "reason": rationale, "decision_id": did})
intent["unmanaged"] = unmanaged
else:
console.print(f"[yellow]Unknown question type {qtype}; skipped[/yellow]")
mark_wizard_answered(intent, qid)
def run(
mode: str | None = typer.Option(None, "--mode", help="greenfield|brownfield (default: from intent)"),
resume: bool = typer.Option(False, "--resume", help="Only ask unanswered questions"),
path: Path | None = typer.Option(None, "--path", help="Customer project path"),
answers: Path | None = typer.Option(None, "--answers", help="Non-interactive answers YAML"),
) -> None:
"""Guided interview that writes intent.yaml and decisions/."""
customer_root = path.resolve() if path else find_customer_root()
intent = load_intent(customer_root)
mode = mode or intent.get("mode") or "greenfield"
catalog_name = f"catalog.{mode}.yaml"
catalog = load_yaml(blueprint_dir() / "wizard" / catalog_name)
questions = catalog.get("questions") or []
answered = set((intent.get("wizard") or {}).get("answered") or [])
answer_map: dict[str, Any] = {}
if answers:
answer_map = load_yaml(answers) or {}
console.print(f"[bold]Sky Lattice wizard[/bold] ({mode}) — {customer_root}")
if "discover" in (catalog.get("prerequisites") or []):
console.print("[dim]Tip: run platformctl discover before brownfield adoption.[/dim]")
for q in questions:
qid = q["id"]
if resume and qid in answered:
continue
if qid in answer_map:
raw = answer_map[qid]
if isinstance(raw, list):
raw = ",".join(str(x) for x in raw)
else:
raw = str(raw)
console.print(f"{q['prompt']}{raw}")
else:
default = q.get("default")
if isinstance(default, list):
default_s = ",".join(str(x) for x in default)
else:
default_s = "" if default is None else str(default)
raw = Prompt.ask(q["prompt"], default=default_s)
_apply_answer(intent, customer_root, q, raw, answer_map=answer_map)
intent["mode"] = mode
save_intent(customer_root, intent)
console.print("[green]Wizard complete.[/green] Run: platformctl validate && platformctl plan")

View File

@ -0,0 +1 @@
"""Core libraries for Sky Lattice."""

View File

@ -0,0 +1,86 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from skylattice.core.paths import blueprint_dir, dump_yaml, load_json, load_yaml
def decision_schema() -> dict:
return load_json(blueprint_dir() / "schemas" / "decision.schema.json")
def decisions_dir(customer_root: Path) -> Path:
d = customer_root / "decisions"
d.mkdir(parents=True, exist_ok=True)
return d
def list_decisions(customer_root: Path) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for path in sorted(decisions_dir(customer_root).glob("*.yaml")):
if path.name.startswith("."):
continue
data = load_yaml(path)
if data:
out.append(data)
return out
def get_decision(customer_root: Path, decision_id: str) -> dict[str, Any] | None:
path = decisions_dir(customer_root) / f"{decision_id}.yaml"
if not path.exists():
return None
return load_yaml(path)
def validate_decision(decision: dict[str, Any]) -> list[str]:
validator = Draft202012Validator(decision_schema())
return [e.message for e in sorted(validator.iter_errors(decision), key=lambda e: e.path)]
def create_decision(
customer_root: Path,
*,
decision_id: str,
applies_to: str,
rationale: str,
expires_on: str | None = None,
alternatives_rejected: list[str] | None = None,
client_constraint: str | None = None,
created_by: str | None = None,
) -> dict[str, Any]:
decision = {
"id": decision_id,
"applies_to": applies_to,
"rationale": rationale,
"alternatives_rejected": alternatives_rejected or [],
"status": "active",
"expires_on": expires_on,
"client_constraint": client_constraint,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_by": created_by,
"superseded_by": None,
}
errors = validate_decision(decision)
if errors:
raise ValueError("; ".join(errors))
path = decisions_dir(customer_root) / f"{decision_id}.yaml"
dump_yaml(path, decision)
return decision
def active_decisions(customer_root: Path) -> list[dict[str, Any]]:
return [d for d in list_decisions(customer_root) if d.get("status") == "active"]
def explain_path(customer_root: Path, path: str) -> list[dict[str, Any]]:
matches = []
for d in list_decisions(customer_root):
applies = d.get("applies_to") or ""
if applies == path or path.startswith(applies) or applies.startswith(path):
matches.append(d)
return matches

View File

@ -0,0 +1,87 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from skylattice.core.paths import dump_yaml, load_yaml
from skylattice.core.planner import build_plan
def load_inventory(path: Path) -> list[dict[str, Any]]:
data = load_yaml(path)
if isinstance(data, dict):
return list(data.get("objects") or [])
if isinstance(data, list):
return data
return []
def classify(
intent: dict[str, Any],
inventory: list[dict[str, Any]],
) -> dict[str, Any]:
plan = build_plan(intent)
desired = {c.lower() for c in plan["creates"]}
live_keys: dict[str, dict[str, Any]] = {}
for obj in inventory:
key = f"{obj.get('kind')}:{obj.get('name')}".lower()
live_keys[key] = obj
objects: list[dict[str, Any]] = []
unmanaged = 0
conflicts = 0
drifted = 0
matched = 0
for key, obj in live_keys.items():
if key in desired:
classification = "in_sync"
matched += 1
else:
classification = "unmanaged_live"
unmanaged += 1
objects.append(
{
"kind": obj.get("kind"),
"name": obj.get("name"),
"classification": classification,
"tf_address": None,
"notes": obj.get("notes") or "",
}
)
for create in plan["creates"]:
if create.lower() not in live_keys:
kind, name = create.split(":", 1)
objects.append(
{
"kind": kind,
"name": name,
"classification": "blueprint_match",
"tf_address": None,
"notes": "desired by intent; not found in inventory",
}
)
total = max(len(live_keys), 1)
report = {
"customer": intent.get("customer"),
"generated_at": datetime.now(timezone.utc).isoformat(),
"source": "inventory_file",
"objects": objects,
"summary": {
"total": len(objects),
"matched_pct": round(100.0 * matched / total, 1),
"unmanaged": unmanaged,
"conflicts": conflicts,
"drifted": drifted,
},
}
return report
def write_discover_report(customer_root: Path, report: dict[str, Any]) -> Path:
out = customer_root / "observed" / "discover-latest.yaml"
dump_yaml(out, report)
return out

View File

@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from skylattice.core.paths import blueprint_dir, dump_yaml, load_json, load_yaml
def intent_schema() -> dict:
return load_json(blueprint_dir() / "schemas" / "intent.schema.json")
def load_intent(customer_root: Path) -> dict[str, Any]:
return load_yaml(customer_root / "intent.yaml")
def save_intent(customer_root: Path, intent: dict[str, Any]) -> None:
dump_yaml(customer_root / "intent.yaml", intent)
def validate_intent_schema(intent: dict[str, Any]) -> list[str]:
validator = Draft202012Validator(intent_schema())
return [e.message for e in sorted(validator.iter_errors(intent), key=lambda e: e.path)]
def default_intent(mode: str, customer: str) -> dict[str, Any]:
name = "intent.greenfield.yaml" if mode == "greenfield" else "intent.brownfield.yaml"
data = load_yaml(blueprint_dir() / "defaults" / name)
data["customer"] = customer
data["blueprint"] = (blueprint_dir() / "VERSION").read_text(encoding="utf-8").strip()
data["mode"] = mode
data["wizard"] = {"answered": [], "last_run": None}
return data
def mark_wizard_answered(intent: dict[str, Any], question_id: str) -> None:
wizard = intent.setdefault("wizard", {"answered": []})
answered = list(wizard.get("answered") or [])
if question_id not in answered:
answered.append(question_id)
wizard["answered"] = answered
wizard["last_run"] = datetime.now(timezone.utc).isoformat()

View File

@ -0,0 +1,75 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import yaml
def repo_root() -> Path:
"""Locate Sky Lattice product root (contains blueprint/ and VERSION)."""
here = Path(__file__).resolve()
for p in [here, *here.parents]:
if (p / "blueprint" / "VERSION").exists() and (p / "VERSION").exists():
return p
# Fallback: cwd walking up
cwd = Path.cwd().resolve()
for p in [cwd, *cwd.parents]:
if (p / "blueprint" / "VERSION").exists():
return p
raise FileNotFoundError("Cannot find Sky Lattice root (blueprint/VERSION missing).")
def blueprint_dir() -> Path:
return repo_root() / "blueprint"
def blueprint_version() -> str:
return (blueprint_dir() / "VERSION").read_text(encoding="utf-8").strip()
def load_yaml(path: Path) -> Any:
with path.open(encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def dump_yaml(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)
def load_json(path: Path) -> Any:
with path.open(encoding="utf-8") as f:
return json.load(f)
def find_customer_root(start: Path | None = None) -> Path:
"""Find directory containing intent.yaml walking upward from start/cwd."""
start = (start or Path.cwd()).resolve()
for p in [start, *start.parents]:
if (p / "intent.yaml").exists():
return p
raise FileNotFoundError(
"No intent.yaml found. Run from a customer project or pass --path."
)
def set_by_path(data: dict, dotted: str, value: Any) -> None:
parts = dotted.split(".")
cur: Any = data
for part in parts[:-1]:
if part not in cur or not isinstance(cur[part], dict):
cur[part] = {}
cur = cur[part]
cur[parts[-1]] = value
def get_by_path(data: dict, dotted: str, default: Any = None) -> Any:
cur: Any = data
for part in dotted.split("."):
if not isinstance(cur, dict) or part not in cur:
return default
cur = cur[part]
return cur

View File

@ -0,0 +1,124 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from skylattice.core.paths import blueprint_dir, dump_yaml, load_yaml
def warehouse_profiles() -> dict[str, Any]:
return load_yaml(blueprint_dir() / "defaults" / "warehouse_profiles.yaml")
def access_profiles() -> dict[str, Any]:
return load_yaml(blueprint_dir() / "defaults" / "access_profiles.yaml")
def build_plan(intent: dict[str, Any]) -> dict[str, Any]:
"""Deterministic object graph from intent (v0 planner)."""
customer = intent["customer"]
envs = intent.get("environments") or ["dev"]
strategy = intent.get("env_strategy") or "database_per_env"
wh_profile_name = (intent.get("warehouses") or {}).get("profile") or "standard_cost_saver"
wh_cfg = warehouse_profiles().get(wh_profile_name, {})
access = access_profiles()
objects: list[dict[str, Any]] = []
summary_lines: list[str] = []
for env in envs:
prefix = f"{customer.upper()}_{env.upper()}" if strategy == "database_per_env" else customer.upper()
objects.append(
{
"kind": "warehouse",
"name": f"{prefix}_WH",
"env": env,
"config": wh_cfg,
"module": "warehouses",
}
)
objects.append(
{
"kind": "resource_monitor",
"name": f"{prefix}_MONITOR",
"env": env,
"module": "monitoring",
}
)
if (intent.get("identity") or {}).get("service_users", True):
objects.append({"kind": "user", "name": f"{prefix}_TF_SVC", "env": env, "module": "service_principals"})
objects.append({"kind": "user", "name": f"{prefix}_LOADER_SVC", "env": env, "module": "service_principals"})
for domain in intent.get("domains") or []:
dname = domain["name"]
zones = domain.get("zones") or ["landing", "raw", "curated", "marts"]
profile = domain.get("access_profile") or "standard"
db = f"{prefix}_{dname.upper()}"
objects.append(
{
"kind": "database",
"name": db,
"env": env,
"domain": dname,
"module": "database_zones",
}
)
for z in zones:
objects.append(
{
"kind": "schema",
"name": f"{db}.{z.upper()}",
"env": env,
"domain": dname,
"module": "database_zones",
}
)
for role_suffix in ("ANALYST", "LOADER", "ENGINEER"):
objects.append(
{
"kind": "role",
"name": f"{prefix}_{dname.upper()}_{role_suffix}",
"env": env,
"domain": dname,
"access_profile": profile,
"module": "rbac",
}
)
profile_meta = access.get(profile) or {}
summary_lines.append(
f"[{env}] domain={dname} profile={profile}: "
f"db={db}, zones={zones}, analyst_read={profile_meta.get('analyst_zones_read')}"
)
for ov in intent.get("overrides") or []:
summary_lines.append(f"override {ov.get('path')} = {ov.get('value')} (decision {ov.get('decision_id')})")
for um in intent.get("unmanaged") or []:
summary_lines.append(f"unmanaged {um.get('kind')}:{um.get('name')} ({um.get('reason') or 'deferred'})")
return {
"customer": customer,
"blueprint": intent.get("blueprint"),
"object_count": len(objects),
"objects": objects,
"summary": summary_lines,
"creates": [f"{o['kind']}:{o['name']}" for o in objects],
"updates": [],
"destroys": [],
}
def write_plan_artifacts(customer_root: Path, plan: dict[str, Any]) -> None:
plans = customer_root / "plans"
plans.mkdir(parents=True, exist_ok=True)
dump_yaml(plans / "latest.yaml", plan)
# Machine-readable planned objects for TF consumers / review
tf_dir = customer_root / "terraform"
tf_dir.mkdir(parents=True, exist_ok=True)
planned = {
"planned_objects": plan["creates"],
"summary": plan["summary"],
"object_count": plan["object_count"],
}
dump_yaml(tf_dir / "planned_objects.yaml", planned)

View File

@ -0,0 +1,76 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from skylattice.core.decisions import active_decisions, get_decision
from skylattice.core.intent import validate_intent_schema
from skylattice.core.paths import blueprint_dir, load_yaml
@dataclass
class PolicyResult:
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return not self.errors
def run_policies(customer_root, intent: dict[str, Any]) -> PolicyResult:
result = PolicyResult()
result.errors.extend(validate_intent_schema(intent))
rules = load_yaml(blueprint_dir() / "policies" / "rules.yaml").get("rules", [])
checks = {r["check"]: r for r in rules}
if "domains_non_empty" in checks and not intent.get("domains"):
result.errors.append(f"[{checks['domains_non_empty']['id']}] At least one domain is required.")
if "blueprint_pinned" in checks and not intent.get("blueprint"):
result.errors.append(f"[{checks['blueprint_pinned']['id']}] intent.blueprint must be set.")
if "overrides_have_decisions" in checks:
for ov in intent.get("overrides") or []:
did = ov.get("decision_id")
if not did:
result.errors.append(
f"[override_requires_decision] Override {ov.get('path')} missing decision_id."
)
continue
dec = get_decision(customer_root, did)
if not dec:
result.errors.append(
f"[override_requires_decision] Decision '{did}' not found for override {ov.get('path')}."
)
elif dec.get("status") != "active":
result.errors.append(
f"[override_requires_decision] Decision '{did}' is not active."
)
if "unmanaged_have_decisions" in checks:
for um in intent.get("unmanaged") or []:
if not um.get("decision_id"):
result.warnings.append(
f"[unmanaged_requires_decision] Unmanaged {um.get('kind')}:{um.get('name')} has no decision_id."
)
if "restricted_prod_curated_guard" in checks:
restricted = {
d["name"] for d in intent.get("domains") or [] if d.get("access_profile") == "restricted"
}
for ov in intent.get("overrides") or []:
path = ov.get("path") or ""
if "prod_curated_read" in path:
# path like domains.finance.prod_curated_read
parts = path.split(".")
domain = parts[1] if len(parts) > 1 else ""
if domain in restricted and not ov.get("decision_id"):
result.errors.append(
f"[restricted_prod_curated_read] {path} requires a decision."
)
# Ensure referenced active decisions exist for explainability coverage
_ = active_decisions(customer_root)
return result

27
tests/test_planner.py Normal file
View File

@ -0,0 +1,27 @@
from __future__ import annotations
from pathlib import Path
from skylattice.core.intent import load_intent, validate_intent_schema
from skylattice.core.planner import build_plan
from skylattice.core.policy import run_policies
def test_reference_intent_validates_and_plans():
root = Path(__file__).resolve().parents[1] / "blueprint" / "tests" / "reference-customer"
# policy needs decisions dir; use reference as customer root
intent = load_intent(root)
errors = validate_intent_schema(intent)
assert errors == [], errors
plan = build_plan(intent)
assert plan["object_count"] > 0
assert any(c.startswith("database:") for c in plan["creates"])
def test_policy_ok_on_reference(tmp_path: Path):
src = Path(__file__).resolve().parents[1] / "blueprint" / "tests" / "reference-customer" / "intent.yaml"
(tmp_path / "intent.yaml").write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
(tmp_path / "decisions").mkdir()
intent = load_intent(tmp_path)
result = run_policies(tmp_path, intent)
assert result.ok, result.errors